Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
@@ -40,35 +42,70 @@ public sealed class BrowseService
|
||||
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
|
||||
return await ListSourcesAsync(
|
||||
var listing = await ListSourcesAsync(
|
||||
LocationRoots.ThisPc,
|
||||
source => !groupNetwork || !source.Kind.IsNetwork(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var items = listing.Items.ToList();
|
||||
if (!groupNetwork)
|
||||
{
|
||||
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
items.Add(RecycleBinItem());
|
||||
return new FolderListing { Path = listing.Path, Items = items };
|
||||
}
|
||||
|
||||
public Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
|
||||
=> ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken);
|
||||
|
||||
public Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
|
||||
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(place =>
|
||||
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var items = listing.Items.ToList();
|
||||
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
|
||||
return new FolderListing { Path = listing.Path, Items = items };
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = new List<FileSystemItem>();
|
||||
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
|
||||
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
var exists = Directory.Exists(place.Path);
|
||||
var space = exists ? _volumes.GetSpace(place.Path) : default;
|
||||
var quota = await _providers.TryGetQuotaAsync(place.Path, cancellationToken).ConfigureAwait(false);
|
||||
var capacity = quota?.TotalBytes ?? space.CapacityBytes;
|
||||
long? free = quota is { TotalBytes: long total, UsedBytes: long used }
|
||||
? Math.Max(0, total - used)
|
||||
: space.FreeBytes;
|
||||
items.Add(new FileSystemItem
|
||||
{
|
||||
var exists = Directory.Exists(place.Path);
|
||||
var space = exists ? _volumes.GetSpace(place.Path) : default;
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = place.Path,
|
||||
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory,
|
||||
FreeSpaceBytes = space.FreeBytes,
|
||||
CapacityBytes = space.CapacityBytes
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
return Task.FromResult(new FolderListing { Path = LocationRoots.Cloud, Items = items });
|
||||
FullPath = place.Path,
|
||||
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory,
|
||||
FreeSpaceBytes = free,
|
||||
CapacityBytes = capacity
|
||||
});
|
||||
}
|
||||
|
||||
return new FolderListing { Path = LocationRoots.Cloud, Items = items };
|
||||
}
|
||||
|
||||
public FolderListing ListRecycleBin(string? path = null)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(path) ? LocationRoots.RecycleBin : path;
|
||||
var summary = _recycle?.TrySummarize(root);
|
||||
var hint = summary is null
|
||||
? "Recycle Bin contents are managed by Windows."
|
||||
: $"{summary.ItemCount:N0} deleted items · {FormatBytes(summary.UsedBytes)} used";
|
||||
return new FolderListing
|
||||
{
|
||||
Path = LocationRoots.RecycleBin,
|
||||
IsOffline = false,
|
||||
Items = [RecycleBinItem(summary)],
|
||||
Error = hint
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FolderListing> ListSourcesAsync(
|
||||
@@ -109,6 +146,31 @@ public sealed class BrowseService
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = new List<FileSystemItem>();
|
||||
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
string? error = null;
|
||||
var offline = false;
|
||||
var listingPath = path;
|
||||
await foreach (var delta in ListProgressiveAsync(path, viewport: null, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
listingPath = delta.Path;
|
||||
offline = delta.IsOffline;
|
||||
if (delta.Error is not null)
|
||||
{
|
||||
error = delta.Error;
|
||||
}
|
||||
|
||||
ApplyDelta(items, byPath, delta);
|
||||
}
|
||||
|
||||
return new FolderListing { Path = listingPath, IsOffline = offline, Items = items, Error = error };
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<BrowseDelta> ListProgressiveAsync(
|
||||
string path,
|
||||
BrowseViewport? viewport = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
|
||||
@@ -116,69 +178,472 @@ public sealed class BrowseService
|
||||
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveListing is not null)
|
||||
{
|
||||
return archiveListing;
|
||||
yield return CompleteDelta(archiveListing);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsRecycleBinPath(path))
|
||||
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
|
||||
{
|
||||
return ListRecycleBin(path);
|
||||
yield return CompleteDelta(ListRecycleBin(path));
|
||||
yield break;
|
||||
}
|
||||
|
||||
var reachable = _volumes.IsPathReachable(path);
|
||||
|
||||
if (reachable)
|
||||
if (!reachable)
|
||||
{
|
||||
var items = _enumerator.EnumerateChildrenSafe(path, out var error);
|
||||
var listing = (await _providers.EnrichAsync(items.ToList(), cancellationToken).ConfigureAwait(false)).ToList();
|
||||
if (source is { IsIndexed: true })
|
||||
{
|
||||
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return FinishListing(path, AttachVolumeSpace(listing), isOffline: false, error);
|
||||
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (source is { IsIndexed: true })
|
||||
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
|
||||
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
|
||||
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (dir is null)
|
||||
{
|
||||
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
|
||||
}
|
||||
|
||||
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var items = children
|
||||
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
|
||||
.Select(c => new FileSystemItem
|
||||
{
|
||||
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, 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,
|
||||
Cloud = c.CloudAvailability is { } availability
|
||||
? new CloudPresence(null, availability, c.SizeBytes, c.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
|
||||
: null
|
||||
})
|
||||
.ToList();
|
||||
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
|
||||
yield return delta;
|
||||
}
|
||||
|
||||
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
|
||||
}
|
||||
|
||||
public bool CanBrowseArchive(string name)
|
||||
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
|
||||
|
||||
private static void ApplyDelta(
|
||||
List<FileSystemItem> items,
|
||||
Dictionary<string, int> byPath,
|
||||
BrowseDelta delta)
|
||||
{
|
||||
foreach (var added in delta.Added)
|
||||
{
|
||||
Upsert(items, byPath, added);
|
||||
}
|
||||
|
||||
foreach (var updated in delta.Updated)
|
||||
{
|
||||
Upsert(items, byPath, updated);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Upsert(
|
||||
List<FileSystemItem> items,
|
||||
Dictionary<string, int> byPath,
|
||||
FileSystemItem item)
|
||||
{
|
||||
if (byPath.TryGetValue(item.FullPath, out var index))
|
||||
{
|
||||
items[index] = item;
|
||||
return;
|
||||
}
|
||||
|
||||
byPath[item.FullPath] = items.Count;
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
private static BrowseDelta CompleteDelta(FolderListing listing)
|
||||
=> new()
|
||||
{
|
||||
Path = listing.Path,
|
||||
IsOffline = listing.IsOffline,
|
||||
Error = listing.Error,
|
||||
Added = listing.Items,
|
||||
EnumerationComplete = true,
|
||||
HydrationComplete = true,
|
||||
CompletedStages = ItemHydrationFlags.All
|
||||
};
|
||||
|
||||
private async Task<FolderListing> ListOfflineAsync(Source? source, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source is not { IsIndexed: true })
|
||||
{
|
||||
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
|
||||
}
|
||||
|
||||
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
|
||||
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
|
||||
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (dir is null)
|
||||
{
|
||||
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
|
||||
}
|
||||
|
||||
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var items = children
|
||||
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
|
||||
.Select(c => FromIndex(source, c))
|
||||
.ToList();
|
||||
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
|
||||
}
|
||||
|
||||
private static FileSystemItem FromIndex(Source source, IndexEntry entry)
|
||||
=> new()
|
||||
{
|
||||
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, entry.PathRel),
|
||||
Name = entry.Name,
|
||||
IsDirectory = entry.IsDirectory,
|
||||
SizeBytes = entry.IsDirectory ? entry.AggregateSize : entry.SizeBytes,
|
||||
CreatedUtc = entry.CreatedUtc,
|
||||
ModifiedUtc = entry.ModifiedUtc,
|
||||
Attributes = entry.Attributes,
|
||||
FileId = entry.FileId,
|
||||
ReparseTag = entry.ReparseTag,
|
||||
AllocatedSizeBytes = entry.AllocatedSizeBytes,
|
||||
Cloud = entry.CloudAvailability is { } availability
|
||||
? new CloudPresence(null, availability, entry.SizeBytes, entry.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
|
||||
: null,
|
||||
Hydration = ItemHydrationFlags.All
|
||||
};
|
||||
|
||||
private async IAsyncEnumerable<BrowseDelta> ListLiveProgressiveAsync(
|
||||
string path,
|
||||
Source? source,
|
||||
BrowseViewport? viewport,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var prefs = _preferences.Load();
|
||||
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexTask = source is { IsIndexed: true }
|
||||
? LoadIndexChildrenAsync(source, path, cancellationToken)
|
||||
: Task.FromResult(new Dictionary<string, IndexEntry>(StringComparer.Ordinal));
|
||||
|
||||
var batch = new List<FileSystemItem>(BrowseHydration.PublishBatch);
|
||||
var all = new List<FileSystemItem>();
|
||||
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, IndexEntry>? indexMap = indexTask.IsCompletedSuccessfully ? indexTask.Result : null;
|
||||
var sink = new FileEnumerationSink();
|
||||
|
||||
await foreach (var raw in StreamEnumerationAsync(path, sink, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (indexMap is null && indexTask.IsCompleted)
|
||||
{
|
||||
indexMap = await indexTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var item = Annotate(raw, sizeFromIndex: false, prefs, probeAccess: false);
|
||||
if (!LocationVisibility.ShouldShow(item.Location, prefs))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexMap is not null)
|
||||
{
|
||||
item = OverlayIndex(item, indexMap, prefs);
|
||||
}
|
||||
|
||||
item = AttachVolumeSpace(item, spaceCache);
|
||||
Upsert(all, byPath, item);
|
||||
batch.Add(item);
|
||||
if (batch.Count >= BrowseHydration.PublishBatch)
|
||||
{
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
Added = batch.ToArray(),
|
||||
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
||||
};
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
Error = sink.Error,
|
||||
Added = batch.Count > 0 ? batch.ToArray() : [],
|
||||
EnumerationComplete = true,
|
||||
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
||||
};
|
||||
|
||||
if (indexMap is null)
|
||||
{
|
||||
indexMap = await indexTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var indexUpdates = OverlayPendingIndex(all, byPath, indexMap, prefs);
|
||||
if (indexUpdates.Count > 0)
|
||||
{
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
Updated = indexUpdates,
|
||||
CompletedStages = ItemHydrationFlags.Index
|
||||
};
|
||||
}
|
||||
|
||||
var accessUpdates = await ProbeAccessDeniedAsync(all, byPath, prefs, source?.Kind, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (accessUpdates.Count > 0)
|
||||
{
|
||||
yield return new BrowseDelta { Path = path, Updated = accessUpdates };
|
||||
}
|
||||
|
||||
var constrained = _providers.Find(path) is not null;
|
||||
if (constrained)
|
||||
{
|
||||
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (enriched.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
Updated = enriched,
|
||||
CompletedStages = ItemHydrationFlags.Provider
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
Error = sink.Error,
|
||||
HydrationComplete = true,
|
||||
CompletedStages = ItemHydrationFlags.All
|
||||
};
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<FileSystemItem> StreamEnumerationAsync(
|
||||
string path,
|
||||
FileEnumerationSink sink,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateBounded<FileSystemItem>(new BoundedChannelOptions(256)
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
});
|
||||
|
||||
var writer = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var item in _enumerator.EnumerateChildrenStreaming(path, sink, cancellationToken))
|
||||
{
|
||||
await channel.Writer.WriteAsync(item, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Reader observes cancellation.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sink.Error ??= ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await writer.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// superseded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, IndexEntry>> LoadIndexChildrenAsync(
|
||||
Source source,
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
|
||||
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
|
||||
?? (string.IsNullOrEmpty(rel)
|
||||
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
|
||||
: null);
|
||||
if (indexed is null)
|
||||
{
|
||||
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private FileSystemItem OverlayIndex(
|
||||
FileSystemItem item,
|
||||
IReadOnlyDictionary<string, IndexEntry> byName,
|
||||
UiPreferences preferences)
|
||||
{
|
||||
if (!byName.TryGetValue(NameNormalizer.Normalize(item.Name), out var entry))
|
||||
{
|
||||
return item;
|
||||
}
|
||||
|
||||
var size = item.IsDirectory ? entry.AggregateSize : item.SizeBytes;
|
||||
var cloud = item.Cloud;
|
||||
if (cloud is null && entry.CloudAvailability is { } availability)
|
||||
{
|
||||
cloud = new CloudPresence(
|
||||
null,
|
||||
availability,
|
||||
entry.SizeBytes,
|
||||
entry.AllocatedSizeBytes,
|
||||
availability == CloudAvailability.OnlineOnly);
|
||||
}
|
||||
|
||||
var hydrated = item.Overlay(
|
||||
sizeBytes: size,
|
||||
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
|
||||
fileId: item.FileId ?? entry.FileId,
|
||||
cloud: cloud,
|
||||
hydration: item.Hydration | ItemHydrationFlags.Index);
|
||||
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
|
||||
}
|
||||
|
||||
private List<FileSystemItem> OverlayPendingIndex(
|
||||
List<FileSystemItem> all,
|
||||
Dictionary<string, int> byPath,
|
||||
IReadOnlyDictionary<string, IndexEntry> indexMap,
|
||||
UiPreferences preferences)
|
||||
{
|
||||
if (indexMap.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var updates = new List<FileSystemItem>();
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
{
|
||||
var current = all[i];
|
||||
if ((current.Hydration & ItemHydrationFlags.Index) != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var updated = OverlayIndex(current, indexMap, preferences);
|
||||
if (ReferenceEquals(updated, current))
|
||||
{
|
||||
all[i] = current.Overlay(hydration: current.Hydration | ItemHydrationFlags.Index);
|
||||
continue;
|
||||
}
|
||||
|
||||
all[i] = updated;
|
||||
byPath[updated.FullPath] = i;
|
||||
updates.Add(updated);
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private async Task<List<FileSystemItem>> ProbeAccessDeniedAsync(
|
||||
List<FileSystemItem> all,
|
||||
Dictionary<string, int> byPath,
|
||||
UiPreferences preferences,
|
||||
SourceKind? kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!preferences.ShowProtectedSystemLocations)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var candidates = all
|
||||
.Where(i => i.IsDirectory && i.Location.IsProtected && !i.Location.IsRecycleBin && !i.Location.AccessDenied)
|
||||
.ToList();
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var updates = new List<FileSystemItem>();
|
||||
var gate = new object();
|
||||
await Parallel.ForEachAsync(
|
||||
candidates,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = BrowseHydration.WorkerCount(kind, constrained: false),
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
(item, token) =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!IsAccessDenied(item.FullPath))
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
var updated = Annotate(item, sizeFromIndex: item.SizeBytes > 0 && item.IsDirectory, preferences, probeAccess: true);
|
||||
lock (gate)
|
||||
{
|
||||
if (byPath.TryGetValue(updated.FullPath, out var index))
|
||||
{
|
||||
all[index] = updated;
|
||||
}
|
||||
|
||||
updates.Add(updated);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<IReadOnlyList<FileSystemItem>> EnrichInBatchesAsync(
|
||||
List<FileSystemItem> all,
|
||||
Dictionary<string, int> byPath,
|
||||
BrowseViewport? viewport,
|
||||
SourceKind? kind,
|
||||
bool constrained,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (all.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var pending = all.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
|
||||
var batchSize = BrowseHydration.ProviderBatchSize(kind, constrained);
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var leftover = all.Where(i => pending.ContainsKey(i.FullPath)).ToList();
|
||||
var ordered = BrowseHydration.Prioritize(leftover, viewport?.Snapshot() ?? []);
|
||||
var chunk = ordered.Take(batchSize).ToList();
|
||||
var enriched = await _providers.EnrichAsync(chunk, cancellationToken).ConfigureAwait(false);
|
||||
var changed = new List<FileSystemItem>(chunk.Count);
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
var original = chunk[i];
|
||||
pending.Remove(original.FullPath);
|
||||
var next = i < enriched.Count ? enriched[i] : original;
|
||||
if (ReferenceEquals(next, original))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byPath.TryGetValue(next.FullPath, out var index))
|
||||
{
|
||||
all[index] = next;
|
||||
}
|
||||
|
||||
changed.Add(next);
|
||||
}
|
||||
|
||||
yield return changed;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FolderListing?> TryListArchiveAsync(Source source, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.LastRootPath is null)
|
||||
@@ -243,54 +708,6 @@ public sealed class BrowseService
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
|
||||
Source source,
|
||||
string path,
|
||||
List<FileSystemItem> listing,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
|
||||
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
|
||||
?? (string.IsNullOrEmpty(rel)
|
||||
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
|
||||
: null);
|
||||
if (indexed is null)
|
||||
{
|
||||
return listing;
|
||||
}
|
||||
|
||||
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var byName = children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
|
||||
return listing.Select(i =>
|
||||
{
|
||||
if (!i.IsDirectory || !byName.TryGetValue(NameNormalizer.Normalize(i.Name), out var e))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = i.FullPath,
|
||||
Name = i.Name,
|
||||
IsDirectory = true,
|
||||
SizeBytes = e.AggregateSize,
|
||||
CreatedUtc = i.CreatedUtc,
|
||||
ModifiedUtc = i.ModifiedUtc,
|
||||
Attributes = i.Attributes,
|
||||
FileId = i.FileId,
|
||||
ReparseTag = i.ReparseTag,
|
||||
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
|
||||
Cloud = i.Cloud,
|
||||
Location = i.Location,
|
||||
SizeKnowledge = i.SizeKnowledge,
|
||||
DisplayName = i.DisplayName,
|
||||
FreeSpaceBytes = i.FreeSpaceBytes,
|
||||
CapacityBytes = i.CapacityBytes
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private FolderListing FinishListing(string path, IReadOnlyList<FileSystemItem> items, bool isOffline, string? error)
|
||||
{
|
||||
var prefs = _preferences.Load();
|
||||
@@ -306,73 +723,44 @@ public sealed class BrowseService
|
||||
return new FolderListing { Path = path, IsOffline = isOffline, Items = visible, Error = error };
|
||||
}
|
||||
|
||||
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences)
|
||||
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences, bool probeAccess = true)
|
||||
{
|
||||
var looksRestricted = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
|
||||
var accessDenied = preferences.ShowProtectedSystemLocations
|
||||
var accessDenied = probeAccess
|
||||
&& preferences.ShowProtectedSystemLocations
|
||||
&& item.IsDirectory
|
||||
&& (looksRestricted.IsProtected || looksRestricted.IsRecycleBin)
|
||||
&& IsAccessDenied(item.FullPath);
|
||||
var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory, accessDenied);
|
||||
var knowledge = LocationVisibility.ResolveSizeKnowledge(location, item.IsDirectory, item.SizeBytes, sizeFromIndex);
|
||||
return new FileSystemItem
|
||||
return item.Overlay(
|
||||
location: location,
|
||||
sizeKnowledge: knowledge,
|
||||
displayName: location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
|
||||
hydration: item.Hydration | ItemHydrationFlags.Location);
|
||||
}
|
||||
|
||||
private FileSystemItem AttachVolumeSpace(FileSystemItem item, Dictionary<string, VolumeSpace> cache)
|
||||
{
|
||||
var key = SpaceKey(item.FullPath);
|
||||
if (!cache.TryGetValue(key, out var space))
|
||||
{
|
||||
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 = item.AllocatedSizeBytes,
|
||||
Cloud = item.Cloud,
|
||||
Location = location,
|
||||
SizeKnowledge = knowledge,
|
||||
DisplayName = location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
|
||||
FreeSpaceBytes = item.FreeSpaceBytes,
|
||||
CapacityBytes = item.CapacityBytes
|
||||
};
|
||||
space = _volumes.GetSpace(item.FullPath);
|
||||
cache[key] = space;
|
||||
}
|
||||
|
||||
if (space.FreeBytes is null && space.CapacityBytes is null)
|
||||
{
|
||||
return item;
|
||||
}
|
||||
|
||||
return item.Overlay(freeSpaceBytes: space.FreeBytes, capacityBytes: space.CapacityBytes);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
|
||||
{
|
||||
var cache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
||||
return items.Select(item =>
|
||||
{
|
||||
var key = SpaceKey(item.FullPath);
|
||||
if (!cache.TryGetValue(key, out var space))
|
||||
{
|
||||
space = _volumes.GetSpace(item.FullPath);
|
||||
cache[key] = space;
|
||||
}
|
||||
|
||||
if (space.FreeBytes is null && space.CapacityBytes is null)
|
||||
{
|
||||
return item;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
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 = item.AllocatedSizeBytes,
|
||||
Cloud = item.Cloud,
|
||||
Location = item.Location,
|
||||
SizeKnowledge = item.SizeKnowledge,
|
||||
DisplayName = item.DisplayName,
|
||||
FreeSpaceBytes = space.FreeBytes,
|
||||
CapacityBytes = space.CapacityBytes
|
||||
};
|
||||
}).ToList();
|
||||
return items.Select(item => AttachVolumeSpace(item, cache)).ToList();
|
||||
}
|
||||
|
||||
private static string SpaceKey(string path)
|
||||
@@ -386,18 +774,57 @@ public sealed class BrowseService
|
||||
return string.IsNullOrWhiteSpace(root) ? path : root;
|
||||
}
|
||||
|
||||
private FolderListing ListRecycleBin(string path)
|
||||
private FileSystemItem RecycleBinItem(RecycleBinSummary? summary = null)
|
||||
{
|
||||
var summary = _recycle?.TrySummarize(path);
|
||||
var hint = summary is null
|
||||
? "Recycle Bin contents are managed by Windows."
|
||||
: $"{summary.ItemCount} deleted items · {summary.UsedBytes} bytes used";
|
||||
if (_elevation is { IsElevated: false })
|
||||
summary ??= _recycle?.TrySummarize(LocationRoots.RecycleBin);
|
||||
return new FileSystemItem
|
||||
{
|
||||
hint += " " + _elevation.ProtectedContentHint;
|
||||
FullPath = LocationRoots.RecycleBin,
|
||||
Name = LocationRoots.RecycleBin,
|
||||
DisplayName = LocationRoots.RecycleBin,
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory,
|
||||
SizeBytes = summary?.UsedBytes ?? 0,
|
||||
SizeKnowledge = summary is null ? SizeKnowledge.Unknown : SizeKnowledge.Calculated,
|
||||
Location = new LocationInfo(false, false, false, false, true)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<FileSystemItem>> UntrackedNetworkItemsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var untracked = await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return untracked
|
||||
.Where(fp => fp.Kind.IsNetwork())
|
||||
.Select(fp =>
|
||||
{
|
||||
var space = _volumes.GetSpace(fp.RootPath);
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = fp.RootPath,
|
||||
Name = fp.DisplayName ?? fp.RootPath,
|
||||
DisplayName = (fp.DisplayName ?? fp.RootPath) + " (Windows)",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory,
|
||||
FreeSpaceBytes = space.FreeBytes ?? fp.FreeBytes,
|
||||
CapacityBytes = space.CapacityBytes ?? fp.CapacityBytes,
|
||||
AvailableToImport = true
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
double value = Math.Max(0, bytes);
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return new FolderListing { Path = path, IsOffline = false, Items = [], Error = hint };
|
||||
return unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
|
||||
}
|
||||
|
||||
private static bool IsRecycleBinPath(string path)
|
||||
|
||||
Reference in New Issue
Block a user