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

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

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

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

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

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

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

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

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

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

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

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

* feat: Editor settings tab UI (Task 12)

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

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

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

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

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

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

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

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

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

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

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

* docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
2026-08-02 20:38:23 +08:00
committed by GitHub
parent e32fe7967c
commit e0c0db8352
68 changed files with 3885 additions and 553 deletions
@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
describe("ChangesQueuePanel", () => {
beforeEach(() => {
useDbViewerStore.setState({ changesQueue: [] });
useDbViewerStore.getState().reset();
useUiStore.setState({ activeConnectionId: "c1" });
vi.resetAllMocks();
});
it("shows nothing when queue is empty", () => {
@@ -14,7 +18,7 @@ describe("ChangesQueuePanel", () => {
expect(container.textContent).toBe("");
});
it("shows pending changes", () => {
it("shows the pending-changes header and a change card", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
@@ -22,13 +26,15 @@ describe("ChangesQueuePanel", () => {
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
expect(screen.getByText(/users/i)).toBeInTheDocument();
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
expect(screen.getByText(/update/i)).toBeInTheDocument();
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
});
it("toggle button flips the store expanded state", async () => {
it("revert removes the change from the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
@@ -37,27 +43,138 @@ describe("ChangesQueuePanel", () => {
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
await user.click(screen.getByRole("button", { name: /revert/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
it("labels bulk_insert / empty_table / drop_table cards", () => {
useDbViewerStore.getState().addChange({
type: "update",
type: "bulk_insert",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
table: "t",
columns: ["a"],
rows: [[1]],
description: "Import 2 rows into public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "empty_table",
schema: "public",
table: "t",
description: "Empty Table: public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument();
expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument();
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
});
it("commit calls executeChange with buildChangePayload output for insert", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { id: 1, name: "Alice" },
description: "Insert row into t",
});
render(<ChangesQueuePanel />);
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelBtn);
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(exec).toHaveBeenCalled());
expect(exec.mock.calls[0][0]).toBe("c1");
expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({
type: "insert",
schema: "public",
table: "t",
data: expect.any(String),
}));
});
it("refreshes the schema tree after committing a drop_table change", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("SQL toggle shows the generated SQL", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { name: "Alice" },
description: "Insert row into users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument();
});
it("Cmd+S commits all pending changes", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel />);
fireEvent.keyDown(document, { key: "s", metaKey: true });
await waitFor(() => expect(exec).toHaveBeenCalled());
});
it("Clear All empties the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /clear all/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("shows a green check on committed changes after Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument());
});
it("auto-closes tabs for a table dropped via Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => {
const tabs = useDbViewerStore.getState().tabs;
expect(tabs.some((t) => t.table === "users")).toBe(false);
expect(tabs.some((t) => t.table === "posts")).toBe(true);
});
});
});