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:
19
src/Explorer.Presentation/Explorer.Presentation.csproj
Normal file
19
src/Explorer.Presentation/Explorer.Presentation.csproj
Normal 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>
|
||||
26
src/Explorer.Presentation/Formatters.cs
Normal file
26
src/Explorer.Presentation/Formatters.cs
Normal 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");
|
||||
}
|
||||
50
src/Explorer.Presentation/NavigationHistory.cs
Normal file
50
src/Explorer.Presentation/NavigationHistory.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
660
src/Explorer.Presentation/ViewModels/AnalysisViewModel.cs
Normal file
660
src/Explorer.Presentation/ViewModels/AnalysisViewModel.cs
Normal 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();
|
||||
}
|
||||
79
src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs
Normal file
79
src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
Normal file
265
src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
Normal 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);
|
||||
73
src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
Normal file
73
src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
Normal 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");
|
||||
}
|
||||
75
src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
Normal file
75
src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
664
src/Explorer.Presentation/ViewModels/MainViewModel.cs
Normal file
664
src/Explorer.Presentation/ViewModels/MainViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/Explorer.Presentation/ViewModels/SearchViewModel.cs
Normal file
172
src/Explorer.Presentation/ViewModels/SearchViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user