From ba3098d30225cf339b93e8a3c697120fbdc9f512 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 14 Apr 2026 19:35:48 +0200 Subject: [PATCH] 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 --- package-lock.json | 41 ++++++ package.json | 1 + src/app/os/keys/page.tsx | 288 +++++++++++++++++++++++---------------- 3 files changed, 216 insertions(+), 114 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ec27a1..45801af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3f88fde..0d39fe0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx index 69c5777..d1ebfff 100644 --- a/src/app/os/keys/page.tsx +++ b/src/app/os/keys/page.tsx @@ -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({ )} ); -} +}); -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 ( -
- -
- ); - } - - return ( - - - - - -
- {keys.map((key) => ( - - ))} -
-
-
- ); + isOpen: boolean; } export default function Keys() { @@ -144,9 +88,18 @@ export default function Keys() { const [loading, setLoading] = useState(true); const [keys, setKeys] = useState([]); const [keyword, setKeyword] = useState(""); - const [forceOpen, setForceOpen] = useState(null); + const [debouncedKeyword, setDebouncedKeyword] = useState(""); + const [openGroups, setOpenGroups] = useState>(new Set()); - const [debouncedKeyword] = useDebounce(keyword, 200); + const parentRef = useRef(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 ( -
-
+
+
- {!loading && hasGroups && ( + {!loading && sortedPrefixes.length > 0 && (
+
+ ); + } + + return ( +
+
+ {row.keys.map((key) => ( + ))}
- )} - {groupKeys.length > 0 && ( -
- {groupKeys.map((prefix) => ( - - ))} -
- )} - - ); - })()} +
+ ); + })} +
)}