import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { GripVertical, Link, Plus, Settings2, Table2, X } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, type DragEndEvent, type Modifier, } from "@dnd-kit/core"; import { SortableContext, useSortable, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useDbViewerStore, type ViewerTab, } from "../../../stores/dbViewerStore"; import { useConnectionStore } from "../../../stores/connectionStore"; import * as cmd from "../../../lib/commands"; import type { ColumnInfo, ConstraintInfo, TablespaceInfo, DbType, } from "../../../lib/types"; import { getCapabilities } from "../../../lib/dbCapabilities"; import { FkPanel, type FkDefinition } from "./FkPanel"; import { FormRow, FormSectionHeader, inputClass, } from "./formRow"; import { DataTypeIcon } from "../../ui/DataTypeIcon"; const SqlEditorField = lazy(() => import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField, })), ); const PG_TYPES = [ "int", "int8", "int2", "serial", "bigserial", "smallserial", "text", "varchar", "char", "bool", "numeric", "real", "float8", "date", "time", "timestamp", "timestamptz", "interval", "uuid", "json", "jsonb", "bytea", "inet", "cidr", "macaddr", "money", ]; const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"]; interface TableFormColumn { rowId: string; name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean; params?: string; auto_increment?: boolean; unique?: boolean; /** transient: true when a FK was just assigned to this column */ fk?: boolean; } interface SqlColumn { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean; unique?: boolean; auto_increment?: boolean; } interface TableFormAction { op: "create" | "edit"; columns: TableFormColumn[]; old_columns?: TableFormColumn[]; tablespace?: string | null; rls?: "disable" | "enable" | "force" | null; } interface TableFormParams { schema: string; name: string; action: TableFormAction; } function toSqlColumn( c: TableFormColumn, mode: "create" | "edit", dbType: DbType | undefined, ): SqlColumn { let type = c.type; const base = c.type.trim().toLowerCase(); if (mode === "create" && c.auto_increment && dbType !== "sqlite") { if (base === "integer" || base === "int" || base === "int4") type = "serial"; else if (base === "bigint" || base === "int8") type = "bigserial"; else if (base === "smallint" || base === "int2") type = "smallserial"; } if (c.params && c.params.trim()) { type = `${type}(${c.params.trim()})`; } const out: SqlColumn = { name: c.name, type, nullable: c.nullable, default: c.default, is_pk: c.is_pk, unique: c.unique ?? undefined, }; if (dbType === "sqlite" && c.auto_increment) { out.auto_increment = true; } return out; } function sameColumns(a: TableFormColumn[], b: TableFormColumn[]): boolean { if (a.length !== b.length) return false; return a.every( (c, i) => c.name === b[i]?.name && c.type.trim().toLowerCase() === b[i]?.type.trim().toLowerCase(), ); } function namesInOrder(cols: TableFormColumn[]): string { return cols.map((c) => c.name).join(","); } function sameColumnNames(a: TableFormColumn[], b: TableFormColumn[]): boolean { const sa = new Set(a.map((c) => c.name)); const sb = new Set(b.map((c) => c.name)); return sa.size === sb.size && [...sa].every((n) => sb.has(n)); } let rowSeq = 0; function emptyColumn(): TableFormColumn { rowSeq += 1; return { rowId: `col-${rowSeq}-${Math.random().toString(36).slice(2, 8)}`, name: "", type: "text", nullable: true, default: null, is_pk: false, params: "", auto_increment: false, unique: false, }; } // Y-axis-only drag (like the tab bar): zero out the X component of the transform. const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0, }); function pickTypeList(dbType: DbType | undefined): string[] { return dbType === "sqlite" ? SQLITE_TYPES : PG_TYPES; } // serial types only exist for the integer family (short + long forms). function supportsAutoIncrement(type: string): boolean { const t = type.trim().toLowerCase(); return ( t === "integer" || t === "int" || t === "int4" || t === "bigint" || t === "int8" || t === "smallint" || t === "int2" ); } // Cell-local input styles for the columns grid — no horizontal padding so the // cell's px-3 supplies it (matches the data-grid cell look). const cellInput = "min-w-0 flex-1 bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted"; const cellMono = "min-w-0 flex-1 bg-transparent font-mono text-xs text-text outline-none placeholder:text-text-muted"; // Select styling matching the FK panel (transparent, no surface bg). const selectTransparent = "min-w-0 flex-1 bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer"; function buildTablePayload( params: TableFormParams, op: "create" | "edit" | "rebuild", foreignKeys: FkDefinition[] = [], dbType: DbType | undefined, ): Record { const action = params.action; const sqlColumns = action.columns.map((c) => toSqlColumn(c, action.op, dbType), ); return { ...params, action: { ...action, op, columns: sqlColumns, // CREATE TABLE embeds FKs inline (single staged change); edit uses separate ALTERs. ...(op === "create" ? { foreign_keys: foreignKeys } : {}), }, } as unknown as Record; } export function TableForm({ connectionId, tab, }: { connectionId: string; tab: ViewerTab; }) { const liveTab = useDbViewerStore( (s) => s.tabs.find((t) => t.id === tab.id && t.tabType === "objectForm") ?? tab, ); const form = liveTab.form; if (!form) return null; const dbType = useConnectionStore( (s) => s.connections.find((c) => c.id === connectionId)?.db_type, ); const capabilities = getCapabilities(dbType ?? "postgresql"); const schemas = useDbViewerStore((s) => s.schemas); if (!capabilities.tableManagement) { return (

