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:
2026-08-22 12:43:05 +02:00
commit e9aba73552
130 changed files with 15110 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Presentation</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
namespace Explorer.Presentation;
public static class Formatters
{
public static string Size(long bytes)
{
if (bytes < 0)
{
return string.Empty;
}
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double v = bytes;
var u = 0;
while (v >= 1024 && u < units.Length - 1)
{
v /= 1024;
u++;
}
return u == 0 ? $"{bytes} B" : $"{v:0.##} {units[u]}";
}
public static string Date(DateTimeOffset? value)
=> value is null ? "" : value.Value.ToLocalTime().ToString("g");
}

View File

@@ -0,0 +1,50 @@
namespace Explorer.Presentation;
public sealed class NavigationHistory
{
private readonly List<string> _items = [];
private int _index = -1;
public bool CanGoBack => _index > 0;
public bool CanGoForward => _index >= 0 && _index < _items.Count - 1;
public string? Current => _index >= 0 && _index < _items.Count ? _items[_index] : null;
public void Navigate(string path)
{
if (_index >= 0 && _index < _items.Count
&& string.Equals(_items[_index], path, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (_index < _items.Count - 1 && _index >= 0)
{
_items.RemoveRange(_index + 1, _items.Count - _index - 1);
}
_items.Add(path);
_index = _items.Count - 1;
}
public string? Back()
{
if (!CanGoBack)
{
return null;
}
_index--;
return _items[_index];
}
public string? Forward()
{
if (!CanGoForward)
{
return null;
}
_index++;
return _items[_index];
}
}

View File

@@ -0,0 +1,660 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed record StorageScopeItem(Source? Source, string Label)
{
public override string ToString() => Label;
}
public sealed partial class StorageNodeViewModel : ObservableObject
{
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _childrenLoaded;
[ObservableProperty] private double _fraction;
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string PathRel { get; init; }
public required long SourceId { get; init; }
public long? EntryId { get; init; }
public long Size { get; init; }
public int FileCount { get; init; }
public int DirCount { get; init; }
public int Depth { get; init; }
public bool IsDirectory { get; init; } = true;
public bool IsSource { get; init; }
public string? State { get; init; }
public bool HasState => !string.IsNullOrEmpty(State);
public string SizeLabel => Formatters.Size(Size);
public string CountLabel => FileCount == 0 && DirCount == 0
? ""
: $"{FileCount:N0} files · {DirCount:N0} folders";
public bool CanExpand => IsDirectory && EntryId is not null;
public double Indent => Depth * 16;
public string Glyph => IsSource ? "\uEDA2" : "\uE8B7";
public ObservableCollection<StorageNodeViewModel> Children { get; } = [];
}
public sealed partial class AnalysisRowViewModel : ObservableObject
{
public required string Name { get; init; }
public required long Size { get; init; }
public required double Fraction { get; init; }
public string? Path { get; init; }
public string? PathRel { get; init; }
public string? ShortPath { get; init; }
public long? EntryId { get; init; }
public long? SourceId { get; init; }
public bool IsDirectory { get; init; }
public int FileCount { get; init; }
public int DirCount { get; init; }
public string? State { get; init; }
public bool HasState => !string.IsNullOrEmpty(State);
public string SizeLabel => Formatters.Size(Size);
public string CountLabel => FileCount == 0 && DirCount == 0
? ""
: FileCount > 0 && DirCount == 0
? $"{FileCount:N0} files"
: $"{FileCount:N0} files · {DirCount:N0} folders";
}
public sealed partial class AnalysisViewModel : ObservableObject
{
public const string PageTree = "Tree";
public const string PageFolders = "Biggest folders";
public const string PageFiles = "Biggest files";
public const string PageTypes = "By file type";
public const string PageSources = "By source";
private readonly AnalysisService _analysis;
private bool _suppressReload;
private CancellationTokenSource? _loadCts;
private int _loadVersion;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private string _page = PageTree;
[ObservableProperty] private string _status = "";
[ObservableProperty] private StorageScopeItem? _selectedScope;
[ObservableProperty] private StorageNodeViewModel? _selectedNode;
[ObservableProperty] private AnalysisRowViewModel? _selectedRow;
[ObservableProperty] private bool _isBusy;
public AnalysisViewModel(AnalysisService analysis)
{
_analysis = analysis;
Scopes = [];
TreeRoots = [];
VisibleNodes = [];
Rows = [];
}
public string[] Pages { get; } = [PageTree, PageFolders, PageFiles, PageTypes, PageSources];
public ObservableCollection<StorageScopeItem> Scopes { get; }
public ObservableCollection<StorageNodeViewModel> TreeRoots { get; }
public ObservableCollection<StorageNodeViewModel> VisibleNodes { get; }
public ObservableCollection<AnalysisRowViewModel> Rows { get; }
public bool IsTree => Page == PageTree;
public bool IsRanking => !IsTree;
public bool CanAct
{
get
{
var path = SelectedPath;
return !string.IsNullOrWhiteSpace(path) && path != "This PC";
}
}
public string? SelectedPath => IsTree
? SelectedNode?.FullPath
: SelectedRow?.Path;
public long? SelectedSourceId => IsTree
? SelectedNode?.SourceId
: SelectedRow?.SourceId;
public string? SelectedPathRel => IsTree ? SelectedNode?.PathRel : SelectedRow?.PathRel;
public bool SelectedIsDirectory => IsTree
? SelectedNode?.IsDirectory != false
: SelectedRow?.IsDirectory != false;
public bool SelectedIsSource => IsTree && SelectedNode?.IsSource == true
|| (!IsTree && SelectedRow is { IsDirectory: true, PathRel: "" or null } && Page == PageSources);
public string? SelectedNavigatePath
{
get
{
var path = SelectedPath;
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
return null;
}
return SelectedIsDirectory ? path : PathRules.Parent(path);
}
}
public string SelectedScanPathRel
{
get
{
var rel = SelectedPathRel;
if (string.IsNullOrEmpty(rel))
{
return "";
}
if (SelectedIsDirectory)
{
return rel;
}
return rel.Contains('\\') ? PathRules.Parent(rel) : "";
}
}
[RelayCommand]
public async Task OpenAsync()
{
IsOpen = true;
await RefreshScopesAsync().ConfigureAwait(true);
await ReloadAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Close()
{
_loadCts?.Cancel();
IsOpen = false;
IsBusy = false;
}
[RelayCommand]
public async Task ReloadAsync()
{
if (_suppressReload)
{
return;
}
_loadCts?.Cancel();
_loadCts?.Dispose();
var cts = new CancellationTokenSource();
_loadCts = cts;
var version = Interlocked.Increment(ref _loadVersion);
var ct = cts.Token;
IsBusy = true;
Status = "Reading index…";
try
{
if (Page == PageTree)
{
await LoadTreeAsync(ct).ConfigureAwait(true);
}
else
{
await LoadRankingAsync(ct).ConfigureAwait(true);
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception)
{
if (version == _loadVersion)
{
Status = "Could not read the index.";
}
}
finally
{
if (version == _loadVersion)
{
IsBusy = false;
NotifySelection();
}
}
}
[RelayCommand]
public async Task ToggleNodeAsync(StorageNodeViewModel? node)
{
if (node is null || !node.CanExpand)
{
return;
}
if (node.IsExpanded)
{
node.IsExpanded = false;
RemoveVisibleDescendants(node);
return;
}
if (!node.ChildrenLoaded)
{
var ct = _loadCts?.Token ?? CancellationToken.None;
await LoadChildrenAsync(node, ct).ConfigureAwait(true);
}
if (_loadCts?.IsCancellationRequested == true)
{
return;
}
node.IsExpanded = true;
InsertVisibleChildren(node);
}
public static void ApplySiblingFractions(IReadOnlyList<StorageNodeViewModel> siblings)
{
var max = siblings.Count == 0 ? 1L : Math.Max(1, siblings.Max(s => s.Size));
foreach (var sibling in siblings)
{
sibling.Fraction = sibling.Size / (double)max;
}
}
private async Task RefreshScopesAsync()
{
var previous = SelectedScope?.Source?.Id;
_suppressReload = true;
try
{
var sources = await _analysis.GetKnownSourcesAsync().ConfigureAwait(true);
Scopes.Clear();
Scopes.Add(new StorageScopeItem(null, "All indexed locations"));
foreach (var source in sources)
{
Scopes.Add(new StorageScopeItem(source, source.DisplayName));
}
SelectedScope = previous is long id
? Scopes.FirstOrDefault(s => s.Source?.Id == id) ?? Scopes[0]
: Scopes[0];
}
finally
{
_suppressReload = false;
}
}
private async Task LoadTreeAsync(CancellationToken cancellationToken)
{
Status = "Preparing storage indexes…";
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Status = "Reading index…";
var scope = SelectedScope;
var page = await AnalysisService.RunOffUiAsync(async ct =>
{
var known = await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false);
var sources = FilterSources(known, scope).ToList();
var rootsBySource = (await _analysis.GetDirectoryRootsAsync(ct).ConfigureAwait(false))
.ToDictionary(r => r.SourceId);
var roots = new List<StorageNodeViewModel>(sources.Count);
foreach (var source in sources)
{
rootsBySource.TryGetValue(source.Id, out var root);
var path = source.LastRootPath ?? source.DisplayName;
roots.Add(new StorageNodeViewModel
{
Name = source.DisplayName,
FullPath = path,
PathRel = "",
SourceId = source.Id,
EntryId = root?.Id,
Size = root?.AggregateSize ?? 0,
FileCount = root?.ChildFileCount ?? 0,
DirCount = root?.ChildDirCount ?? 0,
Depth = 0,
IsDirectory = true,
IsSource = true,
State = SourceState(source)
});
}
ApplySiblingFractions(roots);
return roots;
}, cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
TreeRoots.Clear();
VisibleNodes.Clear();
foreach (var root in page)
{
TreeRoots.Add(root);
}
RebuildVisible();
Status = page.Count == 0
? "Nothing indexed yet."
: "";
}
private async Task LoadChildrenAsync(StorageNodeViewModel node, CancellationToken cancellationToken)
{
if (node.EntryId is not long parentId)
{
node.ChildrenLoaded = true;
return;
}
var created = await AnalysisService.RunOffUiAsync(async ct =>
{
var children = await _analysis.LargestDirectoriesAsync(
node.SourceId, parentId, AppConstants.AnalysisTreeChildTake, ct).ConfigureAwait(false);
var source = (await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false))
.FirstOrDefault(s => s.Id == node.SourceId);
var state = source is null ? node.State : SourceState(source);
var rows = new List<StorageNodeViewModel>(children.Count);
foreach (var entry in children)
{
rows.Add(new StorageNodeViewModel
{
Name = entry.Name,
FullPath = PathRules.JoinDisplay(source?.LastRootPath ?? node.FullPath, entry.PathRel),
PathRel = entry.PathRel,
SourceId = entry.SourceId,
EntryId = entry.Id,
Size = entry.AggregateSize,
FileCount = entry.ChildFileCount,
DirCount = entry.ChildDirCount,
Depth = node.Depth + 1,
IsDirectory = true,
State = state is "Indexed" or null or "" ? null : state
});
}
ApplySiblingFractions(rows);
return rows;
}, cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
node.Children.Clear();
foreach (var child in created)
{
node.Children.Add(child);
}
node.ChildrenLoaded = true;
if (created.Count == AppConstants.AnalysisTreeChildTake)
{
Status = $"Showing the {AppConstants.AnalysisTreeChildTake:N0} largest folders in this directory.";
}
}
private async Task LoadRankingAsync(CancellationToken cancellationToken)
{
var page = Page;
var scope = SelectedScope;
var sourceId = scope?.Source?.Id;
if (scope?.Source is { IsIndexed: false })
{
Rows.Clear();
Status = "This location has not been indexed yet.";
return;
}
Status = "Preparing storage indexes…";
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Status = "Reading index…";
var built = await AnalysisService.RunOffUiAsync(
ct => BuildRankingRowsAsync(page, sourceId, ct),
cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Rows.Clear();
foreach (var row in built)
{
Rows.Add(row);
}
Status = scope?.Source is { Status: SourceStatus.Stale }
? "Index may be out of date."
: "";
}
private async Task<List<AnalysisRowViewModel>> BuildRankingRowsAsync(
string page,
long? sourceId,
CancellationToken cancellationToken)
{
var sources = (await _analysis.GetKnownSourcesAsync(cancellationToken).ConfigureAwait(false))
.ToDictionary(s => s.Id);
IReadOnlyList<(string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)> items;
if (page == PageFolders)
{
var dirs = await _analysis.LargestDirectoriesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = dirs.Select(d => ToRank(d, sources, isDir: true)).ToList();
}
else if (page == PageFiles)
{
var files = await _analysis.LargestFilesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = files.Select(d => ToRank(d, sources, isDir: false)).ToList();
}
else if (page == PageTypes)
{
var types = await _analysis.UsageByExtensionAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = types.Select(t => (
string.IsNullOrEmpty(t.Extension) ? "(none)" : "." + t.Extension,
t.TotalSize,
(string?)null,
(string?)null,
(string?)null,
(long?)null,
sourceId,
false,
(int)t.FileCount,
0,
(string?)null)).ToList();
}
else
{
var usage = await _analysis.UsageBySourceAsync(cancellationToken).ConfigureAwait(false);
items = usage.Select(u =>
{
sources.TryGetValue(u.SourceId, out var source);
var path = source?.LastRootPath ?? u.DisplayName;
var stateSource = source ?? new Source
{
StableKey = "",
DisplayName = u.DisplayName,
Status = u.Status
};
return (u.DisplayName, u.TotalSize, (string?)path, (string?)"", (string?)PathRules.ShortenDisplay(path),
(long?)null, (long?)u.SourceId, true, (int)u.FileCount, 0, SourceState(stateSource));
}).ToList();
}
var max = items.Count == 0 ? 1L : Math.Max(1, items.Max(i => i.Size));
return items.Select(item => new AnalysisRowViewModel
{
Name = item.Name,
Size = item.Size,
Fraction = item.Size / (double)max,
Path = item.Path,
PathRel = item.PathRel,
ShortPath = item.ShortPath,
EntryId = item.Id,
SourceId = item.SourceId,
IsDirectory = item.IsDir,
FileCount = item.Files,
DirCount = item.Dirs,
State = item.State
}).ToList();
}
private static (string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)
ToRank(IndexEntry entry, IReadOnlyDictionary<long, Source> sources, bool isDir)
{
sources.TryGetValue(entry.SourceId, out var source);
var full = PathRules.JoinDisplay(source?.LastRootPath, entry.PathRel);
var state = source is null ? null : SourceState(source);
if (state is "Indexed" or "")
{
state = null;
}
return (
entry.Name,
isDir ? entry.AggregateSize : entry.SizeBytes,
full,
entry.PathRel,
PathRules.ShortenDisplay(full),
entry.Id,
entry.SourceId,
isDir,
entry.ChildFileCount,
entry.ChildDirCount,
state);
}
private void RebuildVisible()
{
VisibleNodes.Clear();
foreach (var root in TreeRoots)
{
AppendVisible(root);
}
}
private void AppendVisible(StorageNodeViewModel node)
{
VisibleNodes.Add(node);
if (!node.IsExpanded)
{
return;
}
foreach (var child in node.Children)
{
AppendVisible(child);
}
}
private void InsertVisibleChildren(StorageNodeViewModel node)
{
var index = IndexOfVisible(node);
if (index < 0)
{
RebuildVisible();
return;
}
var insertAt = index + 1;
foreach (var child in node.Children)
{
VisibleNodes.Insert(insertAt++, child);
if (child.IsExpanded)
{
foreach (var nested in FlattenExpanded(child))
{
VisibleNodes.Insert(insertAt++, nested);
}
}
}
}
private void RemoveVisibleDescendants(StorageNodeViewModel node)
{
var index = IndexOfVisible(node);
if (index < 0)
{
RebuildVisible();
return;
}
var next = index + 1;
while (next < VisibleNodes.Count && VisibleNodes[next].Depth > node.Depth)
{
VisibleNodes.RemoveAt(next);
}
}
private int IndexOfVisible(StorageNodeViewModel node)
{
for (var i = 0; i < VisibleNodes.Count; i++)
{
if (ReferenceEquals(VisibleNodes[i], node))
{
return i;
}
}
return -1;
}
private static IEnumerable<StorageNodeViewModel> FlattenExpanded(StorageNodeViewModel node)
{
foreach (var child in node.Children)
{
yield return child;
if (!child.IsExpanded)
{
continue;
}
foreach (var nested in FlattenExpanded(child))
{
yield return nested;
}
}
}
private static IEnumerable<Source> FilterSources(IReadOnlyList<Source> sources, StorageScopeItem? scope)
{
if (scope?.Source is { } one)
{
return [one];
}
return sources.Where(s => s.IsIndexed);
}
private static string? SourceState(Source source)
=> source.Status switch
{
SourceStatus.Offline => "Offline",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "Stale",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? null : "Not indexed"
};
private void NotifySelection()
{
OnPropertyChanged(nameof(IsTree));
OnPropertyChanged(nameof(IsRanking));
OnPropertyChanged(nameof(CanAct));
OnPropertyChanged(nameof(SelectedPath));
OnPropertyChanged(nameof(SelectedNavigatePath));
OnPropertyChanged(nameof(SelectedSourceId));
OnPropertyChanged(nameof(SelectedPathRel));
OnPropertyChanged(nameof(SelectedIsSource));
OnPropertyChanged(nameof(SelectedIsDirectory));
}
partial void OnPageChanged(string value)
{
OnPropertyChanged(nameof(IsTree));
OnPropertyChanged(nameof(IsRanking));
_ = ReloadAsync();
}
partial void OnSelectedScopeChanged(StorageScopeItem? value) => _ = ReloadAsync();
partial void OnSelectedNodeChanged(StorageNodeViewModel? value) => NotifySelection();
partial void OnSelectedRowChanged(AnalysisRowViewModel? value) => NotifySelection();
}

View File

@@ -0,0 +1,79 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
public DuplicateViewModel(IIndexStore store, SourceManager sources)
{
_store = store;
_sources = sources;
Groups = [];
}
public ObservableCollection<string> Groups { get; }
[RelayCommand]
public async Task OpenAsync()
{
IsOpen = true;
IsBusy = true;
Status = "Finding size collisions…";
Groups.Clear();
try
{
var lines = await Task.Run(async () =>
{
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
var result = new List<string>();
foreach (var g in groups)
{
if (g.SameFileId)
{
continue;
}
var paths = string.Join(" | ", g.Entries.Select(e =>
{
sources.TryGetValue(e.SourceId, out var s);
return PathRules.Combine(s?.LastRootPath ?? s?.DisplayName ?? "", e.PathRel);
}));
result.Add($"{Formatters.Size(g.SizeBytes)} · {g.Entries.Count} files · {paths}");
}
return result;
}).ConfigureAwait(true);
foreach (var line in lines)
{
Groups.Add(line);
}
Status = Groups.Count == 0
? "No confirmed duplicates yet. Hashing continues in the background."
: $"{Groups.Count} duplicate groups";
}
catch (Exception)
{
Status = "Could not load duplicates.";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,265 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ExplorerPaneViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly NavigationHistory _history = new();
private CancellationTokenSource? _loadCts;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private bool _showIndexBanner;
[ObservableProperty] private string? _indexBannerText;
[ObservableProperty] private Source? _currentSource;
[ObservableProperty] private FolderViewMode _viewMode = FolderViewMode.Details;
[ObservableProperty] private string _sortProperty = "Name";
[ObservableProperty] private bool _sortDescending;
[ObservableProperty] private bool _isActive;
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
Items = [];
SelectedItems = [];
}
public ObservableCollection<FolderItemViewModel> Items { get; }
public ObservableCollection<FolderItemViewModel> SelectedItems { get; }
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
public bool CanGoBack => _history.CanGoBack;
public bool CanGoForward => _history.CanGoForward;
public bool CanGoUp => CurrentPath is not "This PC";
public async Task NavigateAsync(string path, bool addHistory = true)
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
IsBusy = true;
try
{
CurrentPath = path;
if (addHistory)
{
_history.Navigate(path);
}
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoForward));
OnPropertyChanged(nameof(CanGoUp));
OnPropertyChanged(nameof(Breadcrumb));
if (path == "This PC")
{
await LoadThisPcAsync(ct).ConfigureAwait(true);
return;
}
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
IsOffline = listing.IsOffline;
StatusMessage = listing.Error;
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
IEnumerable<FileSystemItem> ordered = listing.Items;
ordered = SortProperty switch
{
"Size" => SortDescending ? ordered.OrderByDescending(i => i.SizeBytes) : ordered.OrderBy(i => i.SizeBytes),
"Modified" => SortDescending ? ordered.OrderByDescending(i => i.ModifiedUtc) : ordered.OrderBy(i => i.ModifiedUtc),
"Type" => SortDescending ? ordered.OrderByDescending(i => i.IsDirectory) : ordered.OrderBy(i => i.IsDirectory),
_ => SortDescending
? ordered.OrderByDescending(i => i.IsDirectory).ThenByDescending(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
: ordered.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
};
foreach (var item in ordered)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
}
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src)
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
}
}
catch (OperationCanceledException)
{
// superseded
}
finally
{
IsBusy = false;
}
}
private async Task LoadThisPcAsync(CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
var listing = await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true);
foreach (var item in listing.Items)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
}
}
[RelayCommand]
public Task BackAsync()
{
var path = _history.Back();
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
}
[RelayCommand]
public Task ForwardAsync()
{
var path = _history.Forward();
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
}
[RelayCommand]
public Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
=> segment is null ? Task.CompletedTask : NavigateAsync(segment.Path);
[RelayCommand]
public Task UpAsync()
{
if (CurrentPath == "This PC")
{
return Task.CompletedTask;
}
if (PathRules.IsDriveRoot(CurrentPath)
|| (PathRules.IsUnc(CurrentPath)
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
{
return NavigateAsync("This PC");
}
return NavigateAsync(PathRules.Parent(CurrentPath));
}
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public Task OpenItemAsync(FolderItemViewModel item)
{
if (item.IsDirectory)
{
return NavigateAsync(item.FullPath);
}
_ops.Open([item.FullPath]);
return Task.CompletedTask;
}
public void BuildIndex()
{
if (CurrentSource is null)
{
return;
}
_indexing.EnqueueFullScan(CurrentSource.Id);
ShowIndexBanner = false;
IndexBannerText = null;
StatusMessage = "Building index…";
}
public void RescanFolder()
{
if (CurrentSource is null || CurrentPath == "This PC")
{
return;
}
var rel = PathRules.MakeRelative(CurrentSource.LastRootPath ?? CurrentPath, CurrentPath);
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
}
partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
{
if (path == "This PC")
{
return [new BreadcrumbSegment("This PC", "This PC", IsLast: true)];
}
var parts = new List<BreadcrumbSegment> { new("This PC", "This PC") };
var p = PathRules.FromExtended(path);
if (PathRules.IsUnc(p))
{
var root = PathRules.CanonicalUncRoot(p);
parts.Add(new BreadcrumbSegment(root, root));
if (p.Length > root.Length)
{
var acc = root;
foreach (var piece in p[(root.Length + 1)..].Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
acc = PathRules.Combine(acc, piece);
parts.Add(new BreadcrumbSegment(piece, acc));
}
}
}
else
{
var root = Path.GetPathRoot(p)?.TrimEnd('\\') ?? p;
parts.Add(new BreadcrumbSegment(root, root.EndsWith(':') ? root + "\\" : root));
var rest = p.Length > root.Length ? p[(root.Length)..].Trim('\\') : "";
if (!string.IsNullOrEmpty(rest))
{
var acc = root.EndsWith(':') ? root + "\\" : root;
foreach (var piece in rest.Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
acc = PathRules.Combine(acc, piece);
parts.Add(new BreadcrumbSegment(piece, acc));
}
}
}
if (parts.Count > 0)
{
parts[^1] = parts[^1] with { IsLast = true };
}
return parts;
}
}
public sealed record BreadcrumbSegment(string Label, string Path, bool IsLast = false);

View File

@@ -0,0 +1,73 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ExplorerTabViewModel : ObservableObject
{
private readonly Func<ExplorerPaneViewModel> _paneFactory;
public const double DefaultSplitRatio = 0.5;
public const double MinSplitRatio = 0.18;
public const double MaxSplitRatio = 0.82;
[ObservableProperty] private string _title = "This PC";
[ObservableProperty] private bool _isSplit;
[ObservableProperty] private ExplorerPaneViewModel _activePane;
[ObservableProperty] private double _splitRatio = DefaultSplitRatio;
public ExplorerTabViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
{
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
Left = _paneFactory();
Right = _paneFactory();
_activePane = Left;
Left.IsActive = true;
Left.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
Title = Left.CurrentPath == "This PC" ? "This PC" : Path.GetFileName(Left.CurrentPath.TrimEnd('\\'));
if (string.IsNullOrEmpty(Title))
{
Title = Left.CurrentPath;
}
}
};
}
public ExplorerPaneViewModel Left { get; }
public ExplorerPaneViewModel Right { get; }
public void Activate(ExplorerPaneViewModel pane)
{
ActivePane = pane;
Left.IsActive = pane == Left;
Right.IsActive = pane == Right;
}
public void SetSplitRatio(double ratio)
=> SplitRatio = Math.Clamp(ratio, MinSplitRatio, MaxSplitRatio);
public void ToggleSplit()
{
IsSplit = !IsSplit;
if (IsSplit)
{
_ = Right.NavigateAsync(Left.CurrentPath);
Activate(Right);
}
else
{
Activate(Left);
}
}
public Task OpenInitialAsync() => Left.NavigateAsync("This PC");
}

