Improve file operations, layout memory, and drive status.

Add a sequential file-operations queue with pause, reorder, and optional auto-clear; persist window size and tree width; show free space; and clear leftover indexing status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:52:30 +02:00
parent d79605cde9
commit 9bf451932f
41 changed files with 2855 additions and 275 deletions

View File

@@ -92,22 +92,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
IEnumerable<FileSystemItem> ordered = listing.Items;
ordered = SortProperty switch
{
"Size" => SortDescending ? ordered.OrderByDescending(i => i.SizeBytes) : ordered.OrderBy(i => i.SizeBytes),
"Modified" => SortDescending ? ordered.OrderByDescending(i => i.ModifiedUtc) : ordered.OrderBy(i => i.ModifiedUtc),
"Type" => SortDescending ? ordered.OrderByDescending(i => i.IsDirectory) : ordered.OrderBy(i => i.IsDirectory),
_ => SortDescending
? ordered.OrderByDescending(i => i.IsDirectory).ThenByDescending(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
: ordered.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
};
foreach (var item in ordered)
foreach (var item in listing.Items)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
}
ApplyCurrentSort();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
@@ -141,6 +132,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
}
ApplyCurrentSort();
}
[RelayCommand]
@@ -218,8 +211,56 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
}
partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
public void SortBy(string property)
{
if (string.Equals(SortProperty, property, StringComparison.OrdinalIgnoreCase))
{
SortDescending = !SortDescending;
}
else
{
SortProperty = property;
SortDescending = property is "Size" or "Free" or "Modified";
}
ApplyCurrentSort();
}
public void ApplyCurrentSort()
{
var ordered = OrderItems(Items, SortProperty, SortDescending).ToList();
Items.Clear();
foreach (var item in ordered)
{
Items.Add(item);
}
}
public static IEnumerable<FolderItemViewModel> OrderItems(
IEnumerable<FolderItemViewModel> items,
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)
};
}
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
{

View File

@@ -6,22 +6,53 @@ namespace Explorer.Presentation;
public sealed partial class FolderItemViewModel : ObservableObject
{
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private bool _isRenaming;
[ObservableProperty] private bool _isDropTarget;
[ObservableProperty] private string _editName = "";
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
{
Item = item;
SizeFromIndex = sizeFromIndex;
_editName = item.Name;
}
public void BeginRename()
{
EditName = Item.Name;
IsRenaming = true;
}
public void CancelRename()
{
IsRenaming = false;
EditName = Item.Name;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public string Name => Item.Name;
public string Name => Item.DisplayName ?? Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
? ""
: Formatters.Size(Item.SizeBytes);
public string TypeLabel => 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",
SizeKnowledge.Unknown => "",
SizeKnowledge.Partial when Item.SizeBytes > 0 => $"{Formatters.Size(Item.SizeBytes)} (partial)",
SizeKnowledge.Partial => "Access denied",
_ when Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0 => "",
_ => Formatters.Size(Item.SizeBytes)
};
public string FreeSpaceLabel => Item.FreeSpaceBytes is long free ? Formatters.Size(free) : "";
public string FreeSpaceTooltip
=> Item.FreeSpaceBytes is long free && Item.CapacityBytes is long total && total > 0
? $"{Formatters.Size(free)} free of {Formatters.Size(total)}"
: FreeSpaceLabel;
public string StatusGlyph => Item.Location.IsRecycleBin ? "🗑" : Item.Location.IsProtected || Item.Location.AccessDenied ? "⚠" : "";
public bool HasStatusGlyph => StatusGlyph.Length > 0;
public string ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
@@ -44,6 +75,11 @@ public sealed partial class FolderItemViewModel : ObservableObject
: $"{CloudStatus} · Size {logical} · On disk {Formatters.Size(disk)}";
}
if (Item.Location.AccessDenied)
{
return string.IsNullOrEmpty(CloudStatus) ? "Access denied" : $"{CloudStatus} · Access denied";
}
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
}
}

View File

