633 lines
19 KiB
C#
633 lines
19 KiB
C#
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<NavNodeViewModel> 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<NavNodeViewModel> 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<string>();
|
|
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<string> directories)
|
|
{
|
|
var seen = new HashSet<string>(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<NavNodeViewModel>(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<NavNodeViewModel> 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<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? FindBySourceId(IEnumerable<NavNodeViewModel> 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<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);
|
|
}
|
|
}
|
|
}
|