View File

@@ -0,0 +1,75 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Domain;
namespace Explorer.Presentation;
public sealed partial class FolderItemViewModel : ObservableObject
{
[ObservableProperty] private bool _isSelected;
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
{
Item = item;
SizeFromIndex = sizeFromIndex;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public string Name => Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
? ""
: Formatters.Size(Item.SizeBytes);
public string ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
public bool IsImage => !Item.IsDirectory && MediaKinds.IsImage(Item.Name);
public bool IsVideo => !Item.IsDirectory && MediaKinds.IsVideo(Item.Name);
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
public string SizeTooltip
{
get
{
var logical = SizeLabel;
var allocated = Item.AllocatedSizeBytes ?? Item.Cloud?.AllocatedSizeBytes;
if (allocated is long disk && disk != Item.SizeBytes && !Item.IsDirectory)
{
return string.IsNullOrEmpty(CloudStatus)
? $"Size {logical} · On disk {Formatters.Size(disk)}"
: $"{CloudStatus} · Size {logical} · On disk {Formatters.Size(disk)}";
}
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
}
}
}
internal static class MediaKinds
{
private static readonly HashSet<string> Images = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff", ".ico", ".jfif"
};
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg"
};
public static bool IsImage(string name) => Images.Contains(Path.GetExtension(name));
public static bool IsVideo(string name) => Videos.Contains(Path.GetExtension(name));
}
file static class ItemExt
{
public static string ExtensionDisplay(this FileSystemItem item)
{
var ext = NameNormalizer.Extension(item.Name);
return string.IsNullOrEmpty(ext) ? "File" : ext.ToUpperInvariant() + " file";
}
}

