Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

View File

@@ -0,0 +1,10 @@
using Explorer.Presentation.ViewModels;
namespace Explorer.Presentation;
public interface IThumbnailService
{
void OnViewportChanged(ExplorerPaneViewModel pane, IReadOnlyList<FolderItemViewModel> visible);
void OnSessionChanged(ExplorerPaneViewModel pane, int generation);
void OnPreviewEnabledChanged(ExplorerPaneViewModel pane);
}

View File

@@ -0,0 +1,43 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Explorer.Presentation;
public sealed class RangeObservableCollection<T> : ObservableCollection<T>
{
public void AddRange(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
var list = items as IList<T> ?? items.ToList();
if (list.Count == 0)
{
return;
}
CheckReentrancy();
foreach (var item in list)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void ReplaceAll(IList<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}

View File

@@ -0,0 +1,123 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class BatchRenameViewModel : ObservableObject
{
private readonly RenamePlanner _planner;
private readonly RenameBatchService _batches;
private readonly IReadOnlyList<RenameSubject> _subjects;
[ObservableProperty] private string _search = "";
[ObservableProperty] private string _replace = "";
[ObservableProperty] private bool _useRegex;
[ObservableProperty] private bool _matchCase;
[ObservableProperty] private bool _includeExtensionInSearch;
[ObservableProperty] private string _prefix = "";
[ObservableProperty] private string _suffix = "";
[ObservableProperty] private bool _useCounter;
[ObservableProperty] private int _counterStart = 1;
[ObservableProperty] private int _counterStep = 1;
[ObservableProperty] private int _counterPadding;
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
[ObservableProperty] private bool _changeExtension;
[ObservableProperty] private string _newExtension = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public BatchRenameViewModel(
IReadOnlyList<RenameSubject> subjects,
RenamePlanner planner,
RenameBatchService batches)
{
_subjects = subjects;
_planner = planner;
_batches = batches;
Rows = [];
Rebuild();
}
public ObservableCollection<RenamePreviewRow> Rows { get; }
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
[
new("Leave case", RenameCaseMode.Unchanged),
new("lowercase", RenameCaseMode.Lower),
new("UPPERCASE", RenameCaseMode.Upper),
new("Title Case", RenameCaseMode.Title)
];
public event EventHandler? CloseRequested;
public RenameRuleSet Rules => new()
{
Search = Search,
Replace = Replace,
UseRegex = UseRegex,
MatchCase = MatchCase,
IncludeExtensionInSearch = IncludeExtensionInSearch,
Prefix = Prefix,
Suffix = Suffix,
UseCounter = UseCounter,
CounterStart = CounterStart,
CounterStep = Math.Max(1, CounterStep),
CounterPadding = Math.Max(0, CounterPadding),
CaseMode = CaseMode,
ChangeExtension = ChangeExtension,
NewExtension = NewExtension
};
[RelayCommand]
public async Task QueueAsync()
{
var plan = _batches.Preview(_subjects, Rules);
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
return;
}
await _batches.EnqueueAsync(plan).ConfigureAwait(true);
CloseRequested?.Invoke(this, EventArgs.Empty);
}
partial void OnSearchChanged(string value) => Rebuild();
partial void OnReplaceChanged(string value) => Rebuild();
partial void OnUseRegexChanged(bool value) => Rebuild();
partial void OnMatchCaseChanged(bool value) => Rebuild();
partial void OnIncludeExtensionInSearchChanged(bool value) => Rebuild();
partial void OnPrefixChanged(string value) => Rebuild();
partial void OnSuffixChanged(string value) => Rebuild();
partial void OnUseCounterChanged(bool value) => Rebuild();
partial void OnCounterStartChanged(int value) => Rebuild();
partial void OnCounterStepChanged(int value) => Rebuild();
partial void OnCounterPaddingChanged(int value) => Rebuild();
partial void OnCaseModeChanged(RenameCaseMode value) => Rebuild();
partial void OnChangeExtensionChanged(bool value) => Rebuild();
partial void OnNewExtensionChanged(string value) => Rebuild();
private void Rebuild()
{
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
Rows.Clear();
foreach (var row in plan.Preview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var unchanged = plan.Preview.Count(r => r.Unchanged);
Status = errors > 0
? $"{_subjects.Count} items · {errors} errors"
: plan.Operations.Count == 0
? $"{_subjects.Count} items · nothing to rename"
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
}
}
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -11,19 +12,32 @@ public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly AnalysisService _analysis;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexStore store, SourceManager sources)
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis)
{
_store = store;
_sources = sources;
_analysis = analysis;
Groups = [];
}
public ObservableCollection<string> Groups { get; }
public ObservableCollection<DuplicateGroupViewModel> Groups { get; }
public event EventHandler<string>? RevealPath;
[RelayCommand]
public Task CloseAsync()
{
IsOpen = false;
return Task.CompletedTask;
}
[RelayCommand]
public async Task OpenAsync()
@@ -34,38 +48,41 @@ public sealed partial class DuplicateViewModel : ObservableObject
Groups.Clear();
try
{
var lines = await Task.Run(async () =>
var groups = await Task.Run(async () =>
{
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(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;
return classified
.Select(g => DuplicateGroupViewModel.From(g, sources))
.ToList();
}).ConfigureAwait(true);
foreach (var line in lines)
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
foreach (var group in groups)
{
Groups.Add(line);
if (group.Classification == DuplicateClass.Hardlink)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
continue;
}
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
{
if (!ShowIntentional)
{
hiddenIntentional++;
continue;
}
}
Groups.Add(group);
}
Status = Groups.Count == 0
? "No confirmed duplicates yet. Hashing continues in the background."
: $"{Groups.Count} duplicate groups";
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
catch (Exception)
{
@@ -76,4 +93,132 @@ public sealed partial class DuplicateViewModel : ObservableObject
IsBusy = false;
}
}
[RelayCommand]
public async Task MarkIntentionalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.IntentionalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public async Task MarkAccidentalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.AccidentalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Reveal(DuplicateFileViewModel? file)
{
if (file is null || string.IsNullOrWhiteSpace(file.FullPath))
{
return;
}
RevealPath?.Invoke(this, file.FullPath);
}
partial void OnShowIntentionalChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
partial void OnShowHardlinksChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
if (visible > 0)
{
var extra = hiddenIntentional + hiddenHardlinks;
return extra == 0
? $"{visible} duplicate groups"
: $"{visible} duplicate groups · {extra} hidden as intentional or hard links";
}
if (hiddenIntentional + hiddenHardlinks > 0)
{
return "No accidental duplicates. Turn on intentional or hard links to review those.";
}
return "No confirmed duplicates yet. Hashing continues in the background.";
}
}
public sealed class DuplicateGroupViewModel
{
public required IReadOnlyList<IndexEntry> Entries { get; init; }
public DuplicateClass Classification { get; init; }
public required string ClassLabel { get; init; }
public required string SizeLabel { get; init; }
public required string Summary { get; init; }
public required string WastedLabel { get; init; }
public bool CanMarkIntentional { get; init; }
public bool CanMarkAccidental { get; init; }
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
public static DuplicateGroupViewModel From(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources)
{
var files = classified.Group.Entries.Select(entry =>
{
sources.TryGetValue(entry.SourceId, out var source);
var root = source?.LastRootPath ?? source?.DisplayName ?? "";
var full = PathRules.Combine(root, entry.PathRel);
var hardlink = classified.Group.Entries.Count(e =>
e.Id != entry.Id && e.SourceId == entry.SourceId && e.FileId is > 0 && e.FileId == entry.FileId) > 0;
return new DuplicateFileViewModel
{
Name = entry.Name,
FullPath = full,
LocationLabel = hardlink ? $"{full} (hard link)" : full
};
}).ToList();
return new DuplicateGroupViewModel
{
Entries = classified.Group.Entries,
Classification = classified.Classification,
ClassLabel = DuplicateClassifier.Label(classified.Classification),
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
WastedLabel = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space",
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Accidental,
Files = files
};
}
}
public sealed class DuplicateFileViewModel
{
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string LocationLabel { get; init; }
}

