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 Groups { get; } public event EventHandler? 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( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); var stream = Task.Run(async () => { try { return await _analysis.StreamClassifiedDuplicatesAsync( 200, new DirectProgress(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 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 sources, CancellationTokenSource cts, int hiddenIntentional, int hiddenHardlinks) { var ct = cts.Token; List 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 groups, IReadOnlyDictionary 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(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(Action action) : IProgress { public void Report(T value) => action(value); } } public sealed class DuplicateGroupViewModel { public required IReadOnlyList 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 Files { get; init; } = []; public DuplicateGroupViewModel WithEntries( IReadOnlyList entries, IReadOnlyDictionary 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 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; } }