diff --git a/source/ProjectFilter/Commands/FilterProjectsCommand.cs b/source/ProjectFilter/Commands/FilterProjectsCommand.cs index 4dd674e..ef4f08e 100644 --- a/source/ProjectFilter/Commands/FilterProjectsCommand.cs +++ b/source/ProjectFilter/Commands/FilterProjectsCommand.cs @@ -35,6 +35,9 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { IHierarchyProvider hierarchyProvider; TextFilterFactory textFilterFactory; Func>> hierarchyFactory; + IExtensionSettings globalSettings; + ISolutionSettingsManager solutionSettingsManager; + SolutionSettings? solutionSettings; await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); @@ -43,19 +46,16 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { textFilterFactory = await CreateTextFilterFactoryAsync(); hierarchyFactory = async () => await hierarchyProvider.GetHierarchyAsync(); - using (var vm = new FilterDialogViewModel(hierarchyFactory, Debouncer.Create, textFilterFactory, Package.JoinableTaskFactory)) { - IExtensionSettings globalSettings; - ISolutionSettingsManager solutionSettingsManager; - SolutionSettings? solutionSettings; - FilterDialog dialog; - bool result; + globalSettings = await VS.GetRequiredServiceAsync(); + await globalSettings.LoadAsync(); + solutionSettingsManager = await VS.GetRequiredServiceAsync(); + solutionSettings = await solutionSettingsManager.GetSettingsAsync(); - globalSettings = await VS.GetRequiredServiceAsync(); - await globalSettings.LoadAsync(); + using (var vm = new FilterDialogViewModel(hierarchyFactory, Debouncer.Create, textFilterFactory, solutionSettings?.Nodes, Package.JoinableTaskFactory)) { + FilterDialog dialog; + bool result; - solutionSettingsManager = await VS.GetRequiredServiceAsync(); - solutionSettings = await solutionSettingsManager.GetSettingsAsync(); // Initialize the settings with the settings that were last // used for this solution. If this solution hasn't been @@ -73,13 +73,14 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { // Save the settings for this solution, even if filtering // was cancelled. This saves the user from having to re-apply // the settings the next time they open the dialog. - solutionSettingsManager.SetSettings( - new SolutionSettings { - LoadProjectDependencies = vm.LoadProjectDependencies, - UseRegularExpressions = vm.UseRegularExpressions, - ExpandLoadedProjects = vm.ExpandLoadedProjects - } - ); + solutionSettings = new SolutionSettings { + LoadProjectDependencies = vm.LoadProjectDependencies, + UseRegularExpressions = vm.UseRegularExpressions, + ExpandLoadedProjects = vm.ExpandLoadedProjects + }; + PopulateNodeSettings(vm.Items, solutionSettings.Nodes); + + solutionSettingsManager.SetSettings(solutionSettings); // Also save the settings to the global settings so that the same settings // can be used for solutions that don't have their own settings yet. @@ -97,6 +98,19 @@ protected async override Task ExecuteAsync(OleMenuCmdEventArgs e) { } + private static void PopulateNodeSettings(HierarchyTreeViewItemCollection items, Dictionary settings) { + foreach (var item in items) { + SolutionNodeSettings nodeSettings; + + + nodeSettings = new SolutionNodeSettings { IsExpanded = item.IsExpanded }; + PopulateNodeSettings(item.Children, nodeSettings.Children); + + settings[item.Name] = nodeSettings; + } + } + + private static async Task CreateTextFilterFactoryAsync() { IPatternMatcherFactory patternMatcherFactory; diff --git a/source/ProjectFilter/Services/SolutionNodeSettings.cs b/source/ProjectFilter/Services/SolutionNodeSettings.cs new file mode 100644 index 0000000..67be3ee --- /dev/null +++ b/source/ProjectFilter/Services/SolutionNodeSettings.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + + +namespace ProjectFilter.Services; + + +public class SolutionNodeSettings { + + public SolutionNodeSettings() { + Children = new Dictionary(); + } + + + public bool IsExpanded { get; set; } + + + public Dictionary Children { get; } + +} diff --git a/source/ProjectFilter/Services/SolutionSettings.cs b/source/ProjectFilter/Services/SolutionSettings.cs index f3ee385..b0c6104 100644 --- a/source/ProjectFilter/Services/SolutionSettings.cs +++ b/source/ProjectFilter/Services/SolutionSettings.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; + + namespace ProjectFilter.Services; @@ -7,6 +10,7 @@ public SolutionSettings() { LoadProjectDependencies = true; UseRegularExpressions = false; ExpandLoadedProjects = true; + Nodes = new Dictionary(); } @@ -18,4 +22,7 @@ public SolutionSettings() { public bool ExpandLoadedProjects { get; set; } + + public Dictionary Nodes { get; } + } diff --git a/source/ProjectFilter/UI/FilterDialogViewModel.cs b/source/ProjectFilter/UI/FilterDialogViewModel.cs index 173d88b..bb37ecd 100644 --- a/source/ProjectFilter/UI/FilterDialogViewModel.cs +++ b/source/ProjectFilter/UI/FilterDialogViewModel.cs @@ -19,6 +19,7 @@ public sealed class FilterDialogViewModel : ObservableObject, IDisposable { private readonly Func>> _hierarchyFactory; private readonly TextFilterFactory _textFilterFactory; + private readonly Dictionary? _nodeSettings; private readonly IDebouncer _debouncer; private HierarchyTreeViewItemCollection _items; private string _searchText; @@ -35,6 +36,7 @@ public FilterDialogViewModel( Func>> hierarchyFactory, Func debouncerFactory, TextFilterFactory textFilterFactory, + Dictionary? nodeSettings, JoinableTaskFactory joinableTaskFactory ) { if (debouncerFactory is null) { @@ -43,6 +45,7 @@ JoinableTaskFactory joinableTaskFactory _hierarchyFactory = hierarchyFactory ?? throw new ArgumentNullException(nameof(hierarchyFactory)); _textFilterFactory = textFilterFactory ?? throw new ArgumentNullException(nameof(textFilterFactory)); + _nodeSettings = nodeSettings; _items = new HierarchyTreeViewItemCollection(Enumerable.Empty()); _searchText = ""; @@ -119,18 +122,24 @@ public async Task OnLoadedAsync() { hierarchy = await _hierarchyFactory.Invoke(); - Items = new HierarchyTreeViewItemCollection(hierarchy.Select(CreateItem)); + Items = new HierarchyTreeViewItemCollection( + hierarchy.Select((x) => CreateItem(x, GetSettingsForNode(x, _nodeSettings))) + ); LoadedVisibility = Visibility.Visible; LoadingVisibility = Visibility.Collapsed; } - private static HierarchyTreeViewItem CreateItem(IHierarchyNode node) { + private static HierarchyTreeViewItem CreateItem(IHierarchyNode node, SolutionNodeSettings? nodeSettings) { HierarchyTreeViewItem item; - item = new HierarchyTreeViewItem(node, node.Children.Select(CreateItem)); + item = new HierarchyTreeViewItem( + node, + nodeSettings?.IsExpanded ?? true, + node.Children.Select((x) => CreateItem(x, GetSettingsForNode(x, nodeSettings?.Children))) + ); // Set the checked state of the item. If it has children, then we check // it based on whether all or none of the children are checked. If it @@ -148,6 +157,16 @@ private static HierarchyTreeViewItem CreateItem(IHierarchyNode node) { } + private static SolutionNodeSettings? GetSettingsForNode(IHierarchyNode node, Dictionary? nodeSettings) { + if (nodeSettings is not null) { + nodeSettings.TryGetValue(node.Name, out SolutionNodeSettings settings); + return settings; + } + + return null; + } + + public Visibility LoadingVisibility { get { return _loadingVisibility; } private set { SetProperty(ref _loadingVisibility, value); } diff --git a/source/ProjectFilter/UI/HierarchyTreeViewItem.cs b/source/ProjectFilter/UI/HierarchyTreeViewItem.cs index fe02dad..5946528 100644 --- a/source/ProjectFilter/UI/HierarchyTreeViewItem.cs +++ b/source/ProjectFilter/UI/HierarchyTreeViewItem.cs @@ -22,13 +22,14 @@ public class HierarchyTreeViewItem : ObservableObject { public HierarchyTreeViewItem( IHierarchyNode node, + bool isExpanded, IEnumerable children ) { _node = node; Children = new HierarchyTreeViewItemCollection(children); _isChecked = false; - _isExpanded = true; + _isExpanded = isExpanded; // Connect the children to this parent item. foreach (var child in Children) { diff --git a/tests/ProjectFilter.UnitTests/Helpers/Factory.cs b/tests/ProjectFilter.UnitTests/Helpers/Factory.cs index fc480c4..d080bc2 100644 --- a/tests/ProjectFilter.UnitTests/Helpers/Factory.cs +++ b/tests/ProjectFilter.UnitTests/Helpers/Factory.cs @@ -58,7 +58,7 @@ public static HierarchyTreeViewItem CreateTreeViewItem(string name = "", IEnumer node = Substitute.For(); node.Name.Returns(name); - return new HierarchyTreeViewItem(node, children ?? Enumerable.Empty()) { + return new HierarchyTreeViewItem(node, true, children ?? Enumerable.Empty()) { IsChecked = isChecked }; } diff --git a/tests/ProjectFilter.UnitTests/UI/FilterDialogViewModelTests.cs b/tests/ProjectFilter.UnitTests/UI/FilterDialogViewModelTests.cs index a14fc96..75183b9 100644 --- a/tests/ProjectFilter.UnitTests/UI/FilterDialogViewModelTests.cs +++ b/tests/ProjectFilter.UnitTests/UI/FilterDialogViewModelTests.cs @@ -108,8 +108,8 @@ public async Task IsEmptyUntilHierarchyIsRetrieved() { hierarchy.SetResult( new[] { - CreateNode("a", isLoaded: true), - CreateNode("b", isLoaded: false) + CreateNode("a", isLoaded: true), + CreateNode("b", isLoaded: false) } ); @@ -117,8 +117,8 @@ public async Task IsEmptyUntilHierarchyIsRetrieved() { Assert.Equal( new[] { - ("a", (bool?)true), - ("b", (bool?)false) + ("a", (bool?)true), + ("b", (bool?)false) }, vm.Items.Select((x) => (x.Name, x.IsChecked)) ); @@ -132,27 +132,133 @@ public async Task InitiallyChecksItemsBasedOnWhetherTheyAreLoadedOrTheirChildren hierarchy = new[] { - CreateNode("a", isLoaded: true), - CreateNode("b", isLoaded: false), - CreateNode("c", children: new[] { CreateNode("d", isLoaded: true) }), - CreateNode("e", children: new[] { CreateNode("f", isLoaded: true), CreateNode("g", isLoaded: false) }), - }; + CreateNode("a", isLoaded: true), + CreateNode("b", isLoaded: false), + CreateNode("c", children: new[] { CreateNode("d", isLoaded: true) }), + CreateNode("e", children: new[] { CreateNode("f", isLoaded: true), CreateNode("g", isLoaded: false) }), + }; using (var vm = CreateViewModel(hierarchy)) { await vm.OnLoadedAsync(); Assert.Equal( new[] { - ("a",true), - ("b",false), - ("c",true), - ("e",(bool?)null) + ("a",true), + ("b",false), + ("c",true), + ("e",(bool?)null) }, vm.Items.Select((x) => (x.Name, x.IsChecked)) ); } } + + [Fact] + public async Task InitiallyExpandsAllItemsWhenThereAreNoNodeSettings() { + IEnumerable hierarchy; + + + hierarchy = new[] { + CreateNode("a"), + CreateNode("b"), + CreateNode("c", children: new[] { CreateNode("d") }), + CreateNode("e", children: new[] { CreateNode("f", children: new[] { CreateNode("g") }) }), + }; + + using (var vm = CreateViewModel(hierarchy, nodeSettings: null)) { + await vm.OnLoadedAsync(); + + Assert.Equal( + new[] { + ("a", true), + ("b", true), + ("c", true), + ("d", true), + ("e", true), + ("f", true), + ("g", true) + }, + Flatten(vm.Items).Select((x) => (x.Name, x.IsExpanded)) + ); + } + } + + + + [Fact] + public async Task InitiallyExpandsItemsBasedOnTheGivenNodeSettings() { + IEnumerable hierarchy; + Dictionary nodeSettings; + + + hierarchy = new[] { + CreateNode("a"), + CreateNode("b"), + CreateNode("c", children: new[] { CreateNode("d") }), + CreateNode("e", children: new[] { CreateNode("f", children: new[] { CreateNode("g") }) }), + }; + + nodeSettings = new Dictionary(); + AddNodeSetting(nodeSettings, "a", true); + AddNodeSetting(nodeSettings, "b", false); + AddNodeSetting(nodeSettings, "c", true); + AddNodeSetting(nodeSettings, "c/d", false); + AddNodeSetting(nodeSettings, "e", true); + AddNodeSetting(nodeSettings, "e/f", false); + // Omit "g" to prove that the default is to be expanded. + + using (var vm = CreateViewModel(hierarchy, nodeSettings: nodeSettings)) { + await vm.OnLoadedAsync(); + + Assert.Equal( + new[] { + ("a", true), + ("b", false), + ("c", true), + ("d", false), + ("e", true), + ("f", false), + ("g", true) + }, + Flatten(vm.Items).Select((x) => (x.Name, x.IsExpanded)) + ); + } + + static void AddNodeSetting(Dictionary nodeSettings, string path, bool isExpanded) { + Dictionary collection; + SolutionNodeSettings? node; + + + collection = nodeSettings; + node = null; + + foreach (var segment in path.Split('/')) { + if (!collection.TryGetValue(segment, out node)) { + node = new SolutionNodeSettings(); + collection[segment] = node; + } + + collection = node.Children; + } + + if (node is not null) { + node.IsExpanded = isExpanded; + } + } + } + + + private static IEnumerable Flatten(IEnumerable items) { + foreach (var item in items) { + yield return item; + + foreach (var descendant in Flatten(item.Children)) { + yield return descendant; + } + } + } + } @@ -781,18 +887,19 @@ protected TestBase() { } - protected FilterDialogViewModel CreateViewModel(IEnumerable hierarchy, IDebouncer? debouncer = null, TextFilterFactory? textFilterFactory = null) { - return CreateViewModel(() => Task.FromResult(hierarchy), debouncer, textFilterFactory); + protected FilterDialogViewModel CreateViewModel(IEnumerable hierarchy, IDebouncer? debouncer = null, TextFilterFactory? textFilterFactory = null, Dictionary? nodeSettings = null) { + return CreateViewModel(() => Task.FromResult(hierarchy), debouncer, textFilterFactory, nodeSettings); } - protected FilterDialogViewModel CreateViewModel(Func>> hierarchyFactory, IDebouncer? debouncer = null, TextFilterFactory? textFilterFactory = null) { + protected FilterDialogViewModel CreateViewModel(Func>> hierarchyFactory, IDebouncer? debouncer = null, TextFilterFactory? textFilterFactory = null, Dictionary? nodeSettings = null) { debouncer ??= Substitute.For(); return new FilterDialogViewModel( hierarchyFactory, (x) => debouncer, textFilterFactory ?? Factory.CreateTextFilter, + nodeSettings, _joinableTaskFactory ); } diff --git a/tests/ProjectFilter.UnitTests/UI/HierarchyTreeViewItemTests.cs b/tests/ProjectFilter.UnitTests/UI/HierarchyTreeViewItemTests.cs index 4e04c11..34b9dfb 100644 --- a/tests/ProjectFilter.UnitTests/UI/HierarchyTreeViewItemTests.cs +++ b/tests/ProjectFilter.UnitTests/UI/HierarchyTreeViewItemTests.cs @@ -159,9 +159,17 @@ public void UpdatesAllAncestorsWhenChanged() { public class IsExpandedProperty { - [Fact] - public void IsInitiallyTrue() { - Assert.True(new HierarchyTreeViewItem(Substitute.For(), Enumerable.Empty()).IsExpanded); + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsInitiallySetToTheGivenValue(bool isExpanded) { + Assert.Equal(isExpanded, + new HierarchyTreeViewItem( + Substitute.For(), + isExpanded, + Enumerable.Empty() + ).IsExpanded + ); } @@ -172,6 +180,7 @@ public void RaisesPropertyChangedEventForIcon() { item = new HierarchyTreeViewItem( Substitute.For(), + true, Enumerable.Empty() ); @@ -203,9 +212,7 @@ public void ReturnsIconBasedOnExpandedState() { node.CollapsedIcon.Returns(KnownMonikers.FolderClosed); node.ExpandedIcon.Returns(KnownMonikers.FolderOpened); - item = new HierarchyTreeViewItem(node, Enumerable.Empty()) { - IsExpanded = true - }; + item = new HierarchyTreeViewItem(node, true, Enumerable.Empty()); Assert.Equal(KnownMonikers.FolderOpened, item.Icon); @@ -227,7 +234,7 @@ public void ReturnsNameForItemWithoutParent() { node = Substitute.For(); node.Name.Returns("Root"); - item = new HierarchyTreeViewItem(node, Enumerable.Empty()); + item = new HierarchyTreeViewItem(node, true, Enumerable.Empty()); Assert.Equal("Root", item.Path); } @@ -246,8 +253,8 @@ public void JoinsNameToParentPathForItemWithParent() { parentNode = Substitute.For(); parentNode.Name.Returns("Root"); - childItem = new HierarchyTreeViewItem(childNode, Enumerable.Empty()); - _ = new HierarchyTreeViewItem(parentNode, new[] { childItem }); + childItem = new HierarchyTreeViewItem(childNode, true, Enumerable.Empty()); + _ = new HierarchyTreeViewItem(parentNode, true, new[] { childItem }); Assert.Equal("Root/Child", childItem.Path); }