From d94d88c13913d2080fd14cd32aaaf91a50fc0a2d Mon Sep 17 00:00:00 2001 From: reduckted Date: Tue, 1 Sep 2026 22:03:46 +1000 Subject: [PATCH 1/5] Added a setting to hide folders without projects. --- .editorconfig | 18 ++ Directory.Build.props | 2 +- global.json | 2 +- .../Commands/FilterProjectsCommand.cs | 5 +- .../Services/ExtensionSettings.cs | 4 + .../ProjectFilter/Services/FilterOptions.cs | 7 +- .../ProjectFilter/Services/FilterService.cs | 9 + .../Services/IExtensionSettings.cs | 3 + .../Services/ISolutionExplorer.cs | 3 + .../Services/ISolutionExplorerNode.cs | 20 ++ .../Services/SolutionExplorer.cs | 178 ++++++++++++- .../Services/SolutionSettings.cs | 6 +- source/ProjectFilter/UI/FilterDialog.xaml | 13 + .../ProjectFilter/UI/FilterDialogViewModel.cs | 19 +- .../Helpers/Factory.cs | 15 +- .../Helpers/TreeItem.cs | 2 +- .../Services/FilterOptionsTests.cs | 14 +- .../Services/FilterServiceTests.cs | 153 ++++++++--- .../Services/SolutionExplorerTests.cs | 247 ++++++++++++++++++ 19 files changed, 665 insertions(+), 55 deletions(-) create mode 100644 source/ProjectFilter/Services/ISolutionExplorerNode.cs create mode 100644 tests/ProjectFilter.UnitTests/Services/SolutionExplorerTests.cs diff --git a/.editorconfig b/.editorconfig index e4eee44..6761551 100644 --- a/.editorconfig +++ b/.editorconfig @@ -253,3 +253,21 @@ dotnet_naming_style.begins_with__.capitalization = camel_case dotnet_diagnostic.CA1014.severity = none dotnet_diagnostic.CA2007.severity = none dotnet_diagnostic.IDE0070.severity = none + +# IDE0028: Simplify collection initialization +dotnet_diagnostic.IDE0028.severity = warning + +# IDE0290: Use primary constructor +dotnet_diagnostic.IDE0290.severity = none + +# IDE0300: Simplify collection initialization +dotnet_diagnostic.IDE0300.severity = warning + +# IDE0301: Simplify collection initialization +dotnet_diagnostic.IDE0301.severity = warning + +# IDE0303: Simplify collection initialization +dotnet_diagnostic.IDE0303.severity = warning + +# IDE0305: Simplify collection initialization +dotnet_diagnostic.IDE0305.severity = warning diff --git a/Directory.Build.props b/Directory.Build.props index cc40040..1fea47f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ 2.6.1 net48 enable - 10.0 + latest AllEnabledByDefault diff --git a/global.json b/global.json index d20023b..5d13fe0 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.203" + "version": "10.0.400" } } diff --git a/source/ProjectFilter/Commands/FilterProjectsCommand.cs b/source/ProjectFilter/Commands/FilterProjectsCommand.cs index ef4f08e..aef685a 100644 --- a/source/ProjectFilter/Commands/FilterProjectsCommand.cs +++ b/source/ProjectFilter/Commands/FilterProjectsCommand.cs @@ -63,6 +63,7 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { vm.LoadProjectDependencies = solutionSettings?.LoadProjectDependencies ?? globalSettings.LoadProjectDependencies; vm.UseRegularExpressions = solutionSettings?.UseRegularExpressions ?? globalSettings.UseRegularExpressions; vm.ExpandLoadedProjects = solutionSettings?.ExpandLoadedProjects ?? globalSettings.ExpandLoadedProjects; + vm.HideSolutionFoldersWithoutLoadedProjects = solutionSettings?.HideSolutionFoldersWithoutLoadedProjects ?? globalSettings.HideSolutionFoldersWithoutLoadedProjects; dialog = new FilterDialog { DataContext = vm @@ -76,7 +77,8 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { solutionSettings = new SolutionSettings { LoadProjectDependencies = vm.LoadProjectDependencies, UseRegularExpressions = vm.UseRegularExpressions, - ExpandLoadedProjects = vm.ExpandLoadedProjects + ExpandLoadedProjects = vm.ExpandLoadedProjects, + HideSolutionFoldersWithoutLoadedProjects = vm.HideSolutionFoldersWithoutLoadedProjects }; PopulateNodeSettings(vm.Items, solutionSettings.Nodes); @@ -87,6 +89,7 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { globalSettings.LoadProjectDependencies = vm.LoadProjectDependencies; globalSettings.UseRegularExpressions = vm.UseRegularExpressions; globalSettings.ExpandLoadedProjects = vm.ExpandLoadedProjects; + globalSettings.HideSolutionFoldersWithoutLoadedProjects = vm.HideSolutionFoldersWithoutLoadedProjects; await globalSettings.SaveAsync(); if (result) { diff --git a/source/ProjectFilter/Services/ExtensionSettings.cs b/source/ProjectFilter/Services/ExtensionSettings.cs index a19990b..2477d2d 100644 --- a/source/ProjectFilter/Services/ExtensionSettings.cs +++ b/source/ProjectFilter/Services/ExtensionSettings.cs @@ -13,6 +13,7 @@ public ExtensionSettings() { LoadProjectDependencies = true; UseRegularExpressions = false; ExpandLoadedProjects = true; + HideSolutionFoldersWithoutLoadedProjects = false; } @@ -24,4 +25,7 @@ public ExtensionSettings() { public bool ExpandLoadedProjects { get; set; } + + public bool HideSolutionFoldersWithoutLoadedProjects { get; set; } + } diff --git a/source/ProjectFilter/Services/FilterOptions.cs b/source/ProjectFilter/Services/FilterOptions.cs index 5af2e26..86b44ee 100644 --- a/source/ProjectFilter/Services/FilterOptions.cs +++ b/source/ProjectFilter/Services/FilterOptions.cs @@ -12,12 +12,14 @@ public FilterOptions( IEnumerable projectsToLoad, IEnumerable projectsToUnload, bool loadProjectDependencies, - bool expandLoadedProjects + bool expandLoadedProjects, + bool hideSolutionFoldersWithoutLoadedProjects ) { ProjectsToLoad = projectsToLoad.ToList(); ProjectsToUnload = projectsToUnload.ToList(); LoadProjectDependencies = loadProjectDependencies; ExpandLoadedProjects = expandLoadedProjects; + HideSolutionFoldersWithoutLoadedProjects = hideSolutionFoldersWithoutLoadedProjects; } @@ -32,4 +34,7 @@ bool expandLoadedProjects public bool ExpandLoadedProjects{ get; } + + public bool HideSolutionFoldersWithoutLoadedProjects { get; } + } diff --git a/source/ProjectFilter/Services/FilterService.cs b/source/ProjectFilter/Services/FilterService.cs index 9d88821..67de727 100644 --- a/source/ProjectFilter/Services/FilterService.cs +++ b/source/ProjectFilter/Services/FilterService.cs @@ -121,6 +121,15 @@ public async Task ApplyAsync(FilterOptions options) { // remain hidden because the user cancelled half way through. await solutionExplorer.HideUnloadedProjectsAsync(); + // Hiding unloaded projects will hide the solution folders that those hidden + // projects are in as long as there is nothing else in those folders. If all + // of the projects in a solution folder have been unloaded, but the folder contains + // other solution item (like a readme file or editorconfig file), the folder + // will not be hidden. We have an option that allows those folders to be hidden. + if (options.HideSolutionFoldersWithoutLoadedProjects) { + await solutionExplorer.HideSolutionFoldersWithoutLoadedProjectsAsync(); + } + // Expand the projects if we are supposed to. For some reason, Visual Studio seems to expand // the projects anyway, so if we are not supposed to expand them, then we will collapse them. if (options.ExpandLoadedProjects) { diff --git a/source/ProjectFilter/Services/IExtensionSettings.cs b/source/ProjectFilter/Services/IExtensionSettings.cs index 97c984d..b3706c0 100644 --- a/source/ProjectFilter/Services/IExtensionSettings.cs +++ b/source/ProjectFilter/Services/IExtensionSettings.cs @@ -17,6 +17,9 @@ public interface IExtensionSettings { bool ExpandLoadedProjects { get; set; } + bool HideSolutionFoldersWithoutLoadedProjects { get; set; } + + Task LoadAsync(); diff --git a/source/ProjectFilter/Services/ISolutionExplorer.cs b/source/ProjectFilter/Services/ISolutionExplorer.cs index f521ba0..aa6ce0c 100644 --- a/source/ProjectFilter/Services/ISolutionExplorer.cs +++ b/source/ProjectFilter/Services/ISolutionExplorer.cs @@ -16,6 +16,9 @@ public interface ISolutionExplorer { Task HideUnloadedProjectsAsync(); + Task HideSolutionFoldersWithoutLoadedProjectsAsync(); + + Task ExpandAsync(IEnumerable projects); diff --git a/source/ProjectFilter/Services/ISolutionExplorerNode.cs b/source/ProjectFilter/Services/ISolutionExplorerNode.cs new file mode 100644 index 0000000..9b25df3 --- /dev/null +++ b/source/ProjectFilter/Services/ISolutionExplorerNode.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + + +namespace ProjectFilter.Services; + + +public interface ISolutionExplorerNode { + + bool IsFolder { get; } + + + bool IsProject { get; } + + + bool IsLoaded { get; } + + + IReadOnlyList Children { get; } + +} diff --git a/source/ProjectFilter/Services/SolutionExplorer.cs b/source/ProjectFilter/Services/SolutionExplorer.cs index 2947d3c..af279f5 100644 --- a/source/ProjectFilter/Services/SolutionExplorer.cs +++ b/source/ProjectFilter/Services/SolutionExplorer.cs @@ -8,6 +8,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.Design; +using System.Diagnostics; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Task = System.Threading.Tasks.Task; @@ -21,6 +23,7 @@ public class SolutionExplorer : ISolutionExplorer { private static readonly CommandID HideUnloadedProjectsCommand = new(VSConstants.CMDSETID.StandardCommandSet15_guid, 1654); private static readonly CommandID ShowUnloadedProjectsCommand = new(VSConstants.CMDSETID.StandardCommandSet15_guid, 1653); private static readonly CommandID UnhideFoldersCommand = KnownCommands.Project_UnhideFolders; + private static readonly CommandID HideSolutionFolderCommand = new(VSConstants.CMDSETID.StandardCommandSet2K_guid, 1608); public async Task IsEmptyAsync() { @@ -79,8 +82,9 @@ public async Task HideUnloadedProjectsAsync() { selection = await solutionExplorer.GetSelectionAsync(); solutionExplorer.SetSelection(solution); - // The commands that we need to execute only work when Solution Explorer - // has the focus. Remember the current window, then focus Solution Explorer. + // The commands that we need to execute may only work when + // Solution Explorer has the focus. Play it safe and remember + // the current window, then focus Solution Explorer. window = await VS.Windows.GetCurrentWindowAsync(); solutionExplorer.Frame.Show(); @@ -118,6 +122,127 @@ public async Task HideUnloadedProjectsAsync() { } + public async Task HideSolutionFoldersWithoutLoadedProjectsAsync() { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + SolutionExplorerWindow? solutionExplorer; + + + solutionExplorer = await VS.Windows.GetSolutionExplorerWindowAsync(); + + if (solutionExplorer is not null) { + IVsSolution solution; + IHierarchyProvider provider; + IEnumerable nodes; + IEnumerable selection; + WindowFrame? window; + bool focused; + + + solution = await VS.Services.GetSolutionAsync(); + provider = await VS.GetRequiredServiceAsync(); + nodes = await provider.GetHierarchyAsync(); + + window = null; + focused = false; + + // We need to execute some commands that operate on the solution folders, + // which means we may need to change the selected item solution explorer. + // Remember the current selection so that we can restore it. + selection = await solutionExplorer.GetSelectionAsync(); + + // We only want to hide folders that contain projects where all of the projects are + // unloaded. We don't want to hide folders that only contain miscellaneous files like + // the "Solution Items" folder. The nodes we get from the `IHierarchyProvider` only + // include nodes that contain projects either within them directly or somewhere within + // a descendant folder. That means if we encounter a folder that doesn't have loaded + // projects within it, then we know it must contain only unloaded projects. + foreach (IHierarchyNode node in GetSolutionFoldersWithoutLoadedProjects(nodes)) { + if (ErrorHandler.Succeeded(solution.GetProjectOfGuid(node.Identifier, out IVsHierarchy hierarchy))) { + SolutionItem? folder; + + + folder = await SolutionItem.FromHierarchyAsync(hierarchy, VSConstants.VSITEMID_ROOT); + + if (folder is not null) { + folder.GetItemInfo(out IVsHierarchy vsHierarchy, out uint itemID, out var _); + + if (vsHierarchy is IVsUIHierarchy uiHierarchy) { + // The commands that we need to execute may only work when + // Solution Explorer has the focus. Play it safe and remember + // the current window, then focus Solution Explorer. + if (!focused) { + window = await VS.Windows.GetCurrentWindowAsync(); + solutionExplorer.Frame.Show(); + focused = true; + } + + solutionExplorer.SetSelection(folder); + ExecuteCommand(uiHierarchy, itemID, HideSolutionFolderCommand); + } + } + } + } + + // Restore the original selection. + solutionExplorer.SetSelection(selection); + + // Restore the previously-active window. + if (window is not null) { + await window.ShowAsync(); + } + } + } + + + public static List GetSolutionFoldersWithoutLoadedProjects(IEnumerable nodes) { + List folders; + + + folders = []; + + foreach (IHierarchyNode child in nodes) { + if (child.IsFolder) { + Visit(child, folders, out _); + } + } + + return folders; + + static void Visit( + IHierarchyNode folder, + List foldersWithoutLoadedProjects, + out bool hasLoadedProjectsWithin + ) { + bool hasLoadedProjectsDirectly; + bool hasLoadedProjectsInDescendants; + + + hasLoadedProjectsDirectly = false; + hasLoadedProjectsInDescendants = false; + + foreach (IHierarchyNode child in folder.Children) { + if (child.IsFolder) { + Visit(child, foldersWithoutLoadedProjects, out bool hasLoadedProjectsWithinFolder); + + if (hasLoadedProjectsWithinFolder) { + hasLoadedProjectsInDescendants = true; + } + + } else if (child.IsLoaded) { + hasLoadedProjectsDirectly = true; + } + } + + hasLoadedProjectsWithin = hasLoadedProjectsDirectly || hasLoadedProjectsInDescendants; + + if (!hasLoadedProjectsWithin) { + foldersWithoutLoadedProjects.Add(folder); + } + } + } + + private static void ExecuteCommand(IVsUIHierarchy hierarchy, uint itemID, CommandID command) { ThreadHelper.ThrowIfNotOnUIThread(); @@ -160,7 +285,7 @@ private static async Task ExpandOrCollapseAsync(IEnumerable projects, Acti solution = (IVsSolution4)await VS.Services.GetSolutionAsync(); - items = new List(); + items = []; foreach (var project in projects) { if (solution.TryGetHierarchy(project, out IVsHierarchy hierarchy)) { @@ -190,7 +315,7 @@ public async Task> GetExpandedFoldersAsync() { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - expanded = new HashSet(); + expanded = []; solutionExplorer = await VS.Windows.GetSolutionExplorerWindowAsync(); solution = await VS.Services.GetSolutionAsync() as IVsSolution4; @@ -238,4 +363,49 @@ out uint state return false; } + + [DebuggerDisplay("{SolutionItem.Name,nq}")] + private class Node : ISolutionExplorerNode { + + private List? _children; + + + public Node(SolutionItem solutionItem) { + SolutionItem = solutionItem; + } + + + public SolutionItem SolutionItem { get; } + + + public bool IsFolder { + get { + return SolutionItem.Type == SolutionItemType.SolutionFolder; + } + } + + + public bool IsProject { + get { + return SolutionItem.Type == SolutionItemType.Project; + } + } + + + public bool IsLoaded { + get { + return SolutionItem is Project project && project.IsLoaded; + } + } + + + public IReadOnlyList Children { + get { + // TODO: The children do not include unloaded projects. + return _children ??= [.. SolutionItem.Children.Where((x) => x is not null).Select(x => new Node(x!))]; + } + } + + } + } diff --git a/source/ProjectFilter/Services/SolutionSettings.cs b/source/ProjectFilter/Services/SolutionSettings.cs index b0c6104..be44f3c 100644 --- a/source/ProjectFilter/Services/SolutionSettings.cs +++ b/source/ProjectFilter/Services/SolutionSettings.cs @@ -10,7 +10,8 @@ public SolutionSettings() { LoadProjectDependencies = true; UseRegularExpressions = false; ExpandLoadedProjects = true; - Nodes = new Dictionary(); + HideSolutionFoldersWithoutLoadedProjects = null; + Nodes = []; } @@ -23,6 +24,9 @@ public SolutionSettings() { public bool ExpandLoadedProjects { get; set; } + public bool? HideSolutionFoldersWithoutLoadedProjects { get; set; } + + public Dictionary Nodes { get; } } diff --git a/source/ProjectFilter/UI/FilterDialog.xaml b/source/ProjectFilter/UI/FilterDialog.xaml index 0124f8b..23ef096 100644 --- a/source/ProjectFilter/UI/FilterDialog.xaml +++ b/source/ProjectFilter/UI/FilterDialog.xaml @@ -48,6 +48,8 @@ + + @@ -100,6 +102,17 @@ /> + + + + +