Show folder names immediately and refresh stale index sizes without walking the whole drive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 11:21:20 +02:00
parent 6fc7506eb7
commit e2916aef9c
88 changed files with 5373 additions and 544 deletions

View File

@@ -0,0 +1,322 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
namespace Explorer.Hosting.Tests;
public class BackgroundMaintenanceCoordinatorTests
{
[Fact]
public async Task Activity_pauses_hashing_and_idle_indexing()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.False(indexing.IdleAllowed);
Assert.Empty(indexing.IdleScans);
Assert.Empty(indexing.IdleVerifies);
}
[Fact]
public async Task Below_idle_threshold_does_not_start()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(3), sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.False(indexing.IdleAllowed);
Assert.Empty(indexing.IdleScans);
Assert.Empty(indexing.IdleVerifies);
}
[Fact]
public async Task Idle_allows_maintenance_and_does_not_enqueue_twice()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.True(indexing.IdleAllowed);
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
Assert.Empty(indexing.IdleScans);
await coordinator.TickAsync(CancellationToken.None);
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
Assert.True(hash.IsPaused);
}
[Fact]
public async Task Activity_resume_pauses_after_idle_work_started()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var idle = new FakeIdle(TimeSpan.FromMinutes(20));
var coordinator = Create(hash, indexing, idle, sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.NotEmpty(indexing.IdleVerifies);
idle.Duration = TimeSpan.Zero;
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.False(indexing.IdleAllowed);
Assert.Equal("Paused because user is active", coordinator.Snapshot.Message);
}
[Fact]
public async Task Disabled_policy_never_starts()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), enabled: false, sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.False(indexing.IdleAllowed);
Assert.Empty(indexing.IdleScans);
Assert.Empty(indexing.IdleVerifies);
}
[Fact]
public async Task Foreground_jobs_keep_hashing_paused()
{
var hash = new FakeHash { Paused = false };
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), foreground: true, sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.False(indexing.IdleAllowed);
Assert.Empty(indexing.IdleScans);
Assert.Empty(indexing.IdleVerifies);
}
[Fact]
public async Task Battery_skips_when_ac_only()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), ac: false, sources: [StaleLocal()]);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(hash.IsPaused);
Assert.Empty(indexing.IdleScans);
Assert.Empty(indexing.IdleVerifies);
}
[Fact]
public async Task Run_now_starts_while_user_is_active()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, sources: [StaleLocal()]);
coordinator.RunNow();
await coordinator.TickAsync(CancellationToken.None);
Assert.True(indexing.IdleAllowed);
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
}
[Fact]
public async Task Idle_verifies_a_fresh_online_source()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var source = StaleLocal();
source.Status = SourceStatus.Online;
source.LastIndexedUtc = DateTimeOffset.UtcNow.AddHours(-2);
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [source]);
await coordinator.TickAsync(CancellationToken.None);
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
Assert.Empty(indexing.IdleScans);
}
private static Source StaleLocal()
=> new()
{
Id = 3,
StableKey = "d",
DisplayName = "D:",
Kind = SourceKind.NtfsLocal,
Status = SourceStatus.Stale,
LastRootPath = @"D:\",
LastIndexedUtc = DateTimeOffset.UtcNow.AddDays(-30)
};
private static BackgroundMaintenanceCoordinator Create(
FakeHash hash,
FakeIndexing indexing,
TimeSpan idle,
bool enabled = true,
bool ac = true,
bool foreground = false,
IReadOnlyList<Source>? sources = null)
=> Create(hash, indexing, new FakeIdle(idle), enabled, ac, foreground, sources);
private static BackgroundMaintenanceCoordinator Create(
FakeHash hash,
FakeIndexing indexing,
FakeIdle idle,
bool enabled = true,
bool ac = true,
bool foreground = false,
IReadOnlyList<Source>? sources = null)
{
var dir = Path.Combine(Path.GetTempPath(), "ew-maint", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var prefs = new UiPreferencesStore(new TempEnv(dir));
prefs.Save(UiPreferences.Default with
{
BackgroundMaintenanceWhenIdle = enabled,
IdleMaintenanceMinutes = 10,
IdleMaintenanceAcOnly = true
});
return new BackgroundMaintenanceCoordinator(
idle,
new FakePower(ac),
prefs,
new FakeForeground(foreground),
indexing,
hash,
new FakeHistory(),
new FakeStore(sources ?? []),
new FakeVolumes(),
NullLogger<BackgroundMaintenanceCoordinator>.Instance);
}
private sealed class FakeIdle(TimeSpan idle) : IUserIdleMonitor
{
public TimeSpan Duration { get; set; } = idle;
public TimeSpan GetIdleDuration() => Duration;
}
private sealed class FakePower(bool ac) : IPowerSourceMonitor
{
public bool IsOnAcPower => ac;
}
private sealed class FakeForeground(bool busy) : IForegroundWorkSignal
{
public bool HasForegroundWork() => busy;
}
private sealed class FakeIndexing : IIdleIndexWork
{
public bool Busy { get; set; }
public bool IdleAllowed { get; private set; }
public List<long> IdleScans { get; } = [];
public List<long> IdleVerifies { get; } = [];
public bool IsBusy => Busy;
public bool HasIdleWork => IdleScans.Count > 0 || IdleVerifies.Count > 0;
public void SetIdleAllowed(bool allowed) => IdleAllowed = allowed;
public void EnqueueIdleFullScan(long sourceId) => IdleScans.Add(sourceId);
public void EnqueueIdleVerify(long sourceId, string pathRel) => IdleVerifies.Add(sourceId);
}
private sealed class FakeHash : IIdleHashWork
{
public bool Paused { get; set; } = true;
public bool IsPaused => Paused;
public void Pause() => Paused = true;
public void Resume() => Paused = false;
public void BeginUserRequested() { }
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
}
private sealed class FakeHistory : IHistoryMaintenance
{
public Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
}
private sealed class FakeVolumes : IVolumeService
{
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => [];
public VolumeFingerprint? Probe(string path) => null;
public VolumeSpace GetSpace(string path) => default;
public bool IsPathReachable(string path) => true;
}
private sealed class FakeStore(IReadOnlyList<Source> sources) : IIndexStore
{
public ISourceStore Sources { get; } = new FakeSources(sources);
public IEntryStore Entries => throw new NotSupportedException();
public IExcludeStore Excludes => throw new NotSupportedException();
public IScanJobStore ScanJobs => throw new NotSupportedException();
public ITransferStore Transfers => throw new NotSupportedException();
public ISearchStore Search => throw new NotSupportedException();
public IAnalysisStore Analysis => throw new NotSupportedException();
public IHistoryStore History => throw new NotSupportedException();
public IHashStore Hashes { get; } = new FakeHashes();
public IFileRelationStore Relations => throw new NotSupportedException();
public IRenameBatchStore RenameBatches => throw new NotSupportedException();
public ISyncProfileStore SyncProfiles => throw new NotSupportedException();
public IOperationProfileStore OperationProfiles => throw new NotSupportedException();
public bool CanWrite => true;
public Task OpenAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<string> QuickCheckAsync(CancellationToken cancellationToken = default) => Task.FromResult("ok");
public Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default) => work(this);
public Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default) => work(this);
}
private sealed class FakeSources(IReadOnlyList<Source> sources) : ISourceStore
{
public Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(sources);
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
=> Task.FromResult(sources.FirstOrDefault(s => s.Id == id));
public Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default)
=> Task.FromResult(source.Id);
public Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class FakeHashes : IHashStore
{
public Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
public Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<HashWorkItem>>([]);
public Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
}
private sealed class TempEnv : IAppEnvironment
{
public TempEnv(string dir)
{
DataDirectory = 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; }
}
}

