mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-10 23:07:47 +02:00
Add virtual scrolling and debounced search to keys page
- Implement @tanstack/react-virtual for performance with large key lists - Add native debouncing (300ms) for search input - Handle single-key groups without collapsible UI
This commit is contained in:
Generated
+41
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -2464,6 +2465,33 @@
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -9145,6 +9173,19 @@
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"requires": {
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
}
|
||||
},
|
||||
"@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="
|
||||
},
|
||||
"@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
+174
-114
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, memo, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import Link from "next/link";
|
||||
import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
import { createEngine } from "@/lib/engine";
|
||||
|
||||
@@ -25,7 +20,6 @@ function groupKeysByPrefix(keys: string[]): GroupedKeys {
|
||||
|
||||
for (const key of keys) {
|
||||
const parts = key.split(".");
|
||||
// Use first 3 segments for com.apple.*, otherwise use the whole key
|
||||
let prefix: string;
|
||||
if (parts.length >= 3 && parts[0] === "com" && parts[1] === "apple") {
|
||||
prefix = `${parts[0]}.${parts[1]}.${parts[2]}`;
|
||||
@@ -44,7 +38,7 @@ function groupKeysByPrefix(keys: string[]): GroupedKeys {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function KeyBadge({
|
||||
const KeyBadge = memo(function KeyBadge({
|
||||
keyName,
|
||||
prefix,
|
||||
os,
|
||||
@@ -77,63 +71,13 @@ function KeyBadge({
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function KeyGroup({
|
||||
prefix,
|
||||
keys,
|
||||
os,
|
||||
forceOpen,
|
||||
}: {
|
||||
interface RowItem {
|
||||
type: "header" | "keys" | "single";
|
||||
prefix: string;
|
||||
keys: string[];
|
||||
os: string;
|
||||
forceOpen: boolean | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(forceOpen ?? keys.length <= 8);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceOpen !== null) {
|
||||
setOpen(forceOpen);
|
||||
}
|
||||
}, [forceOpen]);
|
||||
|
||||
// Single key in group - just show it inline without collapsible wrapper
|
||||
if (keys.length === 1) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
<KeyBadge keyName={keys[0]} prefix="" os={os} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="py-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 px-2 py-1 hover:bg-accent rounded transition-colors text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{prefix}
|
||||
<span className="text-foreground font-medium">.*</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-background border px-1.5 py-0.5 rounded-full">
|
||||
{keys.length}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5 pl-6 pt-1.5 pb-2">
|
||||
{keys.map((key) => (
|
||||
<KeyBadge key={key} keyName={key} prefix={prefix} os={os} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function Keys() {
|
||||
@@ -144,9 +88,18 @@ export default function Keys() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keys, setKeys] = useState<string[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [forceOpen, setForceOpen] = useState<boolean | null>(null);
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const [debouncedKeyword] = useDebounce(keyword, 200);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce keyword with 300ms delay
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -174,22 +127,83 @@ export default function Keys() {
|
||||
[grouped]
|
||||
);
|
||||
|
||||
// Build flat list of rows for virtualization
|
||||
const rows = useMemo(() => {
|
||||
const result: RowItem[] = [];
|
||||
for (const prefix of sortedPrefixes) {
|
||||
const keys = grouped[prefix];
|
||||
|
||||
// Single key that equals its prefix - show as simple item, not collapsible
|
||||
if (keys.length === 1 && keys[0] === prefix) {
|
||||
result.push({
|
||||
type: "single",
|
||||
prefix,
|
||||
keys,
|
||||
isOpen: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOpen = openGroups.has(prefix) || keys.length <= 8 || debouncedKeyword.length > 0;
|
||||
|
||||
result.push({
|
||||
type: "header",
|
||||
prefix,
|
||||
keys,
|
||||
isOpen,
|
||||
});
|
||||
|
||||
if (isOpen) {
|
||||
result.push({
|
||||
type: "keys",
|
||||
prefix,
|
||||
keys,
|
||||
isOpen: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [sortedPrefixes, grouped, openGroups, debouncedKeyword]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = rows[index];
|
||||
if (row.type === "single") return 36;
|
||||
if (row.type === "header") return 40;
|
||||
// Estimate based on number of keys (3 columns, ~32px per row)
|
||||
const keyRows = Math.ceil(row.keys.length / 3);
|
||||
return keyRows * 32 + 24; // padding
|
||||
},
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
const isFiltering = debouncedKeyword.length > 0;
|
||||
const hasGroups = sortedPrefixes.some(
|
||||
(p) => grouped[p].length > 1 || grouped[p][0] !== p
|
||||
);
|
||||
|
||||
const handleExpandAll = useCallback(() => setForceOpen(true), []);
|
||||
const handleCollapseAll = useCallback(() => setForceOpen(false), []);
|
||||
const toggleGroup = useCallback((prefix: string) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(prefix)) {
|
||||
next.delete(prefix);
|
||||
} else {
|
||||
next.add(prefix);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset forceOpen when filter changes
|
||||
useEffect(() => {
|
||||
setForceOpen(isFiltering ? true : null);
|
||||
}, [isFiltering]);
|
||||
const handleExpandAll = useCallback(() => {
|
||||
setOpenGroups(new Set(sortedPrefixes));
|
||||
}, [sortedPrefixes]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setOpenGroups(new Set());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-4 shrink-0">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -211,7 +225,7 @@ export default function Keys() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && hasGroups && (
|
||||
{!loading && sortedPrefixes.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -292,45 +306,91 @@ export default function Keys() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Single keys in multi-column grid */}
|
||||
{(() => {
|
||||
const singleKeys = sortedPrefixes.filter(
|
||||
(p) => grouped[p].length === 1 && grouped[p][0] === p
|
||||
);
|
||||
const groupKeys = sortedPrefixes.filter(
|
||||
(p) => grouped[p].length > 1 || grouped[p][0] !== p
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{singleKeys.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-1.5 mb-4">
|
||||
{singleKeys.map((prefix) => (
|
||||
<KeyBadge
|
||||
key={prefix}
|
||||
keyName={grouped[prefix][0]}
|
||||
prefix=""
|
||||
os={os}
|
||||
/>
|
||||
<div ref={parentRef} className="flex-1 min-h-0 overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
|
||||
if (row.type === "single") {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<KeyBadge keyName={row.keys[0]} prefix="" os={os} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === "header") {
|
||||
const isOpen = row.isOpen;
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleGroup(row.prefix)}
|
||||
className="flex items-center gap-2 px-2 py-2 hover:bg-accent rounded transition-colors text-left w-full"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{row.prefix}
|
||||
<span className="text-foreground font-medium">.*</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-background border px-1.5 py-0.5 rounded-full">
|
||||
{row.keys.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1 pl-8 pr-2 pb-3">
|
||||
{row.keys.map((key) => (
|
||||
<KeyBadge key={key} keyName={key} prefix={row.prefix} os={os} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{groupKeys.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{groupKeys.map((prefix) => (
|
||||
<KeyGroup
|
||||
key={prefix}
|
||||
prefix={prefix}
|
||||
keys={grouped[prefix]}
|
||||
os={os}
|
||||
forceOpen={forceOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user