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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user