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:
@@ -21,6 +21,11 @@ vi.mock("./lib/commands", () => ({
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
} satisfies Settings),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
@@ -112,6 +117,11 @@ describe("App", () => {
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(useUiStore.getState().activeFolderId).toBe("folder-1");
|
||||
|
||||
@@ -20,6 +20,7 @@ const BASE_FORM: ConnectionFormData = {
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
ssh_password: null,
|
||||
};
|
||||
|
||||
function StatefulForm(
|
||||
|
||||
@@ -16,6 +16,7 @@ const BASE_FORM: ConnectionFormData = {
|
||||
password: "secret",
|
||||
database: "mydb",
|
||||
use_keychain: true,
|
||||
ssh_password: null,
|
||||
};
|
||||
|
||||
describe("GeneralTab", () => {
|
||||
|
||||
@@ -46,6 +46,11 @@ describe("NewConnectionScreen", () => {
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
},
|
||||
});
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||
|
||||
@@ -42,6 +42,7 @@ function createEmptyForm(
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
ssh_password: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +113,13 @@ export function NewConnectionScreen({
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
use_keychain: form.use_keychain,
|
||||
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,
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const BASE_FORM: ConnectionFormData = {
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
ssh_password: null,
|
||||
};
|
||||
|
||||
function StatefulForm(
|
||||
|
||||
@@ -14,4 +14,14 @@ export interface ConnectionFormData {
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
use_keychain: boolean;
|
||||
// SSH tunnel fields (non-secret flat fields are optional; SshFields writes
|
||||
// the key path under ssh_private_key, which submit handlers map to
|
||||
// ssh_private_key_path on ConnectionInput)
|
||||
ssh_host?: string | null;
|
||||
ssh_port?: number | null;
|
||||
ssh_user?: string | null;
|
||||
ssh_auth_method?: "password" | "key" | null;
|
||||
ssh_private_key?: string | null;
|
||||
ssh_password: string | null;
|
||||
ssh_passphrase?: string | null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { editor as monacoEditor } from "monaco-editor";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
|
||||
const { registeredActions } = vi.hoisted(() => ({
|
||||
const { registeredActions, updateOptions } = vi.hoisted(() => ({
|
||||
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
|
||||
updateOptions: vi.fn(),
|
||||
}));
|
||||
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
|
||||
|
||||
@@ -23,6 +25,7 @@ vi.mock("@monaco-editor/react", () => ({
|
||||
getValue: () => value,
|
||||
setValue: (v: string) => onChange?.(v),
|
||||
focus: vi.fn(),
|
||||
updateOptions,
|
||||
});
|
||||
}
|
||||
return (
|
||||
@@ -40,7 +43,10 @@ vi.mock("@monaco-editor/react", () => ({
|
||||
describe("QueryEditor", () => {
|
||||
beforeEach(() => {
|
||||
registeredActions.length = 0;
|
||||
editorOptions.length = 0;
|
||||
updateOptions.mockClear();
|
||||
vi.mocked(monacoEditor.remeasureFonts).mockClear();
|
||||
useSettingsStore.setState({ settings: null });
|
||||
});
|
||||
|
||||
it("renders a textarea editor", () => {
|
||||
@@ -81,6 +87,37 @@ describe("QueryEditor", () => {
|
||||
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls updateOptions with editor settings when settings change", () => {
|
||||
const settings = {
|
||||
confirm_before_delete: true,
|
||||
default_folder_id: null,
|
||||
theme: "dark" as const,
|
||||
font_size: "medium" as const,
|
||||
default_ports: {},
|
||||
tag_order: null,
|
||||
table_refresh_rate: 5,
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "blue",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off" as const,
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
};
|
||||
useSettingsStore.setState({ settings });
|
||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||
updateOptions.mockClear();
|
||||
act(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: { ...settings, editor_font_size: 16, editor_word_wrap: "on" as const },
|
||||
});
|
||||
});
|
||||
expect(updateOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fontSize: 16, wordWrap: "on" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps the editor without padding, border, or rounding", () => {
|
||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||
const wrapper = screen.getByTestId("query-editor");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
interface QueryEditorProps {
|
||||
value: string;
|
||||
@@ -15,8 +16,38 @@ export function QueryEditor({
|
||||
onRun,
|
||||
readOnly = false,
|
||||
}: QueryEditorProps) {
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
const editorFontFamily = useSettingsStore(
|
||||
(s) => s.settings?.editor_font_family ?? "Space Mono",
|
||||
);
|
||||
const editorFontSize = useSettingsStore(
|
||||
(s) => s.settings?.editor_font_size ?? 13,
|
||||
);
|
||||
const editorWordWrap = useSettingsStore(
|
||||
(s) => s.settings?.editor_word_wrap ?? "off",
|
||||
);
|
||||
const editorMinimap = useSettingsStore(
|
||||
(s) => s.settings?.editor_minimap ?? false,
|
||||
);
|
||||
const editorTabSize = useSettingsStore(
|
||||
(s) => s.settings?.editor_tab_size ?? 4,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.updateOptions?.({
|
||||
fontFamily: editorFontFamily,
|
||||
fontSize: editorFontSize,
|
||||
wordWrap: editorWordWrap === "on" ? "on" : "off",
|
||||
minimap: { enabled: editorMinimap },
|
||||
tabSize: editorTabSize,
|
||||
});
|
||||
monaco.editor.remeasureFonts();
|
||||
}, [editorFontFamily, editorFontSize, editorWordWrap, editorMinimap, editorTabSize]);
|
||||
|
||||
const handleMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.addAction({
|
||||
id: "run-query",
|
||||
label: "Run Query",
|
||||
@@ -71,15 +102,16 @@ export function QueryEditor({
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
onMount={handleMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: "'Space Mono', 'Fira Code', monospace",
|
||||
minimap: { enabled: editorMinimap },
|
||||
fontSize: editorFontSize,
|
||||
fontFamily: editorFontFamily,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "off",
|
||||
wordWrap: editorWordWrap === "on" ? "on" : "off",
|
||||
readOnly,
|
||||
placeholder: "Enter your SQL query…",
|
||||
automaticLayout: true,
|
||||
tabSize: editorTabSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -166,5 +166,10 @@ function baseSettings() {
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off" as const,
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { EditorSettingsTab } from "./EditorSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||
default_ports: {}, tag_order: null, table_refresh_rate: 0, table_page_size: 50,
|
||||
shortcuts: {}, accent_color: "#2563EB",
|
||||
editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off",
|
||||
editor_minimap: false, editor_tab_size: 4,
|
||||
} as any, loading: false, error: null,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("EditorSettingsTab", () => {
|
||||
it("renders the five editor option controls", () => {
|
||||
render(<EditorSettingsTab />);
|
||||
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/word wrap/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: /minimap/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tab size/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls updateSetting when word wrap changes", () => {
|
||||
const update = vi.fn();
|
||||
useSettingsStore.setState({ updateSetting: update } as any);
|
||||
render(<EditorSettingsTab />);
|
||||
fireEvent.change(screen.getByLabelText(/word wrap/i), { target: { value: "on" } });
|
||||
expect(update).toHaveBeenCalledWith("editor_word_wrap", "on");
|
||||
});
|
||||
|
||||
it("calls updateSetting when minimap toggled", () => {
|
||||
const update = vi.fn();
|
||||
useSettingsStore.setState({ updateSetting: update } as any);
|
||||
render(<EditorSettingsTab />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: /minimap/i }));
|
||||
expect(update).toHaveBeenCalledWith("editor_minimap", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { Select } from "../ui/Select";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { Toggle } from "../ui/Toggle";
|
||||
|
||||
const FONT_FAMILY_OPTIONS = [
|
||||
{ value: "Space Mono", label: "Space Mono" },
|
||||
{ value: "Fira Code", label: "Fira Code" },
|
||||
{ value: "Menlo", label: "Menlo" },
|
||||
{ value: "Monaco", label: "Monaco" },
|
||||
{ value: "Consolas", label: "Consolas" },
|
||||
{ value: "JetBrains Mono", label: "JetBrains Mono" },
|
||||
{ value: "monospace", label: "monospace" },
|
||||
];
|
||||
const FONT_SIZE_OPTIONS = [8,10,11,12,13,14,16,18,20,24].map((v) => ({ value: String(v), label: String(v) }));
|
||||
const TAB_SIZE_OPTIONS = [2,4,6,8].map((v) => ({ value: String(v), label: String(v) }));
|
||||
const WORD_WRAP_OPTIONS = [{ value: "off", label: "Off" }, { value: "on", label: "On" }];
|
||||
|
||||
export function EditorSettingsTab() {
|
||||
const { settings, updateSetting } = useSettingsStore();
|
||||
if (!settings) return null;
|
||||
|
||||
const set = (key: string) => (value: string) => { void updateSetting(key, value); };
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<SettingsRow title="Font size" description="Editor font size in pixels">
|
||||
<Select label="Font size" value={String(settings.editor_font_size)} onChange={set("editor_font_size")} options={FONT_SIZE_OPTIONS} />
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Font family" description="Monospace font for the SQL editor">
|
||||
<Select label="Font family" value={settings.editor_font_family} onChange={set("editor_font_family")} options={FONT_FAMILY_OPTIONS} />
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Word wrap" description="Wrap long lines in the editor">
|
||||
<Select label="Word wrap" value={settings.editor_word_wrap} onChange={set("editor_word_wrap")} options={WORD_WRAP_OPTIONS} />
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Minimap" description="Show the code minimap">
|
||||
<Toggle label="Minimap" checked={settings.editor_minimap} onChange={(c) => void updateSetting("editor_minimap", c ? "true" : "false")} />
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Tab size" description="Spaces per indentation level">
|
||||
<Select label="Tab size" value={String(settings.editor_tab_size)} onChange={set("editor_tab_size")} options={TAB_SIZE_OPTIONS} />
|
||||
</SettingsRow>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,11 @@ vi.mock("../../lib/commands", () => ({
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
@@ -68,6 +73,11 @@ const baseSettings = {
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off" as const,
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
@@ -118,14 +128,18 @@ describe("SettingsPage", () => {
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
|
||||
});
|
||||
|
||||
it("switches to the Editor tab and shows placeholder", async () => {
|
||||
it("switches to the Editor tab and shows editor settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /editor/i }));
|
||||
expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/word wrap/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: /minimap/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tab size/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import { EditorSettingsTab } from "./EditorSettingsTab";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Cog,
|
||||
@@ -42,14 +43,7 @@ export function SettingsPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const renderEditor = () => (
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Editor</h2>
|
||||
<div className="py-8 text-center text-sm text-text-muted">
|
||||
Editor settings are coming soon.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const renderEditor = () => <EditorSettingsTab />;
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("../lib/commands", () => ({
|
||||
getSchemas: vi.fn().mockResolvedValue([]),
|
||||
getTables: vi.fn().mockResolvedValue([]),
|
||||
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
|
||||
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const mockCommands = vi.mocked(commands);
|
||||
@@ -27,10 +29,10 @@ const mockConnection = {
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
ssh_host: null,
|
||||
ssh_host: null as string | null,
|
||||
ssh_port: null,
|
||||
ssh_user: null,
|
||||
ssh_auth_method: null,
|
||||
ssh_auth_method: null as string | null,
|
||||
ssh_private_key_path: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
@@ -65,6 +67,10 @@ describe("useDbConnection", () => {
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||
mockCommands.getTables.mockResolvedValue([]);
|
||||
mockCommands.getConnectionSshPassword.mockResolvedValue(null);
|
||||
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||
mockConnection.ssh_host = null;
|
||||
mockConnection.ssh_auth_method = null;
|
||||
});
|
||||
|
||||
it("connects and smart-selects the public schema when available", async () => {
|
||||
@@ -150,4 +156,22 @@ describe("useDbConnection", () => {
|
||||
]);
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches ssh secrets from keychain before connecting when ssh_host is set", async () => {
|
||||
mockConnection.ssh_host = "bastion.example.com";
|
||||
mockConnection.ssh_auth_method = "password";
|
||||
mockCommands.getConnectionSshPassword.mockResolvedValue("sshpw");
|
||||
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCommands.dbConnect).toHaveBeenCalled();
|
||||
});
|
||||
const config = mockCommands.dbConnect.mock.calls[0][1];
|
||||
expect(mockCommands.getConnectionSshPassword).toHaveBeenCalledWith("c1");
|
||||
expect(mockCommands.getConnectionSshPassphrase).toHaveBeenCalledWith("c1");
|
||||
expect(config.ssh_password).toBe("sshpw");
|
||||
expect(config.ssh_passphrase).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,12 @@ export function useDbConnection(connectionId: string) {
|
||||
}
|
||||
try {
|
||||
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
||||
const sshPassword = conn.ssh_host
|
||||
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
||||
: null;
|
||||
const sshPassphrase = conn.ssh_host
|
||||
? await cmd.getConnectionSshPassphrase(conn.id).catch(() => null)
|
||||
: null;
|
||||
const input: ConnectionInput = {
|
||||
name: conn.name,
|
||||
db_type: conn.db_type,
|
||||
@@ -45,6 +51,8 @@ export function useDbConnection(connectionId: string) {
|
||||
ssh_auth_method:
|
||||
conn.ssh_auth_method as "password" | "key" | null | undefined,
|
||||
ssh_private_key_path: conn.ssh_private_key_path,
|
||||
ssh_password: sshPassword,
|
||||
ssh_passphrase: sshPassphrase,
|
||||
ssl_mode:
|
||||
conn.ssl_mode as
|
||||
| "disable"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildChangePayload, buildChangeSql } from "./changePayload";
|
||||
import type { QueueItem } from "../stores/dbViewerStore";
|
||||
|
||||
const base = { id: "c1", status: "pending" as const, createdAt: 0 };
|
||||
|
||||
describe("buildChangePayload", () => {
|
||||
it("insert -> {schema, table, data}", () => {
|
||||
const item: QueueItem = { ...base, type: "insert", sql: "", schema: "public", table: "t",
|
||||
newData: { a: 1 } } as unknown as QueueItem;
|
||||
const p = buildChangePayload(item);
|
||||
expect(p).toEqual({ id: "c1", type: "insert", schema: "public", table: "t", data: "{\"a\":1}" });
|
||||
});
|
||||
|
||||
it("delete -> {schema, table, primary_key}", () => {
|
||||
const item = { ...base, type: "delete", sql: "", schema: "public", table: "t",
|
||||
primaryKey: { id: 5 } } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "delete", schema: "public", table: "t", primary_key: "{\"id\":5}" });
|
||||
});
|
||||
|
||||
it("bulk_insert -> {schema, table, columns, rows}", () => {
|
||||
const item = { ...base, type: "bulk_insert", sql: "", schema: "public", table: "t",
|
||||
columns: ["a", "b"], rows: [[1, 2], [3, 4]] } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, 2], [3, 4]] });
|
||||
});
|
||||
|
||||
it("drop_table -> {schema, table}", () => {
|
||||
const item = { ...base, type: "drop_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "drop_table", schema: "public", table: "t" });
|
||||
});
|
||||
|
||||
it("empty_table -> {schema, table}", () => {
|
||||
const item = { ...base, type: "empty_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "empty_table", schema: "public", table: "t" });
|
||||
});
|
||||
|
||||
it("alter_table -> {schema, table, sql, rollback_sql}", () => {
|
||||
const item = { ...base, type: "alter_table", sql: "ALTER TABLE t ADD c int", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "alter_table", schema: "public", table: "t", sql: "ALTER TABLE t ADD c int", rollback_sql: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChangeSql", () => {
|
||||
it("insert", () => {
|
||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||
});
|
||||
it("update", () => {
|
||||
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||
});
|
||||
it("delete", () => {
|
||||
const item = { id: "1", type: "delete", schema: "public", table: "t", primaryKey: { id: 5 } } as any;
|
||||
expect(buildChangeSql(item)).toBe('DELETE FROM "public"."t" WHERE "id" = 5');
|
||||
});
|
||||
it("bulk_insert", () => {
|
||||
const item = { id: "1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, "y"], [2, null]] } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'y\'), (2, NULL)');
|
||||
});
|
||||
it("empty_table / drop_table", () => {
|
||||
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
||||
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { QueueItem } from "../stores/dbViewerStore";
|
||||
|
||||
/**
|
||||
* Payload emitted for a single queued change, keyed to match the Rust
|
||||
* `Change` enum variants (snake_case). Sent to `executeChange`.
|
||||
*/
|
||||
export type ChangePayload = Record<string, unknown>;
|
||||
|
||||
function j(v: unknown): string {
|
||||
return JSON.stringify(v ?? {});
|
||||
}
|
||||
|
||||
function q(s: string): string {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function lit(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
|
||||
return `'${JSON.stringify(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function tableRef(schema: string | undefined, table: string | undefined): string {
|
||||
if (!table) return "-";
|
||||
return schema ? `${q(schema)}.${q(table)}` : q(table);
|
||||
}
|
||||
|
||||
export function buildChangeSql(item: QueueItem): string {
|
||||
const t = tableRef(item.schema, item.table);
|
||||
switch (item.type) {
|
||||
case "insert": {
|
||||
const data = item.newData ?? {};
|
||||
const cols = Object.keys(data);
|
||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||
}
|
||||
case "update": {
|
||||
const data = item.newData ?? {};
|
||||
const pk = item.primaryKey ?? {};
|
||||
const setClause = Object.keys(data).map((c) => `${q(c)} = ${lit(data[c])}`).join(", ");
|
||||
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||
return `UPDATE ${t} SET ${setClause} WHERE ${whereClause}`;
|
||||
}
|
||||
case "delete": {
|
||||
const pk = item.primaryKey ?? {};
|
||||
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||
return `DELETE FROM ${t} WHERE ${whereClause}`;
|
||||
}
|
||||
case "bulk_insert": {
|
||||
const cols = item.columns ?? [];
|
||||
const rows = item.rows ?? [];
|
||||
const valueRows = rows
|
||||
.map((row) => `(${row.map(lit).join(", ")})`)
|
||||
.join(", ");
|
||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES ${valueRows}`;
|
||||
}
|
||||
case "empty_table":
|
||||
return `DELETE FROM ${t}`;
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
return item.sql ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
const schema = item.schema ?? "";
|
||||
const table = item.table ?? "";
|
||||
switch (item.type) {
|
||||
case "insert":
|
||||
return { id: item.id, type: "insert", schema, table, data: j(item.newData) };
|
||||
case "update":
|
||||
return { id: item.id, type: "update", schema, table,
|
||||
primary_key: j(item.primaryKey), old_data: j(item.oldData), new_data: j(item.newData) };
|
||||
case "delete":
|
||||
return { id: item.id, type: "delete", schema, table, primary_key: j(item.primaryKey) };
|
||||
case "alter_table":
|
||||
return { id: item.id, type: "alter_table", schema, table, sql: item.sql, rollback_sql: "" };
|
||||
case "bulk_insert":
|
||||
return { id: item.id, type: "bulk_insert", schema, table,
|
||||
columns: item.columns ?? [], rows: item.rows ?? [] };
|
||||
case "drop_table":
|
||||
return { id: item.id, type: "drop_table", schema, table };
|
||||
case "empty_table":
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
|
||||
// NOTE on argument key naming:
|
||||
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
||||
@@ -45,6 +46,32 @@ export async function deleteConnectionPassword(connectionId: string): Promise<vo
|
||||
return invoke<void>("delete_connection_password", { connectionId });
|
||||
}
|
||||
|
||||
// ─── Keychain: SSH secrets ────────────────────────────────────
|
||||
|
||||
export async function saveConnectionSshPassword(connectionId: string, password: string): Promise<void> {
|
||||
return invoke<void>("save_connection_ssh_password", { connectionId, password });
|
||||
}
|
||||
|
||||
export async function getConnectionSshPassword(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_ssh_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionSshPassword(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_ssh_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function saveConnectionSshPassphrase(connectionId: string, passphrase: string): Promise<void> {
|
||||
return invoke<void>("save_connection_ssh_passphrase", { connectionId, passphrase });
|
||||
}
|
||||
|
||||
export async function getConnectionSshPassphrase(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_ssh_passphrase", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionSshPassphrase(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_ssh_passphrase", { connectionId });
|
||||
}
|
||||
|
||||
export async function recreateDemoDb(): Promise<string> {
|
||||
return invoke<string>("recreate_demo_db");
|
||||
}
|
||||
@@ -83,10 +110,14 @@ export async function getTableData(
|
||||
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
|
||||
}
|
||||
|
||||
export async function executeChange(connectionId: string, change: ChangeItem): Promise<void> {
|
||||
export async function executeChange(connectionId: string, change: ChangePayload): Promise<void> {
|
||||
return invoke<void>("execute_change", { connectionId, change });
|
||||
}
|
||||
|
||||
export async function getTableDdl(connectionId: string, schema: string, table: string): Promise<string> {
|
||||
return invoke<string>("get_table_ddl", { connectionId, schema, table });
|
||||
}
|
||||
|
||||
export async function getFkPreview(
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCsv } from "./csvParser";
|
||||
|
||||
describe("parseCsv", () => {
|
||||
it("parses a simple header + rows", () => {
|
||||
expect(parseCsv("a,b,c\n1,2,3\n4,5,6")).toEqual({
|
||||
headers: ["a", "b", "c"], rows: [["1", "2", "3"], ["4", "5", "6"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles quoted fields containing commas and quotes", () => {
|
||||
expect(parseCsv('x,y\n"a,b","c""d"""')).toEqual({
|
||||
headers: ["x", "y"], rows: [["a,b", 'c"d"']],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports CRLF line endings", () => {
|
||||
expect(parseCsv("a,b\r\n1,2\r\n")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||
});
|
||||
|
||||
it("strips a leading UTF-8 BOM", () => {
|
||||
expect(parseCsv("\uFEFFa,b\n1,2")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||
});
|
||||
|
||||
it("returns empty rows for header-only input", () => {
|
||||
expect(parseCsv("a,b,c")).toEqual({ headers: ["a", "b", "c"], rows: [] });
|
||||
});
|
||||
|
||||
it("errors on empty input", () => {
|
||||
expect(() => parseCsv("")).toThrow(/empty/i);
|
||||
});
|
||||
|
||||
it("ragged rows pad with empty strings", () => {
|
||||
expect(parseCsv("a,b\n1")).toEqual({ headers: ["a", "b"], rows: [["1", ""]] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ParsedCsv {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export function parseCsv(input: string): ParsedCsv {
|
||||
const text = input.replace(/^\uFEFF/, "");
|
||||
if (text.trim() === "") throw new Error("CSV input is empty");
|
||||
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') { field += '"'; i++; }
|
||||
else inQuotes = false;
|
||||
} else field += ch;
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
row.push(field); field = "";
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(field); field = "";
|
||||
rows.push(row); row = [];
|
||||
} else field += ch;
|
||||
}
|
||||
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
|
||||
if (rows.length === 0) throw new Error("CSV input is empty");
|
||||
|
||||
const [headers, ...data] = rows;
|
||||
const width = headers.length;
|
||||
const padded = data.map((r) => {
|
||||
const out = r.slice(0, width);
|
||||
while (out.length < width) out.push("");
|
||||
return out;
|
||||
});
|
||||
return { headers, rows: padded };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { exportData } from "./exportData";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
||||
];
|
||||
|
||||
describe("exportData", () => {
|
||||
it("csv quotes cells and escapes quotes", () => {
|
||||
const create = vi.spyOn(document, "createElement");
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const click = vi.fn();
|
||||
create.mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||
exportData([[1, "a"], [2, 'b"c']], columns, "csv", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("json serializes rows as objects", () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const click = vi.fn();
|
||||
vi.spyOn(document, "createElement").mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||
exportData([[1, "a"]], columns, "json", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeImport, coerceRow } from "./importNormalize";
|
||||
|
||||
describe("normalizeImport", () => {
|
||||
it("parses CSV input", () => {
|
||||
expect(normalizeImport("a,b\n1,2\n3,4")).toEqual({
|
||||
headers: ["a", "b"],
|
||||
rows: [
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON array of objects", () => {
|
||||
expect(normalizeImport('[{"a":"1","b":"2"},{"a":"3","b":"4"}]')).toEqual({
|
||||
headers: ["a", "b"],
|
||||
rows: [
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON object whose sole value is an array", () => {
|
||||
expect(normalizeImport('{"data":[{"x":"10","y":"20"},{"x":"30","y":"40"}]}')).toEqual({
|
||||
headers: ["x", "y"],
|
||||
rows: [
|
||||
["10", "20"],
|
||||
["30", "40"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on empty input", () => {
|
||||
expect(() => normalizeImport("")).toThrow(/empty/i);
|
||||
expect(() => normalizeImport(" ")).toThrow(/empty/i);
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => normalizeImport('{"a":')).toThrow(/invalid json/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceRow", () => {
|
||||
it("converts empty strings to null", () => {
|
||||
expect(coerceRow("")).toBeNull();
|
||||
});
|
||||
|
||||
it("converts boolean literals", () => {
|
||||
expect(coerceRow("true")).toBe(true);
|
||||
expect(coerceRow("false")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts numeric strings to numbers", () => {
|
||||
expect(coerceRow("42")).toBe(42);
|
||||
expect(coerceRow("4.5")).toBe(4.5);
|
||||
expect(coerceRow("-7")).toBe(-7);
|
||||
});
|
||||
|
||||
it("keeps other values as strings", () => {
|
||||
expect(coerceRow("abc")).toBe("abc");
|
||||
expect(coerceRow("12abc")).toBe("12abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseCsv } from "./csvParser";
|
||||
|
||||
export interface NormalizedImport {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export function normalizeImport(text: string): NormalizedImport {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") throw new Error("Import input is empty");
|
||||
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
if (Array.isArray(parsed)) {
|
||||
rows = parsed as Record<string, unknown>[];
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
const values = Object.values(parsed);
|
||||
const arrays = values.filter((v): v is Record<string, unknown>[] => Array.isArray(v));
|
||||
if (arrays.length === 1 && values.length === 1) {
|
||||
rows = arrays[0];
|
||||
} else {
|
||||
rows = [parsed as Record<string, unknown>];
|
||||
}
|
||||
} else {
|
||||
throw new Error("JSON import must be an array of objects or an object");
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { headers: [], rows: [] };
|
||||
|
||||
const headers = Object.keys(rows[0] ?? {});
|
||||
const data = rows.map((r) => headers.map((h) => String((r as Record<string, unknown>)[h] ?? "")));
|
||||
return { headers, rows: data };
|
||||
}
|
||||
|
||||
return parseCsv(text);
|
||||
}
|
||||
|
||||
export function coerceRow(v: string): unknown {
|
||||
if (v === "") return null;
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||
return v;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
TableNode,
|
||||
GraphColumn,
|
||||
Relationship,
|
||||
Settings,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
@@ -97,6 +98,13 @@ describe("Connection", () => {
|
||||
});
|
||||
|
||||
describe("ConnectionInput", () => {
|
||||
it("accepts ssh_password", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "x", db_type: "postgresql", host: "h", port: 5432, ssh_password: "ssh-pw",
|
||||
};
|
||||
expect(input.ssh_password).toBe("ssh-pw");
|
||||
});
|
||||
|
||||
it("accepts all SSH/SSL fields", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "Test",
|
||||
@@ -430,4 +438,18 @@ describe("Schema graph types", () => {
|
||||
};
|
||||
expect(col.fk_ref).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings", () => {
|
||||
it("includes the five editor option fields", () => {
|
||||
const s: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark",
|
||||
font_size: "medium", default_ports: {}, tag_order: null, table_refresh_rate: 0,
|
||||
table_page_size: 50, shortcuts: {}, accent_color: "#2563EB",
|
||||
editor_font_size: 13, editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off", editor_minimap: false, editor_tab_size: 4,
|
||||
};
|
||||
expect(s.editor_font_size).toBe(13);
|
||||
expect(s.editor_word_wrap).toBe("off");
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -68,6 +68,7 @@ export interface ConnectionInput {
|
||||
ssh_user?: string | null;
|
||||
ssh_auth_method?: "password" | "key" | null;
|
||||
ssh_private_key_path?: string | null;
|
||||
ssh_password?: string | null;
|
||||
ssh_passphrase?: string | null;
|
||||
// SSL/TLS fields
|
||||
ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null;
|
||||
@@ -98,6 +99,11 @@ export interface Settings {
|
||||
table_page_size: number;
|
||||
shortcuts: Record<string, string>;
|
||||
accent_color: string;
|
||||
editor_font_size: number;
|
||||
editor_font_family: string;
|
||||
editor_word_wrap: "off" | "on";
|
||||
editor_minimap: boolean;
|
||||
editor_tab_size: number;
|
||||
}
|
||||
|
||||
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
||||
@@ -159,7 +165,9 @@ export type ChangeItemType =
|
||||
| "update"
|
||||
| "delete"
|
||||
| "create_index"
|
||||
| "drop_index";
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table";
|
||||
|
||||
export interface ChangeItem {
|
||||
type: ChangeItemType;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getDescendantFolderIds,
|
||||
getFolderPathLabel,
|
||||
isDestructiveQuery,
|
||||
isSchemaModifyingQuery,
|
||||
pickDefaultSchema,
|
||||
} from "./utils";
|
||||
import type { Connection, Folder, Tag } from "./types";
|
||||
@@ -414,4 +415,25 @@ describe("pickDefaultSchema", () => {
|
||||
it("returns null for an empty list", () => {
|
||||
expect(pickDefaultSchema([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSchemaModifyingQuery", () => {
|
||||
it("returns true for CREATE / DROP / ALTER / TRUNCATE", () => {
|
||||
expect(isSchemaModifyingQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("DROP TABLE t")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("ALTER TABLE t ADD COLUMN c int")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("TRUNCATE TABLE t")).toBe(true);
|
||||
});
|
||||
it("returns false for data-only and read statements", () => {
|
||||
expect(isSchemaModifyingQuery("SELECT * FROM t")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("INSERT INTO t VALUES (1)")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("UPDATE t SET c = 1")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("DELETE FROM t")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("REPLACE INTO t VALUES (1)")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||
});
|
||||
it("strips comments before checking", () => {
|
||||
expect(isSchemaModifyingQuery("-- note\nCREATE TABLE t (id int)")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("/* x */ SELECT 1")).toBe(false);
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -149,6 +149,21 @@ const DESTRUCTIVE_KEYWORDS = new Set([
|
||||
"TRUNCATE", "CREATE", "REPLACE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return the first significant keyword of `sql` (uppercased) after stripping
|
||||
* comments and collapsing whitespace, or null when there is none.
|
||||
*/
|
||||
export function firstSignificantKeyword(sql: string): string | null {
|
||||
// Strip block comments /* ... */
|
||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
// Strip line comments -- ...
|
||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||
// Collapse whitespace
|
||||
const tokens = stripped.trim().split(/\s+/);
|
||||
if (tokens.length === 0 || tokens[0].length === 0) return null;
|
||||
return tokens[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether `sql` is a data-modifying statement by checking the
|
||||
* first significant keyword after stripping comments and whitespace.
|
||||
@@ -158,15 +173,22 @@ const DESTRUCTIVE_KEYWORDS = new Set([
|
||||
* accidental data loss, not malicious access.
|
||||
*/
|
||||
export function isDestructiveQuery(sql: string): boolean {
|
||||
// Strip block comments /* ... */
|
||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
// Strip line comments -- ...
|
||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||
// Collapse whitespace
|
||||
const tokens = stripped.trim().split(/\s+/);
|
||||
if (tokens.length === 0 || tokens[0].length === 0) return false;
|
||||
const first = tokens[0].toUpperCase();
|
||||
return DESTRUCTIVE_KEYWORDS.has(first);
|
||||
const first = firstSignificantKeyword(sql);
|
||||
return first !== null && DESTRUCTIVE_KEYWORDS.has(first);
|
||||
}
|
||||
|
||||
const SCHEMA_MODIFYING_KEYWORDS = new Set([
|
||||
"CREATE", "DROP", "ALTER", "TRUNCATE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detect whether `sql` changes the database schema (DDL) by checking the
|
||||
* first significant keyword. Used to auto-refresh the schema tree after a
|
||||
* successful query run.
|
||||
*/
|
||||
export function isSchemaModifyingQuery(sql: string): boolean {
|
||||
const k = firstSignificantKeyword(sql);
|
||||
return k !== null && SCHEMA_MODIFYING_KEYWORDS.has(k);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,6 +57,14 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
if (input.password) {
|
||||
await cmd.saveConnectionPassword(conn.id, input.password);
|
||||
}
|
||||
// Persist SSH secrets to OS keychain (not SQLite): password for password
|
||||
// auth, passphrase for private-key auth.
|
||||
if (input.ssh_host && (input.ssh_auth_method ?? "password") === "password" && input.ssh_password) {
|
||||
await cmd.saveConnectionSshPassword(conn.id, input.ssh_password);
|
||||
}
|
||||
if (input.ssh_host && input.ssh_passphrase) {
|
||||
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
|
||||
}
|
||||
set((s) => ({ connections: [...s.connections, conn] }));
|
||||
},
|
||||
deleteConnection: async (id) => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useDbViewerStore } from "./dbViewerStore";
|
||||
import type { QueryResult, TableInfo } from "../lib/types";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
vi.mock("../lib/commands", () => ({
|
||||
getDatabases: vi.fn(),
|
||||
getSchemas: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
@@ -63,6 +70,26 @@ describe("dbViewerStore", () => {
|
||||
expect(state.activeTabId).toBe(state.tabs[0].id);
|
||||
});
|
||||
|
||||
it("closeTabsForTable closes matching table tabs and keeps others", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
||||
const tabs = useDbViewerStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].table).toBe("posts");
|
||||
});
|
||||
|
||||
it("closeTabsForTable fixes the active tab when it is closed", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const usersId = useDbViewerStore.getState().tabs[0].id;
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
useDbViewerStore.getState().setActiveTab(usersId);
|
||||
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.activeTabId).toBe(st.tabs[0].id);
|
||||
});
|
||||
|
||||
it("setPage updates pagination", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
@@ -116,6 +143,17 @@ describe("dbViewerStore", () => {
|
||||
expect(queue[0].createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("addChange stages a bulk_insert with columns+rows", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "bulk_insert", schema: "public", table: "t",
|
||||
columns: ["a", "b"], rows: [[1, 2]], description: "Import",
|
||||
} as any);
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q[q.length - 1].type).toBe("bulk_insert");
|
||||
expect((q[q.length - 1] as any).columns).toEqual(["a", "b"]);
|
||||
expect((q[q.length - 1] as any).rows).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it("cancelChange marks change as cancelled", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
||||
@@ -147,6 +185,20 @@ describe("dbViewerStore", () => {
|
||||
expect(change.error).toBe("Constraint violation");
|
||||
});
|
||||
|
||||
it("removeChange drops the change from the queue", () => {
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||
const id = useDbViewerStore.getState().changesQueue[0].id;
|
||||
useDbViewerStore.getState().removeChange(id);
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clearChanges empties the queue", () => {
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
||||
useDbViewerStore.getState().clearChanges();
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reset clears all state", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
@@ -181,6 +233,37 @@ describe("dbViewerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTree", () => {
|
||||
it("fetches databases/schemas/tables and populates", async () => {
|
||||
vi.mocked(commands.getDatabases).mockResolvedValue(["mydb"]);
|
||||
vi.mocked(commands.getSchemas).mockResolvedValue(["public"]);
|
||||
vi.mocked(commands.getTables).mockResolvedValue([
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
] as any);
|
||||
useDbViewerStore.setState({ currentSchema: "public" });
|
||||
await useDbViewerStore.getState().refreshTree("c1");
|
||||
expect(useDbViewerStore.getState().databases).toEqual(["mydb"]);
|
||||
expect(useDbViewerStore.getState().schemas).toEqual(["public"]);
|
||||
expect(useDbViewerStore.getState().tables).toHaveLength(1);
|
||||
expect(commands.getTables).toHaveBeenCalledWith("c1", "public");
|
||||
});
|
||||
it("falls back to no schema when currentSchema is null", async () => {
|
||||
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
||||
vi.mocked(commands.getSchemas).mockResolvedValue([] as any);
|
||||
vi.mocked(commands.getTables).mockResolvedValue([] as any);
|
||||
useDbViewerStore.setState({ currentSchema: null });
|
||||
await useDbViewerStore.getState().refreshTree("c1");
|
||||
expect(commands.getTables).toHaveBeenCalledWith("c1", undefined);
|
||||
});
|
||||
it("swallows fetch errors", async () => {
|
||||
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
||||
vi.mocked(commands.getSchemas).mockRejectedValue(new Error("boom"));
|
||||
await expect(
|
||||
useDbViewerStore.getState().refreshTree("c1"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabType discriminator", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +30,8 @@ export interface QueueItem {
|
||||
primaryKey?: Record<string, unknown>;
|
||||
oldData?: Record<string, unknown> | null;
|
||||
newData?: Record<string, unknown> | null;
|
||||
columns?: string[];
|
||||
rows?: unknown[][];
|
||||
status: QueueStatus;
|
||||
error?: string | null;
|
||||
description?: string | null;
|
||||
@@ -99,6 +102,7 @@ interface DbViewerState {
|
||||
openQueryTab: () => void;
|
||||
setDefaultPageSize: (size: number) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
closeTabsForTable: (schema: string, table: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
setPage: (tabId: string, page: number) => void;
|
||||
setPageSize: (tabId: string, pageSize: number) => void;
|
||||
@@ -120,9 +124,13 @@ interface DbViewerState {
|
||||
primaryKey?: Record<string, unknown>;
|
||||
oldData?: Record<string, unknown> | null;
|
||||
newData?: Record<string, unknown> | null;
|
||||
columns?: string[];
|
||||
rows?: unknown[][];
|
||||
description?: string | null;
|
||||
}) => void;
|
||||
cancelChange: (changeId: string) => void;
|
||||
removeChange: (changeId: string) => void;
|
||||
clearChanges: () => void;
|
||||
markChangeCommitted: (changeId: string) => void;
|
||||
markChangeFailed: (changeId: string, error: string) => void;
|
||||
toggleChangesPanel: () => void;
|
||||
@@ -138,6 +146,7 @@ interface DbViewerState {
|
||||
schemas: string[],
|
||||
tables: TableInfo[],
|
||||
) => void;
|
||||
refreshTree: (connectionId: string, schema?: string) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -220,6 +229,21 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
set({ tabs: remaining, activeTabId: newActiveId });
|
||||
},
|
||||
|
||||
closeTabsForTable: (schema, table) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const remaining = tabs.filter(
|
||||
(t) => !(t.tabType === "table" && t.schema === schema && t.table === table),
|
||||
);
|
||||
if (remaining.length === tabs.length) return;
|
||||
const newActiveId =
|
||||
activeTabId !== null && !remaining.some((t) => t.id === activeTabId)
|
||||
? remaining.length > 0
|
||||
? remaining[remaining.length - 1].id
|
||||
: null
|
||||
: activeTabId;
|
||||
set({ tabs: remaining, activeTabId: newActiveId });
|
||||
},
|
||||
|
||||
setActiveTab: (tabId) => set({ activeTabId: tabId }),
|
||||
|
||||
setPage: (tabId, page) =>
|
||||
@@ -327,6 +351,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
primaryKey: input.primaryKey,
|
||||
oldData: input.oldData ?? null,
|
||||
newData: input.newData ?? null,
|
||||
columns: input.columns,
|
||||
rows: input.rows,
|
||||
status: "pending",
|
||||
description: input.description ?? null,
|
||||
createdAt: Date.now(),
|
||||
@@ -341,6 +367,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
removeChange: (changeId) =>
|
||||
set((state) => ({
|
||||
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
||||
})),
|
||||
|
||||
clearChanges: () => set({ changesQueue: [] }),
|
||||
|
||||
markChangeCommitted: (changeId) =>
|
||||
set((state) => ({
|
||||
changesQueue: state.changesQueue.map((c) =>
|
||||
@@ -371,6 +404,22 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
populate: (databases, schemas, tables) =>
|
||||
set({ databases, schemas, tables }),
|
||||
|
||||
// Best-effort re-fetch of the schema tree (databases/schemas/tables) so
|
||||
// newly created/dropped objects show up without a manual refresh. A
|
||||
// failure must never surface to the user.
|
||||
refreshTree: async (connectionId, schema) => {
|
||||
try {
|
||||
const [dbs, scs, tbls] = await Promise.all([
|
||||
getDatabases(connectionId),
|
||||
getSchemas(connectionId),
|
||||
getTables(connectionId, schema ?? get().currentSchema ?? undefined),
|
||||
]);
|
||||
get().populate(dbs, scs, tbls);
|
||||
} catch {
|
||||
// Best-effort refresh; a failure must not surface to the user.
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
tabCounter = 0;
|
||||
changeCounter = 0;
|
||||
|
||||
@@ -9,7 +9,7 @@ beforeEach(() => {
|
||||
|
||||
describe("settingsStore", () => {
|
||||
it("load fetches settings", async () => {
|
||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "dark" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "dark" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off" as const, editor_minimap: false, editor_tab_size: 4 };
|
||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||
await useSettingsStore.getState().load();
|
||||
expect(useSettingsStore.getState().settings).toEqual(settings);
|
||||
@@ -17,7 +17,7 @@ describe("settingsStore", () => {
|
||||
|
||||
it("updateSetting persists then reloads", async () => {
|
||||
vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined);
|
||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off" as const, editor_minimap: false, editor_tab_size: 4 };
|
||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||
await useSettingsStore.getState().updateSetting("theme", "light");
|
||||
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light");
|
||||
|
||||
Reference in New Issue
Block a user