diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFolder.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFolder.cs index 81e40f0..c26d73d 100644 --- a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFolder.cs +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFolder.cs @@ -11,8 +11,8 @@ public class SharpIdeFolder : ISharpIdeNode, IExpandableSharpIdeNode, IChildShar public required string Path { get; set; } public string ChildNodeBasePath => Path; public required string Name { get; set; } - public ObservableHashSet Files { get; init; } - public ObservableHashSet Folders { get; init; } + public ObservableSortedSet Files { get; init; } + public ObservableSortedSet Folders { get; init; } public bool Expanded { get; set; } [SetsRequiredMembers] @@ -21,8 +21,8 @@ public class SharpIdeFolder : ISharpIdeNode, IExpandableSharpIdeNode, IChildShar Parent = parent; Path = folderInfo.FullName; Name = folderInfo.Name; - Files = new ObservableHashSet(folderInfo.GetFiles(this, allFiles)); - Folders = new ObservableHashSet(this.GetSubFolders(this, allFiles, allFolders)); + Files = new ObservableSortedSet(folderInfo.GetFiles(this, allFiles), SharpIdeFileComparer.Instance); + Folders = new ObservableSortedSet(this.GetSubFolders(this, allFiles, allFolders), SharpIdeFolderComparer.Instance); } public SharpIdeFolder() diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeModelComparers.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeModelComparers.cs new file mode 100644 index 0000000..9e3f476 --- /dev/null +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeModelComparers.cs @@ -0,0 +1,31 @@ +namespace SharpIDE.Application.Features.SolutionDiscovery; + +public class SharpIdeFileComparer : IComparer +{ + public static readonly SharpIdeFileComparer Instance = new SharpIdeFileComparer(); + public int Compare(SharpIdeFile? x, SharpIdeFile? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + + int result = string.Compare(x.Path, y.Path, StringComparison.OrdinalIgnoreCase); + + return result; + } +} + +public class SharpIdeFolderComparer : IComparer +{ + public static readonly SharpIdeFolderComparer Instance = new SharpIdeFolderComparer(); + public int Compare(SharpIdeFolder? x, SharpIdeFolder? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + + int result = string.Compare(x.Path, y.Path, StringComparison.OrdinalIgnoreCase); + + return result; + } +} diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs index 308bc1e..4e065f1 100644 --- a/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs @@ -17,8 +17,8 @@ public interface IExpandableSharpIdeNode public interface IFolderOrProject : IExpandableSharpIdeNode, IChildSharpIdeNode { - public ObservableHashSet Folders { get; init; } - public ObservableHashSet Files { get; init; } + public ObservableSortedSet Folders { get; init; } + public ObservableSortedSet Files { get; init; } public string Name { get; set; } public string ChildNodeBasePath { get; } } @@ -96,8 +96,8 @@ public class SharpIdeProjectModel : ISharpIdeNode, IExpandableSharpIdeNode, IChi public required string Name { get; set; } public required string FilePath { get; set; } public string ChildNodeBasePath => Path.GetDirectoryName(FilePath)!; - public required ObservableHashSet Folders { get; init; } - public required ObservableHashSet Files { get; init; } + public required ObservableSortedSet Folders { get; init; } + public required ObservableSortedSet Files { get; init; } public bool Expanded { get; set; } public required IExpandableSharpIdeNode Parent { get; set; } public bool Running { get; set; } @@ -110,8 +110,8 @@ public class SharpIdeProjectModel : ISharpIdeNode, IExpandableSharpIdeNode, IChi Parent = parent; Name = projectModel.Model.ActualDisplayName; FilePath = projectModel.FullFilePath; - Files = new ObservableHashSet(TreeMapperV2.GetFiles(projectModel.FullFilePath, this, allFiles)); - Folders = new ObservableHashSet(TreeMapperV2.GetSubFolders(projectModel.FullFilePath, this, allFiles, allFolders)); + Files = new ObservableSortedSet(TreeMapperV2.GetFiles(projectModel.FullFilePath, this, allFiles), SharpIdeFileComparer.Instance); + Folders = new ObservableSortedSet(TreeMapperV2.GetSubFolders(projectModel.FullFilePath, this, allFiles, allFolders), SharpIdeFolderComparer.Instance); MsBuildEvaluationProjectTask = ProjectEvaluation.GetProject(projectModel.FullFilePath); allProjects.Add(this); } diff --git a/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.Views.cs b/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.Views.cs new file mode 100644 index 0000000..ffcff89 --- /dev/null +++ b/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.Views.cs @@ -0,0 +1,227 @@ +using System.Collections; +using System.Collections.Specialized; + +namespace ObservableCollections +{ + public partial class ObservableSortedSet : IReadOnlyCollection, IObservableCollection + { + public ISynchronizedView CreateView(Func transform) + { + return new View(this, transform); + } + + sealed class View : ISynchronizedView + { + public ISynchronizedViewFilter Filter + { + get { lock (SyncRoot) return filter; } + } + + readonly ObservableSortedSet source; + readonly Func selector; + readonly Dictionary dict; + int filteredCount; + + ISynchronizedViewFilter filter; + + public event NotifyViewChangedEventHandler? ViewChanged; + public event Action? RejectedViewChanged; + public event Action? CollectionStateChanged; + + public object SyncRoot { get; } + + public View(ObservableSortedSet source, Func selector) + { + this.source = source; + this.selector = selector; + this.filter = SynchronizedViewFilter.Null; + this.SyncRoot = new object(); + lock (source.SyncRoot) + { + this.dict = source._set.ToDictionary(x => x, x => (x, selector(x))); + this.filteredCount = dict.Count; + this.source.CollectionChanged += SourceCollectionChanged; + } + } + + public int Count + { + get + { + lock (SyncRoot) + { + return filteredCount; + } + } + } + + public int UnfilteredCount + { + get + { + lock (SyncRoot) + { + return dict.Count; + } + } + } + + public void AttachFilter(ISynchronizedViewFilter filter) + { + if (filter.IsNullFilter()) + { + ResetFilter(); + return; + } + + lock (SyncRoot) + { + this.filter = filter; + this.filteredCount = 0; + foreach (var (_, (value, view)) in dict) + { + if (filter.IsMatch(value, view)) + { + filteredCount++; + } + } + ViewChanged?.Invoke(new SynchronizedViewChangedEventArgs(NotifyCollectionChangedAction.Reset, true)); + } + } + + public void ResetFilter() + { + lock (SyncRoot) + { + this.filter = SynchronizedViewFilter.Null; + this.filteredCount = dict.Count; + ViewChanged?.Invoke(new SynchronizedViewChangedEventArgs(NotifyCollectionChangedAction.Reset, true)); + } + } + + public ISynchronizedViewList ToViewList() + { + return new FiltableSynchronizedViewList(this, isSupportRangeFeature: true); + } + + public NotifyCollectionChangedSynchronizedViewList ToNotifyCollectionChanged() + { + return new FiltableSynchronizedViewList(this, isSupportRangeFeature: false); + } + + public NotifyCollectionChangedSynchronizedViewList ToNotifyCollectionChanged(ICollectionEventDispatcher? collectionEventDispatcher) + { + return new FiltableSynchronizedViewList(this, isSupportRangeFeature: false, collectionEventDispatcher); + } + + public IEnumerator GetEnumerator() + { + lock (SyncRoot) + { + foreach (var item in dict) + { + if (filter.IsMatch(item.Value)) + { + yield return item.Value.Item2; + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerable<(T Value, TView View)> Filtered + { + get + { + lock (SyncRoot) + { + foreach (var item in dict) + { + if (filter.IsMatch(item.Value)) + { + yield return item.Value; + } + } + } + } + } + + public IEnumerable<(T Value, TView View)> Unfiltered + { + get + { + lock (SyncRoot) + { + foreach (var item in dict) + { + yield return item.Value; + } + } + } + } + + public void Dispose() + { + this.source.CollectionChanged -= SourceCollectionChanged; + } + + private void SourceCollectionChanged(in NotifyCollectionChangedEventArgs e) + { + lock (SyncRoot) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + if (e.IsSingleItem) + { + var v = (e.NewItem, selector(e.NewItem)); + dict.Add(e.NewItem, v); + this.InvokeOnAdd(ref filteredCount, ViewChanged, RejectedViewChanged, v, -1); + } + else + { + var i = e.NewStartingIndex; + foreach (var item in e.NewItems) + { + var v = (item, selector(item)); + dict.Add(item, v); + this.InvokeOnAdd(ref filteredCount, ViewChanged, RejectedViewChanged, v, i++); + } + } + break; + case NotifyCollectionChangedAction.Remove: + if (e.IsSingleItem) + { + if (dict.Remove(e.OldItem, out var value)) + { + this.InvokeOnRemove(ref filteredCount, ViewChanged, RejectedViewChanged, value, -1); + } + } + else + { + foreach (var item in e.OldItems) + { + if (dict.Remove(item, out var value)) + { + this.InvokeOnRemove(ref filteredCount, ViewChanged, RejectedViewChanged, value, -1); + } + } + } + break; + case NotifyCollectionChangedAction.Reset: + dict.Clear(); + this.InvokeOnReset(ref filteredCount, ViewChanged); + break; + case NotifyCollectionChangedAction.Replace: + case NotifyCollectionChangedAction.Move: + default: + break; + } + + CollectionStateChanged?.Invoke(e.Action); + } + } + } + } +} diff --git a/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.cs b/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.cs new file mode 100644 index 0000000..6025dec --- /dev/null +++ b/src/SharpIDE.Application/ObservableCollectionExtensions/ObservableSortedSet.cs @@ -0,0 +1,318 @@ +using ObservableCollections.Internal; +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +// Courtesy of Copilot Agent +// Replace with https://github.com/Cysharp/ObservableCollections/pull/111 if it gets merged +namespace ObservableCollections +{ + // can not implements ISet because set operation can not get added/removed values. + public partial class ObservableSortedSet : IReadOnlySet, IReadOnlyCollection, IObservableCollection where T : notnull + { + private readonly SortedSet _set; + public object SyncRoot { get; } = new object(); + + public ObservableSortedSet() + { + _set = new SortedSet(); + } + + public ObservableSortedSet(IComparer? comparer) + { + _set = new SortedSet(comparer: comparer); + } + + public ObservableSortedSet(IEnumerable collection) + { + _set = new SortedSet(collection: collection); + } + + public ObservableSortedSet(IEnumerable collection, IComparer? comparer) + { + _set = new SortedSet(collection: collection, comparer: comparer); + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public int Count + { + get + { + lock (SyncRoot) + { + return _set.Count; + } + } + } + + public bool IsReadOnly => false; + + public bool Add(T item) + { + lock (SyncRoot) + { + if (_set.Add(item)) + { + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Add(item, -1)); + return true; + } + + return false; + } + } + + public void AddRange(IEnumerable items) + { + lock (SyncRoot) + { + if (!items.TryGetNonEnumeratedCount(out var capacity)) + { + capacity = 4; + } + + using (var list = new ResizableArray(capacity)) + { + foreach (var item in items) + { + if (_set.Add(item)) + { + list.Add(item); + } + } + + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Add(list.Span, -1)); + } + } + } + + public void AddRange(T[] items) + { + AddRange(items.AsSpan()); + } + + public void AddRange(ReadOnlySpan items) + { + lock (SyncRoot) + { + using (var list = new ResizableArray(items.Length)) + { + foreach (var item in items) + { + if (_set.Add(item)) + { + list.Add(item); + } + } + + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Add(list.Span, -1)); + } + } + } + + public bool Remove(T item) + { + lock (SyncRoot) + { + if (_set.Remove(item)) + { + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Remove(item, -1)); + return true; + } + + return false; + } + } + + public void RemoveRange(IEnumerable items) + { + lock (SyncRoot) + { + if (!items.TryGetNonEnumeratedCount(out var capacity)) + { + capacity = 4; + } + + using (var list = new ResizableArray(capacity)) + { + foreach (var item in items) + { + if (_set.Remove(item)) + { + list.Add(item); + } + } + + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Remove(list.Span, -1)); + } + } + } + + public void RemoveRange(T[] items) + { + RemoveRange(items.AsSpan()); + } + + public void RemoveRange(ReadOnlySpan items) + { + lock (SyncRoot) + { + using (var list = new ResizableArray(items.Length)) + { + foreach (var item in items) + { + if (_set.Remove(item)) + { + list.Add(item); + } + } + + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Remove(list.Span, -1)); + } + } + } + + public void Clear() + { + lock (SyncRoot) + { + _set.Clear(); + CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs.Reset()); + } + } + +#if !NETSTANDARD2_0 && !NET_STANDARD_2_0 && !NET_4_6 + + public bool TryGetValue(T equalValue, [MaybeNullWhen(false)] out T actualValue) + { + lock (SyncRoot) + { + return _set.TryGetValue(equalValue, out actualValue); + } + } + +#endif + + public bool Contains(T item) + { + lock (SyncRoot) + { + return _set.Contains(item); + } + } + + public bool IsProperSubsetOf(IEnumerable other) + { + lock (SyncRoot) + { + return _set.IsProperSubsetOf(other); + } + } + + public bool IsProperSupersetOf(IEnumerable other) + { + lock (SyncRoot) + { + return _set.IsProperSupersetOf(other); + } + } + + public bool IsSubsetOf(IEnumerable other) + { + lock (SyncRoot) + { + return _set.IsSubsetOf(other); + } + } + + public bool IsSupersetOf(IEnumerable other) + { + lock (SyncRoot) + { + return _set.IsSupersetOf(other); + } + } + + public bool Overlaps(IEnumerable other) + { + lock (SyncRoot) + { + return _set.Overlaps(other); + } + } + + public bool SetEquals(IEnumerable other) + { + lock (SyncRoot) + { + return _set.SetEquals(other); + } + } + + public IEnumerator GetEnumerator() + { + lock (SyncRoot) + { + foreach (var item in _set) + { + yield return item; + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public IComparer Comparer + { + get + { + lock (SyncRoot) + { + return _set.Comparer; + } + } + } + + // SortedSet-specific properties + public T? Min + { + get + { + lock (SyncRoot) + { + return _set.Count > 0 ? _set.Min : default; + } + } + } + + public T? Max + { + get + { + lock (SyncRoot) + { + return _set.Count > 0 ? _set.Max : default; + } + } + } + + // SortedSet-specific methods + public IEnumerable Reverse() + { + lock (SyncRoot) + { + return _set.Reverse().ToArray(); + } + } + + public IEnumerable GetViewBetween(T lowerValue, T upperValue) + { + lock (SyncRoot) + { + return _set.GetViewBetween(lowerValue, upperValue).ToArray(); + } + } + } +} diff --git a/src/SharpIDE.Application/SharpIDE.Application.csproj b/src/SharpIDE.Application/SharpIDE.Application.csproj index 089a0bb..4324197 100644 --- a/src/SharpIDE.Application/SharpIDE.Application.csproj +++ b/src/SharpIDE.Application/SharpIDE.Application.csproj @@ -8,6 +8,7 @@ +