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:
230
src/Explorer.Application/StorageProviderRegistry.cs
Normal file
230
src/Explorer.Application/StorageProviderRegistry.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public sealed class StorageProviderRegistry
|
||||
{
|
||||
private readonly IReadOnlyList<IStorageProvider> _providers;
|
||||
private readonly HashSet<string> _disabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ILogger<StorageProviderRegistry> _logger;
|
||||
|
||||
public StorageProviderRegistry(IEnumerable<IStorageProvider> providers, ILogger<StorageProviderRegistry> logger)
|
||||
{
|
||||
_providers = providers.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IStorageProvider> Providers => _providers;
|
||||
|
||||
public void SetEnabled(string providerId, bool enabled)
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
_disabled.Remove(providerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_disabled.Add(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
public IStorageProvider? Find(string path)
|
||||
{
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (_disabled.Contains(provider.Manifest.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (provider.TryMatchRoot(path))
|
||||
{
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Provider {Id} failed matching {Path}", provider.Manifest.Id, path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
|
||||
IReadOnlyList<FileSystemItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (items.Count == 0 || _providers.Count == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var groups = new Dictionary<IStorageProvider, List<int>>();
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var provider = Find(items[i].FullPath);
|
||||
if (provider is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.TryGetValue(provider, out var list))
|
||||
{
|
||||
list = [];
|
||||
groups[provider] = list;
|
||||
}
|
||||
|
||||
list.Add(i);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var copy = items.ToArray();
|
||||
foreach (var (provider, indexes) in groups)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paths = indexes.Select(i => copy[i].FullPath).ToList();
|
||||
var states = await provider.GetItemStatesAsync(paths, cancellationToken).ConfigureAwait(false);
|
||||
var byPath = states.ToDictionary(s => s.Path, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var index in indexes)
|
||||
{
|
||||
if (byPath.TryGetValue(copy[index].FullPath, out var state))
|
||||
{
|
||||
copy[index] = CloudPresenceMapper.Apply(copy[index], state);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Provider {Id} failed enriching items", provider.Manifest.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
public bool HasCapability(string path, ProviderCapability capability)
|
||||
{
|
||||
var provider = Find(path);
|
||||
return provider is not null && (provider.GetCapabilities() & capability) != 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces()
|
||||
{
|
||||
var places = new List<ProviderPlace>();
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (_disabled.Contains(provider.Manifest.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
places.AddRange(provider.GetPlaces());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Provider {Id} failed listing places", provider.Manifest.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return places;
|
||||
}
|
||||
|
||||
public async Task<ProviderActionResult> InvokeAsync(
|
||||
ProviderAction action,
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
return new ProviderActionResult(ProviderActionStatus.Failed, "Nothing selected.");
|
||||
}
|
||||
|
||||
var provider = Find(paths[0]);
|
||||
if (provider is null)
|
||||
{
|
||||
return new ProviderActionResult(ProviderActionStatus.Unsupported, "No cloud provider is available for this location.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await provider.TryInvokeAsync(new ProviderActionRequest(action, paths), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Provider {Id} failed invoking {Action}", provider.Manifest.Id, action);
|
||||
return new ProviderActionResult(ProviderActionStatus.Failed, "The cloud provider could not complete this action.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var provider = Find(path);
|
||||
if (provider is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var states = await provider.GetItemStatesAsync([path], cancellationToken).ConfigureAwait(false);
|
||||
return states.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Provider {Id} failed reading state for {Path}", provider.Manifest.Id, path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class CloudPresenceMapper
|
||||
{
|
||||
public static FileSystemItem Apply(FileSystemItem item, ProviderItemState state)
|
||||
=> new()
|
||||
{
|
||||
FullPath = item.FullPath,
|
||||
Name = item.Name,
|
||||
IsDirectory = item.IsDirectory,
|
||||
SizeBytes = item.SizeBytes,
|
||||
CreatedUtc = item.CreatedUtc,
|
||||
ModifiedUtc = item.ModifiedUtc,
|
||||
Attributes = item.Attributes,
|
||||
FileId = item.FileId,
|
||||
ReparseTag = item.ReparseTag,
|
||||
AllocatedSizeBytes = state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
|
||||
Cloud = ToPresence(state)
|
||||
};
|
||||
|
||||
public static CloudPresence ToPresence(ProviderItemState state)
|
||||
=> new(
|
||||
state.ProviderId,
|
||||
(Domain.CloudAvailability)(int)state.Availability,
|
||||
state.LogicalSizeBytes,
|
||||
state.AllocatedSizeBytes,
|
||||
state.MayHydrateOnRead,
|
||||
state.StatusText);
|
||||
|
||||
public static void ApplyToEntry(IndexEntry entry, CloudPresence? cloud)
|
||||
{
|
||||
if (cloud is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entry.AllocatedSizeBytes = cloud.AllocatedSizeBytes;
|
||||
entry.CloudAvailability = cloud.Availability;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user