View File

@@ -14,8 +14,16 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly IGitStatusProvider _git;
private readonly IThumbnailService? _thumbnails;
private readonly NavigationHistory _history = new();
private CancellationTokenSource? _loadCts;
private BrowseViewport? _viewport;
private bool _userChoseSort;
private bool _awaitingSizeSort;
private bool _didAutoSort;
private int _browseGeneration;
private Dictionary<string, FolderItemViewModel>? _rows;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
@@ -28,34 +36,53 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[ObservableProperty] private string _sortProperty = "Name";
[ObservableProperty] private bool _sortDescending;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _gitBadge = "";
[ObservableProperty] private bool _hasGitRepo;
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
Items = [];
_git = git;
_thumbnails = thumbnails;
Items = new RangeObservableCollection<FolderItemViewModel>();
SelectedItems = [];
}
public ObservableCollection<FolderItemViewModel> Items { get; }
partial void OnGitBadgeChanged(string value) => OnPropertyChanged(nameof(HasGitBadge));
public RangeObservableCollection<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 => !LocationRoots.IsVirtual(CurrentPath) || CurrentPath is LocationRoots.Network or LocationRoots.Cloud;
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath)
|| CurrentPath is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin;
public async Task NavigateAsync(string path, bool addHistory = true)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
var generation = Interlocked.Increment(ref _browseGeneration);
_thumbnails?.OnSessionChanged(this, generation);
_userChoseSort = false;
_awaitingSizeSort = false;
_didAutoSort = false;
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
_viewport = new BrowseViewport();
IsBusy = true;
StatusMessage = null;
try
{
CurrentPath = path;
@@ -71,39 +98,86 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
if (LocationRoots.IsVirtual(path))
{
GitBadge = "";
HasGitRepo = false;
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
return;
}
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
IsOffline = listing.IsOffline;
StatusMessage = listing.Error;
Items.Clear();
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();
foreach (var item in listing.Items)
var published = false;
await foreach (var delta in _browse.ListProgressiveAsync(path, _viewport, ct).ConfigureAwait(true))
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
if (generation != _browseGeneration || path != CurrentPath)
{
return;
}
IsOffline = delta.IsOffline;
if (delta.Error is not null)
{
StatusMessage = delta.Error;
}
if (delta.Added.Count > 0)
{
var rows = delta.Added
.Select(item => new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory))
.ToList();
foreach (var row in rows)
{
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
published = true;
IsBusy = false;
}
if (delta.Updated.Count > 0)
{
ApplyUpdates(delta.Updated, sizeFromIndex);
}
if (delta.EnumerationComplete)
{
IsBusy = false;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
TryAutoSort(sizeMetadataReady: CurrentSource is not { IsIndexed: true });
_ = ApplyGitAsync(path, ct);
KickThumbnails();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
}
}
if ((delta.CompletedStages & ItemHydrationFlags.Index) != 0
|| delta.HydrationComplete)
{
TryAutoSort(sizeMetadataReady: true);
}
}
ApplyCurrentSort();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
if (!published)
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
IsBusy = false;
}
}
catch (OperationCanceledException)
@@ -112,30 +186,172 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
}
finally
{
IsBusy = false;
if (generation == _browseGeneration)
{
IsBusy = false;
}
}
}
public int BrowseGeneration => _browseGeneration;
public void NotifyViewport(IReadOnlyList<string> visiblePaths)
{
_viewport?.SetVisible(visiblePaths);
if (_thumbnails is null || ViewMode != FolderViewMode.Preview || _rows is null)
{
return;
}
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
foreach (var path in visiblePaths)
{
if (!string.IsNullOrEmpty(path) && _rows.TryGetValue(path, out var item))
{
visible.Add(item);
}
}
_thumbnails.OnViewportChanged(this, visible);
}
public IReadOnlyList<FolderItemViewModel> SnapshotItems() => Items.ToList();
private void KickThumbnails()
{
if (ViewMode != FolderViewMode.Preview || _thumbnails is null || Items.Count == 0)
{
return;
}
_thumbnails.OnViewportChanged(this, Items.Take(40).ToList());
}
partial void OnViewModeChanged(FolderViewMode value)
=> _thumbnails?.OnPreviewEnabledChanged(this);
private void ApplyUpdates(IReadOnlyList<FileSystemItem> updated, bool sizeFromIndex)
{
if (updated.Count == 0 || _rows is null)
{
return;
}
foreach (var item in updated)
{
if (_rows.TryGetValue(item.FullPath, out var vm))
{
vm.Apply(item, sizeFromIndex && item.IsDirectory);
}
}
}
private void TryAutoSort(bool sizeMetadataReady)
{
if (_userChoseSort)
{
return;
}
if (FolderListingSort.ShouldDeferAutoSort(SortProperty, enumerationComplete: true, sizeMetadataReady))
{
_awaitingSizeSort = SortProperty == "Size";
return;
}
if (_didAutoSort && !_awaitingSizeSort)
{
return;
}
_awaitingSizeSort = false;
_didAutoSort = true;
ApplyCurrentSort();
}
private async Task LoadVirtualRootAsync(string path, CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
var listing = path switch
{
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.RecycleBin => _browse.ListRecycleBin(),
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
};
foreach (var item in listing.Items)
StatusMessage = listing.Error;
var rows = listing.Items.Select(item => new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0)).ToList();
foreach (var row in rows)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
ApplyCurrentSort();
}
private async Task ApplyGitAsync(string path, CancellationToken cancellationToken)
{
GitBadge = "";
HasGitRepo = false;
foreach (var item in Items)
{
item.SetGit(null);
}
if (LocationRoots.IsVirtual(path))
{
return;
}
var dirs = Items.Where(i => i.IsDirectory).Select(i => i.FullPath).Take(24).ToList();
try
{
var (folder, root, child) = await Task.Run(
async () =>
{
var status = await _git.GetStatusAsync(path, cancellationToken).ConfigureAwait(false);
var found = _git.FindRepoRoot(path);
var overlays = new List<(string Path, GitStatus? Status)>();
foreach (var dir in dirs)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_git.IsRepoRoot(dir))
{
continue;
}
overlays.Add((dir, await _git.GetStatusAsync(dir, cancellationToken).ConfigureAwait(false)));
}
return (status, found, overlays);
},
cancellationToken).ConfigureAwait(true);
if (cancellationToken.IsCancellationRequested || path != CurrentPath)
{
return;
}
GitBadge = folder?.Badge ?? "";
HasGitRepo = folder is not null || root is not null;
foreach (var (full, status) in child)
{
var vm = Items.FirstOrDefault(i =>
string.Equals(i.FullPath, full, StringComparison.OrdinalIgnoreCase));
vm?.SetGit(status);
}
}
catch (OperationCanceledException)
{
// superseded
}
}
[RelayCommand]
public Task BackAsync()
{
@@ -176,15 +392,23 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public Task OpenItemAsync(FolderItemViewModel item)
public async Task OpenItemAsync(FolderItemViewModel item)
{
if (item.Item.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(item.FullPath).ConfigureAwait(true);
var path = source?.LastRootPath ?? item.FullPath;
await NavigateAsync(path).ConfigureAwait(true);
return;
}
if (item.IsDirectory || _browse.CanBrowseArchive(item.Item.Name))
{
return NavigateAsync(item.FullPath);
await NavigateAsync(item.FullPath).ConfigureAwait(true);
return;
}
_ops.Open([item.FullPath]);
return Task.CompletedTask;
}
public void BuildIndex()
@@ -223,17 +447,15 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
SortDescending = property is "Size" or "Free" or "Modified";
}
_userChoseSort = true;
_awaitingSizeSort = false;
ApplyCurrentSort();
}
public void ApplyCurrentSort()
{
var ordered = OrderItems(Items, SortProperty, SortDescending).ToList();
Items.Clear();
foreach (var item in ordered)
{
Items.Add(item);
}
Items.ReplaceAll(OrderItems(Items, SortProperty, SortDescending).ToList());
_rows = Items.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
}
public static IEnumerable<FolderItemViewModel> OrderItems(
@@ -241,25 +463,10 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
string property,
bool descending)
{
var names = StringComparer.CurrentCultureIgnoreCase;
return property switch
{
"Size" => descending
? items.OrderByDescending(i => i.Item.SizeBytes).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.SizeBytes).ThenBy(i => i.Name, names),
"Free" => descending
? items.OrderByDescending(i => i.Item.FreeSpaceBytes ?? -1).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.FreeSpaceBytes ?? long.MaxValue).ThenBy(i => i.Name, names),
"Modified" => descending
? items.OrderByDescending(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(i => i.TypeLabel, names).ThenByDescending(i => i.Name, names)
: items.OrderBy(i => i.TypeLabel, names).ThenBy(i => i.Name, names),
_ => descending
? items.OrderBy(i => i.IsDirectory).ThenByDescending(i => i.Name, names)
: items.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, names)
};
var map = items as IList<FolderItemViewModel> ?? items.ToList();
var ordered = FolderListingSort.Order(map.Select(i => i.Item), property, descending);
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
return ordered.Select(item => byPath[item.FullPath]);
}
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
@@ -269,7 +476,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
}
if (path is LocationRoots.Network or LocationRoots.Cloud)
if (path is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin)
{
return
[

View File

@@ -23,9 +23,11 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources, git, thumbnails);
Left = _paneFactory();
Right = _paneFactory();
_activePane = Left;

View File

@@ -29,14 +29,27 @@ public sealed partial class FolderItemViewModel : ObservableObject
EditName = Item.Name;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public FileSystemItem Item { get; private set; }
public bool SizeFromIndex { get; private set; }
public void Apply(FileSystemItem item, bool? sizeFromIndex = null)
{
Item = item;
if (sizeFromIndex is bool value)
{
SizeFromIndex = value;
}
OnPropertyChanged(string.Empty);
}
public string Name => Item.DisplayName ?? Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string TypeLabel => Item.AvailableToImport
? "Available in Windows"
: Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.SizeKnowledge switch
{
SizeKnowledge.Unknown when Item.Location.AccessDenied => "Access denied",
@@ -62,6 +75,22 @@ public sealed partial class FolderItemViewModel : ObservableObject
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
[ObservableProperty] private string _gitLabel = "";
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
[ObservableProperty] private object? _thumbnail;
public bool HasThumbnail => Thumbnail is not null;
public void SetGit(GitStatus? status)
{
GitLabel = status?.Badge ?? "";
}
public void SetThumbnail(object? image) => Thumbnail = image;
partial void OnGitLabelChanged(string value) => OnPropertyChanged(nameof(HasGitLabel));
partial void OnThumbnailChanged(object? value) => OnPropertyChanged(nameof(HasThumbnail));
public string SizeTooltip
{
get

View File

@@ -0,0 +1,228 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class FolderSyncViewModel : ObservableObject
{
private readonly FolderSyncService _sync;
private OperationPlan? _plan;
[ObservableProperty] private SyncProfile? _selected;
[ObservableProperty] private string _name = "Photos backup";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private SyncMode _mode = SyncMode.CopyUpdate;
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public FolderSyncViewModel(FolderSyncService sync)
{
_sync = sync;
Profiles = [];
Rows = [];
Modes =
[
new SyncModeOption("Copy / Update", SyncMode.CopyUpdate),
new SyncModeOption("Mirror", SyncMode.Mirror)
];
}
public ObservableCollection<SyncProfile> Profiles { get; }
public ObservableCollection<SyncPreviewRow> Rows { get; }
public IReadOnlyList<SyncModeOption> Modes { get; }
public bool AutoRunEnabled => Mode == SyncMode.CopyUpdate;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _sync.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
partial void OnSelectedChanged(SyncProfile? value)
{
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
Mode = value.Mode;
Excludes = value.Excludes;
AutoRun = value.AutoRun && value.Mode == SyncMode.CopyUpdate;
ClearPlan("Profile loaded. Preview to see what would change.");
}
partial void OnModeChanged(SyncMode value)
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (value != SyncMode.CopyUpdate)
{
AutoRun = false;
}
}
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New sync";
SourcePath = "";
DestPath = "";
Mode = SyncMode.CopyUpdate;
Excludes = "";
AutoRun = false;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(SourcePath) || string.IsNullOrWhiteSpace(DestPath))
{
Status = "Choose a source folder and a destination folder.";
return;
}
var profile = CurrentProfile();
profile.Id = await _sync.SaveAsync(profile).ConfigureAwait(true);
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _sync.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _sync.PreviewAsync(profile)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _sync.EnqueueAsync(profile, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public SyncProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Sync" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
Mode = Mode,
Excludes = Excludes ?? "",
AutoRun = AutoRun && Mode == SyncMode.CopyUpdate,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.SyncPreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Folders already match."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
}
public sealed record SyncModeOption(string Label, SyncMode Mode);

View File

@@ -22,6 +22,14 @@ public sealed partial class MainViewModel : ObservableObject
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly RenamePlanner _renamePlanner;
private readonly RenameBatchService _renameBatches;
private readonly FolderSyncService _folderSync;
private readonly OperationProfileService _operationProfiles;
private readonly ReorganizeService _reorganize;
private readonly IGitStatusProvider _git;
private readonly IWorkspaceLauncher _workspace;
private readonly IThumbnailService? _thumbnails;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -33,6 +41,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
[ObservableProperty] private bool _showForgetSource;
[ObservableProperty] private bool _showEmptyRecycleBin;
[ObservableProperty] private bool _showImportWindowsLocation;
[ObservableProperty] private bool _showBatchRename;
[ObservableProperty] private bool _showRunProfile;
[ObservableProperty] private bool _showOrganizeFolder;
[ObservableProperty] private bool _canUndoRenameBatch;
[ObservableProperty] private bool _showExtractArchive;
[ObservableProperty] private bool _showCompress;
[ObservableProperty] private bool _showAddToArchive;
[ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showGitActions;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
@@ -51,7 +72,15 @@ public sealed partial class MainViewModel : ObservableObject
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IVolumeService volumes)
IVolumeService volumes,
RenamePlanner renamePlanner,
RenameBatchService renameBatches,
FolderSyncService folderSync,
OperationProfileService operationProfiles,
ReorganizeService reorganize,
IGitStatusProvider git,
IWorkspaceLauncher workspace,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
@@ -61,13 +90,22 @@ public sealed partial class MainViewModel : ObservableObject
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
_renamePlanner = renamePlanner;
_renameBatches = renameBatches;
_folderSync = folderSync;
_operationProfiles = operationProfiles;
_reorganize = reorganize;
_git = git;
_workspace = workspace;
_thumbnails = thumbnails;
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Duplicates = new DuplicateViewModel(store, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(transfers, preferences);
Tabs = [];
Clipboard = clipboard;
@@ -133,7 +171,7 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
@@ -170,6 +208,24 @@ public sealed partial class MainViewModel : ObservableObject
PathText = ActivePane.CurrentPath;
}
public async Task RevealDuplicateAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return;
}
var folder = Directory.Exists(path) ? path : PathRules.Parent(path);
if (string.IsNullOrWhiteSpace(folder) || LocationRoots.IsVirtual(folder))
{
return;
}
await ActivePane.NavigateAsync(folder).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Footer = folder;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
@@ -232,6 +288,17 @@ public sealed partial class MainViewModel : ObservableObject
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (node.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(node.Path).ConfigureAwait(true);
var path = source?.LastRootPath ?? node.Path;
await Tree.ReloadAsync(path).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = path;
Footer = "Added Windows location. Indexing is optional.";
return;
}
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
@@ -363,6 +430,86 @@ public sealed partial class MainViewModel : ObservableObject
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
}
public BatchRenameViewModel? CreateBatchRenameViewModel()
{
var items = ActivePane.SelectedItems
.Where(i => !LocationRoots.IsVirtual(i.FullPath))
.Select(i => new RenameSubject(i.FullPath, i.Item.Name, i.IsDirectory))
.ToList();
if (items.Count == 0)
{
Footer = "Select files or folders to rename.";
return null;
}
return new BatchRenameViewModel(items, _renamePlanner, _renameBatches);
}
public async Task RefreshUndoRenameAsync()
=> CanUndoRenameBatch = await _renameBatches.GetUndoableAsync().ConfigureAwait(true) is not null;
public FolderSyncViewModel CreateFolderSyncViewModel()
=> new(_folderSync);
public OperationProfilesViewModel CreateOperationProfilesViewModel()
=> new(_operationProfiles);
public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath());
public string? OrganizeSourcePath() => WorkspaceDirectory();
public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync()
=> _operationProfiles.ListAsync();
public IReadOnlyList<string> SelectedRealPaths()
=> RealSelected().Select(i => i.FullPath).ToList();
[RelayCommand]
public void OpenTerminal()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open a terminal.";
return;
}
_workspace.OpenTerminal(directory);
}
[RelayCommand]
public void OpenInCursor()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open in Cursor.";
return;
}
if (!_workspace.TryOpenInCursor(directory))
{
Footer = "Cursor is not installed.";
}
}
[RelayCommand]
public async Task UndoRenameBatchAsync()
{
var plan = await _renameBatches.UndoLastAsync().ConfigureAwait(true);
await RefreshUndoRenameAsync().ConfigureAwait(true);
if (plan.HasErrors)
{
Footer = plan.Issues[0].Message;
return;
}
Footer = plan.Operations.Count == 0
? plan.Issues.FirstOrDefault()?.Message ?? "Nothing to undo."
: $"Undo rename queued ({plan.Operations.Count}).";
}
[RelayCommand]
public async Task BuildIndexAsync()
{
@@ -401,6 +548,23 @@ public sealed partial class MainViewModel : ObservableObject
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
ShowEmptyRecycleBin = ActivePane.CurrentPath == LocationRoots.RecycleBin
|| ActivePane.SelectedItems.Any(i => i.FullPath == LocationRoots.RecycleBin);
ShowImportWindowsLocation = ActivePane.SelectedItems.Count == 1
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
ShowBatchRename = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
ShowRunProfile = ShowBatchRename;
ShowOrganizeFolder = OrganizeSourcePath() is not null;
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name));
ShowCompress = real.Count > 0;
ShowAddToArchive = real.Any(i => i.IsDirectory || !ArchiveFormats.IsArchive(i.Item.Name));
ShowVerifyArchive = ShowExtractArchive;
var target = WorkspaceDirectory();
ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
_ = RefreshForgetActionAsync();
}
@@ -411,6 +575,117 @@ public sealed partial class MainViewModel : ObservableObject
&& await _sources.CanForgetPathAsync(ActivePane.SelectedItems[0].FullPath).ConfigureAwait(true);
}
public async Task ExtractSelectedAsync(string? destinationDirectory)
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to extract.";
return;
}
var root = destinationDirectory;
if (string.IsNullOrWhiteSpace(root))
{
root = ActivePane.CurrentPath;
}
if (string.IsNullOrWhiteSpace(root) || LocationRoots.IsVirtual(root))
{
Footer = "Choose a folder to extract to.";
return;
}
foreach (var archive in archives)
{
var dest = Path.Combine(root, ArchiveFormats.Stem(archive.Item.Name));
await _ops.ExtractAsync(archive.FullPath, dest).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Extract queued." : $"{archives.Count} extracts queued.";
}
public async Task CompressSelectedAsync(ArchiveFormat format)
{
var items = RealSelected();
if (items.Count == 0)
{
Footer = "Select files or folders to compress.";
return;
}
var folder = ActivePane.CurrentPath;
if (LocationRoots.IsVirtual(folder))
{
folder = PathRules.Parent(items[0].FullPath);
}
var stem = items.Count == 1
? ArchiveFormats.Stem(items[0].Item.Name)
: (string.IsNullOrWhiteSpace(PathRules.GetFileName(folder)) ? "Archive" : PathRules.GetFileName(folder));
var ext = format == ArchiveFormat.SevenZip ? "7z" : "zip";
var archive = FileOperationService.UniqueArchivePath(folder, stem, ext);
await _ops.CompressAsync(items.Select(i => i.FullPath).ToList(), archive).ConfigureAwait(true);
Footer = $"Compress queued → {PathRules.GetFileName(archive)}.";
}
public async Task AddSelectedToArchiveAsync(string archivePath)
{
var items = RealSelected().Where(i => !ArchiveFormats.IsArchive(i.Item.Name) || i.IsDirectory).ToList();
if (items.Count == 0 || string.IsNullOrWhiteSpace(archivePath))
{
Footer = "Select files to add to an archive.";
return;
}
await _ops.AddToArchiveAsync(archivePath, items.Select(i => i.FullPath).ToList()).ConfigureAwait(true);
Footer = "Add to archive queued.";
}
public async Task VerifySelectedAsync()
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to verify.";
return;
}
foreach (var archive in archives)
{
await _ops.VerifyArchiveAsync(archive.FullPath).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Verify queued." : $"{archives.Count} verifies queued.";
}
private List<FolderItemViewModel> RealSelected()
=> ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
private static bool IsRealFileSystemItem(FolderItemViewModel item)
{
if (LocationRoots.IsVirtual(item.FullPath))
{
return false;
}
return item.IsDirectory
? Directory.Exists(item.FullPath)
: File.Exists(item.FullPath);
}
private string? WorkspaceDirectory()
{
if (ActivePane.SelectedItems.Count == 1 && ActivePane.SelectedItems[0].IsDirectory
&& IsRealFileSystemItem(ActivePane.SelectedItems[0]))
{
return ActivePane.SelectedItems[0].FullPath;
}
var path = ActivePane.CurrentPath;
return LocationRoots.IsVirtual(path) || !Directory.Exists(path) ? null : path;
}
public async Task<bool> ForgetSourceAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -456,6 +731,12 @@ public sealed partial class MainViewModel : ObservableObject
return true;
}
public Task EmptyRecycleBinAsync()
{
Footer = "Empty Recycle Bin queued.";
return _ops.EmptyRecycleBinAsync();
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
@@ -752,6 +1033,11 @@ public sealed partial class MainViewModel : ObservableObject
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge)
or nameof(ExplorerPaneViewModel.HasGitRepo))
{
RefreshCloudActions();
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
@@ -774,7 +1060,7 @@ public sealed partial class MainViewModel : ObservableObject
}
Add(job.SourcePath);
if (job.Op != TransferOp.Delete)
if (job.Op != TransferOp.Delete && job.Op != TransferOp.EmptyRecycleBin)
{
Add(job.DestinationPath);
}
@@ -784,6 +1070,8 @@ public sealed partial class MainViewModel : ObservableObject
Add(extra);
}
_ = _folderSync.TryMarkRelationAsync(job);
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
@@ -793,6 +1081,10 @@ public sealed partial class MainViewModel : ObservableObject
{
Footer = job.Error ?? "Delete failed.";
}
else if (job.Op == TransferOp.EmptyRecycleBin)
{
Footer = "Recycle Bin emptied.";
}
else if (job.Op == TransferOp.Delete)
{
Footer = string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)

