Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)

* feat: editor settings model + typed clamped defaults (Task 1)

* feat: carry SSH config + ssh_password to backend DbConfig (Task 2)

* feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3)

* feat: csv parser + shared export util (Task 4)

* feat: rustls TLS connector factory with modes + client auth (Task 5)

* feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6)

* feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7)

* feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8)

* feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9)

* feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10)

* feat: fetch SSH secrets into dbConnect + save on connection form (Task 11)

* feat: Editor settings tab UI (Task 12)

* feat: QueryEditor applies editor settings live (Task 13)

* feat: ImportDialog with CSV/JSON preview + column mapping (Task 14)

* feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15)

* fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16)

* docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17)

* feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop)

* fix: use theme-consistent red classes for danger menu items (text-error was undefined)

* feat: changes queue as tab-bar popover + amber pending border

* feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S)

* refactor: drop per-card status label from changes popover cards

* feat: green completion indicator on committed cards + auto-close tabs of dropped tables

* docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
2026-08-02 20:38:23 +08:00
committed by GitHub
parent e32fe7967c
commit e0c0db8352
68 changed files with 3885 additions and 553 deletions
@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
describe("ChangesQueuePanel", () => {
beforeEach(() => {
useDbViewerStore.setState({ changesQueue: [] });
useDbViewerStore.getState().reset();
useUiStore.setState({ activeConnectionId: "c1" });
vi.resetAllMocks();
});
it("shows nothing when queue is empty", () => {
@@ -14,7 +18,7 @@ describe("ChangesQueuePanel", () => {
expect(container.textContent).toBe("");
});
it("shows pending changes", () => {
it("shows the pending-changes header and a change card", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
@@ -22,13 +26,15 @@ describe("ChangesQueuePanel", () => {
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
expect(screen.getByText(/users/i)).toBeInTheDocument();
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
expect(screen.getByText(/update/i)).toBeInTheDocument();
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
});
it("toggle button flips the store expanded state", async () => {
it("revert removes the change from the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
@@ -37,27 +43,138 @@ describe("ChangesQueuePanel", () => {
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
await user.click(screen.getByRole("button", { name: /revert/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
it("labels bulk_insert / empty_table / drop_table cards", () => {
useDbViewerStore.getState().addChange({
type: "update",
type: "bulk_insert",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
table: "t",
columns: ["a"],
rows: [[1]],
description: "Import 2 rows into public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "empty_table",
schema: "public",
table: "t",
description: "Empty Table: public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument();
expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument();
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
});
it("commit calls executeChange with buildChangePayload output for insert", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { id: 1, name: "Alice" },
description: "Insert row into t",
});
render(<ChangesQueuePanel />);
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelBtn);
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(exec).toHaveBeenCalled());
expect(exec.mock.calls[0][0]).toBe("c1");
expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({
type: "insert",
schema: "public",
table: "t",
data: expect.any(String),
}));
});
it("refreshes the schema tree after committing a drop_table change", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("SQL toggle shows the generated SQL", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { name: "Alice" },
description: "Insert row into users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument();
});
it("Cmd+S commits all pending changes", async () => {
const exec = 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 />);
fireEvent.keyDown(document, { key: "s", metaKey: true });
await waitFor(() => expect(exec).toHaveBeenCalled());
});
it("Clear All empties the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /clear all/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
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);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument());
});
it("auto-closes tabs for a table dropped via Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => {
const tabs = useDbViewerStore.getState().tabs;
expect(tabs.some((t) => t.table === "users")).toBe(false);
expect(tabs.some((t) => t.table === "posts")).toBe(true);
});
});
});
+146 -128
View File
@@ -1,11 +1,11 @@
import { useCallback } from "react";
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Check, X, RotateCcw } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as cmd from "../../lib/commands";
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
import type { ChangeItem } from "../../lib/types";
const statusBg: Record<QueueStatus, string> = {
pending: "bg-accent/5",
@@ -14,54 +14,40 @@ const statusBg: Record<QueueStatus, string> = {
cancelled: "bg-surface-raised/50",
};
function formatChangeLabel(change: QueueItem): string {
const schema = change.schema ?? "";
const table = change.table ?? "";
const fullName = schema ? `${schema}.${table}` : table;
switch (change.type) {
case "bulk_insert":
return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`;
case "empty_table":
return `Empty Table: ${fullName}`;
case "drop_table":
return `Drop Table: ${fullName}`;
default:
return change.table ?? "-";
}
}
function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
function StatusIndicator({ status }: { status: QueueStatus }) {
switch (status) {
case "pending":
return (
<div className="flex items-center gap-1.5 text-amber-400">
<span className="h-2 w-2 rounded-full bg-amber-400" />
<span>Pending</span>
</div>
);
case "committed":
return (
<div className="flex items-center gap-1.5 text-green-500">
<Check className="h-4 w-4" />
<span>Committed</span>
</div>
);
case "failed":
return (
<div className="flex items-center gap-1.5 text-red-500">
<X className="h-4 w-4" />
<span>Failed</span>
</div>
);
case "cancelled":
return (
<div className="flex items-center gap-1.5 text-text-muted">
<span>Cancelled</span>
</div>
);
default:
return null;
}
function tableRef(change: QueueItem): string {
if (change.schema && change.table) return `${change.schema}.${change.table}`;
return change.table ?? "-";
}
export function ChangesQueuePanel() {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const cancelChange = useDbViewerStore((state) => state.cancelChange);
const removeChange = useDbViewerStore((state) => state.removeChange);
const clearChanges = useDbViewerStore((state) => state.clearChanges);
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const expanded = useDbViewerStore((state) => state.changesPanelExpanded);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
const [view, setView] = useState<"visual" | "sql">("visual");
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
@@ -76,19 +62,19 @@ export function ChangesQueuePanel() {
if (pending.length === 0) return;
let committedCount = 0;
let treeDirty = false;
for (const change of pending) {
try {
const payload = {
id: change.id,
type: change.type,
sql: change.sql,
status: "pending" as const,
description: change.description ?? null,
} satisfies ChangeItem;
const payload = buildChangePayload(change);
await cmd.executeChange(connectionId, payload);
markChangeCommitted(change.id);
committedCount++;
if (change.type === "drop_table") {
treeDirty = true;
const st = useDbViewerStore.getState();
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
markChangeFailed(change.id, msg);
@@ -100,102 +86,134 @@ export function ChangesQueuePanel() {
if (committedCount > 0) {
notify(`${committedCount} change(s) committed`, "success");
}
if (treeDirty) {
const st = useDbViewerStore.getState();
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
}
}, [markChangeCommitted, markChangeFailed, notify]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
void handleCommitAll();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [handleCommitAll]);
if (changesQueue.length === 0) {
return null;
}
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
const processedCount = changesQueue.filter(
(c) => c.status === "committed" || c.status === "failed",
).length;
const changeWord = pendingCount === 1 ? "change" : "changes";
return (
<div className="border-t border-border bg-surface">
<button
type="button"
onClick={() => toggleChangesPanel()}
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronUp className="h-4 w-4 text-text-muted" />
)}
<span className="font-medium">
Changes Queue ({pendingCount} pending {changeWord}, {processedCount}{" "}
processed)
</span>
{pendingCount > 0 && (
<span className="rounded-full bg-accent/20 px-2 py-0.5 text-xs text-accent-muted">
{pendingCount}
</span>
)}
<div className="flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="font-medium text-sm text-text">Pending Changes</span>
<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",
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",
view === "sql"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto px-2 py-2 space-y-2">
{view === "visual" ? (
changesQueue.map((change) => (
<div
key={change.id}
className={`rounded-lg border border-border bg-surface-raised/40 px-3 py-2 ${statusBg[change.status]}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
</span>
<span className="text-sm text-text truncate">
{tableRef(change)}
</span>
</div>
{change.status === "pending" ? (
<button
type="button"
aria-label="Revert change"
title="Revert change"
onClick={() => removeChange(change.id)}
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer shrink-0"
>
<RotateCcw className="h-4 w-4" />
</button>
) : change.status === "committed" ? (
<span title="Committed" className="shrink-0 text-green-500">
<Check className="h-4 w-4" />
</span>
) : change.status === "failed" ? (
<span title="Failed" className="shrink-0 text-red-500">
<X className="h-4 w-4" />
</span>
) : null}
</div>
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</div>
</div>
))
) : (
changesQueue.map((change) => (
<pre
key={change.id}
className="text-xs text-text-muted whitespace-pre-wrap rounded-md bg-canvas px-3 py-2 font-mono border border-border"
>
{buildChangeSql(change)}
</pre>
))
)}
</div>
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<button
type="button"
onClick={clearChanges}
className="text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-md px-2 py-1 transition-colors cursor-pointer"
>
Clear All
</button>
<button
type="button"
disabled={pendingCount === 0}
onClick={(e) => {
e.stopPropagation();
handleCommitAll();
}}
className="rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
onClick={handleCommitAll}
className="inline-flex items-center rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Commit All
Commit All ({pendingCount})
<kbd className="ml-1.5 rounded bg-surface-raised px-1 text-[10px]">S</kbd>
</button>
</button>
{expanded && (
<div className="max-h-48 overflow-y-auto">
{changesQueue.map((change) => (
<ChangeRow
key={change.id}
change={change}
onCancel={() => cancelChange(change.id)}
/>
))}
</div>
)}
</div>
);
}
function ChangeRow({
change,
onCancel,
}: {
change: QueueItem;
onCancel: () => void;
}) {
return (
<div
className={`flex items-center justify-between px-4 py-2 text-sm ${statusBg[change.status]}`}
>
<div className="flex items-center gap-3">
<span className="rounded-md bg-surface-raised px-2 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
</span>
<span className="text-text">
{change.table ? change.table : "-"}
</span>
</div>
<div className="flex items-center gap-3">
<StatusIndicator status={change.status} />
{change.status === "pending" && (
<button
type="button"
aria-label="Cancel"
onClick={onCancel}
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
@@ -202,6 +202,62 @@ describe("DbViewerScreen", () => {
);
});
it("refreshes the schema tree after a DDL query runs", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
);
const getSchemas = vi
.spyOn(commands, "getSchemas")
.mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "CREATE TABLE users_new (id INTEGER)" },
});
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
// CREATE is destructive -> confirm dialog appears -> click Execute
await waitFor(() =>
screen.getByRole("button", { name: /execute/i }),
);
fireEvent.click(screen.getByRole("button", { name: /execute/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("does not refresh the schema tree after a SELECT", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
);
const getSchemas = vi
.spyOn(commands, "getSchemas")
.mockResolvedValue(["public"]);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
expect(getSchemas).not.toHaveBeenCalled();
});
it("formats the query SQL when Auto format is clicked", async () => {
render(
<DbViewerScreen
+5 -3
View File
@@ -4,7 +4,7 @@ import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery } from "../../lib/utils";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
@@ -16,7 +16,6 @@ import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection";
@@ -130,6 +129,10 @@ export function DbViewerScreen({
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result);
useQueryStore.getState().invalidateHistory(connectionId);
if (isSchemaModifyingQuery(sql)) {
const st = useDbViewerStore.getState();
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
}
} catch (e) {
setTabError(tabId, e instanceof Error ? e.message : String(e));
useQueryStore.getState().invalidateHistory(connectionId);
@@ -1056,7 +1059,6 @@ const onQueriesPanelResizeStart = useCallback(
}}
/>
) : null}
{(currentView === "db-viewer" || currentView === "queries") && <ChangesQueuePanel />}
</div>
{currentConnection && (
<EditConnectionModal
@@ -4,7 +4,7 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -34,6 +34,15 @@ export function EditConnectionModal({
password: null,
database: connection.database ?? null,
use_keychain: false,
ssh_host: connection.ssh_host ?? null,
ssh_port: connection.ssh_port ?? null,
ssh_user: connection.ssh_user ?? null,
ssh_auth_method:
(connection.ssh_auth_method as "password" | "key" | null | undefined) ??
null,
ssh_private_key: connection.ssh_private_key_path ?? null,
ssh_password: null,
ssh_passphrase: null,
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -55,11 +64,25 @@ export function EditConnectionModal({
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null,
};
const updated = await updateConnection(connection.id, input);
if (form.password) {
await saveConnectionPassword(connection.id, form.password).catch(() => {});
}
// Persist SSH secrets to the OS keychain (not SQLite)
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
}
if (form.ssh_host && form.ssh_passphrase) {
await saveConnectionSshPassphrase(connection.id, form.ssh_passphrase).catch(() => {});
}
notify("Connection updated", "success");
onSaved(updated);
onClose();
@@ -91,6 +114,7 @@ export function EditConnectionModal({
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
ssh_password: form.ssh_password ?? null,
});
if (result.ok) {
notify("Connection successful", "success");
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { ImportDialog } from "./ImportDialog";
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/f.csv") }));
vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn().mockResolvedValue("a,b\n1,2\n3,4") }));
describe("ImportDialog", () => {
it("parses CSV and stages a bulk_insert change", async () => {
const addChange = vi.fn();
render(<ImportDialog open schema="public" table="t" columns={["a", "b"]} onStage={addChange} onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(screen.getByText(/preview/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /stage import/i }));
await waitFor(() => {
expect(addChange).toHaveBeenCalledWith(expect.objectContaining({
type: "bulk_insert", schema: "public", table: "t",
columns: ["a", "b"],
}));
expect(addChange.mock.calls[0][0].rows.length).toBe(2);
});
});
it("rejects files over the row cap", async () => {
vi.mocked(readTextFile).mockResolvedValue("a\n" + "1\n".repeat(100_001));
const onStage = vi.fn();
render(<ImportDialog open schema="public" table="t" columns={["a"]} onStage={onStage} onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(screen.getByText(/limit/i)).toBeInTheDocument());
expect(onStage).not.toHaveBeenCalled();
});
});
+187
View File
@@ -0,0 +1,187 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { normalizeImport, coerceRow } from "../../lib/importNormalize";
const MAX_ROWS = 100_000;
const MAX_BYTES = 100 * 1024 * 1024;
const SKIP = "<skip>";
export interface ImportDialogProps {
open: boolean;
schema: string;
table: string;
columns: string[];
onStage: (change: {
type: "bulk_insert";
schema: string;
table: string;
columns: string[];
rows: unknown[][];
description: string;
}) => void;
onClose: () => void;
}
export function ImportDialog({
open: isOpen,
schema,
table,
columns,
onStage,
onClose,
}: ImportDialogProps) {
const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const chooseFile = async () => {
try {
const path = await open({
filters: [{ name: "Data", extensions: ["csv", "json"] }],
});
if (!path || Array.isArray(path)) return;
const text = await readTextFile(path as string);
if (text.length > MAX_BYTES) {
setError("File exceeds 100 MB limit");
return;
}
const { headers, rows } = normalizeImport(text);
if (rows.length > MAX_ROWS) {
setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`);
return;
}
setParsed({ headers, rows });
setMapping(
Object.fromEntries(
headers.map((h, i) => [h, columns[i] ?? SKIP]),
),
);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const stage = () => {
if (!parsed) return;
const selected = parsed.headers
.map((header) => ({ header, col: mapping[header] }))
.filter(({ col }) => col && col !== SKIP);
const targetColumns = selected.map(({ col }) => col);
const dataRows = parsed.rows.map((row) =>
selected.map(({ header }) => {
const idx = parsed.headers.indexOf(header);
return coerceRow(row[idx]);
}),
);
onStage({
type: "bulk_insert",
schema,
table,
columns: targetColumns,
rows: dataRows,
description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`,
});
onClose();
};
const mappingOptions = [
{ value: SKIP, label: "<skip>" },
...columns.map((c) => ({ value: c, label: c })),
];
const previewRows = parsed ? parsed.rows.slice(0, 100) : [];
return (
<AnimatedModal open={isOpen} onClose={onClose}>
<div className="w-full min-w-md max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">
Import into {schema}.{table}
</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button onClick={chooseFile}>Choose file</Button>
<span className="text-xs text-text-muted">CSV or JSON, up to 100 MB / 100,000 rows</span>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-md px-4 py-3">
<span className="text-red-300 text-sm">{error}</span>
</div>
)}
{parsed && (
<div className="space-y-3">
<p className="text-sm text-text-muted">
Preview ({parsed.rows.length.toLocaleString()} rows × {parsed.headers.length} columns)
</p>
<div className="space-y-2">
{parsed.headers.map((header) => (
<div key={header} className="flex items-center gap-3">
<span className="text-sm text-text w-24 truncate" title={header}>{header}</span>
<span className="text-xs text-text-muted"></span>
<Select
value={mapping[header] ?? SKIP}
onChange={(v) =>
setMapping((prev) => ({ ...prev, [header]: v }))
}
options={mappingOptions}
label={`Map ${header}`}
/>
</div>
))}
</div>
<div className="overflow-auto max-h-64 rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-surface sticky top-0">
<tr className="border-b border-border">
{parsed.headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-text-muted font-heading whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri} className="border-b border-border last:border-0 hover:bg-surface/30">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 text-text whitespace-nowrap">
{cell === "" ? (
<span className="italic text-text-muted/50">null</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={stage} disabled={!parsed}>Stage import</Button>
</div>
</div>
</div>
</AnimatedModal>
);
}
+51 -6
View File
@@ -14,8 +14,7 @@ describe("TabBar", () => {
it("renders the fixed Query and Changes actions when no tabs are open", () => {
render(<TabBar />);
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /changes queue/i })).toBeInTheDocument();
expect(screen.queryAllByRole("tab")).toHaveLength(0);
expect(screen.getByRole("button", { name: "Changes queue" })).toBeInTheDocument();
});
it("renders open tab names", () => {
@@ -61,8 +60,7 @@ describe("TabBar", () => {
useDbViewerStore.setState({ changesPanelExpanded: false });
render(<TabBar />);
const changesButton = screen.getByRole("button", { name: /changes queue/i });
expect(within(changesButton).getByText("1")).toBeInTheDocument();
const changesButton = screen.getByRole("button", { name: "Changes queue" });
await user.click(changesButton);
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
@@ -102,7 +100,7 @@ describe("TabBar", () => {
});
render(<TabBar />);
const button = screen.getByRole("button", { name: /changes queue/i });
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.querySelector("svg")).not.toBeNull();
expect(within(button).getByText("2")).toBeInTheDocument();
expect(screen.queryByText("Changes")).toBeNull();
@@ -110,7 +108,7 @@ describe("TabBar", () => {
it("hides the count badge when there are no pending changes", () => {
render(<TabBar />);
const button = screen.getByRole("button", { name: /changes queue/i });
const button = screen.getByRole("button", { name: "Changes queue" });
expect(within(button).queryByText(/\d/)).toBeNull();
});
@@ -130,4 +128,51 @@ describe("TabBar", () => {
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
).toBeUndefined();
});
it("opens the changes popover when the button is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.setState({ changesPanelExpanded: false });
render(<TabBar />);
expect(screen.queryByText(/pending changes/i)).toBeNull();
await user.click(screen.getByRole("button", { name: "Changes queue" }));
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /commit all/i })).toBeInTheDocument();
});
it("closes the changes popover on Escape", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 1 },
description: "Insert row into users",
});
useDbViewerStore.setState({ changesPanelExpanded: true });
render(<TabBar />);
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByText(/pending changes/i)).toBeNull();
});
it("turns the button border amber when there are pending changes", () => {
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 1 },
description: "Insert row into users",
});
render(<TabBar />);
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.className).toContain("border-amber-500");
});
});
+53 -19
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef } from "react";
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
export function TabBar() {
const tabs = useDbViewerStore((state) => state.tabs);
@@ -8,6 +10,9 @@ export function TabBar() {
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const changesPanelExpanded = useDbViewerStore(
(state) => state.changesPanelExpanded,
);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
@@ -16,6 +21,28 @@ export function TabBar() {
(c) => c.status === "pending",
).length;
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!changesPanelExpanded) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
toggleChangesPanel();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
toggleChangesPanel();
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [changesPanelExpanded, toggleChangesPanel]);
return (
<div className="flex h-9 items-stretch border-b border-border">
{/* Left: open tabs (scrollable) */}
@@ -79,26 +106,33 @@ export function TabBar() {
<Play className="h-3 w-3 fill-current" />
Query
</button>
<button
type="button"
onClick={() => {
if (changesQueue.length > 0) toggleChangesPanel();
}}
aria-label="Changes queue"
className={[
"flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
pendingCount > 0
? "text-amber-400 border-amber-500/40 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<ListChecks className="h-3.5 w-3.5" />
{pendingCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
{pendingCount}
</span>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => {
if (changesQueue.length > 0) toggleChangesPanel();
}}
aria-label="Changes queue"
className={[
"flex items-center gap-1.5 rounded-md border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
pendingCount > 0
? "text-amber-400 border-amber-500 bg-amber-500/10 hover:bg-amber-500/20"
: "border-border text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<ListChecks className="h-3.5 w-3.5" />
{pendingCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
{pendingCount}
</span>
)}
</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 />
</div>
)}
</button>
</div>
</div>
</div>
);
+1 -71
View File
@@ -6,6 +6,7 @@ import {
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types";
const AUTO_REFRESH_OPTIONS = [
@@ -41,77 +42,6 @@ type SortRule = {
// ─── helpers ────────────────────────────────────────────
function exportData(
rows: unknown[][],
columns: ColumnInfo[],
format: string,
tableName: string,
) {
const headers = columns.map((c) => c.name);
let content: string;
let mime: string;
switch (format) {
case "json": {
const jsonRows = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
content = JSON.stringify(jsonRows, null, 2);
mime = "application/json";
break;
}
case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of rows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
content = csvRows.join("\n");
mime = "text/csv";
break;
}
case "sql": {
const lines = [`-- ${tableName}`];
for (const row of rows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
content = lines.join("\n");
mime = "application/sql";
break;
}
case "md": {
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
for (const row of rows) {
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
}
content = mdRows.join("\n");
mime = "text/markdown";
break;
}
default:
return;
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}.${format === "md" ? "md" : format}`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Format an execution duration using the most sensible unit:
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableOverflowMenu } from "./TableOverflowMenu";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
import * as exportData from "../../lib/exportData";
describe("TableOverflowMenu", () => {
beforeEach(() => {
@@ -10,6 +14,9 @@ describe("TableOverflowMenu", () => {
configurable: true,
writable: true,
});
useDbViewerStore.getState().reset();
useUiStore.setState({ activeConnectionId: "c1" });
vi.resetAllMocks();
});
it("renders menu trigger button", () => {
@@ -34,4 +41,44 @@ describe("TableOverflowMenu", () => {
await user.click(screen.getByText("Open in new tab"));
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
});
it("Copy table schema calls getTableDdl and writes clipboard", async () => {
vi.spyOn(commands, "getTableDdl").mockResolvedValue("CREATE TABLE t (id int)");
const writeText = (navigator.clipboard as any).writeText as ReturnType<typeof vi.fn>;
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/copy table schema/i));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("CREATE TABLE t (id int)"));
});
it("Empty Table opens confirm then stages an empty_table change", async () => {
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/empty table/i));
fireEvent.click(screen.getByRole("button", { name: /empty table/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "empty_table", schema: "public", table: "t" }));
});
});
it("Delete Table opens confirm then stages a drop_table change", async () => {
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/delete table/i));
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "drop_table", schema: "public", table: "t" }));
});
});
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 }];
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));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
});
});
+83 -17
View File
@@ -1,23 +1,43 @@
import { useEffect, useRef, useState } from "react";
import { MoreVertical } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { ImportDialog } from "./ImportDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
interface TableOverflowMenuProps {
schema: string;
table: string;
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
connectionId?: string;
columns?: ColumnInfo[];
rows?: unknown[][];
}
interface MenuItem {
id: string;
label: string;
stub?: boolean;
danger?: boolean;
}
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
export function TableOverflowMenu({
schema,
table,
onOpenTab,
connectionId: connectionIdProp,
columns,
rows,
}: TableOverflowMenuProps) {
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
const connectionId = connectionIdProp ?? storeConnectionId;
const addChange = useDbViewerStore((s) => s.addChange);
const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const [importOpen, setImportOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -40,20 +60,40 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
};
}, [open]);
const handleAction = (id: string) => {
const handleAction = async (id: string) => {
switch (id) {
case "open":
onOpenTab(schema, table, true);
setOpen(false);
break;
case "copy-schema": {
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
if (navigator.clipboard) {
void navigator.clipboard.writeText(sql);
if (!connectionId) break;
try {
const ddl = await cmd.getTableDdl(connectionId, schema, table);
if (navigator.clipboard) {
void navigator.clipboard.writeText(ddl);
}
} catch {
/* ignore copy failures */
}
setOpen(false);
break;
}
case "export-csv":
case "export-json":
case "export-sql":
case "export-md": {
const format = id.replace("export-", "");
if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`);
}
setOpen(false);
break;
}
case "import":
setImportOpen(true);
setOpen(false);
break;
case "empty":
setConfirmAction("empty");
setOpen(false);
@@ -70,9 +110,11 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
const items: MenuItem[] = [
{ id: "open", label: "Open in new tab" },
{ id: "copy-schema", label: "Copy table schema" },
{ id: "export-csv", label: "Export data (CSV)", stub: true },
{ id: "export-json", label: "Export data (JSON)", stub: true },
{ id: "export-sql", label: "Export data (SQL)", stub: true },
{ id: "export-csv", label: "Export data (CSV)" },
{ id: "export-json", label: "Export data (JSON)" },
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
@@ -93,19 +135,12 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
key={item.id}
type="button"
onClick={() => handleAction(item.id)}
disabled={item.stub}
className={[
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
item.stub ? "opacity-50 cursor-not-allowed" : "",
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<span>{item.label}</span>
{item.stub && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
Soon
</span>
)}
</button>
))}
</div>
@@ -118,6 +153,12 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
confirmLabel="Empty Table"
onConfirm={() => {
addChange({
type: "empty_table",
schema,
table,
description: `Empty Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
@@ -130,11 +171,36 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
confirmLabel="Delete Table"
onConfirm={() => {
addChange({
type: "drop_table",
schema,
table,
description: `Drop Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
<ImportDialog
open={importOpen}
schema={schema}
table={table}
columns={columns?.map((c) => c.name) ?? []}
onStage={(change) => {
addChange({
type: "bulk_insert",
schema: change.schema,
table: change.table,
columns: change.columns,
rows: change.rows,
description: change.description,
});
setImportOpen(false);
}}
onClose={() => setImportOpen(false)}
/>
</div>
);
}
+2
View File
@@ -98,6 +98,8 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
<TableOverflowMenu
schema={table.schema}
table={table.name}
connectionId={connectionId ?? undefined}
columns={cols}
onOpenTab={handleOpenTab}
/>
</div>