v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)
Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, windows-latest) (push) Canceled after 0s

* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1)

* feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2)

* feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3)

* feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1)

* feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2)

* feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3)

* feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4)

* feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5)

* feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1)

* feat(store): roles slice + openFormTab table/role dedup (Task 3.2)

* feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1)

* feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2)

* feat(ui): wire roles into object registry + explorer (Task 4.3)

* feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4)

* chore: bump version to 0.7.7 (Task 5.1)

* docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2)

* test: v0.7.7 manual smoke checklist (Task 5.3)

* chore: sync Cargo.lock to v0.7.7

* style(ui): match TableForm visual layout to shared object-form rows (remove stray padding)

* feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls

* style(ui): table-creator columns grid — bordered cells like the data table view

* feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row

* feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints

* style(ui): table creator — constraints column sized to content (no clip), overscroll containment

* style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2

* feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema

* style(ui): table creator — columns grid fills container width, scrolls only when too narrow

* feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family

* feat(ui): FK panel — animate in/out from the right, flush create-table-style rows

* fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid

* feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs

* fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs

* style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker

* style(ui): FK panel — vertical divider between local/ref columns

* style(ui): FK panel — section title padding py-2

* style(ui): FK panel — remove row margins + select/text padding in column picker

* style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells

* style(ui): FK panel — cell padding + row borders on column picker (user polish)

* feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class

* style(ui): hide FK icon on PK columns; fix grid header/row column alignment

* feat(ui): SQL preview in table creator uses read-only Monaco editor

* feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions

* style(ui): FK relation row — label, icon, table on one line

* style(ui): FormRow selects transparent like FK panel; test matches FK relation label

* style(ui): form-row focus outline amber-400/20 (subtle)

* style(ui): SQL view — remove px-4 py-3 wrapper padding

* feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card

* fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64

* fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed)

* feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more

* feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all

* chore(test): drop unused fireEvent import in RoleDetail test

* style(ui): hide chevron when section has 5 or fewer entries

* docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins)

* docs(readme): collapse Recent changes and Key features into details blocks

* docs(readme): move hero caption under image; align download + comparison table columns

* docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible

* docs(readme): per-subsection collapsibles with meaningful summary labels
This commit is contained in:
2026-08-07 05:51:23 +08:00
committed by GitHub
parent 0c74d77e75
commit 7401e91a12
64 changed files with 6255 additions and 200 deletions
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { X, Link, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import { buildObjectDdl } from "../../../lib/objectCrud";
import type { SchemaGraph } from "../../../lib/types";
// Panel selects: transparent, fill the available width (no surface bg like the grid).
const panelSelect =
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
export interface FkColumn {
name: string;
data_type: string;
}
export interface FkStagedPair {
localCol: string;
refType: string;
}
export interface FkDefinition {
columns: string[];
ref_schema: string;
ref_table: string;
ref_columns: string[];
on_delete?: string;
on_update?: string;
deferrable?: boolean;
initially_deferred?: boolean;
}
interface FkPair {
localCol: string;
refCol: string;
}
interface Props {
connectionId: string;
schema: string;
table: string;
column: string | null;
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
localColumns: FkColumn[];
mode: "create" | "edit";
onClose: () => void;
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
}
const FK_ACTIONS = [
"NO ACTION",
"RESTRICT",
"CASCADE",
"SET NULL",
"SET DEFAULT",
];
function Section({
label,
children,
flush = false,
}: {
label: string;
children: ReactNode;
flush?: boolean;
}) {
return (
<div className="border-b border-border">
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{label}
</div>
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
</div>
);
}
export function FkPanel({
connectionId,
schema,
table,
column,
localColumns,
mode,
onClose,
onStaged,
}: Props) {
const [graph, setGraph] = useState<SchemaGraph>({
tables: [],
relationships: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [staging, setStaging] = useState(false);
const [pairs, setPairs] = useState<FkPair[]>(() =>
column
? [{ localCol: column, refCol: "" }]
: [{ localCol: "", refCol: "" }],
);
const [refSchema, setRefSchema] = useState<string>(schema);
const [refTable, setRefTable] = useState<string>("");
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
const [onUpdate, setOnUpdate] = useState<string>("NO ACTION");
useEffect(() => {
let active = true;
setLoading(true);
cmd.getSchemaGraph(connectionId, undefined)
.then((g) => {
if (!active) return;
setGraph(g);
setLoading(false);
})
.catch((e) => {
if (!active) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
active = false;
};
}, [connectionId]);
const localNames = useMemo(
() => localColumns.map((c) => c.name),
[localColumns],
);
const schemas = useMemo(
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
[graph],
);
const tablesInSchema = useMemo(
() =>
graph.tables
.filter((t) => t.schema === refSchema)
.sort((a, b) => a.name.localeCompare(b.name)),
[graph, refSchema],
);
const refTableInfo = useMemo(
() => tablesInSchema.find((t) => t.name === refTable),
[tablesInSchema, refTable],
);
const refColumns = useMemo(() => {
if (!refTableInfo) return [];
return [...refTableInfo.columns].sort((a, b) => {
if (a.is_pk && !b.is_pk) return -1;
if (!a.is_pk && b.is_pk) return 1;
return a.name.localeCompare(b.name);
});
}, [refTableInfo]);
// Defaults: keep selections valid as the graph loads / inputs change.
useEffect(() => {
if (schemas.length > 0 && !schemas.includes(refSchema)) {
setRefSchema(schemas[0] ?? "");
}
}, [schemas, refSchema]);
useEffect(() => {
if (
tablesInSchema.length > 0 &&
!tablesInSchema.some((t) => t.name === refTable)
) {
setRefTable(tablesInSchema[0]?.name ?? "");
} else if (tablesInSchema.length === 0) {
setRefTable("");
}
}, [tablesInSchema, refTable]);
useEffect(() => {
if (localNames.length === 0) return;
setPairs((prev) =>
prev.map((p) =>
localNames.includes(p.localCol)
? p
: { ...p, localCol: localNames[0] ?? "" },
),
);
}, [localNames]);
useEffect(() => {
setPairs((prev) =>
prev.map((p) =>
p.refCol && refColumns.some((c) => c.name === p.refCol)
? p
: { ...p, refCol: refColumns[0]?.name ?? "" },
),
);
}, [refColumns]);
const setPair = (i: number, key: keyof FkPair, value: string) =>
setPairs((prev) =>
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
);
const addPair = () =>
setPairs((prev) => [
...prev,
{
localCol: localNames[0] ?? "",
refCol: refColumns[0]?.name ?? "",
},
]);
const removePair = (i: number) =>
setPairs((prev) =>
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
);
const typeMappings = pairs
.map((p) => ({
localType:
localColumns.find((c) => c.name === p.localCol)?.data_type ??
"",
refType:
refTableInfo?.columns.find((c) => c.name === p.refCol)
?.data_type ?? "",
}))
.filter((m) => m.localType !== "" || m.refType !== "");
const tableLabel = table.trim() === "" ? "unnamed table" : table;
const addFk = async () => {
const cols = pairs.map((p) => p.localCol);
const refCols = pairs.map((p) => p.refCol);
if (
cols.length === 0 ||
cols.some((c) => c === "") ||
refCols.some((c) => c === "")
)
return;
setStaging(true);
setError(null);
const refTypes: Record<string, string> = {};
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
const pairsResult = pairs.map((p) => ({
localCol: p.localCol,
refType: refTypes[p.refCol] ?? "",
}));
const fk: FkDefinition = {
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
deferrable: false,
initially_deferred: false,
};
try {
if (mode === "create") {
// Inline the FK into the CREATE TABLE — no separate ALTER change.
onStaged?.({ pairs: pairsResult, fk });
onClose();
return;
}
const sqls = await buildObjectDdl(connectionId, "constraint", {
schema,
table,
name: "",
action: {
op: "foreign_key",
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
},
});
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description:
sqls.length > 1
? `Add FK ${cols.join(", ")} → ${refSchema}.${refTable} (${i + 1}/${sqls.length})`
: `Add FK ${cols.join(", ")} → ${refSchema}.${refTable}`,
}),
);
onStaged?.({ pairs: pairsResult });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setStaging(false);
}
};
return (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
style={{ willChange: "transform" }}
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 text-text">
<Link size={14} />
<span className="text-sm font-semibold">Foreign key</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-text-muted hover:text-text"
>
<X size={16} />
</button>
</div>
<div
className="flex-1 overflow-auto"
style={{ overscrollBehavior: "none" }}
>
{table.trim() === "" && (
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
Enter a table name in the form first — a foreign key
needs a named table.
</p>
)}
{loading && (
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
Loading schema graph…
</p>
)}
{!loading && graph.tables.length === 0 && (
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
No tables available for foreign key reference.
</p>
)}
{!loading && graph.tables.length > 0 && (
<>
<Section label="Select a Schema">
<select
aria-label="Schema"
value={refSchema}
onChange={(e) => setRefSchema(e.target.value)}
className={panelSelect}
>
{schemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</Section>
<Section label="Select a Table to reference to">
<select
aria-label="Table"
value={refTable}
onChange={(e) => setRefTable(e.target.value)}
className={panelSelect}
>
{tablesInSchema.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
</Section>
{refSchema && refTable && (
<>
<Section
flush
label={`Select columns from ${schema}.${tableLabel} to reference to`}
>
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
<div className="border-r border-border px-4 py-1">
<span className="truncate">
{schema}.{tableLabel}
</span>
</div>
<div className="px-4 py-1">
<span className="truncate">
{refSchema}.{refTable || "—"}
</span>
</div>
</div>
{pairs.map((pair, i) => (
<div
key={i}
className="grid grid-cols-2 items-center border-b border-border"
>
<div className="border-r border-border px-2 py-1">
<select
aria-label={`Local column ${i + 1}`}
value={pair.localCol}
onChange={(e) =>
setPair(
i,
"localCol",
e.target.value,
)
}
className={panelSelect}
>
{localColumns.map((c) => (
<option
key={c.name}
value={c.name}
>
{c.name} (
{c.data_type})
</option>
))}
</select>
</div>
<div className="flex items-center gap-1 px-2 py-1">
<select
aria-label={`Referenced column ${i + 1}`}
value={pair.refCol}
onChange={(e) =>
setPair(
i,
"refCol",
e.target.value,
)
}
className={panelSelect}
>
{refColumns.map((c) => (
<option
key={c.name}
value={c.name}
>
{c.name} (
{c.data_type})
{c.is_pk
? " (PK)"
: ""}
</option>
))}
</select>
{pairs.length > 1 && (
<button
type="button"
aria-label={`Remove pair ${i + 1}`}
onClick={() =>
removePair(i)
}
className="shrink-0 text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
)}
</div>
</div>
))}
<button
type="button"
aria-label="Add another column"
onClick={addPair}
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
>
<Plus size={12} className="inline" />{" "}
Add another column
</button>
</Section>
{typeMappings.length > 0 && (
<Section label="Types will be updated">
<ul className="space-y-0.5">
{typeMappings.map((m, i) => (
<li
key={i}
className="px-2 font-mono text-xs text-text"
>
<span className="text-text-muted mr-2">
{m.localType || "?"}
</span>
<span className="text-accent">
→
</span>{" "}
<span className="">
{m.refType || "?"}
</span>
</li>
))}
</ul>
</Section>
)}
<Section label="Action if referenced row is updated">
<select
aria-label="On update"
value={onUpdate}
onChange={(e) =>
setOnUpdate(e.target.value)
}
className={panelSelect}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Section>
<Section label="Action if referenced row is removed">
<select
aria-label="On delete"
value={onDelete}
onChange={(e) =>
setOnDelete(e.target.value)
}
className={panelSelect}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Section>
</>
)}
</>
)}
{error && (
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
{error}
</p>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
>
Cancel
</button>
<button
type="button"
onClick={addFk}
disabled={
staging ||
table.trim() === "" ||
pairs.length === 0 ||
pairs.some((p) => p.localCol === "" || p.refCol === "")
}
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"
>
Add FK
</button>
</div>
</motion.div>
);
}