View File

@@ -33,7 +33,9 @@ public class CoreRegistrationTests
Assert.Null(sp.GetService<IOsClipboard>());
Assert.NotNull(sp.GetService<FilesystemScanner>());
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
Assert.NotNull(sp.GetService<ICloudOverlay>());
Assert.NotNull(sp.GetService<IBackgroundMaintenance>());
Assert.NotNull(sp.GetService<IUserIdleMonitor>());
Assert.NotNull(sp.GetService<IPowerSourceMonitor>());
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
}
finally
@@ -76,7 +78,7 @@ public class CoreRegistrationTests
Assert.Empty(sp.GetServices<IStorageProvider>());
var hosted = sp.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is IndexStoreLifetime);
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService" or "BackgroundMaintenanceCoordinator");
}
finally
{

View File

@@ -22,6 +22,9 @@ public class WorkbenchPipeTests
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
Assert.True(ping.Ok);
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
Assert.True(ready.Ok);
var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
Assert.True(scan.Ok);
Assert.Equal(42, indexing.FullScanId);
@@ -131,6 +134,18 @@ public class WorkbenchPipeTests
Assert.True(ping.Ok);
}
[Fact]
public void Host_Ready_needs_a_workbench()
{
using var sp = new ServiceCollection().BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
Assert.False(ready.Ok);
}
[Fact]
public async Task Ping_roundtrip_over_a_live_named_pipe()
{