diff --git a/src/app/admin/ontology/ontology-outline.tsx b/src/app/admin/ontology/ontology-outline.tsx new file mode 100644 index 0000000..5450ff4 --- /dev/null +++ b/src/app/admin/ontology/ontology-outline.tsx @@ -0,0 +1,359 @@ +"use client" + +import { memo, useCallback, useMemo, useRef, useState, type ReactNode } from "react" +import { Check, ChevronDown, ChevronRight, Copy, Search } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { SchemaNode, SchemaEdge } from "@/lib/schema-types" +import { ontologyDigest } from "@/lib/ontology-digest" +import { + allExpandableRefs, + buildOutlineModel, + defaultExpandedRefs, + outlineAncestors, + relationshipsForType, + searchOutline, + type OutlineDomain, + type OutlineNode, +} from "@/lib/ontology-outline" + +interface Props { + /** Already domain-filtered by the page, like the other two views. */ + schemas: SchemaNode[] + edges: SchemaEdge[] + selectedId: string | null + onSelect: (id: string) => void +} + +const INDENT_PX = 16 +const COPIED_MS = 1500 + +/** Wrap every occurrence of `query` (already lowercased) in . */ +function highlight(text: string, query: string): ReactNode { + if (!query) return text + const lower = text.toLowerCase() + const parts: ReactNode[] = [] + let from = 0 + let at = lower.indexOf(query, from) + while (at !== -1) { + if (at > from) parts.push(text.slice(from, at)) + parts.push( + + {text.slice(at, at + query.length)} + + ) + from = at + query.length + at = lower.indexOf(query, from) + } + if (from < text.length) parts.push(text.slice(from)) + return parts +} + +type Row = { kind: "domain"; domain: OutlineDomain } | { kind: "node"; node: OutlineNode } + +interface NodeRowProps { + node: OutlineNode + expanded: boolean + selected: boolean + matched: boolean + query: string + onToggle: (refId: string) => void + onSelect: (refId: string) => void +} + +const NodeRow = memo(function NodeRow({ node, expanded, selected, matched, query, onToggle, onSelect }: NodeRowProps) { + const hasChildren = node.directCount > 0 + return ( +
onSelect(node.refId)} + className={`group flex w-full cursor-pointer items-center gap-1.5 rounded-md py-1 pr-2 text-left transition-colors ${ + selected + ? "bg-primary/10 text-foreground" + : matched + ? "text-foreground hover:bg-muted/50" + : "text-muted-foreground hover:bg-muted/50 hover:text-foreground" + }`} + style={{ paddingLeft: 8 + node.depth * INDENT_PX }} + > + {hasChildren ? ( + + ) : ( + + )} + {highlight(node.type, query)} + {node.nodeKey && ( + + {highlight(node.nodeKey, query)} + + )} + {hasChildren && ( + + {node.directCount} direct · {node.totalCount} total + + )} +
+ ) +}) + +interface DomainRowProps { + domain: OutlineDomain + collapsed: boolean + onToggle: (key: string) => void +} + +const DomainRow = memo(function DomainRow({ domain, collapsed, onToggle }: DomainRowProps) { + return ( + + ) +}) + +export const OntologyOutline = memo(function OntologyOutline({ schemas, edges, selectedId, onSelect }: Props) { + const [query, setQuery] = useState("") + // null = the model's default expansion; a Set once the user has touched it. + const [expanded, setExpanded] = useState | null>(null) + const [collapsedDomains, setCollapsedDomains] = useState>(() => new Set()) + const [copied, setCopied] = useState(false) + const copiedTimer = useRef | null>(null) + const fallbackRef = useRef(null) + + const model = useMemo(() => buildOutlineModel(schemas), [schemas]) + const search = useMemo(() => searchOutline(model, query), [model, query]) + const baseExpanded = useMemo(() => expanded ?? defaultExpandedRefs(model), [expanded, model]) + + // Reveal a selection made elsewhere (sidebar, another view) by opening its + // ancestors. Done as a render-time state adjustment keyed on selectedId so + // the user can still collapse those ancestors afterwards. + const [revealedFor, setRevealedFor] = useState(null) + if (selectedId !== revealedFor) { + setRevealedFor(selectedId) + if (selectedId && model.byRef.has(selectedId)) { + const ancestors = outlineAncestors(model, selectedId) + if (ancestors.some((a) => !baseExpanded.has(a))) { + const next = new Set(baseExpanded) + for (const a of ancestors) next.add(a) + setExpanded(next) + } + } + } + + const rows = useMemo(() => { + const searching = search.query.length > 0 + const out: Row[] = [] + const visit = (node: OutlineNode) => { + if (searching && !search.visible.has(node.refId)) return + out.push({ kind: "node", node }) + const open = baseExpanded.has(node.refId) || (searching && search.expanded.has(node.refId)) + if (!open) return + for (const c of node.children) visit(c) + } + for (const d of model.domains) { + if (searching && !d.roots.some((r) => search.visible.has(r.refId))) continue + out.push({ kind: "domain", domain: d }) + if (collapsedDomains.has(d.key) && !searching) continue + for (const r of d.roots) visit(r) + } + return out + }, [model, search, baseExpanded, collapsedDomains]) + + const isOpen = useCallback( + (refId: string) => baseExpanded.has(refId) || (search.query.length > 0 && search.expanded.has(refId)), + [baseExpanded, search] + ) + + // While a search is active, ancestors of matches stay open regardless of + // this set (see `isOpen`), so toggling only affects the persisted choice. + const handleToggle = useCallback( + (refId: string) => { + setExpanded((prev) => { + const next = new Set(prev ?? defaultExpandedRefs(model)) + if (next.has(refId)) next.delete(refId) + else next.add(refId) + return next + }) + }, + [model] + ) + const handleToggleDomain = useCallback((key: string) => { + setCollapsedDomains((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + const handleExpandAll = useCallback(() => { + setExpanded(allExpandableRefs(model)) + setCollapsedDomains(new Set()) + }, [model]) + const handleCollapseAll = useCallback(() => { + setExpanded(new Set()) + }, []) + + const handleCopy = useCallback(() => { + const text = ontologyDigest(schemas, edges) + const done = () => { + setCopied(true) + if (copiedTimer.current) clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), COPIED_MS) + } + const fallback = () => { + const ta = fallbackRef.current + if (!ta) return + ta.value = text + ta.focus() + ta.select() + try { + document.execCommand("copy") + } catch { + // the text stays selected in the textarea for a manual copy + } + done() + } + try { + navigator.clipboard.writeText(text).then(done, fallback) + } catch { + fallback() + } + }, [schemas, edges]) + + const selectedNode = selectedId ? model.byRef.get(selectedId) : undefined + const relationships = useMemo( + () => (selectedNode ? relationshipsForType(selectedNode.type, model.byType, edges) : []), + [selectedNode, model, edges] + ) + + const typeCount = model.byRef.size + const matchCount = search.matched.size + + return ( +
+ {/* Toolbar */} +
+
+ + setQuery(e.target.value)} + placeholder="Filter by type or key..." + aria-label="Filter outline" + className="h-8 pl-8 text-sm" + /> +
+ + {search.query ? `${matchCount} of ${typeCount}` : `${typeCount} types · ${model.domains.length} domains`} + +
+ + + +
+