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:
10
src/Explorer.Presentation/IThumbnailService.cs
Normal file
10
src/Explorer.Presentation/IThumbnailService.cs
Normal 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);
|
||||
}
|
||||
43
src/Explorer.Presentation/RangeObservableCollection.cs
Normal file
43
src/Explorer.Presentation/RangeObservableCollection.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
123
src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs
Normal file
123
src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs
Normal 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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
228
src/Explorer.Presentation/ViewModels/FolderSyncViewModel.cs
Normal file
228
src/Explorer.Presentation/ViewModels/FolderSyncViewModel.cs
Normal 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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
118
src/Explorer.Presentation/ViewModels/ReorganizeViewModel.cs
Normal file
118
src/Explorer.Presentation/ViewModels/ReorganizeViewModel.cs
Normal 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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user