View File

@@ -21,6 +21,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public bool AvailableToImport { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
}
@@ -83,11 +84,22 @@ public sealed class NavigationTreeViewModel
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
thisPc.Children.Add(new NavNodeViewModel
{
Label = LocationRoots.RecycleBin,
Path = LocationRoots.RecycleBin,
Glyph = "\uE74D",
ChildrenLoaded = true
});
var untracked = (await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(true))
.Where(fp => fp.Kind.IsNetwork())
.ToList();
var network = sources.Where(s => s.Kind.IsNetwork())
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
if (prefs.GroupNetworkPlaces && network.Count > 0)
if (prefs.GroupNetworkPlaces && (network.Count > 0 || untracked.Count > 0))
{
var group = new NavNodeViewModel
{
@@ -103,6 +115,11 @@ public sealed class NavigationTreeViewModel
group.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
group.Children.Add(CreateImportNode(fp));
}
Roots.Add(group);
}
else
@@ -111,6 +128,11 @@ public sealed class NavigationTreeViewModel
{
Roots.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
Roots.Add(CreateImportNode(fp));
}
}
var places = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
@@ -404,6 +426,17 @@ public sealed class NavigationTreeViewModel
return node;
}
private static NavNodeViewModel CreateImportNode(VolumeFingerprint fingerprint)
=> new()
{
Label = (fingerprint.DisplayName ?? fingerprint.RootPath) + " (Windows)",
Path = fingerprint.RootPath,
Status = "Available in Windows",
Glyph = "\uE968",
ChildrenLoaded = true,
AvailableToImport = true
};
private static int ProviderOrder(string providerId) => providerId switch
{
"onedrive" => 0,

View File

@@ -0,0 +1,320 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class OperationProfilesViewModel : ObservableObject
{
private readonly OperationProfileService _profiles;
private OperationPlan? _plan;
private IReadOnlyList<string>? _sourceOverride;
[ObservableProperty] private OperationProfile? _selected;
[ObservableProperty] private string _name = "New profile";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private bool _requireGitClean;
[ObservableProperty] private bool _doCompress;
[ObservableProperty] private ArchiveFormat _archiveFormat = ArchiveFormat.SevenZip;
[ObservableProperty] private bool _doCopy = true;
[ObservableProperty] private bool _doRename;
[ObservableProperty] private string _renamePrefix = "";
[ObservableProperty] private string _renameSuffix = "";
[ObservableProperty] private string _renameSearch = "";
[ObservableProperty] private string _renameReplace = "";
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public OperationProfilesViewModel(OperationProfileService profiles)
{
_profiles = profiles;
Profiles = [];
Rows = [];
Formats =
[
new ArchiveFormatOption("7-Zip (.7z)", ArchiveFormat.SevenZip),
new ArchiveFormatOption("ZIP", ArchiveFormat.Zip)
];
}
public ObservableCollection<OperationProfile> Profiles { get; }
public ObservableCollection<ProfilePreviewRow> Rows { get; }
public IReadOnlyList<ArchiveFormatOption> Formats { get; }
public bool AutoRunEnabled => DoCopy && !DoCompress && !HasRenameText;
public bool CompressOptionsEnabled => DoCompress;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _profiles.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
public async Task RunOnAsync(long profileId, IReadOnlyList<string> sources)
{
var match = Profiles.FirstOrDefault(p => p.Id == profileId);
if (match is null)
{
Status = "That profile is no longer available.";
return;
}
Selected = match;
_sourceOverride = sources;
await AnalyzeAsync().ConfigureAwait(true);
}
partial void OnSelectedChanged(OperationProfile? value)
{
_sourceOverride = null;
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
RequireGitClean = value.RequireGitClean;
DoCompress = value.DoCompress;
ArchiveFormat = value.ArchiveFormat;
DoCopy = value.DoCopy;
DoRename = value.DoRename;
RenamePrefix = value.RenamePrefix;
RenameSuffix = value.RenameSuffix;
RenameSearch = value.RenameSearch;
RenameReplace = value.RenameReplace;
Excludes = value.Excludes;
AutoRun = value.CanAutoRun;
ClearPlan("Profile loaded. Preview to see what would run.");
}
partial void OnDoCopyChanged(bool value) => RefreshAutoRun();
partial void OnDoCompressChanged(bool value)
{
OnPropertyChanged(nameof(CompressOptionsEnabled));
RefreshAutoRun();
}
partial void OnDoRenameChanged(bool value) => RefreshAutoRun();
partial void OnRenamePrefixChanged(string value) => RefreshAutoRun();
partial void OnRenameSuffixChanged(string value) => RefreshAutoRun();
partial void OnRenameSearchChanged(string value) => RefreshAutoRun();
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New profile";
SourcePath = "";
DestPath = "";
RequireGitClean = false;
DoCompress = false;
ArchiveFormat = ArchiveFormat.SevenZip;
DoCopy = true;
DoRename = false;
RenamePrefix = "";
RenameSuffix = "";
RenameSearch = "";
RenameReplace = "";
Excludes = "";
AutoRun = false;
_sourceOverride = null;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
var profile = CurrentProfile();
profile.Id = await _profiles.SaveAsync(profile).ConfigureAwait(true);
ReplaceInList(profile);
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _profiles.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task DuplicateAsync()
{
var profile = CurrentProfile();
if (profile.Id <= 0)
{
Status = "Save the profile first.";
return;
}
var copy = await _profiles.DuplicateAsync(profile).ConfigureAwait(true);
Profiles.Add(copy);
Selected = copy;
Status = "Duplicated.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _profiles.PreviewAsync(profile, _sourceOverride)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _profiles.EnqueueAsync(profile, _plan, _sourceOverride).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
ReplaceInList(profile);
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public OperationProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Profile" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
RequireGitClean = RequireGitClean,
DoCompress = DoCompress,
ArchiveFormat = ArchiveFormat,
DoCopy = DoCopy,
DoRename = DoRename,
RenamePrefix = RenamePrefix ?? "",
RenameSuffix = RenameSuffix ?? "",
RenameSearch = RenameSearch ?? "",
RenameReplace = RenameReplace ?? "",
Excludes = Excludes ?? "",
AutoRun = AutoRun && AutoRunEnabled,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
IsBuiltIn = Selected?.IsBuiltIn ?? false,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.ProfilePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
if (_sourceOverride is { Count: > 0 } sources)
{
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: $"{plan.Operations.Count} operations on {sources.Count} item(s)"
+ (warnings > 0 ? $" · {warnings} skipped" : "");
return;
}
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Nothing to queue."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
private void ReplaceInList(OperationProfile profile)
{
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
}
private bool HasRenameText
=> DoRename && (!string.IsNullOrWhiteSpace(RenamePrefix)
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
private void RefreshAutoRun()
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (!AutoRunEnabled)
{
AutoRun = false;
}
}
}
public sealed record ArchiveFormatOption(string Label, ArchiveFormat Format);

View File

@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ReorganizeViewModel : ObservableObject
{
private readonly ReorganizeService _organize;
private OperationPlan? _plan;
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _picturesPath = "";
[ObservableProperty] private string _videosPath = "";
[ObservableProperty] private string _audioPath = "";
[ObservableProperty] private string _documentsPath = "";
[ObservableProperty] private string _installersPath = "";
[ObservableProperty] private string _archivesPath = "";
[ObservableProperty] private string _developmentPath = "";
[ObservableProperty] private string _status = "Preview to see proposed moves. Nothing is moved until you Queue.";
[ObservableProperty] private bool _canQueue;
public ReorganizeViewModel(ReorganizeService organize, string? sourcePath)
{
_organize = organize;
var dest = organize.LoadDestinations();
SourcePath = string.IsNullOrWhiteSpace(sourcePath) ? SuggestDownloads() : sourcePath;
PicturesPath = dest.Pictures;
VideosPath = dest.Videos;
AudioPath = dest.Audio;
DocumentsPath = dest.Documents;
InstallersPath = dest.Installers;
ArchivesPath = dest.Archives;
DevelopmentPath = dest.Development;
Rows = [];
}
public ObservableCollection<OrganizePreviewRow> Rows { get; }
[RelayCommand]
public async Task AnalyzeAsync()
{
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _organize.Preview(SourcePath.Trim(), CurrentDestinations())).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var dest = CurrentDestinations();
var plan = await _organize.EnqueueAsync(SourcePath.Trim(), dest, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = $"Queued {plan.Operations.Count} move(s).";
CanQueue = false;
}
}
[RelayCommand]
public void SaveDestinations()
{
_organize.SaveDestinations(CurrentDestinations());
Status = "Destinations saved.";
}
public OrganizeDestinations CurrentDestinations()
=> new()
{
Pictures = PicturesPath.Trim(),
Videos = VideosPath.Trim(),
Audio = AudioPath.Trim(),
Documents = DocumentsPath.Trim(),
Installers = InstallersPath.Trim(),
Archives = ArchivesPath.Trim(),
Development = DevelopmentPath.Trim()
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.OrganizePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
var moves = plan.Operations.Count;
var skipped = plan.OrganizePreview.Count(r => r.Action == "Skip");
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: moves == 0
? skipped > 0
? $"Nothing to queue · {skipped} left in place"
: "Nothing to queue."
: $"{moves} move(s) · {skipped} left in place"
+ (warnings > 0 ? $" · {warnings} warning(s)" : "");
}
private static string SuggestDownloads()
{
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads) ? downloads : "";
}
}

