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:
273
tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
Normal file
273
tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.FileOperations.Tests;
|
||||
|
||||
public class TransferQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Copies_run_one_after_another()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ctx.Shell.OnCopy = src =>
|
||||
{
|
||||
if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
firstStarted.TrySetResult();
|
||||
releaseFirst.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
else
|
||||
{
|
||||
secondStarted.TrySetResult();
|
||||
}
|
||||
};
|
||||
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
Assert.False(secondStarted.Task.IsCompleted);
|
||||
Assert.Equal(1, ctx.Shell.CopyCount);
|
||||
|
||||
releaseFirst.TrySetResult();
|
||||
await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
|
||||
Assert.Equal(new[] { ctx.File("a.txt"), ctx.File("b.txt") }, ctx.Shell.Copied);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pause_keeps_later_jobs_from_starting()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var awaitingPause = true;
|
||||
ctx.Shell.OnCopy = src =>
|
||||
{
|
||||
if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase) && awaitingPause)
|
||||
{
|
||||
firstStarted.TrySetResult();
|
||||
WaitUntil(() => ctx.Shell.PauseRequested?.Invoke() == true, TimeSpan.FromSeconds(3))
|
||||
.GetAwaiter().GetResult();
|
||||
awaitingPause = false;
|
||||
}
|
||||
};
|
||||
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
ctx.Queue.PauseAll();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot()[0].Status == TransferStatus.Paused);
|
||||
Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
ctx.Queue.ResumeAll();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().All(j => j.Status == TransferStatus.Done));
|
||||
Assert.Contains(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_drops_a_queued_step()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Queue.PauseAll();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
var jobs = ctx.Queue.Snapshot();
|
||||
Assert.Equal(2, jobs.Count);
|
||||
ctx.Queue.Cancel(jobs[1].Id);
|
||||
ctx.Queue.ResumeAll();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Done));
|
||||
Assert.Equal(new[] { ctx.File("a.txt") }, ctx.Shell.Copied);
|
||||
Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.SourcePath.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase)
|
||||
&& j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Done);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_stops_the_running_copy()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ctx.Shell.OnCopy = src =>
|
||||
{
|
||||
if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
firstStarted.TrySetResult();
|
||||
releaseFirst.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
};
|
||||
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
var running = ctx.Queue.Snapshot().Single(j => j.SourcePath.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase));
|
||||
ctx.Queue.Cancel(running.Id);
|
||||
releaseFirst.TrySetResult();
|
||||
await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt")));
|
||||
Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.Id == running.Id && j.Status == TransferStatus.Done);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pause_queued_job_skips_it()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Queue.PauseAll();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
var jobs = ctx.Queue.Snapshot();
|
||||
ctx.Queue.Pause(jobs[0].Id);
|
||||
ctx.Queue.ResumeAll();
|
||||
await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt")));
|
||||
Assert.Equal(new[] { ctx.File("b.txt") }, ctx.Shell.Copied);
|
||||
Assert.Equal(TransferStatus.Paused, ctx.Queue.Snapshot().First(j => j.Id == jobs[0].Id).Status);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reorder_changes_which_job_runs_first()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Queue.PauseAll();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
var jobs = ctx.Queue.Snapshot();
|
||||
Assert.True(ctx.Queue.MoveUp(jobs[1].Id));
|
||||
ctx.Queue.ResumeAll();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
|
||||
Assert.Equal(new[] { ctx.File("b.txt"), ctx.File("a.txt") }, ctx.Shell.Copied);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var limit = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(4));
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > limit)
|
||||
{
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Harness : IAsyncDisposable
|
||||
{
|
||||
public required TransferQueue Queue { get; init; }
|
||||
public required GateShell Shell { get; init; }
|
||||
public required SqliteIndexStore Store { get; init; }
|
||||
public required string Dest { get; init; }
|
||||
public required string Root { get; init; }
|
||||
|
||||
public string File(string name) => Path.Combine(Root, name);
|
||||
|
||||
public static async Task<Harness> CreateAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var dest = Path.Combine(root, "dest");
|
||||
Directory.CreateDirectory(dest);
|
||||
System.IO.File.WriteAllText(Path.Combine(root, "a.txt"), "a");
|
||||
System.IO.File.WriteAllText(Path.Combine(root, "b.txt"), "b");
|
||||
var db = Path.Combine(root, "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var shell = new GateShell();
|
||||
var queue = new TransferQueue(shell, new DiskEnum(), store, NullLogger<TransferQueue>.Instance);
|
||||
return new Harness
|
||||
{
|
||||
Queue = queue,
|
||||
Shell = shell,
|
||||
Store = store,
|
||||
Dest = dest,
|
||||
Root = root
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Store.DisposeAsync();
|
||||
try { Directory.Delete(Root, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DiskEnum : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return Directory.Exists(directoryPath)
|
||||
? Directory.GetFileSystemEntries(directoryPath).Select(GetRequired).ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> System.IO.File.Exists(path) || Directory.Exists(path) ? GetRequired(path) : null;
|
||||
|
||||
private static FileSystemItem GetRequired(string path)
|
||||
{
|
||||
var isDir = Directory.Exists(path);
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = Path.GetFileName(path),
|
||||
IsDirectory = isDir,
|
||||
SizeBytes = isDir ? 0 : new FileInfo(path).Length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GateShell : IShellFileOperations
|
||||
{
|
||||
public int CopyCount { get; private set; }
|
||||
public List<string> Copied { get; } = [];
|
||||
public Action<string>? OnCopy { get; set; }
|
||||
public Func<bool>? PauseRequested { get; private set; }
|
||||
|
||||
public void Open(string path) { }
|
||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
|
||||
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error) { error = null; return true; }
|
||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error) { error = null; return true; }
|
||||
|
||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
|
||||
{
|
||||
CopyCount++;
|
||||
PauseRequested = pauseRequested;
|
||||
progress?.Report(1);
|
||||
OnCopy?.Invoke(source);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
error = "Cancelled";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pauseRequested?.Invoke() == true)
|
||||
{
|
||||
error = "Paused";
|
||||
return false;
|
||||
}
|
||||
|
||||
Copied.Add(source);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
System.IO.File.Copy(source, destination, overwrite);
|
||||
progress?.Report(new FileInfo(source).Length);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
|
||||
=> CopyFileWithProgress(source, destination, overwrite, progress, cancellationToken, out error, pauseRequested);
|
||||
}
|
||||
Reference in New Issue
Block a user