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

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

View File

@@ -7,9 +7,9 @@ namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly ILogger<TransferQueue> _logger;
private readonly List<TransferJob> _jobs = [];
private readonly object _gate = new();
@@ -20,19 +20,20 @@ public sealed class TransferQueue : BackgroundService
private long? _holdJobId;
private CancellationTokenSource? _runningCts;
private DateTime _lastChangedUtc = DateTime.MinValue;
private int _restored;
public event EventHandler? Changed;
public event EventHandler<TransferJob>? JobFinished;
public TransferQueue(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IOperationExecutor executor,
IIndexStore store,
IVolumeService volumes,
ILogger<TransferQueue> logger)
{
_shell = shell;
_enumerator = enumerator;
_executor = executor;
_store = store;
_volumes = volumes;
_logger = logger;
_pauseRequested = () => _haltPause;
}
@@ -92,6 +93,69 @@ public sealed class TransferQueue : BackgroundService
}, cancellationToken).ConfigureAwait(false);
}
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
{
var dest = Path.Combine(PathRules.Parent(path), newName);
return EnqueueAsync(new TransferJob
{
Op = TransferOp.Rename,
SourcePath = path,
DestinationPath = dest,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
}
public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.EmptyRecycleBin,
SourcePath = LocationRoots.RecycleBin,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Extract,
SourcePath = archivePath,
DestinationPath = destinationDirectory,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Compress,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.AddToArchive,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.VerifyArchive,
SourcePath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public void PauseAll()
{
_queuePaused = true;
@@ -110,6 +174,7 @@ public sealed class TransferQueue : BackgroundService
&& (j.StartedUtc is not null || j.CurrentPath is not null)))
{
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
}
@@ -130,6 +195,7 @@ public sealed class TransferQueue : BackgroundService
if (job.Status == TransferStatus.Queued)
{
job.Status = TransferStatus.Paused;
QueuePersist(job);
}
else if (job.Status == TransferStatus.Running)
{
@@ -152,6 +218,7 @@ public sealed class TransferQueue : BackgroundService
}
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
_queuePaused = false;
@@ -165,6 +232,27 @@ public sealed class TransferQueue : BackgroundService
RaiseChanged();
}
public void Retry(long jobId)
{
lock (_gate)
{
var job = Find(jobId);
if (job is null || job.Status != TransferStatus.Failed)
{
return;
}
job.RetryCount++;
job.Status = TransferStatus.Queued;
job.Error = null;
job.WaitReason = null;
QueuePersist(job);
}
Pulse();
RaiseChanged();
}
public void Cancel(long jobId)
{
CancellationTokenSource? running = null;
@@ -176,9 +264,10 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Paused)
if (job.Status is TransferStatus.Queued or TransferStatus.Paused or TransferStatus.Waiting)
{
job.Status = TransferStatus.Cancelled;
QueuePersist(job);
_jobs.Remove(job);
if (_holdJobId == jobId)
{
@@ -195,6 +284,8 @@ public sealed class TransferQueue : BackgroundService
}
else
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
}
@@ -207,7 +298,15 @@ public sealed class TransferQueue : BackgroundService
{
lock (_gate)
{
_jobs.RemoveAll(j => j.Id == jobId && j.Status is TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed);
var job = Find(jobId);
if (job is null || job.Status is not (TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed))
{
return;
}
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
RaiseChanged();
@@ -218,7 +317,13 @@ public sealed class TransferQueue : BackgroundService
var removed = false;
lock (_gate)
{
removed = _jobs.RemoveAll(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled) > 0;
foreach (var job in _jobs.Where(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled).ToList())
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
removed = true;
}
}
if (removed)
@@ -255,6 +360,7 @@ public sealed class TransferQueue : BackgroundService
_jobs.RemoveAt(index);
_jobs.Insert(target, job);
PersistOrder();
}
Pulse();
@@ -262,12 +368,47 @@ public sealed class TransferQueue : BackgroundService
return true;
}
public void NotifyAvailability()
{
var resumed = false;
lock (_gate)
{
foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting))
{
if (!OperationAvailability.IsReady(_volumes, job))
{
continue;
}
job.Status = TransferStatus.Queued;
job.WaitReason = null;
QueuePersist(job);
resumed = true;
}
}
if (resumed)
{
Pulse();
RaiseChanged();
}
}
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
}
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
lock (_gate)
{
_jobs.Add(job);
if (_jobs.All(j => j.Id != job.Id))
{
_jobs.Add(job);
}
}
Pulse();
@@ -276,6 +417,7 @@ public sealed class TransferQueue : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RestoreAsync(stoppingToken).ConfigureAwait(false);
while (!stoppingToken.IsCancellationRequested)
{
TransferJob? job;
@@ -298,10 +440,71 @@ public sealed class TransferQueue : BackgroundService
continue;
}
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
continue;
}
await RunAsync(job, stoppingToken).ConfigureAwait(false);
}
}
private async Task RestoreAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _restored, 1) == 1)
{
return;
}
IReadOnlyList<TransferJob> incomplete;
try
{
incomplete = await _store.Transfers.GetIncompleteAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to restore file operations queue");
return;
}
var interrupted = new List<TransferJob>();
lock (_gate)
{
var known = _jobs.Select(j => j.Id).ToHashSet();
foreach (var job in incomplete)
{
if (!known.Add(job.Id))
{
continue;
}
if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
{
job.Status = TransferStatus.Paused;
job.Error = null;
interrupted.Add(job);
}
_jobs.Add(job);
}
}
foreach (var job in interrupted)
{
await Persist(job).ConfigureAwait(false);
}
if (incomplete.Count > 0)
{
Pulse();
RaiseChanged();
}
}
private bool CanStartNext()
{
if (_queuePaused)
@@ -324,20 +527,17 @@ public sealed class TransferQueue : BackgroundService
job.Status = TransferStatus.Running;
job.StartedUtc ??= DateTimeOffset.UtcNow;
job.Error = null;
job.WaitReason = null;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
try
{
switch (job.Op)
await _executor.ExecuteAsync(job, _pauseRequested, () => RaiseChanged(throttled: true), linked.Token)
.ConfigureAwait(false);
if (_haltPause && job.Status == TransferStatus.Paused)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
_haltPause = false;
}
if (job.Status == TransferStatus.Paused)
@@ -347,6 +547,13 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status == TransferStatus.Waiting)
{
await Persist(job).ConfigureAwait(false);
RaiseChanged();
return;
}
if (job.Status == TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelled;
@@ -369,7 +576,7 @@ public sealed class TransferQueue : BackgroundService
{
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
job.Status = TransferStatus.Failed;
job.Error = ex.Message;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
finally
{
@@ -389,166 +596,6 @@ public sealed class TransferQueue : BackgroundService
JobFinished?.Invoke(this, job);
}
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
if (move && job.Status is not TransferStatus.Failed and not TransferStatus.Cancelling and not TransferStatus.Paused)
{
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
}
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, stoppingToken, out var error))
{
ApplyHalt(job, src, error);
return;
}
job.BytesDone = job.BytesTotal ?? job.BytesDone;
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
job.FilesDone = 0;
job.FilesTotal = 0;
job.BytesDone = 0;
job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling || _haltPause)
{
ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
continue;
}
job.FilesTotal++;
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var destExists = File.Exists(PathRules.ToExtended(childDest));
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
if (destExists && destLen == child.SizeBytes)
{
job.BytesDone += child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
continue;
}
job.CurrentPath = child.FullPath;
var committed = job.BytesDone;
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, stoppingToken, out var err))
{
ApplyHalt(job, child.FullPath, err);
return;
}
job.BytesDone = committed + child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
}
}
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private bool TransferFile(
string src,
string dst,
bool move,
bool resumePartial,
TransferJob job,
long committed,
CancellationToken stoppingToken,
out string? error)
{
var progress = new Progress<long>(b =>
{
job.BytesDone = committed + b;
RaiseChanged(throttled: true);
});
var ok = move
? _shell.MoveFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested)
: _shell.CopyFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested);
return ok;
}
private void ApplyHalt(TransferJob job, string? path, string? error)
{
job.CurrentPath = path;
if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
{
job.Status = TransferStatus.Cancelling;
job.Error = error == "Cancelled" ? null : error;
return;
}
if (_haltPause || error == "Paused")
{
job.Status = TransferStatus.Paused;
job.Error = null;
_haltPause = false;
return;
}
job.Status = TransferStatus.Failed;
job.Error = error;
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
if (!_shell.Delete(paths, recycle, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId);
private void Pulse()
@@ -575,5 +622,28 @@ public sealed class TransferQueue : BackgroundService
Changed?.Invoke(this, EventArgs.Empty);
}
private void PersistOrder()
{
for (var i = 0; i < _jobs.Count; i++)
{
_jobs[i].SortOrder = i;
QueuePersist(_jobs[i]);
}
}
private void QueuePersist(TransferJob job) => _ = PersistSafe(job);
private async Task PersistSafe(TransferJob job)
{
try
{
await Persist(job).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist transfer job {Id}", job.Id);
}
}
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
}