Files
Explorer-Workbench/src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs

461 lines
15 KiB
C#

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;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(
IIndexMutations mutations,
IIndexingHost indexing,
SourceManager sources,
AnalysisService analysis)
{
_mutations = mutations;
_indexing = indexing;
_sources = sources;
_analysis = analysis;
Groups = [];
}
public ObservableCollection<DuplicateGroupViewModel> Groups { get; }
public event EventHandler<string>? RevealPath;
[RelayCommand]
public Task CloseAsync()
{
_load?.Cancel();
IsOpen = false;
return Task.CompletedTask;
}
[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
{
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 () =>
{
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 added = 0;
await foreach (var classified in channel.Reader.ReadAllAsync(ct).ConfigureAwait(true))
{
ApplyGroup(classified, sources, ref hiddenIntentional, ref hiddenHardlinks);
added++;
if (added % 24 == 0)
{
await Task.Yield();
}
}
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)
{
if (IsOpen && ReferenceEquals(_load, cts))
{
Status = "Could not load duplicates.";
}
}
finally
{
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)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.IntentionalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public async Task MarkAccidentalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.AccidentalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Reveal(DuplicateFileViewModel? file)
{
if (file is null || string.IsNullOrWhiteSpace(file.FullPath))
{
return;
}
RevealPath?.Invoke(this, file.FullPath);
}
partial void OnShowIntentionalChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
partial void OnShowHardlinksChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
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)
{
var extra = hiddenIntentional + hiddenHardlinks;
return extra == 0
? $"{visible} duplicate groups"
: $"{visible} duplicate groups · {extra} hidden as intentional or hard links";
}
if (hiddenIntentional + hiddenHardlinks > 0)
{
return "No accidental duplicates. Turn on intentional or hard links to review those.";
}
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; }
public required string WastedLabel { get; init; }
public bool CanMarkIntentional { get; init; }
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)
{
var files = classified.Group.Entries.Select(entry =>
{
sources.TryGetValue(entry.SourceId, out var source);
var root = source?.LastRootPath ?? source?.DisplayName ?? "";
var full = PathRules.Combine(root, entry.PathRel);
var hardlink = classified.Group.Entries.Count(e =>
e.Id != entry.Id && e.SourceId == entry.SourceId && e.FileId is > 0 && e.FileId == entry.FileId) > 0;
return new DuplicateFileViewModel
{
Name = entry.Name,
FullPath = full,
LocationLabel = hardlink ? $"{full} (hard link)" : full
};
}).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,
Header = header,
ClassLabel = classLabel,
SizeLabel = sizeLabel,
Summary = summary,
WastedLabel = wasted,
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Accidental,
Files = files
};
}
}
public sealed class DuplicateFileViewModel
{
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string LocationLabel { get; init; }
}