Add settings, cloud places, and optional archive-content indexing.

Keep official clients in charge of sync while Explorer can group locations, persist UI prefs, and list zip/rar/7z members without extracting them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 12:16:09 +02:00
parent e9aba73552
commit 09a8cfafa3
57 changed files with 3130 additions and 119 deletions

View File

@@ -10,26 +10,68 @@ public sealed class BrowseService
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
public BrowseService(
IFileSystemEnumerator enumerator,
IVolumeService volumes,
IIndexStore store,
SourceManager sources,
StorageProviderRegistry providers)
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{
_enumerator = enumerator;
_volumes = volumes;
_store = store;
_sources = sources;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
}
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
{
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
return await ListSourcesAsync(
LocationRoots.ThisPc,
source => !groupNetwork || !source.Kind.IsNetwork(),
cancellationToken).ConfigureAwait(false);
}
public Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
=> ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken);
public Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
{
var items = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(place =>
{
var exists = Directory.Exists(place.Path);
return new FileSystemItem
{
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory
};
})
.ToList();
return Task.FromResult(new FolderListing { Path = LocationRoots.Cloud, Items = items });
}
private async Task<FolderListing> ListSourcesAsync(
string path,
Func<Source, bool> include,
CancellationToken cancellationToken)
{
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
var items = new List<FileSystemItem>();
foreach (var source in sources.OrderBy(s => s.Kind).ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
foreach (var source in sources.Where(include)
.OrderBy(s => s.Kind)
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
IndexEntry? root = null;
if (source.IsIndexed)
@@ -49,12 +91,21 @@ public sealed class BrowseService
});
}
return new FolderListing { Path = "This PC", Items = items };
return new FolderListing { Path = path, Items = items };
}
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
{
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
return archiveListing;
}
}
var reachable = _volumes.IsPathReachable(path);
if (reachable)
@@ -106,6 +157,73 @@ public sealed class BrowseService
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
public bool CanBrowseArchive(string name)
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
private async Task<FolderListing?> TryListArchiveAsync(Source source, string path, CancellationToken cancellationToken)
{
if (source.LastRootPath is null)
{
return null;
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
var entry = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return null;
}
var isArchiveFile = !entry.IsDirectory && ArchiveFormats.IsArchive(entry.Name);
if (!isArchiveFile && Directory.Exists(path))
{
return null;
}
if (!isArchiveFile && !await IsUnderArchiveAsync(source.Id, rel, cancellationToken).ConfigureAwait(false))
{
return null;
}
var children = await _store.Entries.GetChildrenAsync(source.Id, entry.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var items = children.Select(c => new FileSystemItem
{
FullPath = PathRules.Combine(source.LastRootPath, c.PathRel),
Name = c.Name,
IsDirectory = c.IsDirectory,
SizeBytes = c.IsDirectory ? c.AggregateSize : c.SizeBytes,
CreatedUtc = c.CreatedUtc,
ModifiedUtc = c.ModifiedUtc,
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes
}).ToList();
var hint = isArchiveFile && items.Count == 0
? "Archive contents appear after the next scan."
: null;
return new FolderListing { Path = path, IsOffline = false, Items = items, Error = hint };
}
private async Task<bool> IsUnderArchiveAsync(long sourceId, string pathRel, CancellationToken cancellationToken)
{
var current = pathRel;
while (!string.IsNullOrEmpty(current))
{
var entry = await _store.Entries.GetByPathAsync(sourceId, current, cancellationToken).ConfigureAwait(false);
if (entry is { IsDirectory: false } && ArchiveFormats.IsArchive(entry.Name))
{
return true;
}
current = PathRules.RelativeParent(current);
}
return false;
}
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
Source source,
string path,