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:
cc
2026-04-14 19:35:48 +02:00
parent fad0b347c0
commit ba3098d302
3 changed files with 216 additions and 114 deletions
+174 -114
View File
@@ -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>