feat: add duplicate detection modes and UI enhancements for managing duplicates

This commit is contained in:
shuaiplus
2026-06-15 20:48:57 +08:00
parent a8183166ac
commit 7b3be2c819
11 changed files with 279 additions and 44 deletions
+50 -11
View File
@@ -17,13 +17,14 @@ import {
createEmptyDraft,
creationTimeValue,
draftFromCipher,
buildCipherDuplicateSignature,
buildCipherDuplicateSignatures,
firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>();
const byId = new Map<string, string[]>();
const counts = new Map<string, number>();
for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher);
byId.set(cipher.id, signature);
counts.set(signature, (counts.get(signature) || 0) + 1);
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signatures);
for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
}
return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]);
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
return false;
if (sidebarFilter.kind === 'duplicates') {
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false;
}
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind;
}, [sidebarFilter]);
}, [sidebarFilter, duplicateMode]);
useEffect(() => {
setListScrollTop(0);
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
}
}, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (isCreating) return;
if (!filteredCiphers.length) {
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
searchInput={searchInput}
sortMode={sortMode}
sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount}
totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange}
selectedCipherId={selectedCipherId}
selectedMap={selectedMap}
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}