Add settings, cloud places, and optional archive-content indexing.

Keep official clients in charge of sync while Explorer can group locations, persist UI prefs, and list zip/rar/7z members without extracting them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 12:16:09 +02:00
parent e9aba73552
commit 09a8cfafa3
57 changed files with 3130 additions and 119 deletions

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Presentation.ViewModels;
@@ -18,6 +19,9 @@ public sealed partial class NavNodeViewModel : ObservableObject
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
}
public sealed class NavigationTreeViewModel
@@ -26,17 +30,20 @@ public sealed class NavigationTreeViewModel
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)
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{
_sources = sources;
_browse = browse;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
Roots = [];
}
@@ -55,47 +62,80 @@ public sealed class NavigationTreeViewModel
try
{
Roots.Clear();
var thisPc = new NavNodeViewModel { Label = "This PC", Path = "This PC", Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true };
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)
foreach (var source in sources.Where(s => !s.Kind.IsNetwork()))
{
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);
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
var network = sources.Where(s => s.Kind.IsNetwork()).ToList();
if (prefs.GroupNetworkPlaces && network.Count > 0)
{
var exists = Directory.Exists(place.Path);
var node = new NavNodeViewModel
var group = new NavNodeViewModel
{
Label = place.DisplayName,
Path = place.Path,
Glyph = "\uE753",
Status = exists ? "" : "Offline",
IsOffline = !exists
Label = LocationRoots.Network,
Path = LocationRoots.Network,
Glyph = "\uE968",
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
};
AddPlaceholder(node);
Roots.Add(node);
foreach (var source in network)
{
group.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
Roots.Add(group);
}
else
{
foreach (var source in network)
{
Roots.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
}
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)
@@ -123,7 +163,7 @@ public sealed class NavigationTreeViewModel
public async Task EnsureChildrenAsync(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.ChildrenLoaded || node.Path == "This PC")
if (node.IsPlaceholder || node.ChildrenLoaded || node.IsGroup || LocationRoots.IsVirtual(node.Path))
{
return;
}
@@ -155,13 +195,13 @@ public sealed class NavigationTreeViewModel
IsRevealing = true;
try
{
if (PathsEqual(path, "This PC"))
if (LocationRoots.IsVirtual(path))
{
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
if (thisPc is not null)
var virtualRoot = Roots.FirstOrDefault(r => r.Path == path);
if (virtualRoot is not null)
{
thisPc.IsExpanded = true;
SelectOnly(thisPc);
virtualRoot.IsExpanded = true;
SelectOnly(virtualRoot);
}
return;
}
@@ -224,6 +264,61 @@ public sealed class NavigationTreeViewModel
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 = 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.IsNetwork() ? "\uE968" : "\uEDA2",
SourceId = source.Id,
CanRemove = canRemove
};
AddPlaceholder(node);
return node;
}
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('\\');
@@ -232,7 +327,7 @@ public sealed class NavigationTreeViewModel
void Consider(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.Path == "This PC")
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
{
return;
}
@@ -251,11 +346,11 @@ public sealed class NavigationTreeViewModel
foreach (var root in roots)
{
if (root.Path == "This PC")
if (root.IsGroup || LocationRoots.IsVirtual(root.Path))
{
foreach (var drive in root.Children.Where(c => !c.IsPlaceholder))
foreach (var child in root.Children.Where(c => !c.IsPlaceholder))
{
Consider(drive);
Consider(child);
}
}
else