perf: virtualize the Duplicate Finder group list
The duplicate view rendered every group and every thumbnail at once — a 5,000-pair result mounted ~10K <img> elements, making scroll lag heavily (same class of bug as the old per-month Timeline). Virtualize the group list so only on-screen cards mount; heights are measured dynamically since each group wraps a variable number of copies.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
@@ -130,6 +131,17 @@ export function DuplicateFinder() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteResult, setDeleteResult] = useState<string | null>(null);
|
const [deleteResult, setDeleteResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Virtualize the group list so a large result set (e.g. thousands of pairs)
|
||||||
|
// only mounts the on-screen cards. Group cards vary in height (number of
|
||||||
|
// copies wraps across rows), so heights are measured dynamically.
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: duplicateGroups.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 220,
|
||||||
|
overscan: 4,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedCount = duplicateSelectedIds.size;
|
const selectedCount = duplicateSelectedIds.size;
|
||||||
const hasResults = duplicateGroups.length > 0;
|
const hasResults = duplicateGroups.length > 0;
|
||||||
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
|
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
|
||||||
@@ -277,11 +289,29 @@ export function DuplicateFinder() {
|
|||||||
<p className="text-sm text-white/25">No duplicate files found.</p>
|
<p className="text-sm text-white/25">No duplicate files found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-y-auto px-6 py-5">
|
<div ref={scrollRef} className="overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-4">
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
{duplicateGroups.map((group) => (
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
<DuplicateGroupCard key={group.file_hash} group={group} />
|
const group = duplicateGroups[virtualItem.index];
|
||||||
))}
|
if (!group) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.file_hash}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DuplicateGroupCard group={group} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user