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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user