986 lines
35 KiB
C#
986 lines
35 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Threading.Channels;
|
|
using Explorer.Domain;
|
|
using Explorer.Domain.Abstractions;
|
|
|
|
namespace Explorer.Application;
|
|
|
|
public sealed class BrowseService
|
|
{
|
|
private readonly IFileSystemEnumerator _enumerator;
|
|
private readonly IVolumeService _volumes;
|
|
private readonly IIndexStore _store;
|
|
private readonly SourceManager _sources;
|
|
private readonly ICloudOverlay _providers;
|
|
private readonly CloudPlaceStore _cloudPlaces;
|
|
private readonly UiPreferencesStore _preferences;
|
|
private readonly IElevatedScanService? _elevation;
|
|
private readonly IRecycleBinCatalog? _recycle;
|
|
private readonly IKnownUserFolderCatalog? _knownFolders;
|
|
|
|
public BrowseService(
|
|
IFileSystemEnumerator enumerator,
|
|
IVolumeService volumes,
|
|
IIndexStore store,
|
|
SourceManager sources,
|
|
ICloudOverlay providers,
|
|
CloudPlaceStore cloudPlaces,
|
|
UiPreferencesStore preferences,
|
|
IElevatedScanService? elevation = null,
|
|
IRecycleBinCatalog? recycle = null,
|
|
IKnownUserFolderCatalog? knownFolders = null)
|
|
{
|
|
_enumerator = enumerator;
|
|
_volumes = volumes;
|
|
_store = store;
|
|
_sources = sources;
|
|
_providers = providers;
|
|
_cloudPlaces = cloudPlaces;
|
|
_preferences = preferences;
|
|
_elevation = elevation;
|
|
_recycle = recycle;
|
|
_knownFolders = knownFolders;
|
|
}
|
|
|
|
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
|
|
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 FolderListing ListHome()
|
|
{
|
|
var items = (_knownFolders?.ListExisting() ?? [])
|
|
.Select(folder => new FileSystemItem
|
|
{
|
|
FullPath = folder.Path,
|
|
Name = folder.Name,
|
|
DisplayName = folder.Name,
|
|
IsDirectory = true,
|
|
Attributes = AttributeFlags.Directory
|
|
})
|
|
.ToList();
|
|
return new FolderListing { Path = LocationRoots.Home, Items = items };
|
|
}
|
|
|
|
public FolderListing ListFavorites()
|
|
{
|
|
var items = new List<FileSystemItem>();
|
|
foreach (var path in FavoriteFolders.Normalize(_preferences.Load().FavoriteFolders))
|
|
{
|
|
var exists = Directory.Exists(path);
|
|
var name = FavoriteDisplayName(path);
|
|
items.Add(new FileSystemItem
|
|
{
|
|
FullPath = path,
|
|
Name = name,
|
|
DisplayName = exists ? name : $"{name} (Offline)",
|
|
IsDirectory = true,
|
|
Attributes = AttributeFlags.Directory
|
|
});
|
|
}
|
|
|
|
return new FolderListing { Path = LocationRoots.Favorites, Items = items };
|
|
}
|
|
|
|
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
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
|
|
{
|
|
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(
|
|
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.Where(include)
|
|
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
|
|
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
|
{
|
|
IndexEntry? root = null;
|
|
if (source.IsIndexed)
|
|
{
|
|
root = await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var space = source.LastRootPath is null
|
|
? default
|
|
: _volumes.GetSpace(source.LastRootPath);
|
|
items.Add(new FileSystemItem
|
|
{
|
|
FullPath = source.LastRootPath ?? source.DisplayName,
|
|
Name = source.Status == SourceStatus.Offline
|
|
? $"{source.DisplayName} (Offline)"
|
|
: source.DisplayName,
|
|
IsDirectory = true,
|
|
SizeBytes = root?.AggregateSize ?? 0,
|
|
Attributes = AttributeFlags.Directory,
|
|
FreeSpaceBytes = space.FreeBytes,
|
|
CapacityBytes = space.CapacityBytes ?? source.CapacityBytes
|
|
});
|
|
}
|
|
|
|
return new FolderListing { Path = path, Items = items };
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
|
|
{
|
|
yield return CompleteDelta(ListRecycleBin(path));
|
|
yield break;
|
|
}
|
|
|
|
var reachable = _volumes.IsPathReachable(path);
|
|
if (MightBeArchiveListing(path, reachable))
|
|
{
|
|
var archiveSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
|
if (archiveSource is { IsIndexed: true })
|
|
{
|
|
var archiveListing = await TryListArchiveAsync(archiveSource, path, cancellationToken).ConfigureAwait(false);
|
|
if (archiveListing is not null)
|
|
{
|
|
yield return CompleteDelta(archiveListing);
|
|
yield break;
|
|
}
|
|
}
|
|
|
|
if (!reachable)
|
|
{
|
|
yield return CompleteDelta(await ListOfflineAsync(archiveSource, path, cancellationToken).ConfigureAwait(false));
|
|
yield break;
|
|
}
|
|
}
|
|
else if (!reachable)
|
|
{
|
|
var offlineSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
|
yield return CompleteDelta(await ListOfflineAsync(offlineSource, path, cancellationToken).ConfigureAwait(false));
|
|
yield break;
|
|
}
|
|
|
|
var sourceTask = _sources.FindByPathAsync(path, cancellationToken);
|
|
_ = MarkReachableInBackground(path, cancellationToken);
|
|
await foreach (var delta in ListLiveProgressiveAsync(path, sourceTask, viewport, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
yield return delta;
|
|
}
|
|
}
|
|
|
|
public bool CanBrowseArchive(string name)
|
|
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
|
|
|
|
private bool MightBeArchiveListing(string path, bool reachable)
|
|
{
|
|
if (!_preferences.Load().IndexArchiveContents)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!reachable)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var name = Path.GetFileName(path.TrimEnd('\\', '/'));
|
|
return !string.IsNullOrEmpty(name) && ArchiveFormats.IsArchive(name);
|
|
}
|
|
|
|
private async Task MarkReachableInBackground(string path, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await _sources.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch
|
|
{
|
|
// Listing already started from the live filesystem.
|
|
}
|
|
}
|
|
|
|
private async Task<Dictionary<string, IndexEntry>> LoadIndexAfterSourceAsync(
|
|
Task<Source?> sourceTask,
|
|
string path,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Source? source;
|
|
try
|
|
{
|
|
source = await sourceTask.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
|
}
|
|
|
|
if (source is not { IsIndexed: true })
|
|
{
|
|
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
|
}
|
|
|
|
return await LoadIndexChildrenAsync(source, path, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
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,
|
|
Task<Source?> sourceTask,
|
|
BrowseViewport? viewport,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
var prefs = _preferences.Load();
|
|
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
|
Task<Dictionary<string, IndexEntry>>? indexTask = null;
|
|
|
|
Task<Dictionary<string, IndexEntry>> KickIndex()
|
|
=> indexTask ??= LoadIndexAfterSourceAsync(sourceTask, path, cancellationToken);
|
|
|
|
var batch = new List<FileSystemItem>(BrowseHydration.PublishBatch);
|
|
var all = new List<FileSystemItem>();
|
|
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
var sink = new FileEnumerationSink();
|
|
var firstFlush = true;
|
|
|
|
await foreach (var raw in StreamEnumerationAsync(path, sink, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var item = Annotate(
|
|
raw.Overlay(hydration: raw.Hydration & ~(ItemHydrationFlags.Index | ItemHydrationFlags.Provider)),
|
|
sizeFromIndex: false,
|
|
prefs,
|
|
probeAccess: false);
|
|
if (!LocationVisibility.ShouldShow(item.Location, prefs))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
item = AttachVolumeSpace(item, spaceCache);
|
|
Upsert(all, byPath, item);
|
|
batch.Add(item);
|
|
var limit = firstFlush ? BrowseHydration.FirstPublish : BrowseHydration.PublishBatch;
|
|
if (batch.Count >= limit)
|
|
{
|
|
firstFlush = false;
|
|
yield return new BrowseDelta
|
|
{
|
|
Path = path,
|
|
Added = batch.ToArray(),
|
|
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
|
};
|
|
batch.Clear();
|
|
_ = KickIndex();
|
|
}
|
|
}
|
|
|
|
yield return new BrowseDelta
|
|
{
|
|
Path = path,
|
|
Error = sink.Error,
|
|
Added = batch.Count > 0 ? batch.ToArray() : [],
|
|
EnumerationComplete = true,
|
|
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
|
};
|
|
|
|
var indexMap = await KickIndex().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 source = sourceTask.IsCompletedSuccessfully ? sourceTask.Result : await sourceTask.ConfigureAwait(false);
|
|
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.FindProviderId(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,
|
|
indexedChildCount: entry.ChildFileCount + entry.ChildDirCount,
|
|
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)
|
|
{
|
|
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 = AttachVolumeSpace(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 FolderListing FinishListing(string path, IReadOnlyList<FileSystemItem> items, bool isOffline, string? error)
|
|
{
|
|
var prefs = _preferences.Load();
|
|
var annotated = items.Select(i => Annotate(i, sizeFromIndex: i.SizeBytes > 0, prefs)).ToList();
|
|
var visible = annotated.Where(i => LocationVisibility.ShouldShow(i.Location, prefs)).ToList();
|
|
if (visible.Any(i => i.Location.AccessDenied) && _elevation is { IsElevated: false })
|
|
{
|
|
error = string.IsNullOrEmpty(error)
|
|
? _elevation.ProtectedContentHint
|
|
: error + " " + _elevation.ProtectedContentHint;
|
|
}
|
|
|
|
return new FolderListing { Path = path, IsOffline = isOffline, Items = visible, Error = error };
|
|
}
|
|
|
|
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 = 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 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))
|
|
{
|
|
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);
|
|
}
|
|
|
|
public int CountVisibleChildren(string path)
|
|
{
|
|
var live = _enumerator.EnumerateChildrenSafe(path, out var error);
|
|
if (error is not null)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
var prefs = _preferences.Load();
|
|
var count = 0;
|
|
foreach (var item in live)
|
|
{
|
|
var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
|
|
if (LocationVisibility.ShouldShow(location, prefs) && !location.IsRecycleBin)
|
|
{
|
|
count++;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private IReadOnlyList<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
|
|
{
|
|
var cache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
|
return items.Select(item => AttachVolumeSpace(item, cache)).ToList();
|
|
}
|
|
|
|
private static string SpaceKey(string path)
|
|
{
|
|
if (PathRules.IsUnc(path))
|
|
{
|
|
return PathRules.CanonicalUncRoot(path);
|
|
}
|
|
|
|
var root = Path.GetPathRoot(path);
|
|
return string.IsNullOrWhiteSpace(root) ? path : root;
|
|
}
|
|
|
|
private FileSystemItem RecycleBinItem(RecycleBinSummary? summary = null)
|
|
{
|
|
summary ??= _recycle?.TrySummarize(LocationRoots.RecycleBin);
|
|
return new FileSystemItem
|
|
{
|
|
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 FavoriteDisplayName(string path)
|
|
{
|
|
var name = PathRules.GetFileName(path.TrimEnd('\\'));
|
|
return string.IsNullOrEmpty(name) ? path : name;
|
|
}
|
|
|
|
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 unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
|
|
}
|
|
|
|
private static bool IsRecycleBinPath(string path)
|
|
=> LocationClassifier.IsRecycleBinName(PathRules.GetFileName(path));
|
|
|
|
private static bool IsAccessDenied(string path)
|
|
{
|
|
try
|
|
{
|
|
using var enumerator = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
|
|
enumerator.MoveNext();
|
|
return false;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return true;
|
|
}
|
|
catch (System.Security.SecurityException)
|
|
{
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|