Table management is not supported for this database type.

); } const params = form.params as unknown as TableFormParams; const action = params.action; const mode = form.mode; const isRebuild = mode === "edit" && action.old_columns !== undefined && sameColumnNames(action.columns, action.old_columns) && namesInOrder(action.columns) !== namesInOrder(action.old_columns); const [view, setView] = useState<"visual" | "sql">("visual"); const [preview, setPreview] = useState(""); const [error, setError] = useState(null); const [refusal, setRefusal] = useState(null); const [staging, setStaging] = useState(false); const [fkPanel, setFkPanel] = useState<{ open: boolean; column: string | null; } | null>(null); const [fkColumns, setFkColumns] = useState>(new Set()); const [createFks, setCreateFks] = useState([]); const op = isRebuild ? "rebuild" : action.op; const setParams = (next: TableFormParams) => { useDbViewerStore .getState() .updateFormTabParams( tab.id, next as unknown as Record, ); }; // SQLite has a single schema. useEffect(() => { if (dbType === "sqlite" && params.schema !== "main") { setParams({ ...params, schema: "main" }); } }, [dbType, params.schema]); // Rebuild readiness check useEffect(() => { if (!isRebuild) { setRefusal(null); return; } let active = true; cmd.getTableRebuildReadiness(connectionId, params.schema, params.name) .then((r) => { if (active) setRefusal(r.ok ? null : r.reasons.join("; ")); }) .catch(() => { if (active) setRefusal(null); }); return () => { active = false; }; }, [isRebuild, connectionId, params.schema, params.name]); // Edit mode: mark columns that participate in FKs (from the constraint list). useEffect(() => { if (mode !== "edit") { setFkColumns(new Set()); return; } let active = true; cmd.getConstraints(connectionId, params.schema) .then((cs) => { if (!active) return; const names = new Set(); for (const c of cs.filter((x) => x.table === params.name)) { const m = /FOREIGN KEY\s*\(([^)]+)\)/i.exec( c.definition ?? "", ); if (m) m[1].split(",").forEach((n) => names.add(n.trim())); } setFkColumns(names); }) .catch(() => { if (active) setFkColumns(new Set()); }); return () => { active = false; }; }, [mode, connectionId, params.schema, params.name]); // SQL preview useEffect(() => { let active = true; setError(null); const promise = isRebuild ? cmd.buildRebuildScript( connectionId, params.schema, params.name, action.columns, ) : cmd.buildObjectDdl( connectionId, "table", buildTablePayload(params, op, createFks, dbType), ); promise .then((sqls: string[] | string) => { if (active) setPreview( Array.isArray(sqls) ? sqls.join("\n;\n") : (sqls as string), ); }) .catch((e: unknown) => { if (active) { setPreview(""); setError(e instanceof Error ? e.message : String(e)); } }); return () => { active = false; }; }, [ params, action, op, isRebuild, connectionId, params.schema, params.name, createFks, ]); const patchColumns = (cols: TableFormColumn[]) => setParams({ ...params, action: { ...action, columns: cols } }); const addColumn = () => { const next = [...action.columns, emptyColumn()]; // The first column auto-starts as the PK. if (next.length === 1) next[0].is_pk = true; patchColumns(next); }; const removeColumn = (i: number) => patchColumns(action.columns.filter((_, j) => j !== i)); const setCell = (i: number, key: keyof TableFormColumn, value: unknown) => { const next = [...action.columns]; next[i] = { ...next[i], [key]: value } as TableFormColumn; patchColumns(next); }; const applyFkTypes = (pairs: { localCol: string; refType: string }[]) => { const next = action.columns.map((c) => { const p = pairs.find((x) => x.localCol === c.name); return p ? { ...c, type: p.refType || c.type, fk: true } : c; }); patchColumns(next); }; const handleFkStaged = (result: { pairs: { localCol: string; refType: string }[]; fk?: FkDefinition; }) => { applyFkTypes(result.pairs); if (mode === "create" && result.fk) { setCreateFks((prev) => [...prev, result.fk as FkDefinition]); } }; const removeCreateFk = (fk: FkDefinition) => setCreateFks((prev) => prev.filter((f) => f !== fk)); const editCreateFk = (fk: FkDefinition) => { setCreateFks((prev) => prev.filter((f) => f !== fk)); setFkPanel({ open: true, column: fk.columns[0] ?? null }); }; const stage = async () => { setStaging(true); setError(null); try { if (isRebuild) { if (refusal) return; const sql = await cmd.buildRebuildScript( connectionId, params.schema, params.name, action.columns, ); useDbViewerStore.getState().addChange({ type: "rebuild_table", sql, schema: params.schema, table: params.name, description: `Rebuild table ${params.schema}.${params.name}`, }); useDbViewerStore.getState().closeTab(tab.id); return; } if (mode === "edit" && action.old_columns !== undefined) { const live = await cmd.getTableColumns( connectionId, params.schema, params.name, ); const liveCols: TableFormColumn[] = live.map( (c: ColumnInfo, i) => ({ rowId: `live-${i}`, name: c.name, type: c.data_type, nullable: c.is_nullable, default: c.default_value, is_pk: c.is_pk, }), ); if (!sameColumns(liveCols, action.old_columns)) { setError( "Table changed since you opened it — re-open to see current state", ); return; } } const sqls = await cmd.buildObjectDdl( connectionId, "table", buildTablePayload(params, op, createFks, dbType), ); sqls.forEach((sql, i) => useDbViewerStore.getState().addChange({ type: "ddl", sql, schema: params.schema, table: params.name, description: sqls.length > 1 ? `${form.description} (${i + 1}/${sqls.length})` : form.description, }), ); useDbViewerStore.getState().closeTab(tab.id); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setStaging(false); } }; const cols = useMemo( () => (action.columns ?? []).map((c, i) => ({ ...c, rowId: c.rowId ?? `col-${i}`, })), [action.columns], ); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const handleDragEnd = (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; const from = cols.findIndex((c) => c.rowId === active.id); const to = cols.findIndex((c) => c.rowId === over.id); if (from === -1 || to === -1) return; patchColumns(arrayMove(cols, from, to)); }; return (
{mode} table{isRebuild ? " (rebuild)" : ""}
{refusal && (

Cannot reorder: {refusal}. Use the Query tab with pg_dump for these tables.

)} {view === "visual" ? ( <> {mode === "create" && dbType !== "sqlite" && ( {schemas && schemas.length > 0 ? ( ) : ( setParams({ ...params, schema: e.target.value, }) } /> )} )} {mode === "create" && ( setParams({ ...params, name: e.target.value, }) } /> )}
#
Name
Type
Parameters
Default Value
c.rowId)} strategy={verticalListSortingStrategy} > {cols.map((c, i) => ( removeColumn(i)} onFk={() => setFkPanel({ open: true, column: c.name, }) } dbType={dbType} /> ))}
setParams({ ...params, action: { ...action, tablespace }, }) } onRls={(rls) => setParams({ ...params, action: { ...action, rls }, }) } /> setFkPanel({ open: true, column: null }) } onRemoveCreateFk={removeCreateFk} onEditCreateFk={editCreateFk} onOpenPanel={(col) => setFkPanel({ open: true, column: col }) } /> {fkPanel?.open && ( ({ name: c.name, data_type: c.type, }), )} onStaged={handleFkStaged} onClose={() => setFkPanel(null)} /> )} ) : (
{preview} } > {}} readOnly height={420} />
)}
{error && (

{error}

)}
); } interface ColumnRowProps { c: TableFormColumn; index: number; mode: "create" | "edit"; setCell: (i: number, key: keyof TableFormColumn, value: unknown) => void; onRemove: () => void; onFk: () => void; hasFk: boolean; dbType: DbType | undefined; } function ColumnRow({ c, index, mode, setCell, onRemove, onFk, hasFk, dbType, }: ColumnRowProps) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id: c.rowId }); const [menuOpen, setMenuOpen] = useState(false); const cogRef = useRef(null); const [menuPos, setMenuPos] = useState<{ top: number; right: number; } | null>(null); const toggleMenu = () => { if (!menuOpen && cogRef.current) { const r = cogRef.current.getBoundingClientRect(); setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right, }); } setMenuOpen((v) => !v); }; const constraintsMenu = menuOpen && menuPos ? createPortal( <>
setMenuOpen(false)} />
{mode === "create" && (dbType === "sqlite" ? supportsAutoIncrement(c.type) && c.is_pk : supportsAutoIncrement(c.type)) && ( )} {mode === "create" && ( )}
, document.body, ) : null; return (
{index + 1}
setCell(index, "name", e.target.value)} /> {!c.is_pk && ( )}
setCell(index, "params", e.target.value)} />
setCell( index, "default", e.target.checked ? (c.default ?? "") : null, ) } className="rounded border-border bg-surface text-accent focus:ring-accent" /> setCell(index, "default", e.target.value)} />
{constraintsMenu}
); } interface OptionsSectionProps { connectionId: string; schema: string; table: string; mode: "create" | "edit"; tablespace: string | null; rls: "disable" | "enable" | "force" | null; onTablespace: (value: string | null) => void; onRls: (value: "disable" | "enable" | "force" | null) => void; } function OptionsSection({ connectionId, mode, tablespace, rls, onTablespace, onRls, }: OptionsSectionProps) { const [tablespaces, setTablespaces] = useState([]); useEffect(() => { let active = true; cmd.getTablespaces(connectionId) .then((ts) => { if (active) setTablespaces(Array.isArray(ts) ? ts : []); }) .catch(() => { if (active) setTablespaces([]); }); return () => { active = false; }; }, [connectionId]); return ( <> {mode === "edit" && (
{[ { value: "disable", label: "Disabled" }, { value: "enable", label: "Enabled" }, { value: "force", label: "Forced" }, ].map(({ value, label }) => ( ))}
)} ); } interface RelationshipsSectionProps { connectionId: string; schema: string; table: string; /** FKs to inline into a CREATE TABLE (create mode only). */ createFks?: FkDefinition[]; onAddFk: () => void; onRemoveCreateFk: (fk: FkDefinition) => void; onEditCreateFk: (fk: FkDefinition) => void; onOpenPanel: (column: string | null) => void; } interface FkRow { key: string; source: "create" | "queued" | "db"; localTable: string; localCols: string[]; refSchema: string; refTable: string; refCols: string[]; onDelete?: string; onUpdate?: string; changeId?: string; constraintName?: string; fk?: FkDefinition; } /** Normalize a raw FK SQL fragment (ALTER … or pg_get_constraintdef) into structured parts. */ function parseFkSql( text: string, ): Omit { const strip = (s: string) => s.replace(/"/g, "").trim(); const local = /FOREIGN\s+KEY\s*\(([^)]*)\)/i.exec(text); const ref = /REFERENCES\s+([^(\s]+)\s*\(([^)]*)\)/i.exec(text); const del = /ON\s+DELETE\s+([A-Z\s]+?)(?=\s+ON\s|$)/i.exec(text); const upd = /ON\s+UPDATE\s+([A-Z\s]+?)(?=\s+ON\s|$)/i.exec(text); const localCols = local ? local[1].split(",").map(strip).filter(Boolean) : []; const refCols = ref ? ref[2].split(",").map(strip).filter(Boolean) : []; let refSchema = ""; let refTable = ""; if (ref) { const parts = strip(ref[1]).split("."); if (parts.length >= 2) { refSchema = parts[0]; refTable = parts.slice(1).join("."); } else { refTable = parts[0] ?? ""; } } return { localCols, refSchema, refTable, refCols, onDelete: del ? del[1].trim() : undefined, onUpdate: upd ? upd[1].trim() : undefined, }; } function RelationshipsSection({ connectionId, schema, table, createFks = [], onAddFk, onRemoveCreateFk, onEditCreateFk, onOpenPanel, }: RelationshipsSectionProps) { const [constraints, setConstraints] = useState([]); const queued = useDbViewerStore((s) => s.changesQueue); useEffect(() => { let active = true; cmd.getConstraints(connectionId, schema) .then((cs) => { if (active) setConstraints( Array.isArray(cs) ? cs.filter((c) => c.table === table) : [], ); }) .catch(() => { if (active) setConstraints([]); }); return () => { active = false; }; }, [connectionId, schema, table]); const fks = useMemo( () => constraints.filter((c) => (c.definition ?? "").toUpperCase().includes("FOREIGN"), ), [constraints], ); // FKs staged this session live in the changes queue. const queuedFks = useMemo( () => queued.filter( (q) => q.type === "ddl" && q.sql.toUpperCase().includes("FOREIGN KEY") && q.sql.includes(`"${schema}"."${table}"`), ), [queued, schema, table], ); const rows = useMemo(() => { const out: FkRow[] = createFks.map((fk, i) => ({ key: `cfk-${i}`, source: "create", localTable: table, localCols: fk.columns, refSchema: fk.ref_schema, refTable: fk.ref_table, refCols: fk.ref_columns, onDelete: fk.on_delete, onUpdate: fk.on_update, fk, })); for (const q of queuedFks) { out.push({ key: q.id, source: "queued", localTable: table, changeId: q.id, ...parseFkSql(q.sql), }); } for (const c of fks) { out.push({ key: `db-${c.name}`, source: "db", localTable: table, constraintName: c.name, ...parseFkSql(c.definition ?? ""), }); } return out; }, [createFks, queuedFks, fks, table]); const removeFk = async (row: FkRow) => { if (row.source === "create") { if (row.fk) onRemoveCreateFk(row.fk); return; } if (row.source === "queued") { useDbViewerStore.getState().removeChange(row.changeId!); return; } // db: stage a DROP CONSTRAINT change try { const sqls = await cmd.buildObjectDdl(connectionId, "constraint", { schema, table, name: row.constraintName, action: { op: "drop" }, }); sqls.forEach((sql) => useDbViewerStore.getState().addChange({ type: "ddl", sql, description: `Drop FK ${row.constraintName}`, }), ); } catch { // ignore — preview handles errors } }; const editFk = (row: FkRow) => { if (row.source === "create") { if (row.fk) onEditCreateFk(row.fk); return; } if (row.source === "queued") { useDbViewerStore.getState().removeChange(row.changeId!); } onOpenPanel(row.localCols[0] ?? null); }; return (
Foreign keys
{rows.length === 0 && (

No foreign keys listed.

)} {rows.map((row) => (

Foreign key relation to:

{row.refSchema}.{row.refTable}

{row.localTable ? `${row.localTable}.` : ""} {row.localCols.join(", ")} → {row.refTable}. {row.refCols.join(", ")} {row.onDelete ? ` · ${row.onDelete}` : ""} {row.onUpdate ? ` · ${row.onUpdate}` : ""}

))}
); }