View File

@@ -0,0 +1,664 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Search;
using Explorer.Analysis;
using Explorer.Domain.Abstractions;
using Explorer.Plugin.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class MainViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
[ObservableProperty] private string _pathText = "";
[ObservableProperty] private string _theme = "Dark";
[ObservableProperty] private string _footer = "";
[ObservableProperty] private string? _promptUnc;
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
public MainViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources,
SearchService search,
AnalysisService analysis,
IIndexStore store,
TransferQueue transfers,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
_cloudPlaces = cloudPlaces;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
Search = new SearchViewModel(search, sources);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Transfers = new TransferQueueViewModel(transfers);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
{
void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx)
{
ctx.Post(_ => Go(), null);
}
else
{
Go();
}
};
_indexing.ProgressChanged += (_, p) =>
{
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
if (_ui is { } ctx)
{
ctx.Post(_ => Footer = text, null);
}
else
{
Footer = text;
}
};
}
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
public ObservableCollection<string> PathHistory { get; }
public NavigationTreeViewModel Tree { get; }
public SearchViewModel Search { get; }
public AnalysisViewModel Analysis { get; }
public DuplicateViewModel Duplicates { get; }
public TransferQueueViewModel Transfers { get; }
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane;
public async Task InitializeAsync()
{
await _sources.InitializeAsync().ConfigureAwait(true);
foreach (var path in _pathHistory.Load())
{
PathHistory.Add(path);
}
await NewTabAsync().ConfigureAwait(true);
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready";
}
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
await tab.OpenInitialAsync().ConfigureAwait(true);
PathText = tab.ActivePane.CurrentPath;
}
[RelayCommand]
public void CloseTab(ExplorerTabViewModel? tab)
{
tab ??= ActiveTab;
if (Tabs.Count <= 1)
{
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
ActiveTab = Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
[RelayCommand]
public void Split() => ActiveTab.ToggleSplit();
[RelayCommand]
public async Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
{
if (segment is null)
{
return;
}
await ActivePane.NavigateAsync(segment.Path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
{
"list" => FolderViewMode.List,
"preview" => FolderViewMode.Preview,
_ => FolderViewMode.Details
};
[RelayCommand]
public void CancelTransfer(TransferJob? job)
{
if (job is not null)
{
Transfers.Cancel(job);
}
}
[RelayCommand]
public Task BackAsync() => ActivePane.BackAsync();
[RelayCommand]
public Task ForwardAsync() => ActivePane.ForwardAsync();
[RelayCommand]
public Task UpAsync() => ActivePane.UpAsync();
[RelayCommand]
public Task RefreshAsync() => ActivePane.RefreshAsync();
[RelayCommand]
public async Task GoAsync()
{
var path = PathText.Trim();
if (string.IsNullOrEmpty(path))
{
return;
}
if (PathRules.IsUnc(path))
{
await _sources.AddUncAsync(path).ConfigureAwait(true);
await Tree.ReloadAsync(path).ConfigureAwait(true);
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
RememberEnteredPath(ActivePane.CurrentPath);
}
[RelayCommand]
public async Task TreeSelectAsync(NavNodeViewModel? node)
{
if (node is null || node.IsPlaceholder)
{
return;
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
}
PathText = node.Path;
}
[RelayCommand]
public Task OpenSelectedAsync()
{
var item = ActivePane.SelectedItems.FirstOrDefault() ?? ActivePane.Items.FirstOrDefault();
return item is null ? Task.CompletedTask : ActivePane.OpenItemAsync(item);
}
[RelayCommand]
public void Copy()
{
_clipboard = SelectedPaths();
_clipboardIsCut = false;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public void Cut()
{
_clipboard = SelectedPaths();
_clipboardIsCut = true;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public async Task PasteAsync()
{
if (Clipboard.TryGetFiles(out var osFiles, out var cut) && osFiles.Count > 0)
{
_clipboard = osFiles.ToList();
_clipboardIsCut = cut;
}
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
if (_clipboardIsCut)
{
await _ops.MoveAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
_clipboard = [];
}
else
{
await _ops.CopyAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
}
}
[RelayCommand]
public Task DeleteAsync()
{
var paths = SelectedPaths();
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
}
[RelayCommand]
public void CopyPath()
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
CopyPathsToClipboard(paths);
}
[RelayCommand]
public void NewFolder()
{
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
_ops.NewFolder(ActivePane.CurrentPath);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
public void RenameSelected(string newName)
{
var item = ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
_ops.Rename(item.FullPath, newName);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
[RelayCommand]
public async Task BuildIndexAsync()
{
var path = ActivePane.CurrentPath;
if (path == "This PC")
{
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
}
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
Footer = "Select a drive or folder to index.";
return;
}
var source = await _sources.EnsureForPathAsync(path).ConfigureAwait(true);
if (source is null)
{
Footer = "This location could not be indexed.";
return;
}
ActivePane.CurrentSource = source;
ActivePane.ShowIndexBanner = false;
ActivePane.IndexBannerText = null;
_indexing.EnqueueFullScan(source.Id);
Footer = $"Indexing {source.DisplayName}…";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
[RelayCommand]
public void RescanFolder() => ActivePane.RescanFolder();
public void RefreshCloudActions()
{
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
[RelayCommand]
public Task UnpinCloudAsync() => InvokeCloudAsync(ProviderAction.Unpin);
[RelayCommand]
public Task FreeUpCloudSpaceAsync() => InvokeCloudAsync(ProviderAction.Dehydrate);
private async Task InvokeCloudAsync(ProviderAction action)
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
var result = await _providers.InvokeAsync(action, paths).ConfigureAwait(true);
Footer = result.Message ?? (result.Status == ProviderActionStatus.Succeeded
? "Asked the cloud client to update these items."
: "Cloud action was not available.");
await ActivePane.RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
public void CancelIndex()
{
if (ActivePane.CurrentSource is not null)
{
_indexing.Cancel(ActivePane.CurrentSource.Id);
}
}
[RelayCommand]
public async Task OpenStorageHereAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Analysis.Close();
}
[RelayCommand]
public async Task OpenStorageOtherAsync()
{
if (!ActiveTab.IsSplit || !Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
var other = ActiveTab.ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
ActiveTab.Activate(other);
await other.NavigateAsync(path).ConfigureAwait(true);
PathText = other.CurrentPath;
}
[RelayCommand]
public async Task OpenStorageTabAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await NewTabAsync().ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public async Task SearchStorageAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Search.Scope = SearchScopeKind.CurrentTree;
Search.OpenWithoutSearch();
Analysis.Close();
}
[RelayCommand]
public void RescanStorage()
{
if (Analysis.SelectedSourceId is not long id)
{
return;
}
if (Analysis.SelectedIsSource)
{
_indexing.EnqueueFullScan(id);
}
else
{
_indexing.EnqueueFolderScan(id, Analysis.SelectedScanPathRel);
}
Footer = "Queued an index rescan for this location.";
}
[RelayCommand]
public void CopyStoragePath()
{
if (Analysis.SelectedPath is { } path)
{
CopyPathsToClipboard([path]);
}
}
[RelayCommand]
public async Task AddNetworkAsync()
{
if (string.IsNullOrWhiteSpace(PromptUnc))
{
return;
}
await _sources.AddUncAsync(PromptUnc.Trim()).ConfigureAwait(true);
PromptUnc = "";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
public async Task AddCloudFolderAsync(string path, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
Footer = "Added OneDrive folder to the navigation tree.";
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
}
private const string OneDriveProviderId = "onedrive";
[RelayCommand]
public Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(Search.Text) && !Search.IsOpen)
{
Search.OpenWithoutSearch();
return Task.CompletedTask;
}
return Search.RunAsync(ActivePane);
}
[RelayCommand]
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
{
if (files.Count == 0)
{
return;
}
if (move)
{
await _ops.MoveAsync(files, targetDirectory).ConfigureAwait(true);
}
else
{
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
}
}
private void WireTab(ExplorerTabViewModel tab)
{
tab.Left.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.Right.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ExplorerTabViewModel.ActivePane) && tab == ActiveTab)
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
};
}
private void OnPaneProperty(ExplorerTabViewModel tab, string? propertyName)
{
if (tab != ActiveTab)
{
return;
}
if (propertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
if (!string.IsNullOrEmpty(dir))
{
dirs.Add(dir);
}
}
}
Add(job.SourcePath);
Add(job.DestinationPath);
foreach (var extra in job.AdditionalSources)
{
Add(extra);
}
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
}
await ActivePane.RefreshAsync().ConfigureAwait(true);
if (ActiveTab.IsSplit)
{
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
await other.RefreshAsync().ConfigureAwait(true);
}
}
private void EnqueueReconcile(string path)
{
_ = ReconcileAsync(path);
}
private async Task ReconcileAsync(string path)
{
try
{
var source = await _sources.FindByPathAsync(path).ConfigureAwait(true);
if (source is not { IsIndexed: true } || source.LastRootPath is null)
{
return;
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
_indexing.EnqueueReconcile(source.Id, rel);
}
catch
{
// local errors stay out of the UI
}
}
public void RememberEnteredPath(string path)
{
var next = PathHistoryStore.Remember(PathHistory, path);
if (next.Count == PathHistory.Count
&& PathHistory.Count > 0
&& string.Equals(PathHistory[0], path, StringComparison.OrdinalIgnoreCase))
{
return;
}
PathHistory.Clear();
foreach (var item in next)
{
PathHistory.Add(item);
}
_pathHistory.Save(PathHistory);
}
private List<string> SelectedPaths()
=> ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
private void CopyPathsToClipboard(IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return;
}
Clipboard.SetFiles(paths, _clipboardIsCut);
}
partial void OnActiveTabChanged(ExplorerTabViewModel value)
{
PathText = value.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,172 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Search;
namespace Explorer.Presentation.ViewModels;
public sealed record SearchScopeChoice(SearchScopeKind Kind, string Label);
public sealed partial class SearchViewModel : ObservableObject
{
private readonly SearchService _search;
private readonly SourceManager _sources;
private CancellationTokenSource? _runCts;
[ObservableProperty] private string _text = "";
[ObservableProperty] private SearchScopeKind _scope = SearchScopeKind.AllKnown;
[ObservableProperty] private string? _extension;
[ObservableProperty] private bool _foldersOnly;
[ObservableProperty] private bool _filesOnly;
[ObservableProperty] private string? _minSizeText;
[ObservableProperty] private string? _maxSizeText;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
public SearchViewModel(SearchService search, SourceManager sources)
{
_search = search;
_sources = sources;
Results = [];
}
public ObservableCollection<FolderItemViewModel> Results { get; }
public SearchScopeChoice[] ScopeChoices { get; } =
[
new(SearchScopeKind.CurrentFolder, "Current folder"),
new(SearchScopeKind.CurrentTree, "Current folder tree"),
new(SearchScopeKind.AllKnown, "All indexed locations"),
new(SearchScopeKind.OfflineMedia, "Offline media")
];
public void OpenWithoutSearch()
{
IsOpen = true;
IsBusy = false;
if (string.IsNullOrWhiteSpace(Status) || Status.StartsWith("Searching", StringComparison.Ordinal))
{
Status = "";
}
}
internal static bool? DirectoryFilter(bool foldersOnly, bool filesOnly)
=> foldersOnly == filesOnly ? null : foldersOnly;
[RelayCommand]
public async Task RunAsync(ExplorerPaneViewModel? pane, CancellationToken cancellationToken = default)
{
_runCts?.Cancel();
_runCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var ct = _runCts.Token;
IsOpen = true;
IsBusy = true;
Status = "Searching…";
Results.Clear();
try
{
var source = pane?.CurrentSource;
var sources = await _sources.RefreshOnlineStateAsync(ct).ConfigureAwait(true);
var indexed = sources.Where(s => s.IsIndexed).ToList();
var scope = Scope;
if (scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected
&& source is not { IsIndexed: true })
{
scope = SearchScopeKind.AllKnown;
}
IReadOnlyList<long>? sourceIds = scope switch
{
SearchScopeKind.AllKnown => indexed.Count == 0 ? sources.Select(s => s.Id).ToList() : null,
SearchScopeKind.OfflineMedia => sources.Where(s => s.Status == SourceStatus.Offline).Select(s => s.Id).ToList(),
SearchScopeKind.Sources when source is not null => [source.Id],
_ when source is not null => [source.Id],
_ => indexed.Select(s => s.Id).ToList()
};
string? prefix = null;
if (pane is not null && pane.CurrentPath != "This PC" && source is { IsIndexed: true, LastRootPath: not null }
&& scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected)
{
prefix = PathRules.MakeRelative(source.LastRootPath, pane.CurrentPath);
}
var query = new SearchQuery
{
Scope = scope,
Text = Text,
Extension = Extension,
IsDirectory = DirectoryFilter(FoldersOnly, FilesOnly),
MinSize = ParseSize(MinSizeText),
MaxSize = ParseSize(MaxSizeText),
SourceIds = sourceIds,
PathRelPrefix = prefix,
IncludeOffline = scope is SearchScopeKind.AllKnown or SearchScopeKind.OfflineMedia,
Take = AppConstants.SearchPageSize
};
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
var byId = sources.ToDictionary(s => s.Id);
foreach (var entry in entries)
{
byId.TryGetValue(entry.SourceId, out var src);
var full = src?.LastRootPath is null
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
: PathRules.Combine(src.LastRootPath, entry.PathRel);
Results.Add(new FolderItemViewModel(new FileSystemItem
{
FullPath = full,
Name = entry.Name,
IsDirectory = entry.IsDirectory,
SizeBytes = entry.IsDirectory ? entry.AggregateSize : entry.SizeBytes,
CreatedUtc = entry.CreatedUtc,
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
ReparseTag = entry.ReparseTag
}, entry.IsDirectory));
}
if (Results.Count > 0)
{
Status = $"{Results.Count} results";
}
else if (indexed.Count == 0)
{
Status = "Nothing indexed yet. Index a drive or folder, then search again.";
}
else
{
Status = "No results";
}
}
catch (OperationCanceledException)
{
Status = "Cancelled";
}
finally
{
IsBusy = false;
}
}
private static long? ParseSize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
text = text.Trim();
double mul = 1;
if (text.EndsWith("kb", StringComparison.OrdinalIgnoreCase)) { mul = 1024; text = text[..^2]; }
else if (text.EndsWith("mb", StringComparison.OrdinalIgnoreCase)) { mul = 1024 * 1024; text = text[..^2]; }
else if (text.EndsWith("gb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024; text = text[..^2]; }
else if (text.EndsWith("tb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024 * 1024; text = text[..^2]; }
return double.TryParse(text.Trim(), out var n) ? (long)(n * mul) : null;
}
}

View File

@@ -0,0 +1,60 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class TransferQueueViewModel : ObservableObject
{
private readonly TransferQueue _queue;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
public TransferQueueViewModel(TransferQueue queue)
{
_queue = queue;
Jobs = [];
_queue.Changed += (_, _) =>
{
if (_ui is { } ctx)
{
ctx.Post(_ => Reload(), null);
}
else
{
Reload();
}
};
Reload();
}
public ObservableCollection<TransferJob> Jobs { get; }
public bool HasJobs => Jobs.Count > 0;
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
else
{
_queue.Dismiss(job.Id);
}
}
private void Reload()
{
Jobs.Clear();
foreach (var job in _queue.Snapshot().Where(IsVisible))
{
Jobs.Add(job);
}
OnPropertyChanged(nameof(HasJobs));
}
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running
or TransferStatus.Cancelling or TransferStatus.Failed;
}