Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
@@ -11,19 +12,32 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly SourceManager _sources;
|
||||
private readonly AnalysisService _analysis;
|
||||
|
||||
[ObservableProperty] private bool _isOpen;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _status = "";
|
||||
[ObservableProperty] private bool _showIntentional;
|
||||
[ObservableProperty] private bool _showHardlinks;
|
||||
|
||||
public DuplicateViewModel(IIndexStore store, SourceManager sources)
|
||||
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis)
|
||||
{
|
||||
_store = store;
|
||||
_sources = sources;
|
||||
_analysis = analysis;
|
||||
Groups = [];
|
||||
}
|
||||
|
||||
public ObservableCollection<string> Groups { get; }
|
||||
public ObservableCollection<DuplicateGroupViewModel> Groups { get; }
|
||||
|
||||
public event EventHandler<string>? RevealPath;
|
||||
|
||||
[RelayCommand]
|
||||
public Task CloseAsync()
|
||||
{
|
||||
IsOpen = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task OpenAsync()
|
||||
@@ -34,38 +48,41 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
Groups.Clear();
|
||||
try
|
||||
{
|
||||
var lines = await Task.Run(async () =>
|
||||
var groups = await Task.Run(async () =>
|
||||
{
|
||||
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
|
||||
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
|
||||
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
|
||||
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
|
||||
var result = new List<string>();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
if (g.SameFileId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var paths = string.Join(" | ", g.Entries.Select(e =>
|
||||
{
|
||||
sources.TryGetValue(e.SourceId, out var s);
|
||||
return PathRules.Combine(s?.LastRootPath ?? s?.DisplayName ?? "", e.PathRel);
|
||||
}));
|
||||
result.Add($"{Formatters.Size(g.SizeBytes)} · {g.Entries.Count} files · {paths}");
|
||||
}
|
||||
|
||||
return result;
|
||||
return classified
|
||||
.Select(g => DuplicateGroupViewModel.From(g, sources))
|
||||
.ToList();
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
foreach (var line in lines)
|
||||
var hiddenIntentional = 0;
|
||||
var hiddenHardlinks = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
Groups.Add(line);
|
||||
if (group.Classification == DuplicateClass.Hardlink)
|
||||
{
|
||||
if (!ShowHardlinks)
|
||||
{
|
||||
hiddenHardlinks++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (DuplicateClassifier.IsIntentional(group.Classification))
|
||||
{
|
||||
if (!ShowIntentional)
|
||||
{
|
||||
hiddenIntentional++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Groups.Add(group);
|
||||
}
|
||||
|
||||
Status = Groups.Count == 0
|
||||
? "No confirmed duplicates yet. Hashing continues in the background."
|
||||
: $"{Groups.Count} duplicate groups";
|
||||
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -76,4 +93,132 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[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 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.";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DuplicateGroupViewModel
|
||||
{
|
||||
public required IReadOnlyList<IndexEntry> Entries { get; init; }
|
||||
public DuplicateClass Classification { 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 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();
|
||||
|
||||
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",
|
||||
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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user