461 lines
15 KiB
C#
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; }
|
|
}
|