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:
@@ -1,4 +1,3 @@
|
||||
using System.Threading.Channels;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -12,9 +11,15 @@ public sealed class TransferQueue : BackgroundService
|
||||
private readonly IFileSystemEnumerator _enumerator;
|
||||
private readonly IIndexStore _store;
|
||||
private readonly ILogger<TransferQueue> _logger;
|
||||
private readonly Channel<Work> _channel = Channel.CreateUnbounded<Work>();
|
||||
private readonly List<TransferJob> _jobs = [];
|
||||
private readonly object _gate = new();
|
||||
private readonly SemaphoreSlim _signal = new(0);
|
||||
private readonly Func<bool> _pauseRequested;
|
||||
private volatile bool _queuePaused;
|
||||
private volatile bool _haltPause;
|
||||
private long? _holdJobId;
|
||||
private CancellationTokenSource? _runningCts;
|
||||
private DateTime _lastChangedUtc = DateTime.MinValue;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
public event EventHandler<TransferJob>? JobFinished;
|
||||
@@ -29,8 +34,11 @@ public sealed class TransferQueue : BackgroundService
|
||||
_enumerator = enumerator;
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
_pauseRequested = () => _haltPause;
|
||||
}
|
||||
|
||||
public bool IsPaused => _queuePaused;
|
||||
|
||||
public IReadOnlyList<TransferJob> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -84,19 +92,106 @@ public sealed class TransferQueue : BackgroundService
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Cancel(long jobId)
|
||||
public void PauseAll()
|
||||
{
|
||||
_queuePaused = true;
|
||||
_haltPause = true;
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void ResumeAll()
|
||||
{
|
||||
_queuePaused = false;
|
||||
_haltPause = false;
|
||||
_holdJobId = null;
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Paused
|
||||
&& (j.StartedUtc is not null || j.CurrentPath is not null)))
|
||||
{
|
||||
job.Status = TransferStatus.Queued;
|
||||
}
|
||||
}
|
||||
|
||||
Pulse();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void Pause(long jobId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var job = _jobs.FirstOrDefault(j => j.Id == jobId);
|
||||
var job = Find(jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Status is TransferStatus.Queued or TransferStatus.Running)
|
||||
if (job.Status == TransferStatus.Queued)
|
||||
{
|
||||
job.Status = TransferStatus.Paused;
|
||||
}
|
||||
else if (job.Status == TransferStatus.Running)
|
||||
{
|
||||
_haltPause = true;
|
||||
_holdJobId = jobId;
|
||||
}
|
||||
}
|
||||
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void Resume(long jobId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var job = Find(jobId);
|
||||
if (job is null || job.Status != TransferStatus.Paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = TransferStatus.Queued;
|
||||
}
|
||||
|
||||
_queuePaused = false;
|
||||
_haltPause = false;
|
||||
if (_holdJobId == jobId)
|
||||
{
|
||||
_holdJobId = null;
|
||||
}
|
||||
|
||||
Pulse();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void Cancel(long jobId)
|
||||
{
|
||||
CancellationTokenSource? running = null;
|
||||
lock (_gate)
|
||||
{
|
||||
var job = Find(jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Status is TransferStatus.Queued or TransferStatus.Paused)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
_jobs.Remove(job);
|
||||
if (_holdJobId == jobId)
|
||||
{
|
||||
_holdJobId = null;
|
||||
}
|
||||
|
||||
Pulse();
|
||||
}
|
||||
else if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelling;
|
||||
_haltPause = false;
|
||||
running = _runningCts;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -104,17 +199,67 @@ public sealed class TransferQueue : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
running?.Cancel();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void Dismiss(long jobId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_jobs.RemoveAll(j => j.Id == jobId);
|
||||
_jobs.RemoveAll(j => j.Id == jobId && j.Status is TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void ClearFinished()
|
||||
{
|
||||
var removed = false;
|
||||
lock (_gate)
|
||||
{
|
||||
removed = _jobs.RemoveAll(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled) > 0;
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
RaiseChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool MoveUp(long jobId) => Move(jobId, -1);
|
||||
|
||||
public bool MoveDown(long jobId) => Move(jobId, 1);
|
||||
|
||||
public bool Move(long jobId, int delta)
|
||||
{
|
||||
if (delta == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var index = _jobs.FindIndex(j => j.Id == jobId);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= _jobs.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var job = _jobs[index];
|
||||
if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_jobs.RemoveAt(index);
|
||||
_jobs.Insert(target, job);
|
||||
}
|
||||
|
||||
Pulse();
|
||||
RaiseChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
||||
@@ -122,76 +267,128 @@ public sealed class TransferQueue : BackgroundService
|
||||
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
_jobs.Insert(0, job);
|
||||
_jobs.Add(job);
|
||||
}
|
||||
|
||||
_channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Pulse();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var work in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var job = work.Job;
|
||||
if (job.Status == TransferStatus.Cancelling)
|
||||
TransferJob? job;
|
||||
lock (_gate)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
await Persist(job).ConfigureAwait(false);
|
||||
job = CanStartNext() ? NextRunnable() : null;
|
||||
}
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _signal.WaitAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
job.Status = TransferStatus.Running;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
try
|
||||
{
|
||||
switch (job.Op)
|
||||
{
|
||||
case TransferOp.Copy:
|
||||
await CopyOrMove(job, move: false, stoppingToken).ConfigureAwait(false);
|
||||
break;
|
||||
case TransferOp.Move:
|
||||
await CopyOrMove(job, move: true, stoppingToken).ConfigureAwait(false);
|
||||
break;
|
||||
case TransferOp.Delete:
|
||||
Delete(job);
|
||||
break;
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Cancelling)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
}
|
||||
else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
|
||||
{
|
||||
job.Status = TransferStatus.Failed;
|
||||
}
|
||||
else if (job.Status != TransferStatus.Failed)
|
||||
{
|
||||
job.Status = TransferStatus.Done;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
|
||||
job.Status = TransferStatus.Failed;
|
||||
job.Error = ex.Message;
|
||||
}
|
||||
|
||||
await Persist(job).ConfigureAwait(false);
|
||||
if (job.Status is TransferStatus.Done or TransferStatus.Cancelled)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_jobs.Remove(job);
|
||||
}
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
JobFinished?.Invoke(this, job);
|
||||
await RunAsync(job, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanStartNext()
|
||||
{
|
||||
if (_queuePaused)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _holdJobId is not { } id
|
||||
|| !_jobs.Any(j => j.Id == id && j.Status is TransferStatus.Paused or TransferStatus.Running or TransferStatus.Cancelling);
|
||||
}
|
||||
|
||||
private TransferJob? NextRunnable()
|
||||
=> _jobs.FirstOrDefault(j => j.Status == TransferStatus.Queued);
|
||||
|
||||
private async Task RunAsync(TransferJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_runningCts = linked;
|
||||
_haltPause = false;
|
||||
job.Status = TransferStatus.Running;
|
||||
job.StartedUtc ??= DateTimeOffset.UtcNow;
|
||||
job.Error = null;
|
||||
RaiseChanged();
|
||||
try
|
||||
{
|
||||
switch (job.Op)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Paused)
|
||||
{
|
||||
await Persist(job).ConfigureAwait(false);
|
||||
RaiseChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Cancelling)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
}
|
||||
else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
|
||||
{
|
||||
job.Status = TransferStatus.Failed;
|
||||
}
|
||||
else if (job.Status != TransferStatus.Failed)
|
||||
{
|
||||
job.Status = TransferStatus.Done;
|
||||
job.CurrentPath = null;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
|
||||
job.Status = TransferStatus.Failed;
|
||||
job.Error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_runningCts, linked))
|
||||
{
|
||||
_runningCts = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Persist(job).ConfigureAwait(false);
|
||||
RaiseChanged();
|
||||
JobFinished?.Invoke(this, job);
|
||||
}
|
||||
|
||||
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
|
||||
{
|
||||
var src = job.SourcePath;
|
||||
@@ -207,7 +404,7 @@ public sealed class TransferQueue : BackgroundService
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
|
||||
if (move && job.Status != TransferStatus.Failed && job.Status != TransferStatus.Cancelling)
|
||||
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 */ }
|
||||
}
|
||||
@@ -215,36 +412,36 @@ public sealed class TransferQueue : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
job.BytesTotal = item.SizeBytes;
|
||||
var progress = new Progress<long>(b =>
|
||||
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))
|
||||
{
|
||||
job.BytesDone = b;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
var ok = move
|
||||
? _shell.MoveFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out var error)
|
||||
: _shell.CopyFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out error);
|
||||
if (!ok)
|
||||
{
|
||||
job.Status = error == "Cancelled" ? TransferStatus.Cancelling : TransferStatus.Failed;
|
||||
job.Error = error;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.BytesDone = job.BytesTotal ?? job.BytesDone;
|
||||
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)
|
||||
if (job.Status == TransferStatus.Cancelling || _haltPause)
|
||||
{
|
||||
ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,30 +460,82 @@ public sealed class TransferQueue : BackgroundService
|
||||
{
|
||||
Directory.CreateDirectory(PathRules.ToExtended(childDest));
|
||||
stack.Push((child.FullPath, childDest));
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
|
||||
var ok = move
|
||||
? _shell.MoveFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out var err)
|
||||
: _shell.CopyFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out err);
|
||||
if (ok)
|
||||
{
|
||||
job.BytesDone += child.SizeBytes;
|
||||
}
|
||||
else if (err != "Cancelled")
|
||||
{
|
||||
job.Error = err;
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
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
|
||||
@@ -300,7 +549,31 @@ public sealed class TransferQueue : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
|
||||
private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId);
|
||||
|
||||
private readonly record struct Work(TransferJob Job, CancellationTokenSource Cts);
|
||||
private void Pulse()
|
||||
{
|
||||
if (_signal.CurrentCount == 0)
|
||||
{
|
||||
_signal.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseChanged(bool throttled = false)
|
||||
{
|
||||
if (throttled)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - _lastChangedUtc).TotalMilliseconds < 80)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastChangedUtc = now;
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user