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:
2026-08-22 12:43:05 +02:00
commit e9aba73552
130 changed files with 15110 additions and 0 deletions

View 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);
}