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:
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BrowseHydrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Visible_rows_are_hydrated_before_distant_rows()
|
||||
{
|
||||
var items = Enumerable.Range(0, 100)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}", Name = $"f{i}" })
|
||||
.ToList();
|
||||
var ordered = BrowseHydration.Prioritize(items, [@"C:\f50"]);
|
||||
Assert.Equal(@"C:\f50", ordered[0].FullPath);
|
||||
Assert.Contains(ordered.Take(BrowseHydration.NearbyWindow + 1), i => i.FullPath == @"C:\f49");
|
||||
Assert.Equal(@"C:\f99", ordered[^1].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_and_cloud_use_tighter_batches_than_local()
|
||||
{
|
||||
Assert.Equal(BrowseHydration.LocalProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, true));
|
||||
Assert.Equal(BrowseHydration.ConstrainedWorkers, BrowseHydration.WorkerCount(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.LocalWorkers, BrowseHydration.WorkerCount(SourceKind.NtfsLocal, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_sort_is_deferred_until_folder_metadata_is_ready()
|
||||
{
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: false, sizeMetadataReady: true));
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: true));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Name", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.DependsOnIncompleteMetadata("Modified"));
|
||||
Assert.True(FolderListingSort.DependsOnIncompleteMetadata("Size"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_sort_keeps_folders_first()
|
||||
{
|
||||
FileSystemItem[] items =
|
||||
[
|
||||
new() { FullPath = @"C:\z.txt", Name = "z.txt" },
|
||||
new() { FullPath = @"C:\A", Name = "A", IsDirectory = true },
|
||||
new() { FullPath = @"C:\b.txt", Name = "b.txt" }
|
||||
];
|
||||
var ordered = FolderListingSort.Order(items, "Name", descending: false).Select(i => i.Name).ToList();
|
||||
Assert.Equal(["A", "b.txt", "z.txt"], ordered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_publishes_rows_before_provider_enrichment()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var (browse, store, _) = await CreateAsync(new GatedCloudProvider(@"C:\", gate));
|
||||
try
|
||||
{
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
Assert.NotEmpty(it.Current.Added);
|
||||
Assert.Contains(it.Current.Added, i => i.Name == "Movies");
|
||||
Assert.All(it.Current.Added, i => Assert.Null(i.Cloud));
|
||||
Assert.False(it.Current.HydrationComplete);
|
||||
Assert.False(gate.Task.IsCompleted);
|
||||
|
||||
gate.SetResult();
|
||||
var sawCloud = false;
|
||||
var complete = it.Current.HydrationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
sawCloud |= it.Current.Updated.Any(u => u.Cloud is not null) || it.Current.Added.Any(a => a.Cloud is not null);
|
||||
complete = it.Current.HydrationComplete;
|
||||
}
|
||||
|
||||
Assert.True(complete);
|
||||
Assert.True(sawCloud);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_cancels_without_applying_stale_provider_results()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var provider = new GatedCloudProvider(@"C:\", gate);
|
||||
var (browse, store, _) = await CreateAsync(provider);
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\", viewport: null, cts.Token).GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
while (await it.MoveNextAsync())
|
||||
{
|
||||
}
|
||||
});
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
gate.SetResult();
|
||||
await Task.Delay(50);
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_enrichment_uses_bounded_batches()
|
||||
{
|
||||
var provider = new RecordingProvider(@"C:\");
|
||||
var items = Enumerable.Range(0, 80)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}.txt", Name = $"f{i}.txt", SizeBytes = i })
|
||||
.ToList();
|
||||
var (browse, store, _) = await CreateAsync(provider, new FixedEnumerator(items));
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Equal(80, listing.Items.Count);
|
||||
Assert.NotEmpty(provider.BatchSizes);
|
||||
Assert.All(provider.BatchSizes, size => Assert.InRange(size, 1, BrowseHydration.ConstrainedProviderBatch));
|
||||
Assert.Equal(80, provider.BatchSizes.Sum());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_listing_does_not_open_items_with_GetItem()
|
||||
{
|
||||
var enumerator = new GuardedEnumerator();
|
||||
var (browse, store, _) = await CreateAsync(enumerator: enumerator);
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Contains(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(0, enumerator.GetItemCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_still_returns_index_folder_sizes()
|
||||
{
|
||||
var (browse, store, _) = await CreateAsync();
|
||||
try
|
||||
{
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "C:",
|
||||
NameNorm = "c:",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movies",
|
||||
NameNorm = "movies",
|
||||
IsDirectory = true,
|
||||
PathRel = "Movies",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
AggregateSize = 200
|
||||
});
|
||||
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(200, movies.SizeBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store, IAppEnvironment Env)> CreateAsync(
|
||||
IStorageProvider? provider = null,
|
||||
IFileSystemEnumerator? enumerator = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse-hyd", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new HydrationVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
RootPath = @"C:\",
|
||||
DisplayName = "C:",
|
||||
VolumeSerial = 1,
|
||||
CapacityBytes = 1_000_000_000,
|
||||
FreeBytes = 250_000_000
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new HydrationEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var providers = provider is null ? Array.Empty<IStorageProvider>() : new[] { provider };
|
||||
var browse = new BrowseService(
|
||||
enumerator ?? new GuardedEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry(providers, NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
return (browse, store, env);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GuardedEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public int GetItemCalls { get; private set; }
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
{
|
||||
GetItemCalls++;
|
||||
throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true },
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "loose.txt"), Name = "loose.txt", SizeBytes = 10 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FixedEnumerator(IReadOnlyList<FileSystemItem> items) : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path) => throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GatedCloudProvider(string root, TaskCompletionSource gate) : IStorageProvider
|
||||
{
|
||||
public int CompletedCalls;
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public async Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref CompletedCalls);
|
||||
return paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.OnlineOnly,
|
||||
10,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
"Online-only",
|
||||
null)).ToList();
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class RecordingProvider(string root) : IStorageProvider
|
||||
{
|
||||
public List<int> BatchSizes { get; } = [];
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BatchSizes.Add(paths.Count);
|
||||
return Task.FromResult<IReadOnlyList<ProviderItemState>>(
|
||||
paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.LocallyAvailable,
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class HydrationVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online;
|
||||
public VolumeFingerprint? Probe(string path)
|
||||
=> Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsPathReachable(string path)
|
||||
=> Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public VolumeSpace GetSpace(string path)
|
||||
{
|
||||
var fp = Probe(path);
|
||||
return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class HydrationEnv : IAppEnvironment
|
||||
{
|
||||
public HydrationEnv(string dir)
|
||||
{
|
||||
DataDirectory = dir;
|
||||
Directory.CreateDirectory(dir);
|
||||
DatabasePath = Path.Combine(dir, "index.db");
|
||||
LogDirectory = Path.Combine(dir, "logs");
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
public string DatabasePath { get; }
|
||||
public string LogDirectory { get; }
|
||||
}
|
||||
Reference in New Issue
Block a user