using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using Explorer.Application; using Explorer.Domain; using Explorer.Plugin.Abstractions; namespace Explorer.Presentation.ViewModels; public sealed partial class NavNodeViewModel : ObservableObject { [ObservableProperty] private bool _isExpanded; [ObservableProperty] private bool _isSelected; [ObservableProperty] private string _label = ""; [ObservableProperty] private string _path = ""; [ObservableProperty] private string _status = ""; [ObservableProperty] private bool _isOffline; [ObservableProperty] private bool _childrenLoaded; [ObservableProperty] private bool _isDropTarget; public ObservableCollection Children { get; } = []; public string Glyph { get; init; } = "\uE8B7"; public bool IsPlaceholder { get; init; } public bool IsGroup { get; init; } public bool AvailableToImport { get; init; } public long? SourceId { get; init; } public bool CanRemove { get; init; } } public sealed class NavigationTreeViewModel { private readonly SourceManager _sources; private readonly BrowseService _browse; private readonly StorageProviderRegistry _providers; private readonly CloudPlaceStore _cloudPlaces; private readonly UiPreferencesStore _preferences; public NavigationTreeViewModel( SourceManager sources, BrowseService browse, StorageProviderRegistry providers, CloudPlaceStore cloudPlaces, UiPreferencesStore preferences) { _sources = sources; _browse = browse; _providers = providers; _cloudPlaces = cloudPlaces; _preferences = preferences; Roots = []; } public ObservableCollection Roots { get; } public bool IsRevealing { get; private set; } public void PreparePlaceholder() { if (Roots.Count > 0) { return; } Roots.Add(new NavNodeViewModel { Label = LocationRoots.ThisPc, Path = LocationRoots.ThisPc, Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true, IsGroup = true }); } public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default) { var expanded = new List(); CollectExpanded(Roots, expanded); var selected = FindSelected(Roots)?.Path; var restore = revealPath ?? selected; IsRevealing = true; try { Roots.Clear(); var prefs = _preferences.Load(); var thisPc = new NavNodeViewModel { Label = LocationRoots.ThisPc, Path = LocationRoots.ThisPc, Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true, IsGroup = true }; Roots.Add(thisPc); var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true); foreach (var source in sources.Where(s => !s.Kind.IsNetwork()) .OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath)) .ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)) { thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source))); } thisPc.Children.Add(new NavNodeViewModel { Label = LocationRoots.RecycleBin, Path = LocationRoots.RecycleBin, Glyph = "\uE74D", ChildrenLoaded = true }); var untracked = (await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(true)) .Where(fp => fp.Kind.IsNetwork()) .ToList(); var network = sources.Where(s => s.Kind.IsNetwork()) .OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath)) .ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase) .ToList(); if (prefs.GroupNetworkPlaces && (network.Count > 0 || untracked.Count > 0)) { var group = new NavNodeViewModel { Label = LocationRoots.Network, Path = LocationRoots.Network, Glyph = "\uE968", IsExpanded = true, ChildrenLoaded = true, IsGroup = true }; foreach (var source in network) { group.Children.Add(CreateSourceNode(source, _sources.CanForget(source))); } foreach (var fp in untracked) { group.Children.Add(CreateImportNode(fp)); } Roots.Add(group); } else { foreach (var source in network) { Roots.Add(CreateSourceNode(source, _sources.CanForget(source))); } foreach (var fp in untracked) { Roots.Add(CreateImportNode(fp)); } } var places = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load()) .OrderBy(p => ProviderOrder(p.ProviderId)) .ThenBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase) .Select(CreateCloudNode) .ToList(); if (prefs.GroupCloudPlaces && places.Count > 0) { var group = new NavNodeViewModel { Label = LocationRoots.Cloud, Path = LocationRoots.Cloud, Glyph = "\uE753", IsExpanded = true, ChildrenLoaded = true, IsGroup = true }; foreach (var place in places) { group.Children.Add(place); } Roots.Add(group); } else { foreach (var place in places) { Roots.Add(place); } } foreach (var path in expanded) { var node = FindByPath(Roots, path); if (node is null) { continue; } await EnsureChildrenAsync(node).ConfigureAwait(true); node.IsExpanded = true; } if (!string.IsNullOrWhiteSpace(restore)) { await RevealPathAsync(restore).ConfigureAwait(true); } } finally { IsRevealing = false; } } public async Task EnsureChildrenAsync(NavNodeViewModel node) { if (node.IsPlaceholder || node.ChildrenLoaded || node.IsGroup || LocationRoots.IsVirtual(node.Path)) { return; } var listing = await _browse.ListAsync(node.Path).ConfigureAwait(true); node.Children.Clear(); foreach (var dir in listing.Items.Where(i => i.IsDirectory).OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).Take(200)) { var child = new NavNodeViewModel { Label = dir.DisplayName ?? dir.Name, Path = dir.FullPath }; AddPlaceholder(child); node.Children.Add(child); } node.ChildrenLoaded = true; } public async Task RefreshAfterChangesAsync(IEnumerable directories) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var raw in directories) { if (string.IsNullOrWhiteSpace(raw) || LocationRoots.IsVirtual(raw)) { continue; } var dir = PathRules.EnsureDirectoryTrailingSlashIfRoot(PathRules.FromExtended(raw).TrimEnd('\\')); if (!seen.Add(dir)) { continue; } var node = FindByPath(Roots, dir); if (node is null || !node.ChildrenLoaded) { continue; } await SyncChildrenAsync(node).ConfigureAwait(true); } } private async Task SyncChildrenAsync(NavNodeViewModel node) { if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)) { return; } var listing = await _browse.ListAsync(node.Path).ConfigureAwait(true); var dirs = listing.Items .Where(i => i.IsDirectory) .OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) .Take(200) .ToList(); var previous = node.Children.Where(c => !c.IsPlaceholder).ToList(); var next = new List(dirs.Count); foreach (var dir in dirs) { var match = previous.FirstOrDefault(c => PathsEqual(c.Path, dir.FullPath)); if (match is null) { match = new NavNodeViewModel { Label = dir.DisplayName ?? dir.Name, Path = dir.FullPath }; AddPlaceholder(match); } else { match.Label = dir.DisplayName ?? dir.Name; match.Path = dir.FullPath; } next.Add(match); } node.Children.Clear(); foreach (var child in next) { node.Children.Add(child); } node.ChildrenLoaded = true; } public async Task RevealPathAsync(string path) { if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0) { return; } var nested = IsRevealing; IsRevealing = true; try { if (LocationRoots.IsVirtual(path)) { var virtualRoot = Roots.FirstOrDefault(r => r.Path == path); if (virtualRoot is not null) { virtualRoot.IsExpanded = true; SelectOnly(virtualRoot); } return; } var current = FindBestRoot(Roots, path); if (current is null) { return; } current.IsExpanded = true; await EnsureChildrenAsync(current).ConfigureAwait(true); var remaining = PathRules.MakeRelative(current.Path, path); if (!string.IsNullOrEmpty(remaining)) { foreach (var segment in remaining.Split('\\', StringSplitOptions.RemoveEmptyEntries)) { var next = current.Children.FirstOrDefault(c => !c.IsPlaceholder && c.Label.Equals(segment, StringComparison.OrdinalIgnoreCase)); if (next is null) { break; } current = next; current.IsExpanded = true; await EnsureChildrenAsync(current).ConfigureAwait(true); } } SelectOnly(current); } finally { if (!nested) { IsRevealing = false; } } } public async Task ApplySourceStateAsync(long sourceId, CancellationToken cancellationToken = default) { var source = await _sources.GetAsync(sourceId, cancellationToken).ConfigureAwait(true); if (source is null) { return; } var node = FindBySourceId(Roots, sourceId) ?? (source.LastRootPath is null ? null : FindByPath(Roots, source.LastRootPath)); if (node is null) { return; } node.Status = FormatStatus(source); node.IsOffline = source.Status == SourceStatus.Offline; } public static string FormatStatus(Source source) => source.Status switch { SourceStatus.Offline => source.LastSeenUtc is null ? "Offline" : $"Offline ยท Last seen {source.LastSeenUtc.Value.ToLocalTime():d}", SourceStatus.Scanning => "Indexing", SourceStatus.Stale => "May be out of date", SourceStatus.Error => "Error", _ => source.IsIndexed ? "Indexed" : "" }; public static bool PathsEqual(string a, string b) { if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) { return true; } var na = PathRules.FromExtended(a).TrimEnd('\\'); var nb = PathRules.FromExtended(b).TrimEnd('\\'); if (na.Equals(nb, StringComparison.OrdinalIgnoreCase)) { return true; } return string.Equals( PathRules.EnsureDirectoryTrailingSlashIfRoot(na), PathRules.EnsureDirectoryTrailingSlashIfRoot(nb), StringComparison.OrdinalIgnoreCase); } private static NavNodeViewModel CreateCloudNode(ProviderPlace place) { var exists = Directory.Exists(place.Path); var node = new NavNodeViewModel { Label = place.DisplayName, Path = place.Path, Glyph = place.Glyph ?? CloudGlyph(place.ProviderId), Status = exists ? "" : "Offline", IsOffline = !exists }; AddPlaceholder(node); return node; } private static NavNodeViewModel CreateSourceNode(Source source, bool canRemove) { var node = new NavNodeViewModel { Label = source.DisplayName, Path = source.LastRootPath ?? source.DisplayName, Status = FormatStatus(source), IsOffline = source.Status == SourceStatus.Offline, Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2", SourceId = source.Id, CanRemove = canRemove }; AddPlaceholder(node); return node; } private static NavNodeViewModel CreateImportNode(VolumeFingerprint fingerprint) => new() { Label = (fingerprint.DisplayName ?? fingerprint.RootPath) + " (Windows)", Path = fingerprint.RootPath, Status = "Available in Windows", Glyph = "\uE968", ChildrenLoaded = true, AvailableToImport = true }; private static int ProviderOrder(string providerId) => providerId switch { "onedrive" => 0, "googledrive" => 1, "nextcloud" => 2, _ => 9 }; private static string CloudGlyph(string providerId) => providerId switch { "googledrive" => "\uE753", "nextcloud" => "\uE753", _ => "\uE753" }; private static NavNodeViewModel? FindBestRoot(IEnumerable roots, string path) { var normalized = PathRules.FromExtended(path).TrimEnd('\\'); NavNodeViewModel? best = null; var bestLength = -1; void Consider(NavNodeViewModel node) { if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)) { return; } var root = PathRules.FromExtended(node.Path).TrimEnd('\\'); if (normalized.Equals(root, StringComparison.OrdinalIgnoreCase) || normalized.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase)) { if (root.Length > bestLength) { best = node; bestLength = root.Length; } } } foreach (var root in roots) { if (root.IsGroup || LocationRoots.IsVirtual(root.Path)) { foreach (var child in root.Children.Where(c => !c.IsPlaceholder)) { Consider(child); } } else { Consider(root); } } return best; } private static void AddPlaceholder(NavNodeViewModel node) { if (node.Children.Count == 0) { node.Children.Add(new NavNodeViewModel { IsPlaceholder = true, ChildrenLoaded = true }); } } private static void CollectExpanded(IEnumerable nodes, List into) { foreach (var node in nodes) { if (node.IsPlaceholder) { continue; } if (node.IsExpanded) { into.Add(node.Path); } CollectExpanded(node.Children, into); } } private static NavNodeViewModel? FindSelected(IEnumerable nodes) { foreach (var node in nodes) { if (node.IsPlaceholder) { continue; } if (node.IsSelected) { return node; } var child = FindSelected(node.Children); if (child is not null) { return child; } } return null; } private static NavNodeViewModel? FindBySourceId(IEnumerable nodes, long sourceId) { foreach (var node in nodes) { if (node.IsPlaceholder) { continue; } if (node.SourceId == sourceId) { return node; } var child = FindBySourceId(node.Children, sourceId); if (child is not null) { return child; } } return null; } private static NavNodeViewModel? FindByPath(IEnumerable nodes, string path) { foreach (var node in nodes) { if (node.IsPlaceholder) { continue; } if (PathsEqual(node.Path, path)) { return node; } var child = FindByPath(node.Children, path); if (child is not null) { return child; } } return null; } private void SelectOnly(NavNodeViewModel target) { ClearSelection(Roots); target.IsSelected = true; } private static void ClearSelection(IEnumerable nodes) { foreach (var node in nodes) { if (node.IsSelected) { node.IsSelected = false; } ClearSelection(node.Children); } } }