Add host activity and DB browser, and keep dialogs, drag-drop, and idle maintenance responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:03:52 +02:00
parent b72c375e87
commit b33a78dbbe
45 changed files with 3254 additions and 171 deletions

View File

@@ -1,18 +1,22 @@
using System.Collections.ObjectModel;
using System.Threading.Channels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexMutations _mutations;
private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly AnalysisService _analysis;
private CancellationTokenSource? _load;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
@@ -20,9 +24,14 @@ public sealed partial class DuplicateViewModel : ObservableObject
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
public DuplicateViewModel(
IIndexMutations mutations,
IIndexingHost indexing,
SourceManager sources,
AnalysisService analysis)
{
_mutations = mutations;
_indexing = indexing;
_sources = sources;
_analysis = analysis;
Groups = [];
@@ -35,6 +44,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
[RelayCommand]
public Task CloseAsync()
{
_load?.Cancel();
IsOpen = false;
return Task.CompletedTask;
}
@@ -42,58 +52,230 @@ public sealed partial class DuplicateViewModel : ObservableObject
[RelayCommand]
public async Task OpenAsync()
{
_load?.Cancel();
var cts = new CancellationTokenSource();
_load = cts;
var ct = cts.Token;
IsOpen = true;
IsBusy = true;
Status = "Finding size collisions…";
Groups.Clear();
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
try
{
var groups = await Task.Run(async () =>
await _mutations.EnqueueHashCollisionsAsync(null, ct).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
Status = "Loading duplicate groups…";
var sources = (await _sources.RefreshOnlineStateAsync(ct).ConfigureAwait(true))
.ToDictionary(s => s.Id);
var channel = Channel.CreateUnbounded<ClassifiedDuplicateGroup>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
var stream = Task.Run(async () =>
{
await _mutations.EnqueueHashCollisionsAsync(null).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
return classified
.Select(g => DuplicateGroupViewModel.From(g, sources))
.ToList();
}).ConfigureAwait(true);
try
{
return await _analysis.StreamClassifiedDuplicatesAsync(
200,
new DirectProgress<ClassifiedDuplicateGroup>(g => channel.Writer.TryWrite(g)),
verifyPresence: false,
ct)
.ConfigureAwait(false);
}
finally
{
channel.Writer.TryComplete();
}
}, ct);
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
foreach (var group in groups)
var added = 0;
await foreach (var classified in channel.Reader.ReadAllAsync(ct).ConfigureAwait(true))
{
if (group.Classification == DuplicateClass.Hardlink)
ApplyGroup(classified, sources, ref hiddenIntentional, ref hiddenHardlinks);
added++;
if (added % 24 == 0)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
continue;
}
await Task.Yield();
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
{
if (!ShowIntentional)
{
hiddenIntentional++;
continue;
}
}
Groups.Add(group);
}
await stream.ConfigureAwait(true);
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
IsBusy = false;
_ = VerifyOnDiskAsync(sources, cts, hiddenIntentional, hiddenHardlinks);
}
catch (OperationCanceledException)
{
if (IsOpen && ReferenceEquals(_load, cts))
{
Status = Groups.Count > 0
? BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks)
: "Cancelled.";
}
}
catch (Exception)
{
Status = "Could not load duplicates.";
if (IsOpen && ReferenceEquals(_load, cts))
{
Status = "Could not load duplicates.";
}
}
finally
{
IsBusy = false;
if (ReferenceEquals(_load, cts))
{
IsBusy = false;
}
}
}
private void ApplyGroup(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources,
ref int hiddenIntentional,
ref int hiddenHardlinks)
{
var group = DuplicateGroupViewModel.From(classified, sources);
if (group.Classification == DuplicateClass.Hardlink)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
return;
}
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
{
if (!ShowIntentional)
{
hiddenIntentional++;
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
return;
}
}
Groups.Add(group);
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
private async Task VerifyOnDiskAsync(
IReadOnlyDictionary<long, Source> sources,
CancellationTokenSource cts,
int hiddenIntentional,
int hiddenHardlinks)
{
var ct = cts.Token;
List<DuplicateGroupViewModel> snapshot;
try
{
snapshot = Groups.ToList();
}
catch (Exception)
{
return;
}
if (snapshot.Count == 0)
{
return;
}
IReadOnlyList<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> changes;
HashSet<(long SourceId, string PathRel)> reconcile;
try
{
(changes, reconcile) = await Task.Run(
() => ScanMissing(snapshot, sources, ct),
ct)
.ConfigureAwait(true);
}
catch (OperationCanceledException)
{
return;
}
if (!IsOpen || !ReferenceEquals(_load, cts) || ct.IsCancellationRequested)
{
return;
}
foreach (var (old, next) in changes)
{
var index = Groups.IndexOf(old);
if (index < 0)
{
continue;
}
if (next is null)
{
Groups.RemoveAt(index);
}
else
{
Groups[index] = next;
}
}
foreach (var (sourceId, pathRel) in reconcile)
{
_indexing.EnqueueReconcile(sourceId, pathRel);
}
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
private static (
List<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> Changes,
HashSet<(long SourceId, string PathRel)> Reconcile)
ScanMissing(
IReadOnlyList<DuplicateGroupViewModel> groups,
IReadOnlyDictionary<long, Source> sources,
CancellationToken cancellationToken)
{
var changes = new List<(DuplicateGroupViewModel, DuplicateGroupViewModel?)>();
var reconcile = new HashSet<(long, string)>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
var kept = new List<IndexEntry>(group.Entries.Count);
var dropped = false;
foreach (var entry in group.Entries)
{
if (!sources.TryGetValue(entry.SourceId, out var source)
|| string.IsNullOrWhiteSpace(source.LastRootPath)
|| !IndexedPathPresence.RootReachable(source.LastRootPath)
|| IndexedPathPresence.FileExists(source.LastRootPath, entry.PathRel))
{
kept.Add(entry);
continue;
}
dropped = true;
var prefix = IndexedPathPresence.HighestMissingPrefix(source.LastRootPath, entry.PathRel)
?? entry.PathRel;
reconcile.Add((entry.SourceId, IndexedPathPresence.ReconcilePath(entry.PathRel, prefix)));
}
if (!dropped)
{
continue;
}
if (kept.Count < 2)
{
changes.Add((group, null));
continue;
}
changes.Add((group, group.WithEntries(kept, sources)));
}
return (changes, reconcile);
}
[RelayCommand]
public async Task MarkIntentionalAsync(DuplicateGroupViewModel? group)
{
@@ -147,6 +329,19 @@ public sealed partial class DuplicateViewModel : ObservableObject
}
}
private static string LiveStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
var extra = hiddenIntentional + hiddenHardlinks;
if (visible == 0 && extra == 0)
{
return "Loading duplicate groups…";
}
return extra == 0
? $"{visible} duplicate groups so far…"
: $"{visible} duplicate groups so far · {extra} hidden";
}
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
if (visible > 0)
@@ -164,12 +359,18 @@ public sealed partial class DuplicateViewModel : ObservableObject
return "No confirmed duplicates yet. Hashing continues in the background.";
}
private sealed class DirectProgress<T>(Action<T> action) : IProgress<T>
{
public void Report(T value) => action(value);
}
}
public sealed class DuplicateGroupViewModel
{
public required IReadOnlyList<IndexEntry> Entries { get; init; }
public DuplicateClass Classification { get; init; }
public required string Header { get; init; }
public required string ClassLabel { get; init; }
public required string SizeLabel { get; init; }
public required string Summary { get; init; }
@@ -178,6 +379,30 @@ public sealed class DuplicateGroupViewModel
public bool CanMarkAccidental { get; init; }
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
public DuplicateGroupViewModel WithEntries(
IReadOnlyList<IndexEntry> entries,
IReadOnlyDictionary<long, Source> sources)
{
var unique = DuplicateClassifier.UniqueFileCount(entries);
var size = entries.Count > 0 ? entries[0].SizeBytes : 0;
return From(
new ClassifiedDuplicateGroup
{
Group = new DuplicateGroup
{
SizeBytes = size,
Entries = entries,
SameFileId = DuplicateClassifier.IsHardlinkOnly(entries)
},
Classification = Classification,
UniqueFileCount = unique,
WastedBytes = Classification == DuplicateClass.Hardlink || unique <= 1
? 0
: size * (unique - 1)
},
sources);
}
public static DuplicateGroupViewModel From(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources)
@@ -197,16 +422,27 @@ public sealed class DuplicateGroupViewModel
};
}).ToList();
var classLabel = classified.Classification == DuplicateClass.Unknown
? ""
: DuplicateClassifier.Label(classified.Classification);
var sizeLabel = Formatters.Size(classified.Group.SizeBytes);
var summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names";
var wasted = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space";
var header = string.IsNullOrEmpty(classLabel)
? $"{sizeLabel} · {summary} · {wasted}"
: $"{classLabel} · {sizeLabel} · {summary} · {wasted}";
return new DuplicateGroupViewModel
{
Entries = classified.Group.Entries,
Classification = classified.Classification,
ClassLabel = DuplicateClassifier.Label(classified.Classification),
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
WastedLabel = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space",
Header = header,
ClassLabel = classLabel,
SizeLabel = sizeLabel,
Summary = summary,
WastedLabel = wasted,
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink