Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
304
src/Explorer.FileOperations/TransferQueue.cs
Normal file
304
src/Explorer.FileOperations/TransferQueue.cs
Normal file
@@ -0,0 +1,304 @@
|
||||
using System.Threading.Channels;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.FileOperations;
|
||||
|
||||
public sealed class TransferQueue : BackgroundService
|
||||
{
|
||||
private readonly IShellFileOperations _shell;
|
||||
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();
|
||||
|
||||
public event EventHandler? Changed;
|
||||
public event EventHandler<TransferJob>? JobFinished;
|
||||
|
||||
public TransferQueue(
|
||||
IShellFileOperations shell,
|
||||
IFileSystemEnumerator enumerator,
|
||||
IIndexStore store,
|
||||
ILogger<TransferQueue> logger)
|
||||
{
|
||||
_shell = shell;
|
||||
_enumerator = enumerator;
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TransferJob> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _jobs.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
||||
await EnqueueAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Copy,
|
||||
SourcePath = src,
|
||||
DestinationPath = dest,
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
||||
await EnqueueAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Move,
|
||||
SourcePath = src,
|
||||
DestinationPath = dest,
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnqueueAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Delete,
|
||||
SourcePath = string.Join("|", paths),
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
AdditionalSources = paths.ToList()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Cancel(long jobId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var job = _jobs.FirstOrDefault(j => j.Id == jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Status is TransferStatus.Queued or TransferStatus.Running)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelling;
|
||||
}
|
||||
else
|
||||
{
|
||||
_jobs.Remove(job);
|
||||
}
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dismiss(long jobId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_jobs.RemoveAll(j => j.Id == jobId);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
_jobs.Insert(0, job);
|
||||
}
|
||||
|
||||
_channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var work in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
var job = work.Job;
|
||||
if (job.Status == TransferStatus.Cancelling)
|
||||
{
|
||||
job.Status = TransferStatus.Cancelled;
|
||||
await Persist(job).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 != TransferStatus.Failed && job.Status != TransferStatus.Cancelling)
|
||||
{
|
||||
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
job.BytesTotal = item.SizeBytes;
|
||||
var progress = new Progress<long>(b =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
Directory.CreateDirectory(PathRules.ToExtended(dst));
|
||||
var stack = new Stack<(string From, string To)>();
|
||||
stack.Push((src, dst));
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
if (job.Status == TransferStatus.Cancelling)
|
||||
{
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Delete(TransferJob job)
|
||||
{
|
||||
var paths = job.AdditionalSources.Count > 0
|
||||
? job.AdditionalSources
|
||||
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!_shell.DeleteToRecycleBin(paths, out var error))
|
||||
{
|
||||
job.Status = TransferStatus.Failed;
|
||||
job.Error = error;
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
|
||||
|
||||
private readonly record struct Work(TransferJob Job, CancellationTokenSource Cts);
|
||||
}
|
||||
Reference in New Issue
Block a user