Files
gridline/src/components/db-viewer/objects/TableForm.tsx
T
adrianbonpin 32a7b852ec v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)
* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
2026-08-08 00:09:36 +08:00

1430 lines
53 KiB
TypeScript

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<string, unknown> {
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<string, unknown>;
}
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 (
<div className="flex h-full items-center justify-center p-6 text-center">
<p className="text-sm text-text-muted">
Table management is not supported for this database type.
</p>
</div>
);
}
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<string | null>(null);
const [refusal, setRefusal] = useState<string | null>(null);
const [staging, setStaging] = useState(false);
const [fkPanel, setFkPanel] = useState<{
open: boolean;
column: string | null;
} | null>(null);
const [fkColumns, setFkColumns] = useState<Set<string>>(new Set());
const [createFks, setCreateFks] = useState<FkDefinition[]>([]);
const op = isRebuild ? "rebuild" : action.op;
const setParams = (next: TableFormParams) => {
useDbViewerStore
.getState()
.updateFormTabParams(
tab.id,
next as unknown as Record<string, unknown>,
);
};
// 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<string>();
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 (
<div className="flex h-full flex-col bg-transparent">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{mode} table{isRebuild ? " (rebuild)" : ""}
</span>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-border overflow-hidden">
<button
type="button"
aria-label="Visual"
onClick={() => setView("visual")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "visual"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
Visual
</button>
<button
type="button"
aria-label="SQL"
onClick={() => setView("sql")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "sql"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
<button
type="button"
onClick={stage}
disabled={
!!error || !!refusal || staging || cols.length === 0
}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
{mode === "create" ? "Create Table" : "Stage"}
</button>
</div>
</div>
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
{refusal && (
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-amber-400">
Cannot reorder: {refusal}. Use the Query tab with
pg_dump for these tables.
</p>
</div>
)}
{view === "visual" ? (
<>
{mode === "create" && dbType !== "sqlite" && (
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
aria-label="Schema"
value={params.schema}
onChange={(e) =>
setParams({
...params,
schema: e.target.value,
})
}
className={selectTransparent}
>
{params.schema !== "" &&
!schemas.includes(
params.schema,
) && (
<option value={params.schema}>
{params.schema}
</option>
)}
{schemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
) : (
<input
className={inputClass}
value={params.schema}
onChange={(e) =>
setParams({
...params,
schema: e.target.value,
})
}
/>
)}
</FormRow>
)}
{mode === "create" && (
<FormRow label="Name">
<input
className={inputClass}
placeholder="Table name"
value={params.name}
onChange={(e) =>
setParams({
...params,
name: e.target.value,
})
}
/>
</FormRow>
)}
<FormSectionHeader
label="Columns"
count={cols.length}
/>
<div
className="overflow-x-auto"
style={{ overscrollBehavior: "none" }}
>
<div className="border-b border-border flex items-stretch w-full min-w-[760px]">
<div className="w-8 shrink-0 border-r border-border px-3 py-1.5 flex items-center justify-center" />
<div className="w-8 shrink-0 border-r border-border px-3 py-1.5 flex items-center text-[11px] font-semibold text-text-muted uppercase tracking-wider">
#
</div>
<div className="flex-1 min-w-48 border-r border-border px-3 py-1.5 flex items-center text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Name
</div>
<div className="flex-1 min-w-40 border-r border-border px-3 py-1.5 flex items-center text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Type
</div>
<div className="w-24 shrink-0 border-r border-border px-3 py-1.5 flex items-center text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Parameters
</div>
<div className="flex-1 min-w-44 border-r border-border px-3 py-1.5 flex items-center text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Default Value
</div>
<div className="w-max shrink-0 border-r border-border px-3 py-1.5 flex items-center justify-end gap-1 invisible">
<Link size={12} />
<X size={12} />
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cols.map((c) => c.rowId)}
strategy={verticalListSortingStrategy}
>
{cols.map((c, i) => (
<ColumnRow
key={c.rowId}
c={c}
index={i}
mode={mode}
setCell={setCell}
hasFk={
!!c.fk || fkColumns.has(c.name)
}
onRemove={() => removeColumn(i)}
onFk={() =>
setFkPanel({
open: true,
column: c.name,
})
}
dbType={dbType}
/>
))}
</SortableContext>
</DndContext>
<div className="border-b border-border px-4 h-max">
<button
type="button"
aria-label="Add column"
onClick={addColumn}
className="text-xs text-accent hover:text-accent-hover cursor-pointer py-2 flex items-center gap-1"
>
<Plus size={12} /> Add column
</button>
</div>
</div>
<OptionsSection
connectionId={connectionId}
schema={params.schema}
table={params.name}
mode={mode}
tablespace={action.tablespace ?? null}
rls={action.rls ?? null}
onTablespace={(tablespace) =>
setParams({
...params,
action: { ...action, tablespace },
})
}
onRls={(rls) =>
setParams({
...params,
action: { ...action, rls },
})
}
/>
<RelationshipsSection
connectionId={connectionId}
schema={params.schema}
table={params.name}
createFks={mode === "create" ? createFks : []}
onAddFk={() =>
setFkPanel({ open: true, column: null })
}
onRemoveCreateFk={removeCreateFk}
onEditCreateFk={editCreateFk}
onOpenPanel={(col) =>
setFkPanel({ open: true, column: col })
}
/>
<AnimatePresence>
{fkPanel?.open && (
<FkPanel
connectionId={connectionId}
schema={params.schema}
table={params.name}
column={fkPanel.column}
mode={mode ?? "edit"}
localColumns={(action.columns ?? []).map(
(c) => ({
name: c.name,
data_type: c.type,
}),
)}
onStaged={handleFkStaged}
onClose={() => setFkPanel(null)}
/>
)}
</AnimatePresence>
</>
) : (
<div>
<Suspense
fallback={
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">
{preview}
</pre>
}
>
<SqlEditorField
value={preview}
onChange={() => {}}
readOnly
height={420}
/>
</Suspense>
</div>
)}
</div>
{error && (
<div className="border-t border-border px-4 py-2">
<p className="text-xs text-red-400">{error}</p>
</div>
)}
</div>
);
}
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<HTMLButtonElement | null>(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(
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div
role="menu"
className="fixed z-50 bg-surface-raised border border-border rounded-lg shadow-lg p-2 min-w-44 flex flex-col gap-1"
style={{ top: menuPos.top, right: menuPos.right }}
>
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input
type="checkbox"
aria-label="PK"
checked={c.is_pk}
disabled={mode === "edit"}
onChange={(e) =>
setCell(index, "is_pk", e.target.checked)
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
Primary key
</label>
{mode === "create" &&
(dbType === "sqlite"
? supportsAutoIncrement(c.type) && c.is_pk
: supportsAutoIncrement(c.type)) && (
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input
type="checkbox"
aria-label="Auto-Increment"
checked={c.auto_increment ?? false}
onChange={(e) =>
setCell(
index,
"auto_increment",
e.target.checked,
)
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
Auto-Increment
</label>
)}
{mode === "create" && (
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input
type="checkbox"
aria-label="Unique"
checked={c.unique ?? false}
onChange={(e) =>
setCell(
index,
"unique",
e.target.checked,
)
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
Unique
</label>
)}
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input
type="checkbox"
aria-label="Nullable"
checked={c.nullable}
onChange={(e) =>
setCell(
index,
"nullable",
e.target.checked,
)
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
Nullable
</label>
</div>
</>,
document.body,
)
: null;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`border-b border-border flex items-stretch w-full min-w-[760px] ${isDragging ? "opacity-60" : ""}`}
>
<div className="w-8 shrink-0 border-r border-border px-2 py-2 flex items-center justify-center">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
type="button"
aria-label="Drag to reorder"
className="cursor-grab active:cursor-grabbing touch-none text-text-muted hover:text-text transition-colors"
>
<GripVertical size={14} />
</button>
</div>
<div className="w-8 shrink-0 border-r border-border px-3 py-2 flex items-center text-xs font-mono text-text-muted">
{index + 1}
</div>
<div className="flex-1 min-w-48 border-r border-border px-3 py-2 flex items-center gap-2">
<input
className={cellInput}
placeholder="name"
value={c.name}
onChange={(e) => setCell(index, "name", e.target.value)}
/>
{!c.is_pk && (
<button
type="button"
aria-label="Set foreign key"
onClick={onFk}
title={
hasFk
? "This column has a foreign key"
: "Set foreign key"
}
className={`shrink-0 transition-colors ${hasFk ? "text-accent" : "text-text-muted hover:text-accent"}`}
>
<Link size={12} />
</button>
)}
</div>
<div className="flex-1 min-w-40 border-r border-border px-3 py-2 flex items-center gap-1.5">
<DataTypeIcon dataType={c.type} size={12} />
<select
aria-label="type"
value={c.type}
onChange={(e) => setCell(index, "type", e.target.value)}
className="min-w-0 flex-1 bg-transparent font-mono text-xs text-text outline-none cursor-pointer"
>
{c.type !== "" && !pickTypeList(dbType).includes(c.type) && (
<option value={c.type}>{c.type}</option>
)}
{pickTypeList(dbType).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="w-24 shrink-0 border-r border-border px-3 py-2 flex items-center">
<input
className={cellMono}
placeholder="length"
value={c.params ?? ""}
onChange={(e) => setCell(index, "params", e.target.value)}
/>
</div>
<div className="flex-1 min-w-44 border-r border-border px-3 py-2 flex items-center gap-2">
<input
type="checkbox"
aria-label="Has default"
checked={c.default !== null}
onChange={(e) =>
setCell(
index,
"default",
e.target.checked ? (c.default ?? "") : null,
)
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<input
className={cellMono}
placeholder="default"
disabled={c.default === null}
value={c.default ?? ""}
onChange={(e) => setCell(index, "default", e.target.value)}
/>
</div>
<div className="w-max shrink-0 border-r border-border px-3 py-2 flex items-center justify-end gap-1">
<button
ref={cogRef}
type="button"
aria-label="Column settings"
onClick={toggleMenu}
className="text-text-muted hover:text-text transition-colors"
>
<Settings2 size={12} />
</button>
<button
type="button"
aria-label="Remove column"
onClick={onRemove}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
{constraintsMenu}
</div>
);
}
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<TablespaceInfo[]>([]);
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 (
<>
<FormRow label="Tablespace">
<select
aria-label="Tablespace"
value={tablespace ?? ""}
onChange={(e) => onTablespace(e.target.value || null)}
className={selectTransparent}
>
<option value="">(default)</option>
{tablespaces.map((ts) => (
<option key={ts.name} value={ts.name}>
{ts.name}
</option>
))}
</select>
</FormRow>
{mode === "edit" && (
<FormRow label="Row level security">
<div className="flex items-center gap-4 px-3">
{[
{ value: "disable", label: "Disabled" },
{ value: "enable", label: "Enabled" },
{ value: "force", label: "Forced" },
].map(({ value, label }) => (
<label
key={value}
className="flex items-center gap-1 text-xs text-text"
>
<input
type="radio"
name="rls"
value={value}
checked={rls === value}
onChange={() =>
onRls(
value as
| "disable"
| "enable"
| "force",
)
}
/>
{label}
</label>
))}
</div>
</FormRow>
)}
</>
);
}
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<FkRow, "key" | "source" | "localTable"> {
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<ConstraintInfo[]>([]);
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<FkRow[]>(() => {
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 (
<div>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Foreign keys
</span>
<button
type="button"
aria-label="Add FK"
onClick={onAddFk}
className="text-xs text-accent hover:text-accent-hover"
>
Add FK
</button>
</div>
{rows.length === 0 && (
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-text-muted">
No foreign keys listed.
</p>
</div>
)}
{rows.map((row) => (
<div key={row.key} className="border-b border-border px-4 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="text-[11px] font-semibold text-text-muted uppercase tracking-wider shrink-0">
Foreign key relation to:
</p>
<div className="flex flex-row gap-1 items-center bg-text/10 px-2 py-1 rounded">
<Table2
size={12}
className="text-text-muted shrink-0"
/>
<span className="font-mono text-xs text-text truncate">
{row.refSchema}.{row.refTable}
</span>
</div>
</div>
<p className="font-mono text-[11px] text-text-muted mt-0.5 truncate">
{row.localTable ? `${row.localTable}.` : ""}
{row.localCols.join(", ")} {row.refTable}.
{row.refCols.join(", ")}
{row.onDelete ? ` · ${row.onDelete}` : ""}
{row.onUpdate ? ` · ${row.onUpdate}` : ""}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
aria-label={`Edit FK ${row.refSchema}.${row.refTable}`}
onClick={() => editFk(row)}
className="text-xs text-accent hover:text-accent-hover cursor-pointer"
>
Edit
</button>
<button
type="button"
aria-label={`Remove FK ${row.refSchema}.${row.refTable}`}
onClick={() => removeFk(row)}
className="text-xs text-text-muted hover:text-red-400 cursor-pointer"
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
);
}