Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
362
src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
Normal file
362
src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
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;
|
||||
|
||||
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
||||
public string Glyph { get; init; } = "\uE8B7";
|
||||
public bool IsPlaceholder { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NavigationTreeViewModel
|
||||
{
|
||||
private readonly SourceManager _sources;
|
||||
private readonly BrowseService _browse;
|
||||
private readonly StorageProviderRegistry _providers;
|
||||
private readonly CloudPlaceStore _cloudPlaces;
|
||||
|
||||
public NavigationTreeViewModel(
|
||||
SourceManager sources,
|
||||
BrowseService browse,
|
||||
StorageProviderRegistry providers,
|
||||
CloudPlaceStore cloudPlaces)
|
||||
{
|
||||
_sources = sources;
|
||||
_browse = browse;
|
||||
_providers = providers;
|
||||
_cloudPlaces = cloudPlaces;
|
||||
Roots = [];
|
||||
}
|
||||
|
||||
public ObservableCollection<NavNodeViewModel> Roots { get; }
|
||||
|
||||
public bool IsRevealing { get; private set; }
|
||||
|
||||
public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expanded = new List<string>();
|
||||
CollectExpanded(Roots, expanded);
|
||||
var selected = FindSelected(Roots)?.Path;
|
||||
var restore = revealPath ?? selected;
|
||||
|
||||
IsRevealing = true;
|
||||
try
|
||||
{
|
||||
Roots.Clear();
|
||||
var thisPc = new NavNodeViewModel { Label = "This PC", Path = "This PC", Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true };
|
||||
Roots.Add(thisPc);
|
||||
|
||||
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var node = new NavNodeViewModel
|
||||
{
|
||||
Label = source.DisplayName,
|
||||
Path = source.LastRootPath ?? source.DisplayName,
|
||||
Status = 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" : ""
|
||||
},
|
||||
IsOffline = source.Status == SourceStatus.Offline,
|
||||
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind == SourceKind.Smb ? "\uE968" : "\uEDA2"
|
||||
};
|
||||
AddPlaceholder(node);
|
||||
thisPc.Children.Add(node);
|
||||
}
|
||||
|
||||
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
|
||||
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
var exists = Directory.Exists(place.Path);
|
||||
var node = new NavNodeViewModel
|
||||
{
|
||||
Label = place.DisplayName,
|
||||
Path = place.Path,
|
||||
Glyph = "\uE753",
|
||||
Status = exists ? "" : "Offline",
|
||||
IsOffline = !exists
|
||||
};
|
||||
AddPlaceholder(node);
|
||||
Roots.Add(node);
|
||||
}
|
||||
|
||||
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.Path == "This PC")
|
||||
{
|
||||
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.Name,
|
||||
Path = dir.FullPath
|
||||
};
|
||||
AddPlaceholder(child);
|
||||
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 (PathsEqual(path, "This PC"))
|
||||
{
|
||||
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
|
||||
if (thisPc is not null)
|
||||
{
|
||||
thisPc.IsExpanded = true;
|
||||
SelectOnly(thisPc);
|
||||
}
|
||||
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 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? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
|
||||
{
|
||||
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
|
||||
NavNodeViewModel? best = null;
|
||||
var bestLength = -1;
|
||||
|
||||
void Consider(NavNodeViewModel node)
|
||||
{
|
||||
if (node.IsPlaceholder || node.Path == "This PC")
|
||||
{
|
||||
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.Path == "This PC")
|
||||
{
|
||||
foreach (var drive in root.Children.Where(c => !c.IsPlaceholder))
|
||||
{
|
||||
Consider(drive);
|
||||
}
|
||||
}
|
||||
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<NavNodeViewModel> nodes, List<string> 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<NavNodeViewModel> 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? FindByPath(IEnumerable<NavNodeViewModel> 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<NavNodeViewModel> nodes)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.IsSelected)
|
||||
{
|
||||
node.IsSelected = false;
|
||||
}
|
||||
|
||||
ClearSelection(node.Children);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user