View File

@@ -22,6 +22,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
[ObservableProperty] private bool _hasProgress;
[ObservableProperty] private bool _canPause;
[ObservableProperty] private bool _canResume;
[ObservableProperty] private bool _canRetry;
[ObservableProperty] private bool _canRemove;
[ObservableProperty] private bool _canMoveUp;
[ObservableProperty] private bool _canMoveDown;
@@ -54,10 +55,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
HasProgress = job.BytesTotal is > 0 && job.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
CanResume = job.Status == TransferStatus.Paused;
CanRetry = job.Status == TransferStatus.Failed;
CanRemove = job.Status is not TransferStatus.Cancelling;
CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
CanMoveDown = canMoveDown && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling;
IsFailed = job.Status == TransferStatus.Failed;
}
@@ -71,6 +74,16 @@ public sealed partial class TransferJobViewModel : ObservableObject
return count <= 1 ? FileName(job.SourcePath) : $"{count} items";
}
if (job.Op == TransferOp.EmptyRecycleBin)
{
return LocationRoots.RecycleBin;
}
if (job.Op is TransferOp.Compress or TransferOp.AddToArchive)
{
return FileName(job.DestinationPath);
}
return FileName(job.SourcePath);
}
@@ -79,6 +92,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
{
TransferOp.Copy => $"Copy to {FolderName(job.DestinationPath)}",
TransferOp.Move => $"Move to {FolderName(job.DestinationPath)}",
TransferOp.Rename => $"Rename to {FileName(job.DestinationPath)}",
TransferOp.Extract => $"Extract to {FileName(job.DestinationPath)}",
TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}",
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
: "Move to Recycle Bin",
@@ -93,6 +112,9 @@ public sealed partial class TransferJobViewModel : ObservableObject
? $"{OpWord(job.Op)} {job.FilesDone:N0} of {job.FilesTotal:N0}"
: "Working…",
TransferStatus.Paused => "Paused",
TransferStatus.Waiting => string.IsNullOrWhiteSpace(job.WaitReason)
? "Waiting for destination"
: job.WaitReason,
TransferStatus.Cancelling => "Cancelling…",
TransferStatus.Cancelled => "Cancelled",
TransferStatus.Failed => string.IsNullOrWhiteSpace(job.Error) ? "Failed" : job.Error,
@@ -152,6 +174,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
}
@@ -229,6 +257,15 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
[RelayCommand]
public void Retry(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.Retry(job.Id);
}
}
[RelayCommand]
public void Remove(TransferJobViewModel? job)
{
@@ -237,7 +274,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -270,7 +308,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -336,8 +375,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling).ToList();
var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused)
var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling).ToList();
var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Waiting)
?? active.FirstOrDefault();
HasJobs = Jobs.Count > 0;
HasActiveJobs = active.Count > 0;
@@ -397,6 +437,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return "";
}
var waitingDest = active.Count(j => j.Status == TransferStatus.Waiting);
var waiting = active.Count(j => j.Status == TransferStatus.Queued);
var name = current is null ? $"{active.Count} transfers" : TransferJobViewModel.FileName(current.SourcePath);
if (current?.Status == TransferStatus.Paused)
@@ -404,6 +445,13 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting > 0 ? $"Paused · {name} · {waiting} waiting" : $"Paused · {name}";
}
if (current?.Status == TransferStatus.Waiting)
{
return waitingDest > 1
? $"Waiting for destination · {waitingDest} items"
: $"Waiting for destination · {name}";
}
if (current?.Status == TransferStatus.Running)
{
return waiting > 0 ? $"{OpVerb(current.Op)} {name} · {waiting} waiting" : $"{OpVerb(current.Op)} {name}";
@@ -411,7 +459,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting == active.Count
? $"{active.Count} queued"
: $"{active.Count} transfers";
: waitingDest == active.Count
? "Waiting for destination"
: $"{active.Count} transfers";
}
private static string OpVerb(TransferOp op)
@@ -420,11 +470,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Waiting or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Cancelled;
}