import { useEffect, useState } from 'react'; import { KVAPI } from '../api/kvApi'; const FILE_BRANCH_RE = /^file:[^/]+\/$/; const fileNameCache = new Map(); const fileNamePromises = new Map>(); const fileNameSubscribers = new Set<() => void>(); function notifyFileNameSubscribers() { for (const cb of fileNameSubscribers) cb(); } function ensureFileName(branchPath: string): void { if (fileNameCache.has(branchPath) || fileNamePromises.has(branchPath)) return; const p = KVAPI.get(branchPath + 'fileName') .then((r) => { fileNameCache.set(branchPath, r.exists && r.value ? r.value : null); }) .catch(() => { fileNameCache.set(branchPath, null); }) .finally(() => { fileNamePromises.delete(branchPath); notifyFileNameSubscribers(); }); fileNamePromises.set(branchPath, p); } function useFileName(branchPath: string | null): string | null { const [, force] = useState(0); useEffect(() => { if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return; ensureFileName(branchPath); const cb = () => force((n) => n + 1); fileNameSubscribers.add(cb); return () => { fileNameSubscribers.delete(cb); }; }, [branchPath]); if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return null; return fileNameCache.get(branchPath) ?? null; } function isFileBranch(path: string): boolean { return FILE_BRANCH_RE.test(path); } function shortHashFromFileBranch(path: string): string { // path = "file:/" → last 6 chars of hash const hash = path.slice('file:'.length, -1); if (hash.length <= 12) return hash; return `…${hash.slice(-6)}`; } // A node in the lazy tree. branch-typed nodes have an associated path that // ends with the delimiter and lazy-load their children on first expand. type NodeKind = 'branch' | 'leaf'; interface TreeNode { kind: NodeKind; // Display label: the segment of the path under the parent. label: string; // Full key path. For branches, ends with the delimiter. path: string; } interface BranchState { loaded: boolean; loading: boolean; error?: string; children: TreeNode[]; } // Sub-segment of a path used as the visible label for a branch / leaf. // Splits on either ":" or "/" so file:abc/title/eng becomes file: > abc > title > eng. function labelOf(path: string, parentPath: string): string { const rest = path.slice(parentPath.length); // The "rest" includes a trailing delimiter for branches; strip it for display. return rest.replace(/[/:]+$/, ''); } // Hide internal Redis structures from the tree: // - the `meta:` namespace (event streams, internal state) // - bare top-level leaves under a colon-namespace like file:__index__ and // file:events (Redis sets / streams that aren't user data). function isHiddenNode(node: TreeNode): boolean { if (node.kind === 'branch' && node.path === 'meta:') return true; if (node.kind === 'leaf' && /^[^/]+:[^/]+$/.test(node.path)) return true; return false; } interface Props { // Selected leaf path (full key) — highlighted. selectedKey: string | null; // When set, the tree forces these branches open (used for auto-expand on // search hit or deep-link). expandedPaths: Set; onSelectLeaf: (key: string) => void; onSelectBranch: (path: string) => void; onToggleBranch: (path: string, willOpen: boolean) => void; // Bumping this triggers a full reload of root and any expanded branches. reloadToken: number; } export function KVTree({ selectedKey, expandedPaths, onSelectLeaf, onSelectBranch, onToggleBranch, reloadToken, }: Props) { // Map of branch path → load state. The empty key "" represents the root. const [branches, setBranches] = useState>({}); // Load (or reload) a branch's children from the API. The root is special- // cased: we list at prefix="" with delimiter=":" to surface the namespaces. const loadBranch = async (path: string) => { setBranches((b) => ({ ...b, [path]: { loaded: false, loading: true, children: b[path]?.children ?? [] }, })); try { const isRoot = path === ''; const delim = isRoot ? ':' : '/'; const resp = await KVAPI.treeAll(path, delim, 5000); const branchNodes: TreeNode[] = resp.branches.map((p) => ({ kind: 'branch', label: labelOf(p, path) + delim, path: p, })); const leafNodes: TreeNode[] = resp.leaves.map((p) => ({ kind: 'leaf', label: labelOf(p, path), path: p, })); const children = [...branchNodes, ...leafNodes]; setBranches((b) => ({ ...b, [path]: { loaded: true, loading: false, children }, })); } catch (err: any) { setBranches((b) => ({ ...b, [path]: { loaded: false, loading: false, children: [], error: err.message }, })); } }; // On reloadToken change, refetch root and every currently-expanded branch. useEffect(() => { loadBranch(''); for (const p of expandedPaths) { if (p !== '') loadBranch(p); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reloadToken]); // Whenever expandedPaths gains a path we haven't loaded yet, lazy-load it. useEffect(() => { for (const p of expandedPaths) { if (p === '') continue; const state = branches[p]; if (!state || (!state.loaded && !state.loading)) { loadBranch(p); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [expandedPaths]); return (
{ onToggleBranch(p, open); if (open && (!branches[p] || !branches[p].loaded)) loadBranch(p); }} />
); } interface BranchProps { path: string; state: BranchState | undefined; depth: number; expandedPaths: Set; branches: Record; selectedKey: string | null; onSelectLeaf: (key: string) => void; onSelectBranch: (path: string) => void; onToggleBranch: (path: string, willOpen: boolean) => void; } function Branch({ state, depth, expandedPaths, branches, selectedKey, onSelectLeaf, onSelectBranch, onToggleBranch, }: BranchProps) { if (!state) { return null; } if (state.loading && state.children.length === 0) { return
loading…
; } if (state.error) { return
error: {state.error}
; } const visibleChildren = state.children.filter((c) => !isHiddenNode(c)); if (visibleChildren.length === 0) { return
(empty)
; } return (
    {visibleChildren.map((node) => { if (node.kind === 'branch') { const open = expandedPaths.has(node.path); const childState = branches[node.path]; return (
  • { onToggleBranch(node.path, !open); onSelectBranch(node.path); }} /> {open && ( )}
  • ); } const isSelected = node.path === selectedKey; return (
  • onSelectLeaf(node.path)} > • {node.label}
  • ); })}
); } interface BranchRowProps { node: TreeNode; open: boolean; depth: number; onToggle: () => void; } function BranchRow({ node, open, depth, onToggle }: BranchRowProps) { // Surface the actual filename next to file:/ branches so the tree // reads like a file list at that level instead of a wall of hashes. const fileName = useFileName(isFileBranch(node.path) ? node.path : null); return (
{open ? '▾' : '▸'} {fileName ? ( <> {fileName} {shortHashFromFileBranch(node.path)} ) : ( {node.label} )}
); } // Helper: given a key path with both ":" and "/" separators, return the list // of ancestor branch paths that need to be expanded to reveal it. export function ancestorBranches(key: string): string[] { const out: string[] = []; let i = 0; while (i < key.length) { const next = nextSep(key, i); if (next < 0) break; out.push(key.slice(0, next + 1)); i = next + 1; } return out; } function nextSep(s: string, from: number): number { let best = -1; for (const d of [':', '/']) { const idx = s.indexOf(d, from); if (idx >= 0 && (best < 0 || idx < best)) best = idx; } return best; } // Re-export for convenience in the shell export type { TreeNode };