Files
meta-core/editor/src/kv/KVTree.tsx
T
2026-05-25 23:47:30 +02:00

340 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { KVAPI } from '../api/kvApi';
const FILE_BRANCH_RE = /^file:[^/]+\/$/;
const fileNameCache = new Map<string, string | null>();
const fileNamePromises = new Map<string, Promise<void>>();
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:<hash>/" → 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 editor is for managing
// file metadata; everything else is meta-core's own bookkeeping and noise
// for the user. Hidden:
// - `meta:` — event streams (file:events, meta:events, etc.)
// - `cid:` — reverse index (cid:<algo>:<value> → uuid), derived
// from each file's cid_* fields; the editor surfaces
// these per-file under their parent root
// - `meta-core:` — internal service state (mounts, watcher config,
// leader info), edited via dedicated UI pages, not
// the KV tree
// - 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') {
if (node.path === 'meta:' || node.path === 'cid:' || node.path === 'meta-core:') {
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<string>;
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<Record<string, BranchState>>({});
// 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 (
<div className="kv-tree">
<Branch
path=""
state={branches['']}
depth={0}
expandedPaths={expandedPaths}
branches={branches}
selectedKey={selectedKey}
onSelectLeaf={onSelectLeaf}
onSelectBranch={onSelectBranch}
onToggleBranch={(p, open) => {
onToggleBranch(p, open);
if (open && (!branches[p] || !branches[p].loaded)) loadBranch(p);
}}
/>
</div>
);
}
interface BranchProps {
path: string;
state: BranchState | undefined;
depth: number;
expandedPaths: Set<string>;
branches: Record<string, BranchState>;
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 <div className="kv-tree-loading" style={{ paddingLeft: depth * 14 + 18 }}>loading…</div>;
}
if (state.error) {
return <div className="kv-tree-error" style={{ paddingLeft: depth * 14 + 18 }}>error: {state.error}</div>;
}
const visibleChildren = state.children.filter((c) => !isHiddenNode(c));
if (visibleChildren.length === 0) {
return <div className="kv-tree-empty" style={{ paddingLeft: depth * 14 + 18 }}>(empty)</div>;
}
return (
<ul className="kv-tree-list">
{visibleChildren.map((node) => {
if (node.kind === 'branch') {
const open = expandedPaths.has(node.path);
const childState = branches[node.path];
return (
<li key={node.path}>
<BranchRow
node={node}
open={open}
depth={depth}
onToggle={() => {
onToggleBranch(node.path, !open);
onSelectBranch(node.path);
}}
/>
{open && (
<Branch
path={node.path}
state={childState}
depth={depth + 1}
expandedPaths={expandedPaths}
branches={branches}
selectedKey={selectedKey}
onSelectLeaf={onSelectLeaf}
onSelectBranch={onSelectBranch}
onToggleBranch={onToggleBranch}
/>
)}
</li>
);
}
const isSelected = node.path === selectedKey;
return (
<li key={node.path}>
<div
className={`kv-tree-row kv-tree-leaf${isSelected ? ' selected' : ''}`}
style={{ paddingLeft: depth * 14 + 4 }}
onClick={() => onSelectLeaf(node.path)}
>
<span className="kv-tree-leaf-dot">•</span>
<span className="kv-tree-label">{node.label}</span>
</div>
</li>
);
})}
</ul>
);
}
interface BranchRowProps {
node: TreeNode;
open: boolean;
depth: number;
onToggle: () => void;
}
function BranchRow({ node, open, depth, onToggle }: BranchRowProps) {
// Surface the actual filename next to file:<hash>/ 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 (
<div
className={`kv-tree-row kv-tree-branch${open ? ' open' : ''}`}
style={{ paddingLeft: depth * 14 + 4 }}
onClick={onToggle}
title={isFileBranch(node.path) ? node.path : undefined}
>
<span className="kv-tree-toggle">{open ? '▾' : '▸'}</span>
{fileName ? (
<>
<span className="kv-tree-label kv-tree-filename">{fileName}</span>
<span className="kv-tree-hash-pill">{shortHashFromFileBranch(node.path)}</span>
</>
) : (
<span className="kv-tree-label">{node.label}</span>
)}
</div>
);
}
// 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 };