@@ -50,7 +50,8 @@ public sealed partial class MainViewModel : ObservableObject
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
UiPreferencesStore preferences,
IVolumeService volumes)
{
_browse = browse;
_ops = ops;
@@ -64,10 +65,10 @@ public sealed partial class MainViewModel : ObservableObject
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources);
Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Transfers = new TransferQueueViewModel(transfers);
Transfers = new TransferQueueViewModel(transfers, preferences);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
@@ -87,13 +88,22 @@ public sealed partial class MainViewModel : ObservableObject
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
void Apply()
{
Footer = text;
if (p.Status is ScanJobStatus.Done or ScanJobStatus.Failed or ScanJobStatus.Cancelled)
{
_ = Tree.ApplySourceStateAsync(p.SourceId);
}
}
if (_ui is { } ctx)
{
ctx.Post(_ => Footer = text, null);
ctx.Post(_ => Apply(), null);
}
else
{
Footer = text;
Apply();
}
};
}
@@ -304,32 +314,52 @@ public sealed partial class MainViewModel : ObservableObject
paths = [ActivePane.CurrentPath];
}
CopyPathsToClipboard(paths);
CopyPathText(paths);
}
public event EventHandler<string>? InlineRenameRequested;
[RelayCommand]
public void NewFolder()
public async Task NewFolder()
{
var created = await CreateNewFolderAsync().ConfigureAwait(true);
if (created is not null)
{
InlineRenameRequested?.Invoke(this, created);
}
}
public async Task<string?> CreateNewFolderAsync()
{
if (LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
{
return;
Footer = LocationRoots.IsVirtual(ActivePane.CurrentPath)
? "Open a folder to create a new folder."
: "This location is offline.";
return null;
}
_ops.NewFolder(ActivePane.CurrentPath);
var created = _ops.NewFolder(ActivePane.CurrentPath);
EnqueueReconcile(ActivePane.CurrentPath);
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
Footer = $"Created {PathRules.GetFileName(created)}.";
await RefreshFolderViewsAsync(ActivePane.CurrentPath).ConfigureAwait(true);
return created;
}
public void RenameSelected(string newName)
{
var item = ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
if (item is not null)
{
return;
RenameItem(item, newName);
}
}
public void RenameItem(FolderItemViewModel item, string newName)
{
_ops.Rename(item.FullPath, newName);
EnqueueReconcile(ActivePane.CurrentPath);
Footer = $"Renamed to {newName}.";
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
}
@@ -539,7 +569,7 @@ public sealed partial class MainViewModel : ObservableObject
{
if (Analysis.SelectedPath is { } path)
{
CopyPathsToClipboard([path]);
CopyPathText([path]);
}
}
@@ -623,11 +653,27 @@ public sealed partial class MainViewModel : ObservableObject
return stored with { Theme = UiPreferencesStore.NormalizeTheme(Theme) };
}
public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth)
{
var stored = _preferences.Load();
_preferences.Save(stored with
{
Theme = UiPreferencesStore.NormalizeTheme(Theme),
WindowWidth = width,
WindowHeight = height,
WindowLeft = left,
WindowTop = top,
WindowMaximized = maximized,
TreeWidth = treeWidth
});
}
public async Task ApplyPreferencesAsync(UiPreferences preferences)
{
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };
_preferences.Save(normalized);
Theme = normalized.Theme;
Transfers.ApplyPreferences();
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
foreach (var tab in Tabs)
{
@@ -849,6 +895,23 @@ public sealed partial class MainViewModel : ObservableObject
Clipboard.SetFiles(paths, _clipboardIsCut);
}
private void CopyPathText(IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return;
}
Clipboard.SetText(string.Join(Environment.NewLine, paths.Select(QuotePath)));
Footer = paths.Count == 1 ? "Path copied." : $"{paths.Count} paths copied.";
}
private static string QuotePath(string path)
{
var normalized = PathRules.FromExtended(path);
return normalized.StartsWith('"') ? normalized : $"\"{normalized}\"";
}
partial void OnActiveTabChanged(ExplorerTabViewModel value)
{
PathText = value.ActivePane.CurrentPath;

View File

@@ -15,6 +15,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _isOffline;
[ObservableProperty] private bool _childrenLoaded;
[ObservableProperty] private bool _isDropTarget;
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
public string Glyph { get; init; } = "\uE8B7";
@@ -321,6 +322,37 @@ public sealed class NavigationTreeViewModel
}
}
public async Task ApplySourceStateAsync(long sourceId, CancellationToken cancellationToken = default)
{
var source = await _sources.GetAsync(sourceId, cancellationToken).ConfigureAwait(true);
if (source is null)
{
return;
}
var node = FindBySourceId(Roots, sourceId)
?? (source.LastRootPath is null ? null : FindByPath(Roots, source.LastRootPath));
if (node is null)
{
return;
}
node.Status = FormatStatus(source);
node.IsOffline = source.Status == SourceStatus.Offline;
}
public static string FormatStatus(Source source)
=> source.Status switch
{
SourceStatus.Offline => source.LastSeenUtc is null
? "Offline"
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "May be out of date",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? "Indexed" : ""
};
public static bool PathsEqual(string a, string b)
{
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
@@ -362,16 +394,7 @@ public sealed class NavigationTreeViewModel
{
Label = source.DisplayName,
Path = source.LastRootPath ?? source.DisplayName,
Status = source.Status switch
{
SourceStatus.Offline => source.LastSeenUtc is null
? "Offline"
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "May be out of date",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? "Indexed" : ""
},
Status = FormatStatus(source),
IsOffline = source.Status == SourceStatus.Offline,
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2",
SourceId = source.Id,
@@ -489,6 +512,30 @@ public sealed class NavigationTreeViewModel
return null;
}
private static NavNodeViewModel? FindBySourceId(IEnumerable<NavNodeViewModel> nodes, long sourceId)
{
foreach (var node in nodes)
{
if (node.IsPlaceholder)
{
continue;
}
if (node.SourceId == sourceId)
{
return node;
}
var child = FindBySourceId(node.Children, sourceId);
if (child is not null)
{
return child;
}
}
return null;
}
private static NavNodeViewModel? FindByPath(IEnumerable<NavNodeViewModel> nodes, string path)
{
foreach (var node in nodes)

View File

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Search;
namespace Explorer.Presentation.ViewModels;
@@ -13,6 +14,7 @@ public sealed partial class SearchViewModel : ObservableObject
{
private readonly SearchService _search;
private readonly SourceManager _sources;
private readonly IVolumeService _volumes;
private CancellationTokenSource? _runCts;
[ObservableProperty] private string _text = "";
@@ -26,10 +28,11 @@ public sealed partial class SearchViewModel : ObservableObject
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
public SearchViewModel(SearchService search, SourceManager sources)
public SearchViewModel(SearchService search, SourceManager sources, IVolumeService volumes)
{
_search = search;
_sources = sources;
_volumes = volumes;
Results = [];
}
@@ -111,12 +114,19 @@ public sealed partial class SearchViewModel : ObservableObject
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
var byId = sources.ToDictionary(s => s.Id);
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
byId.TryGetValue(entry.SourceId, out var src);
var full = src?.LastRootPath is null
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
: PathRules.Combine(src.LastRootPath, entry.PathRel);
var spaceKey = PathRules.IsUnc(full) ? PathRules.CanonicalUncRoot(full) : Path.GetPathRoot(full) ?? full;
if (!spaceCache.TryGetValue(spaceKey, out var space))
{
space = _volumes.GetSpace(full);
spaceCache[spaceKey] = space;
}
Results.Add(new FolderItemViewModel(new FileSystemItem
{
FullPath = full,
@@ -127,7 +137,9 @@ public sealed partial class SearchViewModel : ObservableObject
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
ReparseTag = entry.ReparseTag
ReparseTag = entry.ReparseTag,
FreeSpaceBytes = space.FreeBytes,
CapacityBytes = space.CapacityBytes
}, entry.IsDirectory));
}

View File

@@ -1,39 +1,243 @@
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 TransferJobViewModel : ObservableObject
{
[ObservableProperty] private TransferStatus _status;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _subtitle = "";
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _bytesText = "";
[ObservableProperty] private string _speedText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCurrentFile))]
private string _currentFile = "";
[ObservableProperty] private double _progress;
[ObservableProperty] private bool _hasProgress;
[ObservableProperty] private bool _canPause;
[ObservableProperty] private bool _canResume;
[ObservableProperty] private bool _canRemove;
[ObservableProperty] private bool _canMoveUp;
[ObservableProperty] private bool _canMoveDown;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isFailed;
public long Id { get; }
public TransferJob Job { get; }
public bool HasCurrentFile => !string.IsNullOrWhiteSpace(CurrentFile);
public TransferJobViewModel(TransferJob job)
{
Id = job.Id;
Job = job;
Apply(job, canMoveUp: false, canMoveDown: false, speed: null);
}
public void Apply(TransferJob job, bool canMoveUp, bool canMoveDown, string? speed)
{
Status = job.Status;
Title = DisplayName(job);
Subtitle = DestinationText(job);
StatusText = StatusLabel(job);
BytesText = BytesLabel(job);
SpeedText = job.Status == TransferStatus.Running ? speed ?? "" : "";
CurrentFile = job.Status is TransferStatus.Running or TransferStatus.Paused
? FileName(job.CurrentPath)
: "";
Progress = job.BytesTotal is > 0 ? Math.Clamp(job.BytesDone / (double)job.BytesTotal.Value, 0, 1) : 0;
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;
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;
IsFailed = job.Status == TransferStatus.Failed;
}
private static string DisplayName(TransferJob job)
{
if (job.Op == TransferOp.Delete)
{
var count = job.AdditionalSources.Count > 0
? job.AdditionalSources.Count
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries).Length;
return count <= 1 ? FileName(job.SourcePath) : $"{count} items";
}
return FileName(job.SourcePath);
}
private static string DestinationText(TransferJob job)
=> job.Op switch
{
TransferOp.Copy => $"Copy to {FolderName(job.DestinationPath)}",
TransferOp.Move => $"Move to {FolderName(job.DestinationPath)}",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
: "Move to Recycle Bin",
_ => job.Op.ToString()
};
private static string StatusLabel(TransferJob job)
=> job.Status switch
{
TransferStatus.Queued => "Queued",
TransferStatus.Running => job.FilesTotal > 0
? $"{OpWord(job.Op)} {job.FilesDone:N0} of {job.FilesTotal:N0}"
: "Working…",
TransferStatus.Paused => "Paused",
TransferStatus.Cancelling => "Cancelling…",
TransferStatus.Cancelled => "Cancelled",
TransferStatus.Failed => string.IsNullOrWhiteSpace(job.Error) ? "Failed" : job.Error,
TransferStatus.Done => "Done",
_ => job.Status.ToString()
};
private static string BytesLabel(TransferJob job)
{
if (job.BytesTotal is > 0)
{
return $"{FormatBytes(job.BytesDone)} of {FormatBytes(job.BytesTotal.Value)}";
}
return job.BytesDone > 0 ? FormatBytes(job.BytesDone) : "";
}
internal static string FileName(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "";
}
var name = PathRules.GetFileName(path.Split('|')[0]);
return string.IsNullOrWhiteSpace(name) ? path : name;
}
private static string FolderName(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "";
}
var folder = PathRules.Parent(path);
return string.IsNullOrWhiteSpace(folder) ? path : folder;
}
internal static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double value = Math.Max(0, bytes);
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
}
private static string OpWord(TransferOp op)
=> op switch
{
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
_ => "Working"
};
}
public sealed partial class TransferQueueViewModel : ObservableObject
{
private readonly TransferQueue _queue;
private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
public TransferQueueViewModel(TransferQueue queue)
private bool _holdCollapsed;
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _showPanel;
[ObservableProperty] private bool _isQueuePaused;
[ObservableProperty] private bool _hasJobs;
[ObservableProperty] private bool _hasActiveJobs;
[ObservableProperty] private bool _hasFinishedJobs;
[ObservableProperty] private bool _canPauseAll;
[ObservableProperty] private bool _canResumeAll;
[ObservableProperty] private string _summary = "";
[ObservableProperty] private double _overallProgress;
[ObservableProperty] private bool _hasOverallProgress;
public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences)
{
_queue = queue;
_preferences = preferences;
Jobs = [];
_queue.Changed += (_, _) =>
{
if (_ui is { } ctx)
{
ctx.Post(_ => Reload(), null);
}
else
{
Reload();
}
};
_queue.Changed += (_, _) => Dispatch(Reload);
Reload();
}
public ObservableCollection<TransferJob> Jobs { get; }
public bool HasJobs => Jobs.Count > 0;
public void Cancel(TransferJob job)
public void ApplyPreferences()
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling)
if (_preferences.Load().AutoClearQueueWhenDone)
{
_queue.ClearFinished();
}
Reload();
}
public ObservableCollection<TransferJobViewModel> Jobs { get; }
[RelayCommand]
public void ToggleExpanded()
{
IsExpanded = !IsExpanded;
_holdCollapsed = !IsExpanded;
}
[RelayCommand]
public void PauseAll() => _queue.PauseAll();
[RelayCommand]
public void ResumeAll() => _queue.ResumeAll();
[RelayCommand]
public void Pause(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.Pause(job.Id);
}
}
[RelayCommand]
public void Resume(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.Resume(job.Id);
}
}
[RelayCommand]
public void Remove(TransferJobViewModel? job)
{
if (job is null)
{
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -43,18 +247,184 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
private void Reload()
[RelayCommand]
public void MoveUp(TransferJobViewModel? job)
{
Jobs.Clear();
foreach (var job in _queue.Snapshot().Where(IsVisible))
if (job is not null)
{
Jobs.Add(job);
_queue.MoveUp(job.Id);
}
OnPropertyChanged(nameof(HasJobs));
}
[RelayCommand]
public void MoveDown(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.MoveDown(job.Id);
}
}
[RelayCommand]
public void ClearFinished() => _queue.ClearFinished();
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
else
{
_queue.Dismiss(job.Id);
}
}
private void Dispatch(Action action)
{
if (_ui is { } ctx)
{
ctx.Post(_ => action(), null);
}
else
{
action();
}
}
private void Reload()
{
if (_preferences.Load().AutoClearQueueWhenDone)
{
_queue.ClearFinished();
}
var snapshot = _queue.Snapshot();
var visible = snapshot.Where(IsVisible).ToList();
var ids = visible.Select(j => j.Id).ToHashSet();
for (var i = Jobs.Count - 1; i >= 0; i--)
{
if (!ids.Contains(Jobs[i].Id))
{
_speed.Remove(Jobs[i].Id);
Jobs.RemoveAt(i);
}
}
for (var i = 0; i < visible.Count; i++)
{
var job = visible[i];
var existing = Jobs.FirstOrDefault(j => j.Id == job.Id);
var canMoveUp = i > 0 && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
var canMoveDown = i < visible.Count - 1 && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
var speed = SpeedText(job);
if (existing is null)
{
var vm = new TransferJobViewModel(job);
vm.Apply(job, canMoveUp, canMoveDown, speed);
Jobs.Insert(i, vm);
}
else
{
existing.Apply(job, canMoveUp, canMoveDown, speed);
var current = Jobs.IndexOf(existing);
if (current != i && current >= 0)
{
Jobs.Move(current, i);
}
}
}
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)
?? active.FirstOrDefault();
HasJobs = Jobs.Count > 0;
HasActiveJobs = active.Count > 0;
HasFinishedJobs = snapshot.Any(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled);
IsQueuePaused = _queue.IsPaused || active.Any(j => j.Status == TransferStatus.Paused);
CanPauseAll = active.Any(j => j.Status is TransferStatus.Queued or TransferStatus.Running);
CanResumeAll = _queue.IsPaused || active.Any(j => j.Status == TransferStatus.Paused);
HasOverallProgress = currentJob?.BytesTotal is > 0;
OverallProgress = currentJob?.BytesTotal is > 0
? Math.Clamp(currentJob.BytesDone / (double)currentJob.BytesTotal.Value, 0, 1)
: 0;
Summary = BuildSummary(active, currentJob);
if (HasActiveJobs && !_holdCollapsed)
{
IsExpanded = true;
}
else if (!HasActiveJobs)
{
_holdCollapsed = false;
}
ShowPanel = IsExpanded && HasJobs;
}
partial void OnIsExpandedChanged(bool value) => ShowPanel = value && HasJobs;
private string? SpeedText(TransferJob job)
{
if (job.Status != TransferStatus.Running)
{
_speed.Remove(job.Id);
return null;
}
var now = DateTime.UtcNow;
if (_speed.TryGetValue(job.Id, out var prev))
{
var seconds = (now - prev.Utc).TotalSeconds;
if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
{
var rate = (job.BytesDone - prev.Bytes) / seconds;
_speed[job.Id] = (job.BytesDone, now);
return rate > 0 ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s" : null;
}
return null;
}
_speed[job.Id] = (job.BytesDone, now);
return null;
}
private static string BuildSummary(IReadOnlyList<TransferJob> active, TransferJob? current)
{
if (active.Count == 0)
{
return "";
}
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)
{
return waiting > 0 ? $"Paused · {name} · {waiting} waiting" : $"Paused · {name}";
}
if (current?.Status == TransferStatus.Running)
{
return waiting > 0 ? $"{OpVerb(current.Op)} {name} · {waiting} waiting" : $"{OpVerb(current.Op)} {name}";
}
return waiting == active.Count
? $"{active.Count} queued"
: $"{active.Count} transfers";
}
private static string OpVerb(TransferOp op)
=> op switch
{
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
_ => op.ToString()
};
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running
or TransferStatus.Cancelling or TransferStatus.Failed;
=> job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Cancelled;
}