v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)

* chore: bump version to 0.5.0 (Task 1)

* feat(store): v7 migration — favorites + recent_connections (Task 2)

* feat(models): add favorite to Connection + Store CRUD (Task 3)

* feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4)

* chore: bump version to 0.5.0 (Task 1) — lockfile

* feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5)

* feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6)

* feat(db): get_table_data editability flags + ctid/rowid locator (Task 7)

* feat(db): execute_change no-PK locator guard + affected-count check (Task 8)

* feat(db): preserve bigint precision as string on PG read path (Task 9)

* feat(db): get_indexes / get_constraints commands (Task 10)

* feat(store): favorites + recents Store methods (Task 11)

* feat(commands): favorites/recents IPC + register indexes/constraints (Task 12)

* feat(store): connectionStore favorites/recents/move-selection (Task 13)

* feat(lib): recent-connections pure helpers (Task 14)

* feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15)

* feat(grid): pure editability + filter-operator + cell transform (Task 16)

* feat(grid): pure keyboard-nav helper (Task 17)

* feat(grid): CellEditor inline editor (Task 18)

* feat(grid): CellContextMenu + RowDetailDrawer (Task 19)

* feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20)

* feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21)

* feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22)

* feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23)

* feat(home): move-to-folder + recents strip + status wiring (Task 24)

* feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25)

* docs: v0.5.0 status + roadmap updates (Task 26)

* polish: empty/error/loading states for v0.5.0 surfaces (Task 28)

* feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A)

* feat(home): connection card kebab menu — favorite/test/manage (FEAT-B)

* fix(home): populate server_version/latency_ms in test_connection + clean online display

* style(home): swap grab handle and kebab positions on connection card

* style(home): nudge kebab menu to right-1

* style(home): nudge kebab menu to right-0.5

* feat(home): Escape clears + exits focused search

* feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A)

* feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B)

* feat(grid): enums + FK options fed into CellEditor (GRID-C)

* feat(grid): FK dropdown display-column labels + placeholder + empty state

* fix(grid): FK dropdown renders as fixed overlay to avoid clipping

* fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open

* style(grid): move FK reference icon to the start of the cell

* feat(grid): optimistic staged cell values + pending dot, cleared on refetch

* fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace

* fix(db-viewer): type getLocator for staged-value matching

* test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard

* fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name

* test(db-viewer): use index access instead of .at() for TS lib target

* feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5)

* style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling

* style(grid): harden FK dropdown minWidth to 360px

* style(grid): cap FK dropdown cells at 3

* style(grid): cap FK dropdown cells at 4

* fix(grid): pending dot clears on commit — values stay until refetch

* fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data

* docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
2026-08-03 02:42:24 +08:00
committed by GitHub
parent e0c0db8352
commit 16888460b7
77 changed files with 5671 additions and 265 deletions
@@ -34,6 +34,22 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
});
it("shows the old → new value diff on update cards", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
// the diff renders old (struck) → new (accent) as separate spans
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("revert removes the change from the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("calls onCommitted after a successful commit cycle", async () => {
const onCommitted = vi.fn();
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel onCommitted={onCommitted} />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
});
it("shows a green check on committed changes after Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
+29 -1
View File
@@ -30,6 +30,22 @@ function formatChangeLabel(change: QueueItem): string {
}
}
/** Render the old → new value change for update queue items. */
function formatValueDiff(change: QueueItem): string | null {
if (change.type !== "update" || !change.newData) return null;
const colName = Object.keys(change.newData)[0];
if (!colName) return null;
const oldVal =
change.oldData && change.oldData[colName] !== undefined
? String(change.oldData[colName])
: "NULL";
const newVal =
change.newData[colName] === null || change.newData[colName] === undefined
? "NULL"
: String(change.newData[colName]);
return `${colName}: ${oldVal}${newVal}`;
}
function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
return change.table ?? "-";
}
export function ChangesQueuePanel() {
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const removeChange = useDbViewerStore((state) => state.removeChange);
const clearChanges = useDbViewerStore((state) => state.clearChanges);
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
}
if (committedCount > 0) {
onCommitted?.();
notify(`${committedCount} change(s) committed`, "success");
}
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</div>
{formatValueDiff(change) && (
<div className="mt-0.5 font-mono text-xs text-text">
<span className="text-text-muted line-through">
{formatValueDiff(change)!.split(" → ")[0]}
</span>
<span className="mx-1 text-text-muted"></span>
<span className="text-accent">
{formatValueDiff(change)!.split(" → ")[1]}
</span>
</div>
)}
</div>
))
) : (
@@ -1,7 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import {
DbViewerScreen,
deriveStagedValues,
derivePendingCellKeys,
pickDisplayColumn,
} from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
vi.mock("../../hooks/useDbConnection", () => ({
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
useVirtualizer: ({ count }: any) => ({
getVirtualItems: () =>
count > 0
? Array.from({ length: count }, (_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
}))
: [],
getTotalSize: () => count * 36,
measureElement: () => {},
}),
}));
@@ -64,6 +78,8 @@ const mockQueryResult = {
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
],
rows: [[1]],
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
it("full flow: editing a cell shows the staged value + pending dot in the grid", async () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.setTabData(tabId, {
columns: [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
],
rows: [[1, "Alice"]],
total_rows: 1,
page: 1,
page_size: 50,
} as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
const cell = await waitFor(() => screen.getByText("Alice"));
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
// the editor's textarea is the last textbox (toolbar filter input is first)
const textboxes = screen.getAllByRole("textbox");
const input = textboxes[textboxes.length - 1]!;
fireEvent.change(input, { target: { value: "Alicia" } });
fireEvent.keyDown(input, { key: "Enter" });
// staged change carries the correct table (was the root-cause bug)
const staged = useDbViewerStore.getState().changesQueue[0];
expect(staged?.table).toBe("users");
expect(staged?.schema).toBe("public");
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
await waitFor(() => {
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
// committing the change clears the pending dot but keeps the value until refetch
act(() => {
useDbViewerStore
.getState()
.markChangeCommitted(
useDbViewerStore.getState().changesQueue[0].id,
);
});
await waitFor(() => {
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
});
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
// clearing the queue clears the optimistic display
act(() => {
useDbViewerStore.getState().clearChanges();
});
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
expect(screen.queryByText("Alicia")).toBeNull();
});
it("deriveStagedValues maps queue updates to optimistic cell values", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
status: "pending" as const, createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": "Alicia",
});
});
it("deriveStagedValues ignores failed/other-table changes and handles NULL", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: null },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "orders",
primaryKey: { id: 1 }, oldData: { x: 1 }, newData: { x: 2 },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-3", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "X" },
status: "failed" as const, error: "boom", createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": null,
});
});
it("derivePendingCellKeys only includes pending updates (dot clears on commit)", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 2 }, oldData: { name: "Bob" }, newData: { name: "Bobby" },
status: "committed" as const, createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(derivePendingCellKeys(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": true,
});
});
it("renders the New Query button", () => {
render(
<DbViewerScreen
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
).not.toBeInTheDocument(),
);
});
it("disables Insert Row for a materialized-view tab", async () => {
useDbViewerStore.setState({
tables: [
{ name: "mat_users", schema: "public", table_type: "MATERIALIZED VIEW" },
],
tabs: [
{
id: "tab-mv",
schema: "public",
table: "mat_users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: mockQueryResult,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-mv",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.queryByLabelText(/insert row/i)).toBeNull(),
);
});
it("refetches the active tab after a successful Commit All", async () => {
useUiStore.setState({ activeConnectionId: "c1" });
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getTableData = vi
.spyOn(commands, "getTableData")
.mockResolvedValue({
columns: mockQueryResult.columns,
rows: [[2]],
total_rows: 1,
page: 1,
page_size: 50,
} as any);
useDbViewerStore.setState({
tabs: [
{
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: mockQueryResult,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-1",
changesQueue: [],
changesPanelExpanded: true,
});
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 2, name: "Alice" },
description: "Insert row into users",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
});
it("fetches enum labels and FK reference rows for the active table tab", async () => {
const getEnums = vi
.spyOn(commands, "getEnums")
.mockResolvedValue([
{
name: "user_role",
schema: "public",
labels: ["admin", "user"],
},
]);
const getTableData = vi
.spyOn(commands, "getTableData")
.mockResolvedValue({
columns: [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
editable: false,
is_generated: false,
},
],
rows: [[1], [2]],
total_rows: 2,
page: 1,
page_size: 50,
} as any);
useDbViewerStore.setState({
tabs: [
{
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: {
columns: [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
editable: false,
is_generated: false,
},
{
name: "user_id",
data_type: "integer",
is_nullable: true,
is_pk: false,
is_fk: true,
fk_ref: ["users", "id"],
default_value: null,
editable: true,
is_generated: false,
},
{
name: "role",
data_type: "user_role",
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
],
rows: [[1, 2, "admin"]],
total_rows: 1,
page: 1,
page_size: 50,
} as any,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-1",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
// Enum labels are fetched for the tab's schema (cached per schema).
await waitFor(() =>
expect(getEnums).toHaveBeenCalledWith("c1", "public"),
);
// FK reference rows are fetched from the referenced table (page 1, 50).
await waitFor(() =>
expect(getTableData).toHaveBeenCalledWith(
"c1",
"public",
"users",
1,
50,
),
);
});
it("pickDisplayColumn prefers name-like columns over the ref column", () => {
const cols = [
{ name: "id", data_type: "integer" },
{ name: "email", data_type: "text" },
{ name: "name", data_type: "text" },
];
expect(pickDisplayColumn(cols, "id")).toBe("name");
expect(pickDisplayColumn(cols, "id", "email")).toBe("email");
});
it("pickDisplayColumn falls back to the ref column when nothing is name-like", () => {
const cols = [{ name: "id", data_type: "integer" }];
expect(pickDisplayColumn(cols, "id")).toBe("id");
});
});
+348 -1
View File
@@ -16,6 +16,7 @@ import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection";
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore";
import * as cmd from "../../lib/commands";
import type { EnumInfo } from "../../lib/types";
import type { FkOption } from "../grid/CellEditor";
import type { QueueItem } from "../../stores/dbViewerStore";
/**
* Derive optimistic staged cell values from the changes queue for a table
* tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL).
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
* Pending + committed updates count (survive until refetch); Clear All
* empties the queue so the optimistic display vanishes.
*/
export function deriveStagedValues(
changesQueue: QueueItem[],
schema: string,
table: string,
rows: unknown[][],
getLocator: (row: unknown[]) => Record<string, unknown>,
): Record<string, string | null> {
const map: Record<string, string | null> = {};
const updates = changesQueue.filter(
(c) =>
c.type === "update" &&
(c.status === "pending" || c.status === "committed") &&
c.schema === schema &&
c.table === table &&
c.primaryKey &&
c.newData,
);
if (updates.length === 0) return map;
rows.forEach((row, rowIdx) => {
const loc = getLocator(row);
for (const c of updates) {
const pk = c.primaryKey!;
const matches = Object.entries(pk).every(
([k, v]) => String(loc[k]) === String(v),
);
if (!matches) continue;
const colName = Object.keys(c.newData!)[0];
if (!colName) continue;
map[`${rowIdx}:${colName}`] =
(c.newData![colName] as string | null) ?? null;
}
});
return map;
}
/**
* Keys of cells with a PENDING update only — drives the amber pending dot.
* Once a change is committed the dot clears even though the optimistic value
* (from `deriveStagedValues`) stays until the refetch lands.
*/
export function derivePendingCellKeys(
changesQueue: QueueItem[],
schema: string,
table: string,
rows: unknown[][],
getLocator: (row: unknown[]) => Record<string, unknown>,
): Record<string, boolean> {
const keys: Record<string, boolean> = {};
const updates = changesQueue.filter(
(c) =>
c.type === "update" &&
c.status === "pending" &&
c.schema === schema &&
c.table === table &&
c.primaryKey &&
c.newData,
);
if (updates.length === 0) return keys;
rows.forEach((row, rowIdx) => {
const loc = getLocator(row);
for (const c of updates) {
const pk = c.primaryKey!;
const matches = Object.entries(pk).every(
([k, v]) => String(loc[k]) === String(v),
);
if (!matches) continue;
const colName = Object.keys(c.newData!)[0];
if (!colName) continue;
keys[`${rowIdx}:${colName}`] = true;
}
});
return keys;
}
/**
* Pick a human-friendly display column for FK option labels from the
* referenced table's columns: prefer name-like columns, else the first
* text-ish column that isn't the ref column, else the ref column itself.
*/
export function pickDisplayColumn(
columns: { name: string; data_type: string }[],
refCol: string,
preferred?: string,
): string {
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
const nameLike = [
"name",
"title",
"label",
"username",
"email",
"full_name",
"display_name",
"first_name",
"last_name",
"description",
];
for (const n of nameLike) {
if (columns.some((c) => c.name === n)) return n;
}
const textish = columns.find(
(c) =>
c.name !== refCol &&
/text|char|name|uuid/i.test(c.data_type),
);
return textish ? textish.name : refCol;
}
export interface DbViewerScreenProps {
connectionId: string;
@@ -48,6 +167,7 @@ export function DbViewerScreen({
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const connections = useConnectionStore((s) => s.connections);
@@ -84,6 +204,19 @@ export function DbViewerScreen({
const filterRules = activeTab?.filterRules ?? [];
const sortRules = activeTab?.sortRules ?? [];
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const isMatview =
activeTab && activeTab.tabType === "table"
? tables.some(
(t) =>
t.schema === activeTab.schema &&
t.name === activeTab.table &&
t.table_type === "MATERIALIZED VIEW",
)
: false;
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
@@ -96,6 +229,18 @@ export function DbViewerScreen({
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set());
// CellEditor options for the active table tab: PG enum labels (cached per
// connection+schema) + FK reference rows (page 1, 50 per FK column).
const [editorOptions, setEditorOptions] = useState<{
enums: Record<string, string[]>;
fks: Record<string, FkOption[]>;
fkPlaceholders: Record<string, string>;
} | null>(null);
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
// Key identifying the (connection, tab, schema) the options were fetched for;
// guards against refetching on every render while data updates in place.
const editorOptionsKeyRef = useRef<string>("");
const fetchData = useCallback(
async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return;
@@ -139,6 +284,31 @@ export function DbViewerScreen({
}
}
const handleStageEdit = useCallback(
(payload: {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}) => {
if (!activeTab) return;
const { type: _, ...rest } = payload;
stageCellEdit({ tabId: activeTab.id, ...rest });
},
[activeTab, stageCellEdit],
);
const handleOpenRowDetail = useCallback((rowIndex: number) => {
setRowDetailIdx(rowIndex);
}, []);
const handleCommitted = useCallback(() => {
if (!activeTab) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
// Read the active tab from the store directly so the Monaco keybinding action
// (which keeps the first onRun closure) always sees the latest query text.
const handleRunQuery = useCallback(() => {
@@ -243,6 +413,108 @@ export function DbViewerScreen({
fetchData(activeTab);
}, [activeTab, fetchData]);
// Feed the grid's CellEditor with enum labels + FK reference rows for the
// active table tab. Fetched once per tab/schema (enums additionally cached
// per connection+schema across tabs); a failed fetch for one FK column is
// skipped without breaking the tab. Never refetches on in-place data updates.
useEffect(() => {
const key =
activeTab && activeTab.tabType === "table" && activeTab.data
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
: "";
if (key === editorOptionsKeyRef.current) return;
editorOptionsKeyRef.current = key;
if (!key || !activeTab || !activeTab.data) {
setEditorOptions(null);
return;
}
const cols = activeTab.data.columns;
const tab = activeTab;
void (async () => {
const enums: Record<string, string[]> = {};
const fks: Record<string, FkOption[]> = {};
const fkPlaceholders: Record<string, string> = {};
// PG enums: fetched once per connection+schema, reused across tabs.
const cacheKey = `${connectionId}:${tab.schema}`;
let enumList = enumCacheRef.current.get(cacheKey);
if (!enumList) {
try {
enumList = await cmd.getEnums(connectionId, tab.schema);
enumCacheRef.current.set(cacheKey, enumList);
} catch {
enumList = [];
}
}
for (const col of cols) {
const match = enumList.find((e) => e.name === col.data_type);
if (match) enums[col.name] = match.labels;
}
// FK options: referenced rows (page 1, 50) per FK column.
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
await Promise.all(
fkCols.map(async (col) => {
const [refTable, refCol] = col.fk_ref!;
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
refTable,
1,
50,
);
const refIdx = result.columns.findIndex(
(c) => c.name === refCol,
);
if (refIdx >= 0) {
const displayCol = pickDisplayColumn(
result.columns,
refCol,
);
const displayIdx =
displayCol === refCol
? refIdx
: result.columns.findIndex(
(c) => c.name === displayCol,
);
fks[col.name] = result.rows.map((row) => {
const refValue = String(row[refIdx]);
const dispValue =
displayIdx >= 0 && displayIdx !== refIdx
? String(row[displayIdx])
: "";
return {
value: refValue,
label:
dispValue && dispValue !== refValue
? `${refValue}${dispValue}`
: refValue,
// Referenced-row cells for the FK-reference-style
// one-row dropdown (first 5 columns).
cells: result.columns
.slice(0, 5)
.map((c, i) => ({
name: c.name,
value: String(row[i] ?? ""),
})),
};
});
fkPlaceholders[col.name] = `Search ${refTable}`;
}
} catch {
// Skip this FK column; the cell keeps the plain editor.
}
}),
);
// Apply only if no newer fetch superseded this one (tab/schema switched).
if (editorOptionsKeyRef.current === key) {
setEditorOptions({ enums, fks, fkPlaceholders });
}
})();
}, [activeTab, connectionId]);
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
const activeTable = activeTab?.table ?? "";
function renderQueryWorkspace() {
const getLocator = (row: unknown[]) => {
const pkCol = columns.find((c) => c.is_pk);
if (pkCol) {
const pkIndex = columns.findIndex(
(c) => c.name === pkCol.name,
);
return { [pkCol.name]: row[pkIndex] };
}
const dbType = currentConnection?.db_type ?? "postgresql";
const locatorIndex = columns.length;
if (dbType === "sqlite") {
return { rowid: row[locatorIndex] };
}
return { ctid: row[locatorIndex] };
};
// Staged cell values derived from the changes queue (single source of
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
// committed updates survive until refetch; Clear All empties the queue
// so the optimistic display and pending dots vanish immediately.
const stagedValues = activeTab?.data
? deriveStagedValues(
changesQueue,
activeTab.schema,
activeTab.table,
activeTab.data.rows,
getLocator,
)
: {};
const pendingKeys = activeTab?.data
? derivePendingCellKeys(
changesQueue,
activeTab.schema,
activeTab.table,
activeTab.data.rows,
getLocator,
)
: {};
return (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
<TabBar onCommitted={handleCommitted} />
{!activeTab ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? (
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
)
}
variant="query"
isMatview={isMatview}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
onClearSelection={() =>
setSelectedRows(new Set())
}
isMatview={isMatview}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
</div>
</>
)}
{rowDetailIdx !== null && activeTab?.data && (
<RowDetailDrawer
columns={activeTab.data.columns}
row={activeTab.data.rows[rowDetailIdx]}
onClose={() => setRowDetailIdx(null)}
onCopy={(value) =>
navigator.clipboard.writeText(value).catch(() => {})
}
/>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
describe("ObjectExplorerPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
});
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
});
it("renders functions by default", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() =>
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
);
});
it("functions type filters to kind === 'f'", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
});
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
const user = userEvent.setup();
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
{
name: "idx_users_email",
schema: "public",
table: "users",
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
is_unique: true,
method: "btree",
columns: ["email"],
size_bytes: 8192,
tablespace: null,
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
);
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("idx_users_email"));
await waitFor(() => {
expect(screen.getByText("Index")).toBeInTheDocument();
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("8192")).toBeInTheDocument();
});
});
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
const user = userEvent.setup();
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
{
name: "chk_users_positive",
schema: "public",
table: "users",
contype: "CHECK",
definition: "CHECK (age > 0)",
deferrable: false,
validated: true,
columns: ["age"],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Constraints"));
await waitFor(() =>
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
);
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("chk_users_positive"));
await waitFor(() => {
expect(screen.getByText("Constraint")).toBeInTheDocument();
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Deferrable")).toBeInTheDocument();
});
});
it("shows 'No indexes found' when getIndexes returns []", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
);
});
it("shows an error message when getIndexes rejects", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("boom")).toBeInTheDocument(),
);
});
it("procedures type filters getFunctions to kind === 'p'", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Procedures"));
await waitFor(() =>
expect(screen.getByText("do_thing")).toBeInTheDocument(),
);
expect(screen.queryByText("calc")).not.toBeInTheDocument();
});
});
+275 -51
View File
@@ -1,9 +1,12 @@
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
import {
BookMarked,
ChevronRight,
FunctionSquare,
GitBranch,
ListChecks,
ListOrdered,
SquareFunction,
Tag,
Puzzle,
Search,
@@ -19,6 +22,8 @@ import type {
SequenceInfo,
EnumInfo,
ExtensionInfo,
IndexInfo,
ConstraintInfo,
} from "../../lib/types";
export type ObjectType =
@@ -26,7 +31,10 @@ export type ObjectType =
| "triggers"
| "sequences"
| "enums"
| "extensions";
| "extensions"
| "indexes"
| "constraints"
| "procedures";
interface ObjectExplorerPageProps {
connectionId: string;
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
sequences: "Sequences",
enums: "Enums",
extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
};
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
procedures: "procedure",
};
/** 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<string, string> = {
index: "indexes",
constraint: "constraints",
};
return irregulars[singular] ?? `${singular}s`;
}
const ICONS: Record<ObjectType, React.ReactNode> = {
functions: (
<FunctionSquare size={14} className="text-text-muted shrink-0" />
@@ -60,6 +84,9 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
};
type AnyObject =
@@ -67,7 +94,9 @@ type AnyObject =
| TriggerInfo
| SequenceInfo
| EnumInfo
| ExtensionInfo;
| ExtensionInfo
| IndexInfo
| ConstraintInfo;
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
function itemKey(item: AnyObject): string {
@@ -487,105 +516,287 @@ function SyntaxCode({
);
}
function renderFunctionDetail(f: FunctionInfo) {
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Signature
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Returns
</span>
<span className="text-sm text-accent font-mono">
{f.return_type || "void"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Language
</span>
<span className="text-sm text-text">
{f.language}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Kind
</span>
<span className="text-sm text-text">
{f.kind === "f" ? "Function" : "Procedure"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{f.schema}
</span>
</div>
</div>
{f.argument_names.length > 0 && (
<>
<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">
Arguments
</span>
<span className="text-[10px] text-text-subtle">
{f.argument_names.length} total
</span>
</div>
{f.argument_names.map((name, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<div className="w-24 shrink-0">
<span className="text-xs text-text-muted">
{f.argument_modes?.[i] &&
f.argument_modes[i] !==
"IN" && (
<span className="text-amber-400 font-medium mr-1">
{f.argument_modes[i]}
</span>
)}
#{i + 1}
</span>
</div>
<span className="text-sm text-accent font-mono">
{name}
</span>
<span className="mx-2 text-border">:</span>
<span className="text-sm text-text-muted font-mono">
{f.argument_types?.[i] || "unknown"}
</span>
</div>
))}
</>
)}
{f.source && (
<>
<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">
Source
</span>
<span className="text-[10px] text-text-subtle">
{f.language}
</span>
</div>
<SyntaxCode
source={f.source}
language={f.language}
/>
</>
)}
</div>
);
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions": {
const f = item as FunctionInfo;
case "functions":
return renderFunctionDetail(item as FunctionInfo);
case "procedures":
return renderFunctionDetail(item as FunctionInfo);
case "indexes": {
const idx = item as IndexInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Signature
Index
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Returns
Table
</span>
<span className="text-sm text-accent font-mono">
{f.return_type || "void"}
<span className="text-sm text-text font-mono">
{idx.table}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Language
Method
</span>
<span className="text-sm text-text">
{f.language}
<span className="text-sm text-accent font-mono">
{idx.method}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Kind
Unique
</span>
<span className="text-sm text-text">
{f.kind === "f" ? "Function" : "Procedure"}
<span
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
>
{idx.is_unique ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
Size
</span>
<span className="text-sm text-text font-mono">
{f.schema}
{idx.size_bytes ?? "-"}
</span>
</div>
</div>
{f.argument_names.length > 0 && (
{idx.columns.length > 0 && (
<>
<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">
Arguments
Columns
</span>
<span className="text-[10px] text-text-subtle">
{f.argument_names.length} total
{idx.columns.length}
</span>
</div>
{f.argument_names.map((name, i) => (
{idx.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<div className="w-24 shrink-0">
<span className="text-xs text-text-muted">
{f.argument_modes?.[i] &&
f.argument_modes[i] !==
"IN" && (
<span className="text-amber-400 font-medium mr-1">
{f.argument_modes[i]}
</span>
)}
#{i + 1}
</span>
</div>
<span className="text-sm text-accent font-mono">
{name}
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="mx-2 text-border">:</span>
<span className="text-sm text-text-muted font-mono">
{f.argument_types?.[i] || "unknown"}
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{f.source && (
{idx.definition && (
<>
<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">
Source
Definition
</span>
<span className="text-[10px] text-text-subtle">
{f.language}
SQL
</span>
</div>
<SyntaxCode
source={f.source}
language={f.language}
/>
<SyntaxCode source={idx.definition} />
</>
)}
</div>
);
}
case "constraints": {
const c = item as ConstraintInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Constraint
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Type
</span>
<span className="text-sm text-accent font-mono">
{c.contype}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{c.table}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Deferrable
</span>
<span
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
>
{c.deferrable ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Validated
</span>
<span
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
>
{c.validated ? "Yes" : "No"}
</span>
</div>
</div>
{c.columns.length > 0 && (
<>
<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">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{c.columns.length}
</span>
</div>
{c.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{c.definition && (
<>
<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">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={c.definition} />
</>
)}
</div>
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
if (type === "extensions") {
result = await cmd.getExtensions(connectionId);
} else if (type === "functions") {
result = await cmd.getFunctions(
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,
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
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 = [];
}
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
{!loading && !error && filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{items === null
? `No ${label.toLowerCase()} found`
: searchQuery
? `No ${label.toLowerCase()} matching "${searchQuery}"`
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
{searchQuery
? `No ${emptyPlural(type)} matching "${searchQuery}"`
: `No ${emptyPlural(type)} found`}
</div>
)}
+2 -2
View File
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
export function TabBar() {
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
@@ -129,7 +129,7 @@ export function TabBar() {
</button>
{changesPanelExpanded && (
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
<ChangesQueuePanel />
<ChangesQueuePanel onCommitted={onCommitted} />
</div>
)}
</div>
@@ -15,6 +15,8 @@ const columns = [
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
];
@@ -314,4 +316,19 @@ describe("TableControls", () => {
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("hides the Insert Row button for a materialized view", () => {
seed([makeTab()], "tab-1");
renderControls({ isMatview: true });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
});
it("renders the FilterBuilder inside the filter popover", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(
screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument();
});
});
+21 -25
View File
@@ -4,7 +4,8 @@ import {
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types";
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
{ label: "Markdown", ext: "md" },
] as const;
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = {
id: string;
column: string;
order: "asc" | "desc";
};
// ─── helpers ────────────────────────────────────────────
/**
@@ -136,6 +124,7 @@ function FilterModal({
<X size={14} />
</button>
</div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
@@ -414,6 +403,8 @@ interface TableControlsProps {
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
/** Hide data-modifying affordances (e.g. for materialized views). */
isMatview?: boolean;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
}
@@ -435,6 +426,7 @@ export function TableControls({
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
isMatview = false,
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
@@ -631,17 +623,21 @@ export function TableControls({
</>
) : (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
type="button"
onClick={handleInsertRow}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Insert row"
>
<Plus size={14} />
</button>
</Tooltip>
{!isMatview && (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
type="button"
onClick={handleInsertRow}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Insert row"
>
<Plus size={14} />
</button>
</Tooltip>
</>
)}
{refreshControl}
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
it("Export data calls exportData when rows and columns are provided", async () => {
const spy = vi.spyOn(exportData, "exportData");
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }];
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(csv\)/i));
@@ -23,6 +23,23 @@ describe("TableTree", () => {
expect(screen.getByText("orders")).toBeInTheDocument();
});
it("shows a distinct icon and label for materialized views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TableTree />);
expect(screen.getByText("mv_products")).toBeInTheDocument();
expect(screen.getByText("Materialized View")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
+10 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
const isMatView = table.table_type === "MATERIALIZED VIEW";
const TypeIcon = isMatView ? Layers : Table2;
const typeLabel = isMatView ? "Materialized View" : null;
return (
<div key={key}>
<div
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
<ChevronRight size={14} />
)}
</button>
<Table2 size={14} className="text-text-muted" />
<TypeIcon size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
{typeLabel && (
<span className="text-[10px] text-text-subtle shrink-0">
{typeLabel}
</span>
)}
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}