diff --git a/.gitignore b/.gitignore index 523b221..9cd6e18 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ go.work dashboard/node_modules/* dist/* dashboard/dist/* +editor/dist/ +editor/node_modules/ diff --git a/editor/package-lock.json b/editor/package-lock.json index 7bc4dea..dc42fb6 100644 --- a/editor/package-lock.json +++ b/editor/package-lock.json @@ -1,13 +1,14 @@ { - "name": "@meta-sort/editor", + "name": "@meta-core/editor", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@meta-sort/editor", + "name": "@meta-core/editor", "version": "0.1.0", "dependencies": { + "iso-639-3": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1246,6 +1247,16 @@ "node": ">=6.9.0" } }, + "node_modules/iso-639-3": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/iso-639-3/-/iso-639-3-3.0.1.tgz", + "integrity": "sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/editor/src/api/kvApi.ts b/editor/src/api/kvApi.ts new file mode 100644 index 0000000..82868cc --- /dev/null +++ b/editor/src/api/kvApi.ts @@ -0,0 +1,136 @@ +// Client for the meta-core /api/kv endpoints. The keyspace is a slash-tree of +// string-valued keys; this module wraps the S3-style listing, search, and +// per-key get/put/delete operations. + +const API_BASE = '/api/kv'; + +export interface KVTreeResponse { + prefix: string; + delimiter: string; + branches: string[]; + leaves: string[]; + cursor: string; + hasMore: boolean; +} + +export interface KVSearchResponse { + contains: string; + keys: string[]; + truncated: boolean; +} + +export interface KVFindMatch { + key: string; + value: string; + entryPath: string; + field: string; +} + +export interface KVFindResponse { + contains: string; + fields: string[]; + matches: KVFindMatch[]; + truncated: boolean; +} + +export interface KVValueResponse { + key: string; + type: string; + value: string; + exists: boolean; +} + +export interface KVInfoResponse { + prefix: string; + fileCount: number; + keyCount: number; + totalSize: number; + memoryUsage: string; +} + +async function jsonOrThrow(res: Response): Promise { + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const body = await res.json(); + if (body?.message) msg = body.message; + else if (body?.error) msg = body.error; + } catch {} + throw new Error(msg); + } + return res.json(); +} + +export const KVAPI = { + async info(): Promise { + return jsonOrThrow(await fetch(`${API_BASE}/info`)); + }, + + async tree(params: { + prefix?: string; + delimiter?: string; + cursor?: string; + max?: number; + }): Promise { + const q = new URLSearchParams(); + if (params.prefix !== undefined) q.set('prefix', params.prefix); + if (params.delimiter !== undefined) q.set('delimiter', params.delimiter); + if (params.cursor) q.set('cursor', params.cursor); + if (params.max) q.set('max', String(params.max)); + return jsonOrThrow(await fetch(`${API_BASE}/tree?${q}`)); + }, + + // Walk multiple SCAN cycles until the keyspace is exhausted or a soft cap + // is reached. Useful for branches with up to a few thousand children where + // pagination UX would just be a worse spinner. + async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise { + const seenLeaves = new Set(); + const seenBranches = new Set(); + let cursor = ''; + while (true) { + const page = await this.tree({ prefix, delimiter, cursor, max: 1000 }); + for (const k of page.leaves) seenLeaves.add(k); + for (const k of page.branches) seenBranches.add(k); + cursor = page.cursor; + if (!page.hasMore) break; + if (seenLeaves.size + seenBranches.size >= softCap) break; + } + return { + prefix, + delimiter, + branches: [...seenBranches].sort(), + leaves: [...seenLeaves].sort(), + cursor, + hasMore: cursor !== '0' && cursor !== '', + }; + }, + + async search(contains: string, limit = 500): Promise { + const q = new URLSearchParams({ contains, limit: String(limit) }); + return jsonOrThrow(await fetch(`${API_BASE}/search?${q}`)); + }, + + async find(contains: string, fields: string[], limit = 200): Promise { + const q = new URLSearchParams({ contains, fields: fields.join(','), limit: String(limit) }); + return jsonOrThrow(await fetch(`${API_BASE}/find?${q}`)); + }, + + async get(key: string): Promise { + const q = new URLSearchParams({ key }); + return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`)); + }, + + async put(key: string, value: string): Promise { + const res = await fetch(`${API_BASE}/value`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + return jsonOrThrow(res); + }, + + async del(key: string): Promise<{ key: string; deleted: boolean }> { + const q = new URLSearchParams({ key }); + return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' })); + }, +}; diff --git a/editor/src/kv/FilteredKVTree.tsx b/editor/src/kv/FilteredKVTree.tsx new file mode 100644 index 0000000..19a36ea --- /dev/null +++ b/editor/src/kv/FilteredKVTree.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from 'react'; +import { KVAPI, KVFindMatch } from '../api/kvApi'; + +interface Props { + matches: KVFindMatch[]; + truncated: boolean; + searching: boolean; + selectedKey: string | null; + expandedPaths: Set; + onSelectLeaf: (key: string) => void; + onSelectBranch: (path: string) => void; + onToggleBranch: (path: string, willOpen: boolean) => void; +} + +// FilteredKVTree is the search-active counterpart to KVTree. Instead of +// replacing the tree with a flat results list, it shows only the file +// branches that contain matches, with the matched (field, value) pairs +// rendered inline as sub-lines under each branch. +// +// Branches remain expandable: clicking one fetches the full subtree via +// KVAPI.treeAll just like the main tree, so the user can drill into every +// key of a matching file without clearing the search first. +export function FilteredKVTree({ + matches, truncated, searching, + selectedKey, expandedPaths, + onSelectLeaf, onSelectBranch, onToggleBranch, +}: Props) { + // Group matches by their entryPath (file:/) so each entry is one row + // with potentially multiple hit previews. + const groups = useMemo(() => { + const m = new Map(); + for (const x of matches) { + const arr = m.get(x.entryPath); + if (arr) arr.push(x); + else m.set(x.entryPath, [x]); + } + return m; + }, [matches]); + + return ( +
+
+ {searching + ? 'searching…' + : `${matches.length} match${matches.length === 1 ? '' : 'es'} in ${groups.size} file${groups.size === 1 ? '' : 's'}${truncated ? ' (truncated)' : ''}`} +
+ {[...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([entryPath, hits]) => ( + + ))} +
+ ); +} + +interface EntryProps { + entryPath: string; + hits: KVFindMatch[]; + selectedKey: string | null; + expanded: boolean; + onSelectLeaf: (key: string) => void; + onSelectBranch: (path: string) => void; + onToggleBranch: (path: string, willOpen: boolean) => void; +} + +function FilteredEntry({ + entryPath, hits, selectedKey, expanded, + onSelectLeaf, onSelectBranch, onToggleBranch, +}: EntryProps) { + // We deliberately do NOT mark the matched-leaf rows as "selected" via the + // tree row's selected style — the inline hit lines are themselves the + // selected indicator (they're highlighted when their key is current). + const isBranchSelected = entryPath === selectedKey; + const HIT_PREVIEW = 4; + + return ( +
+
{ + onToggleBranch(entryPath, !expanded); + onSelectBranch(entryPath); + }} + > + {expanded ? '▾' : '▸'} + {entryPath} + + {hits.length} + +
+ +
+ {hits.slice(0, HIT_PREVIEW).map((h) => ( +
{ e.stopPropagation(); onSelectLeaf(h.key); }} + title={h.value} + > + {h.field} + {h.value} +
+ ))} + {hits.length > HIT_PREVIEW && ( +
+ +{hits.length - HIT_PREVIEW} more match{hits.length - HIT_PREVIEW === 1 ? '' : 'es'} + {!expanded && ' · expand to see all keys'} +
+ )} +
+ + {expanded && ( + + )} +
+ ); +} + +interface SubtreeProps { + path: string; + depth: number; + selectedKey: string | null; + onSelectLeaf: (key: string) => void; + onSelectBranch: (path: string) => void; +} + +interface SubtreeData { + loading: boolean; + branches: string[]; + leaves: string[]; + error?: string; +} + +// LazySubtree fetches its children once on mount and renders them. Sub- +// branches are themselves LazyBranch components that fetch their own +// children on first expand — recursion all the way down without the global +// expandedPaths state of the main KVTree. +function LazySubtree({ path, depth, selectedKey, onSelectLeaf, onSelectBranch }: SubtreeProps) { + const [data, setData] = useState({ loading: true, branches: [], leaves: [] }); + + useEffect(() => { + let cancelled = false; + KVAPI.treeAll(path, '/', 5000) + .then((r) => { if (!cancelled) setData({ loading: false, branches: r.branches, leaves: r.leaves }); }) + .catch((e) => { if (!cancelled) setData({ loading: false, branches: [], leaves: [], error: e.message }); }); + return () => { cancelled = true; }; + }, [path]); + + if (data.loading) { + return
loading…
; + } + if (data.error) { + return
{data.error}
; + } + + return ( + <> + {data.branches.map((b) => ( + + ))} + {data.leaves.map((l) => ( +
onSelectLeaf(l)} + > + • + {l.slice(path.length)} +
+ ))} + + ); +} + +interface LazyBranchProps { + path: string; + parentPath: string; + depth: number; + selectedKey: string | null; + onSelectLeaf: (key: string) => void; + onSelectBranch: (path: string) => void; +} + +function LazyBranch({ path, parentPath, depth, selectedKey, onSelectLeaf, onSelectBranch }: LazyBranchProps) { + const [expanded, setExpanded] = useState(false); + const label = path.slice(parentPath.length); + return ( + <> +
{ setExpanded(!expanded); onSelectBranch(path); }} + > + {expanded ? '▾' : '▸'} + {label} +
+ {expanded && ( + + )} + + ); +} diff --git a/editor/src/kv/KVApp.css b/editor/src/kv/KVApp.css new file mode 100644 index 0000000..ed4608d --- /dev/null +++ b/editor/src/kv/KVApp.css @@ -0,0 +1,524 @@ +/* KV Tree Editor — fills the iframe viewport with a 2-pane layout. */ + +.kv-app { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + overflow: hidden; + background: #fafbfc; + color: #1f2330; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 13px; +} + +/* ── Header ───────────────────────────────────────────────────────────── */ +.kv-app-header { + border-bottom: 1px solid #e2e6ee; + padding: 8px 12px; + background: #fff; + flex: 0 0 auto; +} + +.kv-app-search { + display: flex; + align-items: center; + gap: 8px; +} + +.kv-search-input { + flex: 1; + padding: 7px 11px; + border: 1px solid #d1d6e0; + border-radius: 6px; + font-size: 13px; + background: #fff; + outline: none; +} +.kv-search-input:focus { border-color: #6c7afa; box-shadow: 0 0 0 2px rgba(108, 122, 250, 0.2); } + +.kv-app-stats { + font-size: 12px; + color: #8089a0; + white-space: nowrap; +} + +.kv-app-breadcrumb { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + font-size: 12px; +} +.kv-crumb-label { + color: #8089a0; + margin-right: 4px; +} +.kv-crumb { + border: none; + background: transparent; + padding: 1px 4px; + border-radius: 3px; + cursor: pointer; + color: #4a5170; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.kv-crumb:hover { background: #eef0fb; color: #2a2f48; } + +/* ── Body ─────────────────────────────────────────────────────────────── */ +.kv-app-body { + flex: 1 1 auto; + display: flex; + min-height: 0; +} + +.kv-app-left { + flex: 0 0 38%; + min-width: 280px; + max-width: 600px; + border-right: 1px solid #e2e6ee; + background: #fff; + overflow: auto; +} + +.kv-app-right { + flex: 1 1 auto; + overflow: auto; + padding: 16px 20px; + background: #fafbfc; +} + +/* ── Tree ─────────────────────────────────────────────────────────────── */ +.kv-tree { + padding: 4px 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; +} + +.kv-tree-list { + list-style: none; + margin: 0; + padding: 0; +} + +.kv-tree-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.kv-tree-row:hover { background: #f3f5fb; } + +.kv-tree-toggle { + display: inline-block; + width: 12px; + color: #8089a0; + font-size: 10px; +} + +.kv-tree-leaf-dot { + display: inline-block; + width: 12px; + color: #c5cad6; +} + +.kv-tree-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.kv-tree-branch .kv-tree-label { + color: #2a3050; + font-weight: 500; +} + +.kv-tree-leaf .kv-tree-label { + color: #4a5170; +} + +.kv-tree-row.selected { + background: #e7eafd; + color: #2532a3; +} +.kv-tree-row.selected:hover { background: #dde2fc; } + +.kv-tree-loading, +.kv-tree-empty, +.kv-tree-error { + font-size: 12px; + color: #8089a0; + padding: 4px 8px; + font-style: italic; +} +.kv-tree-error { color: #c0392b; } + +/* ── Right pane (leaf / branch) ───────────────────────────────────────── */ +.kv-leaf, .kv-branch { + display: flex; + flex-direction: column; + gap: 14px; +} + +.kv-leaf-key { + display: flex; + align-items: center; + gap: 6px; + background: #f0f2f8; + border: 1px solid #e2e6ee; + border-radius: 6px; + padding: 8px 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} +.kv-leaf-key-text { + flex: 1; + word-break: break-all; + color: #2a3050; +} + +.kv-leaf-meta { + display: flex; + align-items: center; + gap: 8px; +} +.kv-leaf-label { + font-size: 12px; + color: #6e7790; + font-weight: 500; +} +.kv-leaf-type { + border: 1px solid #d1d6e0; + border-radius: 5px; + padding: 4px 8px; + font-size: 12px; + background: #fff; +} +.kv-leaf-hint { + font-size: 12px; + color: #6e7790; + font-style: italic; +} + +.kv-leaf-input { + width: 100%; + padding: 8px 10px; + border: 1px solid #d1d6e0; + border-radius: 5px; + font-size: 13px; + background: #fff; + outline: none; + box-sizing: border-box; +} +.kv-leaf-input:focus { border-color: #6c7afa; box-shadow: 0 0 0 2px rgba(108, 122, 250, 0.2); } + +.kv-leaf-textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid #d1d6e0; + border-radius: 5px; + font-size: 13px; + background: #fff; + outline: none; + resize: vertical; + box-sizing: border-box; + font-family: inherit; +} +.kv-leaf-textarea:focus { border-color: #6c7afa; box-shadow: 0 0 0 2px rgba(108, 122, 250, 0.2); } + +.kv-leaf-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.kv-leaf-url { + display: flex; + gap: 6px; + align-items: center; +} +.kv-leaf-url-open { + font-size: 12px; + color: #6c7afa; + text-decoration: none; + white-space: nowrap; +} + +.kv-leaf-actions { + display: flex; + gap: 8px; +} + +/* ── Branch ───────────────────────────────────────────────────────────── */ +.kv-branch-section { + border: 1px solid #e2e6ee; + border-radius: 6px; + background: #fff; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} +.kv-branch-section h4 { + margin: 0; + font-size: 13px; + color: #2a3050; +} +.kv-branch-row { + display: flex; + align-items: center; + gap: 6px; +} +.kv-branch-prefix { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: #6e7790; + background: #f0f2f8; + padding: 6px 8px; + border-radius: 4px; + white-space: nowrap; + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; +} +.kv-branch-note { + margin: 0; + font-size: 12px; + color: #6e7790; + line-height: 1.4; +} + +/* ── Buttons ──────────────────────────────────────────────────────────── */ +.kv-btn { + border: 1px solid #d1d6e0; + background: #fff; + color: #2a3050; + padding: 6px 14px; + border-radius: 5px; + font-size: 12.5px; + cursor: pointer; +} +.kv-btn:hover:not(:disabled) { background: #f3f5fb; border-color: #b8c0d0; } +.kv-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.kv-btn-primary { + background: #6c7afa; + border-color: #6c7afa; + color: #fff; +} +.kv-btn-primary:hover:not(:disabled) { + background: #5868e8; + border-color: #5868e8; +} + +.kv-btn-danger { + border-color: #e0788a; + color: #c0392b; +} +.kv-btn-danger:hover:not(:disabled) { + background: #fdecef; + border-color: #c0392b; +} + +.kv-btn-icon { + border: 1px solid transparent; + background: transparent; + color: #6e7790; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} +.kv-btn-icon:hover { background: #eef0fb; color: #2a3050; } + +/* ── Pane status ──────────────────────────────────────────────────────── */ +.kv-pane-status { + color: #6e7790; + font-size: 12.5px; + padding: 8px 0; +} +.kv-pane-empty { + text-align: center; + padding: 80px 20px; + font-size: 14px; +} +.kv-pane-hint { + margin-top: 8px; + font-size: 12px; + color: #8089a0; +} + +/* ── Field chips (configure search-in fields) ─────────────────────────── */ +.kv-chips { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} +.kv-chips-label { + font-size: 12px; + color: #8089a0; + margin-right: 4px; +} +.kv-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: #eef0fb; + border: 1px solid #d5dafa; + color: #2a3050; + border-radius: 12px; + padding: 1px 4px 1px 8px; + font-size: 11.5px; +} +.kv-chip code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} +.kv-chip-x { + border: none; + background: transparent; + color: #6e7790; + cursor: pointer; + padding: 0 4px; + font-size: 14px; + line-height: 1; +} +.kv-chip-x:hover { color: #c0392b; } +.kv-chip-input { + border: 1px dashed #c5cad6; + background: transparent; + border-radius: 12px; + padding: 2px 8px; + font-size: 11.5px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + outline: none; + min-width: 90px; +} +.kv-chip-input:focus { border-style: solid; border-color: #6c7afa; } + +/* ── Search header (shared across filter mode) ────────────────────────── */ +.kv-search-header { + padding: 6px 10px; + font-size: 11px; + color: #6e7790; + background: #f3f5fb; + border-bottom: 1px solid #e2e6ee; + position: sticky; + top: 0; + z-index: 1; +} + +/* ── Filtered tree (search-active state) ──────────────────────────────── */ +.kv-filtered { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; +} +.kv-filtered-entry { + border-bottom: 1px solid #f0f2f8; +} +.kv-filtered-count { + margin-left: auto; + background: #eef0fb; + color: #4a5170; + padding: 0 6px; + border-radius: 9px; + font-size: 10.5px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 600; +} +.kv-filtered-hits { + padding: 2px 4px 4px 24px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; +} +.kv-filtered-hit { + display: flex; + gap: 6px; + align-items: baseline; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + line-height: 1.35; +} +.kv-filtered-hit:hover { background: #f3f5fb; } +.kv-filtered-hit.selected { + background: #e7eafd; +} +.kv-filtered-hit-field { + background: #eef0fb; + color: #4a5170; + padding: 0 5px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + flex: 0 0 auto; +} +.kv-filtered-hit-value { + color: #2a3050; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.kv-filtered-more { + padding: 2px 6px 0; + font-size: 11px; + font-style: italic; + color: #8089a0; +} + +/* ── Toasts ───────────────────────────────────────────────────────────── */ +.kv-toast-stack { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column-reverse; + gap: 6px; + z-index: 1000; + max-width: 480px; +} +.kv-toast { + background: #2a3050; + color: #fff; + border-radius: 6px; + padding: 9px 12px; + box-shadow: 0 4px 14px rgba(0,0,0,0.15); + font-size: 12.5px; +} +.kv-toast-success { background: #2f9e44; } +.kv-toast-error { background: #c0392b; } +.kv-toast-row { + display: flex; + align-items: flex-start; + gap: 8px; + justify-content: space-between; +} +.kv-toast-text { + flex: 1; + word-break: break-word; +} +.kv-toast-close { + border: none; + background: transparent; + color: rgba(255,255,255,0.8); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.kv-toast-close:hover { color: #fff; } +.kv-toast-details { + margin: 6px 0 0; + padding-left: 18px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: rgba(255,255,255,0.85); + max-height: 160px; + overflow: auto; +} diff --git a/editor/src/kv/KVApp.tsx b/editor/src/kv/KVApp.tsx new file mode 100644 index 0000000..ae65afc --- /dev/null +++ b/editor/src/kv/KVApp.tsx @@ -0,0 +1,244 @@ +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 { SearchFieldChips } from './SearchFieldChips'; +import { ToastStack, makeToast } from './Toast'; +import { ToastEntry } from './types'; +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() { + // 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); + + 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 ? ( + { + 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. +
+
+ )} +
+
+ + +
+ ); +} + diff --git a/editor/src/kv/KVBranchEditor.tsx b/editor/src/kv/KVBranchEditor.tsx new file mode 100644 index 0000000..cf52ac1 --- /dev/null +++ b/editor/src/kv/KVBranchEditor.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react'; +import { KVAPI } from '../api/kvApi'; + +interface Props { + // Branch path, ends with the delimiter (":" or "/"). + branchPath: string; + reloadToken: number; + onChanged: (info: string, errors?: string[]) => void; +} + +// Branch operations are non-atomic — each per-key write/delete goes one round +// trip to Redis and may fail individually. Failures collect into a list and +// surface via the parent's toast stack. Per the design, we accept partial +// failure and surface what failed. +export function KVBranchEditor({ branchPath, reloadToken, onChanged }: Props) { + const [count, setCount] = useState(null); + const [busy, setBusy] = useState(false); + const [newChild, setNewChild] = useState(''); + const [newValue, setNewValue] = useState(''); + const [renameTo, setRenameTo] = useState(''); + + // Use the search endpoint with the branch path as the substring to count + // descendants. Truncated when there are more than the limit; we just show + // an approximate count. + useEffect(() => { + let cancelled = false; + KVAPI.search(branchPath, 5000) + .then((r) => { + if (cancelled) return; + // Filter to true descendants (those starting with branchPath). + const descendants = r.keys.filter((k) => k.startsWith(branchPath)); + setCount(descendants.length); + }) + .catch(() => { if (!cancelled) setCount(null); }); + return () => { cancelled = true; }; + }, [branchPath, reloadToken]); + + // Walk all descendants and run an op against each, collecting failures. + const forEachDescendant = async (op: (key: string) => Promise): Promise => { + const r = await KVAPI.search(branchPath, 5000); + const keys = r.keys.filter((k) => k.startsWith(branchPath)); + const errors: string[] = []; + for (const k of keys) { + try { await op(k); } catch (e: any) { errors.push(`${k}: ${e.message}`); } + } + return errors; + }; + + const addChild = async () => { + if (!newChild) return; + const childKey = branchPath + newChild; + setBusy(true); + try { + await KVAPI.put(childKey, newValue); + setNewChild(''); + setNewValue(''); + onChanged(`Created ${childKey}`); + } catch (e: any) { + onChanged(`Failed to create ${childKey}`, [e.message]); + } finally { + setBusy(false); + } + }; + + const deleteSubtree = async () => { + if (!confirm(`Delete every key under ${branchPath} (${count ?? '?'} entries)?`)) return; + setBusy(true); + try { + const errors = await forEachDescendant((k) => KVAPI.del(k).then(() => undefined)); + if (errors.length === 0) { + onChanged(`Deleted subtree ${branchPath}`); + } else { + onChanged(`Deleted subtree ${branchPath} with ${errors.length} failure(s)`, errors); + } + } finally { + setBusy(false); + } + }; + + const renameBranch = async () => { + if (!renameTo) return; + const target = renameTo.endsWith('/') || renameTo.endsWith(':') ? renameTo : renameTo + '/'; + if (target === branchPath) return; + if (!confirm(`Rename ${branchPath} → ${target}? This copies ${count ?? '?'} keys then deletes the originals.`)) return; + setBusy(true); + try { + // Pass 1: copy + const r = await KVAPI.search(branchPath, 5000); + const keys = r.keys.filter((k) => k.startsWith(branchPath)); + const copyErrors: string[] = []; + for (const oldKey of keys) { + try { + const v = await KVAPI.get(oldKey); + if (!v.exists || v.type !== 'string') { + copyErrors.push(`${oldKey}: skipped (type ${v.type || 'missing'})`); + continue; + } + const newKey = target + oldKey.slice(branchPath.length); + await KVAPI.put(newKey, v.value); + } catch (e: any) { + copyErrors.push(`${oldKey}: ${e.message}`); + } + } + // Pass 2: delete originals (only the ones we successfully copied) + const failedOldKeys = new Set(copyErrors.map((e) => e.split(':')[0])); + const deleteErrors: string[] = []; + for (const oldKey of keys) { + if (failedOldKeys.has(oldKey)) continue; + try { await KVAPI.del(oldKey); } catch (e: any) { deleteErrors.push(`${oldKey}: ${e.message}`); } + } + const allErrors = [...copyErrors, ...deleteErrors]; + if (allErrors.length === 0) { + onChanged(`Renamed ${branchPath} → ${target}`); + } else { + onChanged(`Renamed ${branchPath} → ${target} with ${allErrors.length} failure(s)`, allErrors); + } + setRenameTo(''); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {branchPath} + +
+ +
+ {count == null ? 'counting…' : `${count} descendant key${count === 1 ? '' : 's'}`} +
+ +
+

