import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react"; import { ChevronRight, Plus, Search, X, RefreshCw } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { SelectDropdown } from "../ui/SelectDropdown"; import { DependencyDialog } from "./DependencyDialog"; import { ObjectContextMenu } from "./objects/ObjectContextMenu"; import { ObjectDetail, OBJECT_ICONS, TYPE_LABELS, SINGULAR_LABELS, type AnyObject, } from "./objects/ObjectDetail"; import { initialCrudParams, type ObjectKind } from "../../lib/objectCrud"; import * as cmd from "../../lib/commands"; import type { ObjectType, DependencyInfo } from "../../lib/types"; interface ObjectExplorerPageProps { connectionId: string; sidebarMode?: boolean; } const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( (t) => ({ value: t, label: TYPE_LABELS[t] }), ); /** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */ function emptyPlural(type: ObjectType): string { const singular = SINGULAR_LABELS[type]; const irregulars: Record = { index: "indexes", constraint: "constraints", }; return irregulars[singular] ?? `${singular}s`; } /** 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; } function schemaOf(item: AnyObject): string { // Roles are cluster-scoped and carry no schema — default to "". return (item as { schema?: string }).schema ?? ""; } function objectName(item: AnyObject): string { return item.name; } function typeToDdlType(type: ObjectType): string { const map: Record = { functions: "function", procedures: "procedure", triggers: "trigger", sequences: "sequence", enums: "enum", extensions: "extension", indexes: "index", constraints: "constraint", roles: "role", }; return map[type]; } export function ObjectExplorerPage({ connectionId, sidebarMode = false, }: ObjectExplorerPageProps) { const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType); const [type, setType] = useState(selectedObjectType ?? "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); const changesQueue = useDbViewerStore((s) => s.changesQueue); const openObjectTab = useDbViewerStore((s) => s.openObjectTab); const refreshTree = useDbViewerStore((s) => s.refreshTree); // 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 [openKey, setOpenKey] = useState(null); const [depOpen, setDepOpen] = useState(false); const [depDeps, setDepDeps] = useState([]); 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); // Track the last committed ddl change so a commit triggers exactly one // refetch (no duplicate refetches across re-renders). const lastCommittedDdlRef = useRef(""); // Fetch on mount and when schema changes const fetch = useCallback(async () => { setLoading(true); setError(null); try { let result: AnyObject[]; if (type === "roles") { // Roles are cluster-scoped — no schema filter. result = await cmd.getRoles(connectionId); } else if (type === "extensions") { result = await cmd.getExtensions(connectionId); } else if (type === "functions") { result = (await cmd.getFunctions( connectionId, currentSchema ?? undefined, )).filter((f) => f.kind === "f"); } else if (type === "procedures") { result = (await cmd.getFunctions( connectionId, currentSchema ?? undefined, )).filter((f) => f.kind === "p"); } 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 if (type === "indexes") { result = await cmd.getIndexes( connectionId, currentSchema ?? undefined, ); } else if (type === "constraints") { result = await cmd.getConstraints( 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). Roles are // cluster-scoped — skip the refetch when the schema changes (the mount // fetch still runs via lastSchemaRef === undefined). const schema = currentSchema ?? undefined; const schemaChanged = lastSchemaRef.current !== schema; const skipForRoles = schemaChanged && type === "roles" && lastSchemaRef.current !== undefined; if (schemaChanged && !skipForRoles) { fetch(); } }, [currentSchema, fetch]); // After a commit containing ddl items succeeds, refetch the current // object-type list + refresh the schema tree so newly created/dropped // objects show up without a manual refresh. useEffect(() => { const committedDdl = changesQueue.filter( (c) => c.type === "ddl" && c.status === "committed", ); const lastCommitted = committedDdl[committedDdl.length - 1]; if (lastCommitted && lastCommitted.id !== lastCommittedDdlRef.current) { lastCommittedDdlRef.current = lastCommitted.id; fetch(); void refreshTree(connectionId, currentSchema ?? undefined); } }, [changesQueue, connectionId, currentSchema, fetch, refreshTree]); // 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 = OBJECT_ICONS[type]; const label = TYPE_LABELS[type]; const singular = SINGULAR_LABELS[type]; // Header create button: open an objectForm tab prefilled with create state for the current type. const openCreate = useCallback(() => { const kind = typeToDdlType(type) as ObjectKind; const schema = currentSchema ?? "public"; const singular = SINGULAR_LABELS[type]; useDbViewerStore.getState().openFormTab({ kind, schema, name: "", title: `Create ${singular}`, description: `Create ${singular}`, mode: "create", params: initialCrudParams(kind, { schema, name: "" }, "create"), }); }, [type, currentSchema]); // 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 = useCallback((next: ObjectType) => { setType(next); setSearchQuery(""); setSelectedItem(null); setOpenKey(null); setItems(null); setLoading(true); lastSchemaRef.current = undefined; }, []); // Consume any object-type preselection from the Cmd+K palette. useEffect(() => { if (selectedObjectType && selectedObjectType !== type) { handleTypeChange(selectedObjectType); } if (selectedObjectType) { useDbViewerStore.getState().setSelectedObjectType(null); } }, [selectedObjectType, type, handleTypeChange]); 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 && (
{searchQuery ? `No ${emptyPlural(type)} matching "${searchQuery}"` : `No ${emptyPlural(type)} found`}
)} {!loading && filtered.map((item) => { const name = itemLabel(item); const key = itemKey(item); const isSelected = selectedItem !== null && itemKey(selectedItem) === itemKey(item); const handleCopyDdl = async () => { setOpenKey(null); const ddl = await cmd.getObjectDdl( connectionId, schemaOf(item), typeToDdlType(type), objectName(item), ); try { await navigator.clipboard?.writeText(ddl); } catch { // Ignore clipboard errors. } }; const handleViewDependencies = async () => { setOpenKey(null); const deps = await cmd.getObjectDependencies( connectionId, schemaOf(item), typeToDdlType(type), objectName(item), ); setDepDeps(deps); setDepOpen(true); }; return (
sidebarMode ? openObjectTab( type, schemaOf(item), item.name, item, ) : setSelectedItem(item) } onContextMenu={(e) => { e.preventDefault(); setOpenKey(key); }} 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}
e.stopPropagation()} className="relative shrink-0" > setOpenKey(o ? key : null) } extraItems={[ { id: "copy-ddl", label: "Copy DDL", onClick: handleCopyDdl, }, { id: "dependencies", label: "Dependencies", onClick: handleViewDependencies, }, ]} />
); })}
{!sidebarMode && ( <> {/* Panel resize handle */}
setPanelWidth(280)} /> {/* Right panel: detail view */}
{selectedItem ? ( <> {/* Header */}

{itemLabel(selectedItem)}

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

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

Select a {singular} to view details

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

)}
)} setDepOpen(false)} onCancel={() => setDepOpen(false)} />
); }