import { useEffect, useMemo, useRef, useState } from 'react'; import { KVAPI, KVFindMatch, KVInfoResponse } from '../api/kvApi'; import { ancestorBranches, KVTree } from './KVTree'; import { FilteredKVTree } from './FilteredKVTree'; import { KVLeafEditor } from './KVLeafEditor'; import { KVBranchEditor } from './KVBranchEditor'; import { KVFileEditor } from './KVFileEditor'; const FILE_BRANCH_RE = /^file:[^/]+\/$/; import { SearchFieldChips } from './SearchFieldChips'; import { ToastStack, makeToast } from './Toast'; import { ToastEntry } from './types'; import { SnapshotPanel } from '../components/SnapshotPanel'; import './KVApp.css'; const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1'; const DEFAULT_SEARCH_FIELDS = ['originalTitle', 'filePath', 'fileName', 'plot/eng']; function loadSearchFields(): string[] { try { const raw = localStorage.getItem(SEARCH_FIELDS_LS_KEY); if (!raw) return DEFAULT_SEARCH_FIELDS; const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) return parsed; } catch {} return DEFAULT_SEARCH_FIELDS; } // Top-level shell. Two panes inside the iframe: // left — lazy tree of the keyspace // right — leaf editor (when a leaf is selected) or branch editor (branch). // // A sticky header carries the search bar, the breadcrumb, and basic stats. export default function KVApp() { const [snapshotOpen, setSnapshotOpen] = useState(false); // Selected node. branchPath is set when a branch is selected; selectedKey // when a leaf is. Mutually exclusive. const [selectedKey, setSelectedKey] = useState(null); const [branchPath, setBranchPath] = useState(null); const [expandedPaths, setExpandedPaths] = useState>(new Set([''])); const [searchInput, setSearchInput] = useState(''); const [searchFields, setSearchFields] = useState(loadSearchFields); const [searchResults, setSearchResults] = useState(null); const [searchTruncated, setSearchTruncated] = useState(false); const [searching, setSearching] = useState(false); const [info, setInfo] = useState(null); const [reloadToken, setReloadToken] = useState(0); const [toasts, setToasts] = useState([]); const searchTimerRef = useRef(null); // When the right pane is a file-detail view and the user clicks a leaf // under the same file branch in the tree, we route the click as a // scroll-anchor inside the file detail instead of a leaf-editor switch. // The token bumps each click so identical anchors still trigger a scroll. const [anchor, setAnchor] = useState<{ field: string; token: number } | null>(null); const isFileBranch = (p: string | null) => !!p && /^file:[^/]+\/$/.test(p); const handleLeafClick = (key: string) => { if (isFileBranch(branchPath) && key.startsWith(branchPath!)) { setAnchor({ field: key.slice(branchPath!.length), token: (anchor?.token ?? 0) + 1 }); return; } setSelectedKey(key); setBranchPath(null); setAnchor(null); }; const handleBranchClick = (path: string) => { setBranchPath(path); setSelectedKey(null); setAnchor(null); }; const pushToast = (t: ToastEntry) => setToasts((cur) => [...cur, t]); const dismissToast = (id: number) => setToasts((cur) => cur.filter((t) => t.id !== id)); useEffect(() => { KVAPI.info().then(setInfo).catch(() => {}); }, [reloadToken]); // Deep-link: /editor/file/ opens the tree pre-expanded at that hash. useEffect(() => { const m = window.location.pathname.match(/\/file\/([a-z0-9]+)$/i); if (m) { const hashId = m[1]; const branch = `file:${hashId}/`; setExpandedPaths((cur) => new Set([...cur, '', 'file:', branch])); setBranchPath(branch); } }, []); // Persist field list across reloads. useEffect(() => { try { localStorage.setItem(SEARCH_FIELDS_LS_KEY, JSON.stringify(searchFields)); } catch {} }, [searchFields]); // Debounced live value-search across configured fields. Empty input clears. useEffect(() => { if (searchTimerRef.current) window.clearTimeout(searchTimerRef.current); if (!searchInput.trim() || searchFields.length === 0) { setSearchResults(null); setSearchTruncated(false); setSearching(false); return; } setSearching(true); searchTimerRef.current = window.setTimeout(async () => { try { const r = await KVAPI.find(searchInput.trim(), searchFields, 200); setSearchResults(r.matches); setSearchTruncated(r.truncated); } catch (e: any) { pushToast(makeToast('error', `Search failed: ${e.message}`)); } finally { setSearching(false); } }, 250); return () => { if (searchTimerRef.current) window.clearTimeout(searchTimerRef.current); }; }, [searchInput, searchFields]); // Jump to a key from a search hit: select it, expand all ancestor branches. const jumpToKey = (key: string) => { const ancestors = ancestorBranches(key); setExpandedPaths((cur) => new Set([...cur, '', ...ancestors])); if (key.endsWith('/') || key.endsWith(':')) { setBranchPath(key); setSelectedKey(null); } else { setSelectedKey(key); setBranchPath(null); } }; const breadcrumb = useMemo(() => { const target = selectedKey ?? branchPath ?? ''; if (!target) return null; const parts: { label: string; path: string }[] = []; let i = 0; while (i < target.length) { let next = -1; for (const d of [':', '/']) { const idx = target.indexOf(d, i); if (idx >= 0 && (next < 0 || idx < next)) next = idx; } if (next < 0) { parts.push({ label: target.slice(i), path: target }); break; } parts.push({ label: target.slice(i, next + 1), path: target.slice(0, next + 1) }); i = next + 1; } return parts; }, [selectedKey, branchPath]); return (
setSearchInput(e.target.value)} autoFocus /> {searchInput && ( )} {info ? `${info.keyCount} files · ${info.memoryUsage}` : ''}
{breadcrumb && breadcrumb.length > 0 && (
path: {breadcrumb.map((b, i) => ( ))}
)}
{selectedKey ? ( { pushToast(makeToast('success', `Saved ${selectedKey}`)); setReloadToken((n) => n + 1); }} onDeleted={() => { pushToast(makeToast('success', `Deleted ${selectedKey}`)); setSelectedKey(null); setReloadToken((n) => n + 1); }} onError={(msg) => pushToast(makeToast('error', msg))} /> ) : branchPath && FILE_BRANCH_RE.test(branchPath) ? ( { pushToast(makeToast(errors && errors.length > 0 ? 'error' : 'success', info, errors)); setReloadToken((n) => n + 1); }} onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); setAnchor(null); }} /> ) : branchPath ? ( { pushToast(makeToast(errors && errors.length > 0 ? 'error' : 'success', info, errors)); setReloadToken((n) => n + 1); }} /> ) : (
Select a key from the tree.
Click a branch to add / rename / delete a subtree, or a leaf to edit its value.
)}
{snapshotOpen && (
setSnapshotOpen(false)} >
e.stopPropagation()} role="dialog" aria-modal="true" >
)}
); }