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:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

View File

@@ -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; }
}