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:
295
src/Explorer.Application/SourceManager.cs
Normal file
295
src/Explorer.Application/SourceManager.cs
Normal file
@@ -0,0 +1,295 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public sealed class SourceManager
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly IVolumeService _volumes;
|
||||
private readonly IAppEnvironment _env;
|
||||
private readonly IClock _clock;
|
||||
private readonly ILogger<SourceManager> _logger;
|
||||
|
||||
public SourceManager(
|
||||
IIndexStore store,
|
||||
IVolumeService volumes,
|
||||
IAppEnvironment env,
|
||||
IClock clock,
|
||||
ILogger<SourceManager> logger)
|
||||
{
|
||||
_store = store;
|
||||
_volumes = volumes;
|
||||
_env = env;
|
||||
_clock = clock;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
|
||||
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
|
||||
var online = _volumes.EnumerateOnlineVolumes();
|
||||
var seenIds = new HashSet<long>();
|
||||
|
||||
foreach (var fp in online)
|
||||
{
|
||||
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||
Source source;
|
||||
if (match.Source is not null && !match.Ambiguous)
|
||||
{
|
||||
source = match.Source;
|
||||
source.LastRootPath = fp.RootPath;
|
||||
source.DisplayName = fp.DisplayName ?? source.DisplayName;
|
||||
source.Label = fp.Label ?? source.Label;
|
||||
source.Filesystem = fp.Filesystem ?? source.Filesystem;
|
||||
source.CapacityBytes = fp.CapacityBytes ?? source.CapacityBytes;
|
||||
source.VolumeGuid = fp.VolumeGuid ?? source.VolumeGuid;
|
||||
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
|
||||
source.Kind = fp.Kind;
|
||||
source.LastSeenUtc = _clock.UtcNow;
|
||||
source.Status = source.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online;
|
||||
source.LastError = null;
|
||||
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
if (source.IsIndexed)
|
||||
{
|
||||
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
source = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = fp.Kind,
|
||||
DisplayName = fp.DisplayName ?? fp.RootPath,
|
||||
VolumeGuid = fp.VolumeGuid,
|
||||
VolumeSerial = fp.VolumeSerial,
|
||||
Filesystem = fp.Filesystem,
|
||||
Label = fp.Label,
|
||||
CapacityBytes = fp.CapacityBytes,
|
||||
DeviceInstanceId = fp.DeviceInstanceId,
|
||||
LastRootPath = fp.RootPath,
|
||||
Status = SourceStatus.Online,
|
||||
LastSeenUtc = _clock.UtcNow
|
||||
};
|
||||
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
known.Add(source);
|
||||
}
|
||||
|
||||
seenIds.Add(source.Id);
|
||||
}
|
||||
|
||||
foreach (var source in known)
|
||||
{
|
||||
if (seenIds.Contains(source.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reachable = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
|
||||
if (reachable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.Status != SourceStatus.Offline)
|
||||
{
|
||||
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var unc in LoadRecents())
|
||||
{
|
||||
if (known.Any(s => s.LastRootPath is not null
|
||||
&& PathRules.CanonicalUncRoot(s.LastRootPath)
|
||||
.Equals(PathRules.CanonicalUncRoot(unc), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await AddUncAsync(unc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var root = PathRules.CanonicalUncRoot(path);
|
||||
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
var fp = new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = root,
|
||||
DisplayName = root,
|
||||
Filesystem = "SMB"
|
||||
};
|
||||
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||
if (match.Source is not null)
|
||||
{
|
||||
var existing = match.Source;
|
||||
existing.LastRootPath = root;
|
||||
existing.LastSeenUtc = _clock.UtcNow;
|
||||
existing.Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline;
|
||||
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
RememberUnc(root);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var source = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = SourceKind.Smb,
|
||||
DisplayName = root,
|
||||
Filesystem = "SMB",
|
||||
LastRootPath = root,
|
||||
Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline,
|
||||
LastSeenUtc = _clock.UtcNow
|
||||
};
|
||||
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
RememberUnc(root);
|
||||
return source;
|
||||
}
|
||||
|
||||
public async Task<Source?> FindByPathAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalized = PathRules.FromExtended(path);
|
||||
Source? best = null;
|
||||
var bestLen = -1;
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.LastRootPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var root = PathRules.FromExtended(source.LastRootPath).TrimEnd('\\');
|
||||
var candidate = normalized.TrimEnd('\\');
|
||||
if (candidate.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| candidate.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase)
|
||||
|| (root.Length == 2 && root[1] == ':' && candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (root.Length > bestLen)
|
||||
{
|
||||
best = source;
|
||||
bestLen = root.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public async Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.LastSeenUtc = _clock.UtcNow;
|
||||
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
|
||||
? (existing.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online)
|
||||
: SourceStatus.Offline;
|
||||
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var fp = _volumes.Probe(path);
|
||||
if (fp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||
if (match.Source is not null && !match.Ambiguous)
|
||||
{
|
||||
var source = match.Source;
|
||||
source.LastRootPath = fp.RootPath;
|
||||
source.DisplayName = fp.DisplayName ?? source.DisplayName;
|
||||
source.Kind = fp.Kind;
|
||||
source.Filesystem = fp.Filesystem ?? source.Filesystem;
|
||||
source.Label = fp.Label ?? source.Label;
|
||||
source.LastSeenUtc = _clock.UtcNow;
|
||||
source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline;
|
||||
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (fp.Kind == SourceKind.Smb && PathRules.IsUnc(fp.RootPath))
|
||||
{
|
||||
return await AddUncAsync(fp.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var created = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = fp.Kind,
|
||||
DisplayName = fp.DisplayName ?? fp.RootPath,
|
||||
VolumeGuid = fp.VolumeGuid,
|
||||
VolumeSerial = fp.VolumeSerial,
|
||||
Filesystem = fp.Filesystem,
|
||||
Label = fp.Label,
|
||||
CapacityBytes = fp.CapacityBytes,
|
||||
LastRootPath = fp.RootPath,
|
||||
Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline,
|
||||
LastSeenUtc = _clock.UtcNow
|
||||
};
|
||||
created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false);
|
||||
return created;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> LoadRecents()
|
||||
{
|
||||
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return File.ReadAllLines(file)
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed reading recents");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void RememberUnc(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||
var lines = LoadRecents().ToList();
|
||||
lines.RemoveAll(l => l.Equals(root, StringComparison.OrdinalIgnoreCase));
|
||||
lines.Insert(0, root);
|
||||
File.WriteAllLines(file, lines.Take(30));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed writing recents");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user