import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react"; import { ChevronRight, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search, X, RefreshCw, } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { SelectDropdown } from "../ui/SelectDropdown"; import * as cmd from "../../lib/commands"; import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, } from "../../lib/types"; export type ObjectType = | "functions" | "triggers" | "sequences" | "enums" | "extensions"; interface ObjectExplorerPageProps { connectionId: string; } const TYPE_LABELS: Record = { functions: "Functions", triggers: "Triggers", sequences: "Sequences", enums: "Enums", extensions: "Extensions", }; const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( (t) => ({ value: t, label: TYPE_LABELS[t] }), ); const SINGULAR_LABELS: Record = { functions: "function", triggers: "trigger", sequences: "sequence", enums: "enum", extensions: "extension", }; const ICONS: Record = { functions: ( ), triggers: , sequences: , enums: , extensions: , }; type AnyObject = | FunctionInfo | TriggerInfo | SequenceInfo | EnumInfo | ExtensionInfo; /** Build a unique key per item. Functions use their signature to disambiguate overloads. */ function itemKey(item: AnyObject): string { const name = (item as any).name as string; if ("argument_types" in item && Array.isArray(item.argument_types)) { return `${name}(${item.argument_types.join(",")})`; } return name; } /** Display name for the tree list. Functions show their argument signature. */ function itemLabel(item: AnyObject): string { const name = (item as any).name as string; if ( "argument_types" in item && Array.isArray(item.argument_types) && item.argument_types.length > 0 ) { return `${name}(${item.argument_types.join(", ")})`; } return name; } // ─── syntax highlighting for PL/pgSQL / SQL ────────────── const SQL_KEYWORDS = new Set([ "ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BEGIN", "BETWEEN", "BY", "CALL", "CASCADE", "CASE", "CAST", "CHECK", "CLOSE", "COLLATE", "COLUMN", "COMMIT", "CONSTRAINT", "CONTINUE", "CREATE", "CROSS", "CURRENT", "CURSOR", "DECLARE", "DEFAULT", "DELETE", "DESC", "DISTINCT", "DO", "DROP", "ELSE", "ELSIF", "END", "EXCEPTION", "EXECUTE", "EXISTS", "EXIT", "FETCH", "FOR", "FOREIGN", "FROM", "FULL", "FUNCTION", "GRANT", "GROUP", "HAVING", "IF", "IN", "INDEX", "INNER", "INSERT", "INTO", "IS", "JOIN", "KEY", "LANGUAGE", "LEFT", "LIMIT", "LOOP", "NOT", "NULL", "OF", "OFFSET", "ON", "OPEN", "OR", "ORDER", "OUTER", "OVER", "PERFORM", "PLPGSQL", "PRIMARY", "PROCEDURE", "QUERY", "RAISE", "REFERENCES", "REPLACE", "RETURN", "RETURNS", "REVOKE", "RIGHT", "ROLLBACK", "ROW", "ROWS", "SCHEMA", "SELECT", "SET", "STRICT", "TABLE", "THEN", "TO", "TRIGGER", "UNION", "UPDATE", "USING", "VALUES", "VIEW", "WHEN", "WHERE", "WHILE", "WITH", ]); const SQL_TYPES = new Set([ "BIGINT", "BIGSERIAL", "BIT", "BOOL", "BOOLEAN", "BPCHAR", "BYTEA", "CHAR", "CHARACTER", "DATE", "DECIMAL", "DOUBLE", "FLOAT", "FLOAT4", "FLOAT8", "INT", "INT2", "INT4", "INT8", "INTEGER", "INTERVAL", "JSON", "JSONB", "MONEY", "NAME", "NUMERIC", "OID", "REAL", "SERIAL", "SMALLINT", "TEXT", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "UUID", "VARBIT", "VARCHAR", "VOID", "XML", ]); interface Token { text: string; kind: | "keyword" | "type" | "string" | "comment" | "number" | "operator" | "plain"; } function tokenizeLine(line: string): Token[] { const tokens: Token[] = []; let i = 0; while (i < line.length) { if (/\s/.test(line[i])) { let ws = ""; while (i < line.length && /\s/.test(line[i])) { ws += line[i]; i++; } tokens.push({ text: ws, kind: "plain" }); continue; } if (line[i] === "-" && line[i + 1] === "-") { tokens.push({ text: line.slice(i), kind: "comment" }); return tokens; } if (line[i] === "/" && line[i + 1] === "*") { const end = line.indexOf("*/", i + 2); if (end !== -1) { tokens.push({ text: line.slice(i, end + 2), kind: "comment" }); i = end + 2; } else { tokens.push({ text: line.slice(i), kind: "comment" }); return tokens; } continue; } if (line[i] === "$") { let dollar = ""; const start = i; while (i < line.length && line[i] === "$") { dollar += "$"; i++; } let tag = ""; if (dollar.length === 1 && i < line.length && line[i] !== "$") { while (i < line.length && line[i] !== "$") { tag += line[i]; i++; } if (line[i] === "$") { i++; dollar = `$${tag}$`; } } const endTag = dollar; const endIdx = line.indexOf(endTag, i); if (endIdx !== -1) { tokens.push({ text: line.slice(start, endIdx + endTag.length), kind: "string", }); i = endIdx + endTag.length; } else { tokens.push({ text: line.slice(start), kind: "string" }); return tokens; } continue; } if (line[i] === "'") { let str = "'"; i++; while (i < line.length) { if (line[i] === "'" && line[i + 1] === "'") { str += "''"; i += 2; continue; } if (line[i] === "'") { str += "'"; i++; break; } str += line[i]; i++; } tokens.push({ text: str, kind: "string" }); continue; } if (/[0-9]/.test(line[i])) { let num = ""; while (i < line.length && /[0-9.]/.test(line[i])) { num += line[i]; i++; } tokens.push({ text: num, kind: "number" }); continue; } if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) { let op = line[i]; i++; if (i < line.length) { const pair = op + line[i]; if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) { op = pair; i++; } } tokens.push({ text: op, kind: "operator" }); continue; } let word = ""; while (i < line.length && /[a-zA-Z_]/.test(line[i])) { word += line[i]; i++; } if (word) { const upper = word.toUpperCase(); if (SQL_KEYWORDS.has(upper)) { tokens.push({ text: word, kind: "keyword" }); } else if (SQL_TYPES.has(upper)) { tokens.push({ text: word, kind: "type" }); } else { tokens.push({ text: word, kind: "plain" }); } } else { // Catch-all for any character not matched above (non-ASCII, symbols, etc.) tokens.push({ text: line[i], kind: "plain" }); i++; } } return tokens; } function SyntaxCode({ source, language: _language, }: { source: string; language?: string; }) { const [expanded, setExpanded] = useState(false); const maxLines = 60; // Memoize the tokenized output — source doesn't change while viewing const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => { const lines: string[] = source.split("\n"); const total: number = lines.length; const isTruncated: boolean = !expanded && total > maxLines; const display: string[] = isTruncated ? lines.slice(0, maxLines) : lines; const maxNum: number = String(display.length).length; const tokenized = display.map((line: string) => ({ tokens: tokenizeLine(line), })); return { displayLines: tokenized, maxLineNum: maxNum, truncated: isTruncated, totalLines: total, }; }, [source, expanded]); const TOKEN_COLORS: Record = { keyword: "text-blue-400", type: "text-emerald-400", string: "text-amber-300", comment: "text-text-subtle italic", number: "text-purple-400", operator: "text-text-muted", plain: "text-text", }; return (
                    {displayLines.map(
                        (entry: { tokens: Token[] }, i: number) => {
                            const { tokens } = entry;
                            const num = String(i + 1).padStart(maxLineNum, " ");
                            return (
                                
{num} {tokens.length === 1 && tokens[0].text.trim() === "" ? "\u00A0" : tokens.map((t, j) => ( {t.text} ))}
); }, )}
{truncated && (
)} {expanded && totalLines > maxLines && (
)}
); } function renderDetail(type: ObjectType, item: AnyObject) { switch (type) { case "functions": { const f = item as FunctionInfo; return (
Signature
Returns {f.return_type || "void"}
Language {f.language}
Kind {f.kind === "f" ? "Function" : "Procedure"}
Schema {f.schema}
{f.argument_names.length > 0 && ( <>
Arguments {f.argument_names.length} total
{f.argument_names.map((name, i) => (
{f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && ( {f.argument_modes[i]} )} #{i + 1}
{name} : {f.argument_types?.[i] || "unknown"}
))} )} {f.source && ( <>
Source {f.language}
)}
); } case "triggers": { const t = item as TriggerInfo; return (
Details
Table {t.table_schema}.{t.table_name}
Event {t.event_manipulation}
Timing {t.action_timing} {t.action_orientation}
Status {t.enabled === "O" ? "Enabled" : t.enabled === "D" ? "Disabled" : t.enabled}
Schema {t.schema}
{t.action_statement && ( <>
Definition SQL
)}
); } case "sequences": { const s = item as SequenceInfo; return (
Sequence Values
Current Value {s.current_value}
Increment {s.increment}
Start {s.start_value}
Min / Max {s.min_value} / {s.max_value}
Cycle {s.cycle ? "Yes" : "No"}
); } case "enums": { const e = item as EnumInfo; return (
Details
Schema {e.schema}
Values {e.labels.length} labels
{e.labels.map((label, i) => (
#{i + 1} {label}
))}
); } case "extensions": { const e = item as ExtensionInfo; return (
Extension
Version {e.version}
Schema {e.schema}
{e.comment && (
Comment

{e.comment}

)}
); } } } export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { const [type, setType] = useState("functions"); const [panelWidth, setPanelWidth] = useState(280); const panelResizeRef = useRef<{ startX: number; startW: number } | null>( null, ); const onPanelResizeStart = useCallback( (e: React.MouseEvent) => { panelResizeRef.current = { startX: e.clientX, startW: panelWidth }; const onMove = (ev: MouseEvent) => { if (!panelResizeRef.current) return; const delta = ev.clientX - panelResizeRef.current.startX; const next = Math.max( 180, Math.min(500, panelResizeRef.current.startW + delta), ); setPanelWidth(next); }; const onUp = () => { panelResizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [panelWidth], ); const databases = useDbViewerStore((s) => s.databases); const currentDatabase = useDbViewerStore((s) => s.currentDatabase); const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase); const schemas = useDbViewerStore((s) => s.schemas); const currentSchema = useDbViewerStore((s) => s.currentSchema); const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); // Use store for persistence, but allow re-fetching when schema changes const [items, setItems] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedItem, setSelectedItem] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const searchContainerRef = useRef(null); // Track last-fetched-schema so we know when to re-fetch const lastSchemaRef = useRef(undefined); // Fetch on mount and when schema changes const fetch = useCallback(async () => { setLoading(true); setError(null); try { let result: AnyObject[]; if (type === "extensions") { result = await cmd.getExtensions(connectionId); } else if (type === "functions") { result = await cmd.getFunctions( connectionId, currentSchema ?? undefined, ); } else if (type === "triggers") { result = await cmd.getTriggers( connectionId, currentSchema ?? undefined, ); } else if (type === "sequences") { result = await cmd.getSequences( connectionId, currentSchema ?? undefined, ); } else if (type === "enums") { result = await cmd.getEnums( connectionId, currentSchema ?? undefined, ); } else { result = []; } setItems(result); setSelectedItem(null); lastSchemaRef.current = currentSchema ?? undefined; } catch (e) { setError(e instanceof Error ? e.message : String(e)); setItems(null); } finally { setLoading(false); } }, [type, connectionId, currentSchema]); useEffect(() => { // Only re-fetch if schema actually changed (or first load) if (lastSchemaRef.current !== (currentSchema ?? undefined)) { fetch(); } }, [currentSchema, fetch]); // Search toggle handling useEffect(() => { if (searchOpen && searchInputRef.current) { searchInputRef.current.focus(); } }, [searchOpen]); const handleSearchBlur = useCallback(() => { setTimeout(() => { if (!searchQuery.trim()) { setSearchOpen(false); } }, 150); }, [searchQuery]); const toggleSearch = useCallback(() => { setSearchOpen((prev) => { const next = !prev; if (!next) setSearchQuery(""); return next; }); }, []); const q = searchQuery.toLowerCase().trim(); const filtered = useMemo(() => { if (!items) return []; if (!q) return items; return items.filter((item) => { const label = itemLabel(item).toLowerCase(); return label.includes(q); }); }, [items, q]); const icon = ICONS[type]; const label = TYPE_LABELS[type]; const singular = SINGULAR_LABELS[type]; // Switching object type: reset selection/search, clear the stale list so // the loading state renders (no flash of the previous type's objects), and // reset the last-fetched-schema marker so the fetch effect re-runs. const handleTypeChange = (next: ObjectType) => { setType(next); setSearchQuery(""); setSelectedItem(null); setItems(null); setLoading(true); lastSchemaRef.current = undefined; }; return (
{/* Left panel: toolbar + object list */}
handleTypeChange(v as ObjectType)} options={OBJECT_TYPE_OPTIONS} variant="ghost" aria-label="Object type" />
{/* Search input */}
setSearchQuery(e.target.value)} onBlur={handleSearchBlur} placeholder={`Filter ${label.toLowerCase()}…`} className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors" /> {searchQuery && ( )}
{/* Database/Schema dropdowns */} {(databases.length > 1 || schemas.length > 1) && (
{databases.length > 1 && ( setCurrentDatabase(v || null) } options={databases.map((d) => ({ value: d, label: d, }))} placeholder="Select database" aria-label="Select database" variant="ghost" /> )} {databases.length > 1 && schemas.length > 1 && ( | )} {schemas.length > 1 && ( setCurrentSchema(v || null) } options={schemas.map((s) => ({ value: s, label: s, }))} placeholder="Select schema" aria-label="Select schema" variant="ghost" /> )}
)}
{/* Object list */}
{loading && (
Loading {label.toLowerCase()}...
)} {error && (
{error}
)} {!loading && !error && filtered.length === 0 && (
{items === null ? `No ${label.toLowerCase()} found` : searchQuery ? `No ${label.toLowerCase()} matching "${searchQuery}"` : `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
)} {!loading && filtered.map((item) => { const name = itemLabel(item); const key = itemKey(item); const isSelected = selectedItem !== null && itemKey(selectedItem) === itemKey(item); return (
setSelectedItem(item)} className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${ isSelected ? "bg-accent/10 text-accent" : "text-text hover:bg-surface-raised" }`} > {icon} {name}
); })}
{/* Panel resize handle */}
setPanelWidth(280)} /> {/* Right panel: detail view */}
{selectedItem ? ( <> {/* Header */}

{itemLabel(selectedItem)}

{singular} {"schema" in selectedItem ? ` · ${(selectedItem as any).schema}` : ""}

{/* Detail content */}
{renderDetail(type, selectedItem)}
) : (
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}

Select a {singular} to view details

{filtered.length} {label.toLowerCase()}{" "} available

)}
); }