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,117 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Analysis;
public sealed class AnalysisService
{
private readonly IIndexStore _store;
private readonly AnalysisResultCache _cache = new();
private readonly SemaphoreSlim _ready = new(1, 1);
private bool _readyDone;
public AnalysisService(IIndexStore store) => _store = store;
public Task EnsureReadyAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
if (_readyDone)
{
return 0;
}
await _ready.WaitAsync(ct).ConfigureAwait(false);
try
{
if (!_readyDone)
{
await _store.Analysis.EnsureReadyAsync(CancellationToken.None).ConfigureAwait(false);
_readyDone = true;
}
}
finally
{
_ready.Release();
}
return 0;
}, cancellationToken);
public Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Analysis.GetIndexStampAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default)
=> CachedAsync("roots", ct => _store.Analysis.GetDirectoryRootsAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(
long? sourceId,
long? parentId,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"dirs:{sourceId}:{parentId}:{take}",
ct => _store.Analysis.LargestDirectoriesAsync(sourceId, parentId, take, ct),
cancellationToken);
public Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(
long? sourceId,
string? pathRelPrefix,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"files:{sourceId}:{pathRelPrefix}:{take}",
ct => _store.Analysis.LargestFilesAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(
long? sourceId,
string? pathRelPrefix,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"types:{sourceId}:{pathRelPrefix}:{take}",
ct => _store.Analysis.UsageByExtensionAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
=> CachedAsync("sources", ct => _store.Analysis.UsageBySourceAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> DrilldownAsync(
long parentId,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"children:{parentId}:{take}",
ct => _store.Analysis.ChildrenBySizeAsync(parentId, take, ct),
cancellationToken);
public Task<IReadOnlyList<Source>> GetKnownSourcesAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken);
private async Task<T> CachedAsync<T>(string key, Func<CancellationToken, Task<T>> query, CancellationToken cancellationToken)
{
await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);
var stamp = await GetIndexStampAsync(cancellationToken).ConfigureAwait(false);
if (_cache.TryGet<T>(key, stamp, out var hit))
{
return hit;
}
var value = await RunOffUiAsync(query, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
_cache.Set(key, stamp, value);
return value;
}
public static async Task<T> RunOffUiAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (SynchronizationContext.Current is null)
{
return await work(cancellationToken).ConfigureAwait(false);
}
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
}
}