Add child

+
+ {branchPath} + setNewChild(e.target.value)} + disabled={busy} + /> +
+ setNewValue(e.target.value)} + disabled={busy} + /> + +
+ +
+

Rename branch

+
+ → + setRenameTo(e.target.value)} + disabled={busy} + /> +
+

+ Non-atomic: copies each descendant to the new prefix then deletes the original. + Failures are surfaced individually. +

+ +
+ +
+

Delete subtree

+

+ Deletes every descendant key. Cannot be undone. +

+ +
+
+ ); +} diff --git a/editor/src/kv/KVLeafEditor.tsx b/editor/src/kv/KVLeafEditor.tsx new file mode 100644 index 0000000..89809d3 --- /dev/null +++ b/editor/src/kv/KVLeafEditor.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState } from 'react'; +import { KVAPI } from '../api/kvApi'; +import { LanguageCombobox } from '../components/LanguageCombobox'; +import { ALL_TYPES, describeType, detectType } from './typeHeuristics'; +import { FieldType } from './types'; + +interface Props { + // Full Redis key path of the selected leaf. + selectedKey: string; + // Bumped after a delete or external change to force refetch. + reloadToken: number; + onSaved: () => void; + onDeleted: () => void; + onError: (msg: string) => void; +} + +export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onError }: Props) { + const [original, setOriginal] = useState(''); + const [draft, setDraft] = useState(''); + const [exists, setExists] = useState(true); + const [redisType, setRedisType] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // Manual override for the inferred type. "auto" defers to detectType. + const [typeOverride, setTypeOverride] = useState('auto'); + const detected = detectType(selectedKey, original); + const effectiveType = typeOverride === 'auto' ? detected : typeOverride; + + // Reset override when the selected key changes — the previous override + // doesn't apply to a different leaf. + useEffect(() => { setTypeOverride('auto'); }, [selectedKey]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + KVAPI.get(selectedKey) + .then((r) => { + if (cancelled) return; + setOriginal(r.value); + setDraft(r.value); + setExists(r.exists); + setRedisType(r.type); + }) + .catch((e) => onError(e.message)) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [selectedKey, reloadToken, onError]); + + const dirty = draft !== original; + const editable = exists ? redisType === 'string' || redisType === '' : true; + + const save = async () => { + setSaving(true); + try { + await KVAPI.put(selectedKey, draft); + setOriginal(draft); + setExists(true); + setRedisType('string'); + onSaved(); + } catch (e: any) { + onError(e.message); + } finally { + setSaving(false); + } + }; + + const del = async () => { + if (!confirm(`Delete ${selectedKey}?`)) return; + setSaving(true); + try { + await KVAPI.del(selectedKey); + onDeleted(); + } catch (e: any) { + onError(e.message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
loading…
; + } + + if (!editable) { + return ( +
+ +
+ Key has Redis type {redisType} — only string-typed values are editable here. +
+
+ ); + } + + return ( +
+ + +
+ + + {!exists && new key — save to create} +
+ + + +
+ + + {exists && ( + + )} +
+
+ ); +} + +function KeyHeader({ keyPath }: { keyPath: string }) { + return ( +
+ {keyPath} + +
+ ); +} + +interface ValueProps { + type: FieldType; + value: string; + onChange: (v: string) => void; +} + +function ValueEditor({ type, value, onChange }: ValueProps) { + switch (type) { + case 'bool': + return ( + + ); + case 'number': + return ( + onChange(e.target.value)} + /> + ); + case 'datetime': + return ( + onChange(e.target.value)} + /> + ); + case 'lang': + return ( + + ); + case 'url': + return ( +
+ onChange(e.target.value)} + /> + {value && ( + open ↗ + )} +
+ ); + case 'json': + return ( +