refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+82
View File
@@ -0,0 +1,82 @@
import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import type { BackupJob } from "../lib/types";
interface BackupJobEvent {
job_id: string;
status: "running" | "completed" | "failed";
error?: string | null;
}
interface BackupStore {
jobs: BackupJob[];
activeJobId: string | null;
progress: number;
startJob: (jobId: string, type: string) => void;
completeJob: (jobId: string) => void;
failJob: (jobId: string, error: string) => void;
initListener: () => Promise<void>;
}
export const useBackupStore = create<BackupStore>((set, get) => ({
jobs: [],
activeJobId: null,
progress: 0,
startJob: (jobId: string, type: string) =>
set((s) => ({
activeJobId: jobId,
progress: 0,
jobs: [
...s.jobs,
{
id: jobId,
connection_id: "",
type: type as BackupJob["type"],
format: null,
file_path: null,
source_connection_id: null,
status: "running",
error_message: null,
size_bytes: null,
started_at: new Date().toISOString(),
completed_at: null,
} satisfies BackupJob,
],
})),
completeJob: (jobId: string) =>
set((s) => ({
progress: 100,
jobs: s.jobs.map((j) =>
j.id === jobId
? { ...j, status: "completed" as const, completed_at: new Date().toISOString() }
: j,
),
})),
failJob: (jobId: string, error: string) =>
set((s) => ({
jobs: s.jobs.map((j) =>
j.id === jobId
? {
...j,
status: "failed" as const,
error_message: error,
completed_at: new Date().toISOString(),
}
: j,
),
})),
initListener: async () => {
await listen<BackupJobEvent>("backup-progress", (event) => {
const { job_id, status, error } = event.payload;
if (status === "completed") {
get().completeJob(job_id);
} else if (status === "failed") {
get().failJob(job_id, error || "Unknown error");
}
});
},
}));
+359
View File
@@ -0,0 +1,359 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useConnectionStore } from "./connectionStore";
import * as commands from "../lib/commands";
import type { Connection, ConnectionInput, Folder, Tag } from "../lib/types";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
const makeConn = (over: Partial<Connection> = {}): Connection => ({
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id: null, keychain_ref: null, tag_ids: [],
favorite: false, created_at: "", updated_at: "", ...over,
});
beforeEach(() => {
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
vi.restoreAllMocks();
});
describe("connectionStore", () => {
it("loadAll fetches connections, folders, tags", async () => {
const folders: Folder[] = [{ id: "f1", name: "root", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }];
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
const conns: Connection[] = [makeConn()];
vi.spyOn(commands, "getConnections").mockResolvedValue(conns);
vi.spyOn(commands, "getFolders").mockResolvedValue(folders);
vi.spyOn(commands, "getTags").mockResolvedValue(tags);
await useConnectionStore.getState().loadAll();
expect(useConnectionStore.getState().connections).toEqual(conns);
expect(useConnectionStore.getState().folders).toEqual(folders);
expect(useConnectionStore.getState().tags).toEqual(tags);
});
it("loadAll sets error on failure", async () => {
vi.spyOn(commands, "getConnections").mockRejectedValue(new Error("fail"));
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
vi.spyOn(commands, "getTags").mockResolvedValue([]);
await useConnectionStore.getState().loadAll();
expect(useConnectionStore.getState().error).toContain("fail");
});
it("createConnection adds to list on success", async () => {
const created = makeConn({ id: "c2", name: "New" });
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
await useConnectionStore.getState().createConnection({ name: "New", db_type: "postgresql", host: "h", port: 5432 });
expect(useConnectionStore.getState().connections).toContainEqual(created);
});
it("deleteConnection removes from list", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1" })] });
vi.spyOn(commands, "deleteConnection").mockResolvedValue(undefined);
await useConnectionStore.getState().deleteConnection("c1");
expect(useConnectionStore.getState().connections).toEqual([]);
});
it("createFolder adds to folders", async () => {
const folder: Folder = { id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" };
vi.spyOn(commands, "createFolder").mockResolvedValue(folder);
await useConnectionStore.getState().createFolder({ name: "Work", parent_id: null });
expect(useConnectionStore.getState().folders).toContainEqual(folder);
});
});
describe("moveConnection", () => {
const baseConn: Connection = {
id: "c1",
name: "My DB",
db_type: "postgresql",
host: "localhost",
port: null,
username: null,
database: "mydb",
folder_id: null,
keychain_ref: null,
environment: null,
ssh_host: null,
ssh_port: null,
ssh_user: null,
ssh_auth_method: null,
ssh_private_key_path: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01",
updated_at: "2024-01-01",
};
beforeEach(() => {
useConnectionStore.setState({
connections: [
baseConn,
{ ...baseConn, id: "c2", name: "Other", folder_id: "folder-1" },
],
});
});
it("optimistically moves connection to a folder", async () => {
vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
...baseConn,
folder_id: "folder-2",
} as Connection);
await useConnectionStore.getState().moveConnection("c1", "folder-2");
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1");
expect(conn?.folder_id).toBe("folder-2");
});
it("moves connection to root when folderId is null", async () => {
vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
...baseConn,
id: "c2",
folder_id: null,
} as Connection);
await useConnectionStore.getState().moveConnection("c2", null);
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c2");
expect(conn?.folder_id).toBeNull();
});
it("rolls back on API failure", async () => {
vi.spyOn(commands, "updateConnection").mockRejectedValueOnce(
new Error("Network error"),
);
const original = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1")!;
await expect(
useConnectionStore.getState().moveConnection("c1", "folder-3"),
).rejects.toThrow("Network error");
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1");
expect(conn?.folder_id).toBe(original.folder_id);
});
it("no-ops when moving to same folder", async () => {
const spy = vi.spyOn(commands, "updateConnection");
await useConnectionStore.getState().moveConnection("c1", null); // c1 is already null
expect(spy).not.toHaveBeenCalled();
});
it("handles rapid successive drags without stale state", async () => {
const conn1 = makeConn({ id: "c1", folder_id: null });
const conn2 = makeConn({ id: "c2", folder_id: null, name: "Other" });
useConnectionStore.setState({
connections: [conn1, conn2],
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
// First call hangs until we resolve it; second call resolves immediately
let resolveFirst: (v: Connection) => void;
const firstCall = new Promise<Connection>((r) => { resolveFirst = r; });
let callCount = 0;
vi.spyOn(commands, "updateConnection").mockImplementation(async (_id, input) => {
callCount++;
if (callCount === 1) {
return firstCall;
}
return { ...conn2, folder_id: (input as any).folder_id } as Connection;
});
// Start first drag (c1 → f1) — will be pending on updateConnection
const move1 = useConnectionStore.getState().moveConnection("c1", "f1");
// Immediately start second drag (c2 → f1) — should complete
await useConnectionStore.getState().moveConnection("c2", "f1");
// Second drag should have applied optimistically
expect(
useConnectionStore.getState().connections.find((c) => c.id === "c2")?.folder_id
).toBe("f1");
// Resolve the first drag's network call
resolveFirst!({ ...conn1, folder_id: "f1" } as Connection);
await move1;
// Both should be in f1 after both complete
const state = useConnectionStore.getState();
expect(state.connections.find((c) => c.id === "c1")?.folder_id).toBe("f1");
expect(state.connections.find((c) => c.id === "c2")?.folder_id).toBe("f1");
expect(callCount).toBe(2);
});
});
describe("favorites / recents / move-selection", () => {
it("toggleFavorite optimistically flips favorite and persists", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
vi.spyOn(commands, "setConnectionFavorite").mockResolvedValue(undefined);
await useConnectionStore.getState().toggleFavorite("c1");
expect(useConnectionStore.getState().connections[0].favorite).toBe(true);
expect(commands.setConnectionFavorite).toHaveBeenCalledWith("c1", true);
});
it("toggleFavorite rolls back on failure", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
vi.spyOn(commands, "setConnectionFavorite").mockRejectedValue(new Error("boom"));
await expect(useConnectionStore.getState().toggleFavorite("c1")).rejects.toThrow("boom");
expect(useConnectionStore.getState().connections[0].favorite).toBe(false);
});
it("loadAll sorts favorites first within the returned list", async () => {
const fav = makeConn({ id: "a", name: "A", favorite: true });
const norm = makeConn({ id: "b", name: "B", favorite: false });
vi.spyOn(commands, "getConnections").mockResolvedValue([norm, fav]);
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
vi.spyOn(commands, "getTags").mockResolvedValue([]);
vi.spyOn(commands, "getSettings").mockResolvedValue({} as any);
await useConnectionStore.getState().loadAll();
const ids = useConnectionStore.getState().connections.map((c) => c.id);
expect(ids[0]).toBe("a");
});
it("recordRecent calls the command once", async () => {
vi.spyOn(commands, "recordRecentConnection").mockResolvedValue(undefined);
await useConnectionStore.getState().recordRecent("c1");
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
});
describe("duplicateConnection", () => {
it("copies fields with a '(copy)' name and favorite false", async () => {
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
useConnectionStore.setState({ connections: [src] });
const created = { ...src, id: "c2", name: "Prod (copy)", favorite: false, keychain_ref: null };
vi.spyOn(commands, "createConnection").mockResolvedValue(created as any);
const out = await useConnectionStore.getState().duplicateConnection("c1");
expect(out.name).toBe("Prod (copy)");
expect(out.favorite).toBe(false);
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({
name: "Prod (copy)",
host: src.host,
folder_id: "f1",
tag_ids: ["t1"],
password: null,
// absent use_keychain on the source defaults to true (opt-out)
use_keychain: true,
}));
});
it("throws when the connection is missing", async () => {
useConnectionStore.setState({ connections: [] });
await expect(useConnectionStore.getState().duplicateConnection("nope")).rejects.toThrow("not found");
});
});
it("moveSelectionToFolder moves connections and reparents folders", async () => {
useConnectionStore.setState({
connections: [makeConn({ id: "c1", folder_id: null })],
folders: [{ id: "f1", name: "f", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
const originalMoveConnection = useConnectionStore.getState().moveConnection;
vi.spyOn(useConnectionStore.getState(), "moveConnection").mockResolvedValue(undefined);
vi.spyOn(commands, "updateFolder").mockResolvedValue({} as any);
await useConnectionStore.getState().moveSelectionToFolder(["c1", "f1"], "target");
expect(useConnectionStore.getState().connections[0].folder_id).toBe("target");
expect(useConnectionStore.getState().folders[0].parent_id).toBe("target");
// Restore the real action so the mock doesn't linger on future state objects
useConnectionStore.setState({ moveConnection: originalMoveConnection });
});
});
describe("keychain toggle + session passwords (Task 3.1)", () => {
const baseInput = (overrides: Partial<ConnectionInput> = {}): ConnectionInput => ({
name: "n", db_type: "postgresql", host: "h", port: 5432, username: "u",
database: "d", folder_id: null, tag_ids: [], password: "pw", use_keychain: true,
...overrides,
});
it("saves to keychain when use_keychain=true", async () => {
const created = makeConn({ id: "c1", use_keychain: true });
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
const purge = vi.spyOn(commands, "deleteConnectionPassword").mockResolvedValue(undefined);
await useConnectionStore.getState().createConnection(baseInput({ use_keychain: true }));
expect(save).toHaveBeenCalledWith("c1", "pw");
expect(purge).not.toHaveBeenCalled();
});
it("purges keychain and skips save when use_keychain=false", async () => {
const created = makeConn({ id: "c2", use_keychain: false });
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
const purge = vi.spyOn(commands, "deleteConnectionPassword").mockResolvedValue(undefined);
await useConnectionStore.getState().createConnection(baseInput({ use_keychain: false }));
expect(save).not.toHaveBeenCalled();
expect(purge).toHaveBeenCalledWith("c2");
});
it("use_keychain absent defaults to true (saves to keychain)", async () => {
const created = makeConn({ id: "c1", use_keychain: true });
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
const { use_keychain: _kc, ...withoutFlag } = baseInput({ use_keychain: true });
await useConnectionStore.getState().createConnection(withoutFlag as ConnectionInput);
expect(save).toHaveBeenCalledWith("c1", "pw");
});
it("getConnectionPassword returns the session password when use_keychain=false", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c3", use_keychain: false })] });
useConnectionStore.getState().setSessionPassword("c3", "secret");
const kc = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("kc");
expect(await useConnectionStore.getState().getConnectionPassword("c3")).toBe("secret");
expect(kc).not.toHaveBeenCalled();
});
it("getConnectionPassword falls back to the keychain when use_keychain=true", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c4", use_keychain: true })] });
const kc = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("kc");
expect(await useConnectionStore.getState().getConnectionPassword("c4")).toBe("kc");
expect(kc).toHaveBeenCalledWith("c4");
});
it("cachePassword stays session-only when use_keychain=false", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c5", use_keychain: false })] });
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
await useConnectionStore.getState().cachePassword("c5", "s3cret");
expect(save).not.toHaveBeenCalled();
expect(await useConnectionStore.getState().getConnectionPassword("c5")).toBe("s3cret");
});
it("cachePassword saves to the keychain when use_keychain=true", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c6", use_keychain: true })] });
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
await useConnectionStore.getState().cachePassword("c6", "kc-pass");
expect(save).toHaveBeenCalledWith("c6", "kc-pass");
});
it("setSessionPassword/clearSessionPassword manage the in-memory map", () => {
useConnectionStore.setState({ connections: [makeConn({ id: "cx", use_keychain: false })] });
useConnectionStore.getState().setSessionPassword("cx", "p");
expect(useConnectionStore.getState().getConnectionPassword).toBeDefined();
useConnectionStore.getState().clearSessionPassword("cx");
});
it("deleteConnection clears the session password", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c8", use_keychain: false })] });
useConnectionStore.getState().setSessionPassword("c8", "secret");
vi.spyOn(commands, "deleteConnection").mockResolvedValue(undefined);
await useConnectionStore.getState().deleteConnection("c8");
// re-add the same connection so the cleared session map is observable
useConnectionStore.setState({ connections: [makeConn({ id: "c8", use_keychain: false })] });
expect(await useConnectionStore.getState().getConnectionPassword("c8")).toBeNull();
});
it("duplicateConnection inherits use_keychain from the source", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1", use_keychain: true })] });
vi.spyOn(commands, "createConnection").mockResolvedValue(makeConn({ id: "c2", name: "P (copy)", use_keychain: true }));
await useConnectionStore.getState().duplicateConnection("c1");
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({ use_keychain: true }));
});
});
+286
View File
@@ -0,0 +1,286 @@
import { create } from "zustand";
import type { Connection, ConnectionInput, Folder, FolderInput, Tag, TagInput } from "../lib/types";
import * as cmd from "../lib/commands";
import { persistDbPassword } from "../lib/keychain";
// In-memory passwords for connections with use_keychain=false (never persisted).
const sessionPasswords = new Map<string, string>();
interface ConnectionState {
connections: Connection[]; folders: Folder[]; tags: Tag[];
tagOrder: string[];
recent: Connection[];
loading: boolean; error: string | null;
loadAll: () => Promise<void>;
loadRecent: () => Promise<void>;
recordRecent: (id: string) => Promise<void>;
toggleFavorite: (id: string) => Promise<void>;
loadTagOrder: () => Promise<void>;
setTagOrder: (order: string[]) => Promise<void>;
createConnection: (input: ConnectionInput) => Promise<Connection>;
deleteConnection: (id: string) => Promise<void>;
duplicateConnection: (id: string) => Promise<Connection>;
createFolder: (input: FolderInput) => Promise<void>;
updateFolder: (id: string, input: FolderInput) => Promise<void>;
deleteFolder: (id: string) => Promise<void>;
createTag: (input: TagInput) => Promise<void>;
updateTag: (id: string, input: TagInput) => Promise<void>;
deleteTag: (id: string) => Promise<void>;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
cachePassword: (connectionId: string, password: string) => Promise<void>;
getConnectionPassword: (connectionId: string) => Promise<string | null>;
setSessionPassword: (connectionId: string, password: string) => void;
clearSessionPassword: (connectionId: string) => void;
}
export const useConnectionStore = create<ConnectionState>((set, get) => ({
connections: [], folders: [], tags: [], tagOrder: [], recent: [], loading: false, error: null,
loadAll: async () => {
set({ loading: true, error: null });
try {
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
set({ connections, folders, tags, loading: false });
// Sort favorites first (stable sort preserves name order within groups)
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
// Also load tag order
get().loadTagOrder();
} catch (e) {
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
},
loadRecent: async () => {
try {
const recent = await cmd.getRecentConnections(8);
const map = new Map(get().connections.map((c) => [c.id, c]));
set({ recent: recent.map((r) => map.get(r.connection_id)).filter(Boolean) as Connection[] });
} catch { /* best-effort: recent list is non-critical */ }
},
recordRecent: async (id) => {
try { await cmd.recordRecentConnection(id); } catch { /* best-effort */ }
},
toggleFavorite: async (id) => {
const prev = get().connections;
const next = prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c);
set({ connections: next });
const conn = next.find((c) => c.id === id);
try {
await cmd.setConnectionFavorite(id, conn?.favorite ?? false);
} catch (e) {
set({ connections: prev }); // rollback
throw e;
}
},
loadTagOrder: async () => {
try {
const settings = await cmd.getSettings();
if (settings.tag_order) {
try {
const order = JSON.parse(settings.tag_order);
if (Array.isArray(order)) set({ tagOrder: order });
} catch {}
}
} catch {}
},
setTagOrder: async (order) => {
await cmd.updateSetting("tag_order", JSON.stringify(order));
set({ tagOrder: order });
},
createConnection: async (input) => {
const conn = await cmd.createConnection(input);
// Persist (or purge) the DB password according to the keychain toggle.
await persistDbPassword(conn.id, input.use_keychain, 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] }));
return conn;
},
duplicateConnection: async (id) => {
const source = get().connections.find((c) => c.id === id);
if (!source) throw new Error("Connection not found");
// Passwords live in the OS keychain and are NEVER copied; the duplicate
// starts unkeyed and with no favorite flag. The keychain toggle is inherited
// from the source (absent defaults to true / opt-out).
const input: ConnectionInput = {
name: `${source.name} (copy)`,
db_type: source.db_type,
host: source.host,
port: source.port,
username: source.username ?? null,
database: source.database ?? null,
folder_id: source.folder_id,
tag_ids: source.tag_ids ?? [],
environment: source.environment ?? null,
ssh_host: source.ssh_host ?? null,
ssh_port: source.ssh_port ?? null,
ssh_user: source.ssh_user ?? null,
ssh_auth_method: (source.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
ssh_private_key_path: source.ssh_private_key_path ?? null,
ssl_mode: (source.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
ssl_ca_path: source.ssl_ca_path ?? null,
ssl_cert_path: source.ssl_cert_path ?? null,
ssl_key_path: source.ssl_key_path ?? null,
password: null,
use_keychain: source.use_keychain ?? true,
};
return get().createConnection(input);
},
deleteConnection: async (id) => {
await cmd.deleteConnection(id);
// Remove password from keychain
try { await cmd.deleteConnectionPassword(id); } catch { /* ignore */ }
// Drop any session-only password for the connection
sessionPasswords.delete(id);
set((s) => ({ connections: s.connections.filter((c) => c.id !== id) }));
},
createFolder: async (input) => {
const folder = await cmd.createFolder(input);
set((s) => ({ folders: [...s.folders, folder] }));
},
updateFolder: async (id, input) => {
const folder = await cmd.updateFolder(id, input);
set((s) => ({ folders: s.folders.map((f) => f.id === id ? folder : f) }));
},
deleteFolder: async (id) => {
await cmd.deleteFolder(id);
set((s) => ({ folders: s.folders.filter((f) => f.id !== id), connections: s.connections.map((c) => c.folder_id === id ? { ...c, folder_id: null } : c) }));
},
createTag: async (input) => {
const tag = await cmd.createTag(input);
set((s) => ({ tags: [...s.tags, tag] }));
},
updateTag: async (id, input) => {
const tag = await cmd.updateTag(id, input);
set((s) => ({ tags: s.tags.map((t) => t.id === id ? tag : t) }));
},
deleteTag: async (id) => {
await cmd.deleteTag(id);
set((s) => ({
tags: s.tags.filter((t) => t.id !== id),
connections: s.connections.map((c) => c.tag_ids.includes(id) ? { ...c, tag_ids: c.tag_ids.filter((t) => t !== id) } : c),
folders: s.folders.map((f) => f.tag_ids.includes(id) ? { ...f, tag_ids: f.tag_ids.filter((t) => t !== id) } : f),
}));
},
cachePassword: async (connectionId, password) => {
const conn = get().connections.find((c) => c.id === connectionId);
if (conn && conn.use_keychain === false) {
sessionPasswords.set(connectionId, password);
return;
}
await cmd.saveConnectionPassword(connectionId, password);
},
getConnectionPassword: async (connectionId) => {
const conn = get().connections.find((c) => c.id === connectionId);
if (conn && conn.use_keychain === false) {
return sessionPasswords.get(connectionId) ?? null;
}
return cmd.getConnectionPassword(connectionId);
},
setSessionPassword: (connectionId, password) => {
sessionPasswords.set(connectionId, password);
},
clearSessionPassword: (connectionId) => {
sessionPasswords.delete(connectionId);
},
addTagToItems: async (tagId, folderIds, connectionIds) => {
await Promise.all([
...folderIds.map((fid) => cmd.addFolderTags(fid, [tagId])),
...connectionIds.map((cid) => cmd.addConnectionTags(cid, [tagId])),
]);
set((s) => ({
folders: s.folders.map((f) =>
folderIds.includes(f.id) && !f.tag_ids.includes(tagId)
? { ...f, tag_ids: [...f.tag_ids, tagId] }
: f,
),
connections: s.connections.map((c) =>
connectionIds.includes(c.id) && !c.tag_ids.includes(tagId)
? { ...c, tag_ids: [...c.tag_ids, tagId] }
: c,
),
}));
},
moveConnection: async (connectionId, newFolderId) => {
const state = get();
const conn = state.connections.find((c) => c.id === connectionId);
if (!conn) return;
if (conn.folder_id === newFolderId) return;
// Capture the snapshot atomically INSIDE the optimistic set()
// to avoid stale closure issues on rapid successive drags.
let previousState: { connections: Connection[] };
// Optimistic update
set((s) => {
previousState = { connections: [...s.connections] };
return {
connections: s.connections.map((c) =>
c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
),
};
});
try {
// Build a minimal ConnectionInput with only folder_id changed
const input: any = {
name: conn.name,
db_type: conn.db_type,
host: conn.host,
port: conn.port,
username: conn.username,
database: conn.database,
folder_id: newFolderId,
environment: conn.environment,
ssh_host: conn.ssh_host,
ssh_port: conn.ssh_port,
ssh_user: conn.ssh_user,
ssh_auth_method: conn.ssh_auth_method,
ssh_private_key_path: conn.ssh_private_key_path,
ssl_mode: conn.ssl_mode,
ssl_ca_path: conn.ssl_ca_path,
ssl_cert_path: conn.ssl_cert_path,
ssl_key_path: conn.ssl_key_path,
tag_ids: conn.tag_ids ?? [],
};
await cmd.updateConnection(connectionId, input);
} catch (e) {
set({ connections: previousState!.connections });
throw e;
}
},
moveSelectionToFolder: async (selectedIds, targetFolderId) => {
const prev = { connections: [...get().connections], folders: [...get().folders] };
const connIds = new Set<string>();
const folderIds = new Set<string>();
for (const id of selectedIds) {
if (prev.connections.some((c) => c.id === id)) connIds.add(id);
else if (prev.folders.some((f) => f.id === id)) folderIds.add(id);
}
try {
// Persist first: moveConnection has its own optimistic logic + rollback and
// would no-op (early-return on same folder_id) if we pre-set the target.
for (const id of connIds) {
await get().moveConnection(id, targetFolderId);
}
for (const id of folderIds) {
const f = prev.folders.find((x) => x.id === id);
if (f) await cmd.updateFolder(id, { name: f.name, parent_id: targetFolderId, tag_ids: f.tag_ids });
}
} catch (e) {
set({ connections: prev.connections, folders: prev.folders });
throw e;
}
// Apply the move optimistically at the end so state always ends moved
set((s) => ({
connections: s.connections.map((c) => connIds.has(c.id) ? { ...c, folder_id: targetFolderId } : c),
folders: s.folders.map((f) => folderIds.has(f.id) ? { ...f, parent_id: targetFolderId } : f),
}));
},
}));
+630
View File
@@ -0,0 +1,630 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useDbViewerStore } from "./dbViewerStore";
import type { QueryResult, TableInfo, RoleInfo } 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();
});
describe("dbViewerStore", () => {
it("starts empty", () => {
const state = useDbViewerStore.getState();
expect(state.tabs).toEqual([]);
expect(state.activeTabId).toBeNull();
expect(state.changesQueue).toEqual([]);
expect(state.databases).toEqual([]);
expect(state.schemas).toEqual([]);
expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull();
expect(state.objectSearchOpen).toBe(false);
expect(state.selectedObjectType).toBeNull();
expect(state.schemaTreeLoading).toBe(false);
});
it("object search open + selected object type setters", () => {
const { setObjectSearchOpen, setSelectedObjectType } =
useDbViewerStore.getState();
setObjectSearchOpen(true);
expect(useDbViewerStore.getState().objectSearchOpen).toBe(true);
setSelectedObjectType("functions");
expect(useDbViewerStore.getState().selectedObjectType).toBe("functions");
setSelectedObjectType(null);
expect(useDbViewerStore.getState().selectedObjectType).toBeNull();
});
it("requestedView setter stores then clears", () => {
const { setRequestedView } = useDbViewerStore.getState();
setRequestedView("objects");
expect(useDbViewerStore.getState().requestedView).toBe("objects");
setRequestedView(null);
expect(useDbViewerStore.getState().requestedView).toBeNull();
});
it("openTab adds a new tab", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
const tab = state.tabs[0];
expect(tab.schema).toBe("public");
expect(tab.table).toBe("users");
expect(tab.page).toBe(1);
expect(state.activeTabId).toBe(tab.id);
});
it("openTab does not duplicate", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "users");
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
});
it("openTab forceNew creates duplicate", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "users");
const tabsAfterTwo = useDbViewerStore.getState().tabs;
expect(tabsAfterTwo).toHaveLength(1);
store.openTab("public", "users", true);
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(2);
});
it("closeTab removes tab and switches activeTabId", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tab1Id = useDbViewerStore.getState().tabs[0].id;
store.openTab("public", "posts", true);
store.closeTab(tab1Id);
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
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("reorderTab moves a tab and keeps the active tab by id", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().openTab("public", "comments", true);
const before = useDbViewerStore.getState().tabs;
const activeId = useDbViewerStore.getState().activeTabId;
const activeTable = before.find((t) => t.id === activeId)!.table;
useDbViewerStore.getState().reorderTab(0, 2);
const after = useDbViewerStore.getState().tabs;
expect(after.map((t) => t.table)).toEqual(["posts", "comments", "users"]);
// Active tab must follow the moved item (tracked by id, not index).
expect(useDbViewerStore.getState().activeTabId).toBe(activeId);
expect(after.find((t) => t.id === activeId)!.table).toBe(activeTable);
});
it("reorderTab ignores out-of-range and no-op moves", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const reorderTab = useDbViewerStore.getState().reorderTab;
reorderTab(0, 0);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
reorderTab(-1, 1);
reorderTab(0, 5);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
});
it("setPage updates pagination", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
store.setPage(tabId, 3);
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.page).toBe(3);
});
it("setTabData updates tab data", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
// First set loading to true to verify it gets cleared
store.setTabLoading(tabId, true);
const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [[1, "Alice"]],
total_rows: 1, page: 1, page_size: 50,
};
store.setTabData(tabId, mockData);
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.data).toEqual(mockData);
expect(tab.loading).toBe(false);
expect(tab.error).toBeNull();
});
it("setTabError sets error", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
store.setTabError(tabId, "Something went wrong");
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.error).toBe("Something went wrong");
expect(tab.loading).toBe(false);
});
it("addChange appends to queue", () => {
const store = useDbViewerStore.getState();
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
const queue = useDbViewerStore.getState().changesQueue;
expect(queue).toHaveLength(1);
expect(queue[0].status).toBe("pending");
expect(queue[0].id).toBeTruthy();
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)" });
const changeId = useDbViewerStore.getState().changesQueue[0].id;
store.cancelChange(changeId);
const change = useDbViewerStore.getState().changesQueue[0];
expect(change.status).toBe("cancelled");
});
it("markChangeCommitted updates status", () => {
const store = useDbViewerStore.getState();
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
const changeId = useDbViewerStore.getState().changesQueue[0].id;
store.markChangeCommitted(changeId);
const change = useDbViewerStore.getState().changesQueue[0];
expect(change.status).toBe("committed");
});
it("markChangeFailed updates status and records error", () => {
const store = useDbViewerStore.getState();
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
const changeId = useDbViewerStore.getState().changesQueue[0].id;
store.markChangeFailed(changeId, "Constraint violation");
const change = useDbViewerStore.getState().changesQueue[0];
expect(change.status).toBe("failed");
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("setSchemaTreeLoading toggles the loading flag", () => {
const store = useDbViewerStore.getState();
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
store.setSchemaTreeLoading(true);
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true);
store.setSchemaTreeLoading(false);
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
});
it("reset clears all state", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
store.populate(["mydb"], ["public"], [{ name: "users", schema: "public", table_type: "TABLE" }]);
store.reset();
const state = useDbViewerStore.getState();
expect(state.tabs).toEqual([]);
expect(state.activeTabId).toBeNull();
expect(state.changesQueue).toEqual([]);
expect(state.databases).toEqual([]);
expect(state.schemas).toEqual([]);
expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull();
expect(state.schemaTreeLoading).toBe(false);
});
it("populate sets databases, schemas, tables", () => {
const tables: TableInfo[] = [
{ name: "users", schema: "public", table_type: "TABLE" },
{ name: "posts", schema: "public", table_type: "TABLE" },
];
const store = useDbViewerStore.getState();
store.populate(["mydb", "testdb"], ["public", "private"], tables);
const state = useDbViewerStore.getState();
expect(state.databases).toEqual(["mydb", "testdb"]);
expect(state.schemas).toEqual(["public", "private"]);
expect(state.tables).toEqual(tables);
});
it("stageCellEdit appends an update QueueItem with primaryKey + old/new data", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId,
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Alice" },
newData: { name: "Alicia" },
description: "Edit users.name",
});
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("update");
expect(q[0].primaryKey).toEqual({ id: 1 });
expect(q[0].oldData).toEqual({ name: "Alice" });
expect(q[0].newData).toEqual({ name: "Alicia" });
});
it("re-staging the same cell replaces the pending entry (keeps original oldData)", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia" },
description: "Edit users.name",
});
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia 2" },
description: "Edit users.name",
});
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].newData).toEqual({ name: "Alicia 2" });
expect(q[0].oldData).toEqual({ name: "Alice" }); // original DB value preserved
});
it("staging a different cell appends a second entry", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia" },
});
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { age: 30 }, newData: { age: 31 },
});
expect(useDbViewerStore.getState().changesQueue).toHaveLength(2);
});
it("setIndexes / setConstraints update store slices", () => {
const store = useDbViewerStore.getState();
store.setIndexes([{ name: "idx", schema: "public", table: "t", definition: "", is_unique: true, method: "btree", columns: ["id"], size_bytes: 1, tablespace: null }]);
store.setConstraints([{ name: "ck", schema: "public", table: "t", contype: "CHECK", definition: "", deferrable: false, validated: true, columns: ["x"] }]);
expect(useDbViewerStore.getState().indexes).toHaveLength(1);
expect(useDbViewerStore.getState().constraints).toHaveLength(1);
});
it("reset clears indexes and constraints", () => {
useDbViewerStore.getState().setIndexes([{ name: "x", schema: "s", table: "t", definition: "", is_unique: false, method: "btree", columns: [], size_bytes: null, tablespace: null }]);
useDbViewerStore.getState().reset();
expect(useDbViewerStore.getState().indexes).toBeNull();
});
});
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();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
currentDatabase: "mydb",
});
});
it("openQueryTab creates a query-type tab with empty query", () => {
useDbViewerStore.getState().openQueryTab();
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
const tab = state.tabs[0];
expect(tab.tabType).toBe("query");
expect(tab.query).toBe("");
expect(tab.schema).toBe("public");
expect(tab.table).toBe("Query");
expect(state.activeTabId).toBe(tab.id);
});
it("creates sequential query tabs with unique IDs", () => {
const store = useDbViewerStore.getState();
store.openQueryTab();
store.openQueryTab();
store.openQueryTab();
const tabs = useDbViewerStore.getState().tabs;
expect(tabs).toHaveLength(3);
const ids = tabs.map((t) => t.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(3);
tabs.forEach((t) => expect(t.tabType).toBe("query"));
});
it("openTab creates table-type tabs by default (backward compat)", () => {
useDbViewerStore.getState().openTab("public", "users");
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.tabType).toBe("table");
});
});
describe("openObjectTab", () => {
beforeEach(() => useDbViewerStore.getState().reset());
it("opens an object tab carrying the per-type identity + item", () => {
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0]).toMatchObject({ tabType: "object", objectType: "functions", schema: "public", table: "add" });
expect(st.tabs[0].objectItem).toEqual({ name: "add", schema: "public" });
expect(st.activeTabId).toBe(st.tabs[0].id);
});
it("dedups: re-clicking the same object focuses the existing tab", () => {
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
const first = useDbViewerStore.getState().tabs[0];
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.activeTabId).toBe(first.id);
});
it("does not collide with a table tab of the same name", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openObjectTab("functions", "public", "users", { name: "users" });
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(2);
expect(st.tabs.find((t) => t.tabType === "object")).toBeDefined();
});
});
describe("openFormTab", () => {
beforeEach(() => useDbViewerStore.getState().reset());
const createOpts = (name: string, title = "Create sequence") => ({
kind: "sequence" as const,
schema: "public",
name,
title,
description: `Create ${name}`,
mode: "create" as const,
params: { schema: "public", name, action: { op: "create" } },
});
it("opens a create form tab carrying kind/params/title/description/mode", () => {
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
const tab = st.tabs[0];
expect(tab.tabType).toBe("objectForm");
// table holds the display title, used as the TabBar label
expect(tab.table).toBe("Create sequence");
expect(tab.schema).toBe("public");
expect(tab.objectType).toBeNull();
expect(tab.form).toBeDefined();
expect(tab.form!.kind).toBe("sequence");
expect(tab.form!.mode).toBe("create");
expect(tab.form!.title).toBe("Create sequence");
expect(tab.form!.description).toBe("Create my_seq");
expect(tab.form!.params).toEqual({
schema: "public",
name: "my_seq",
action: { op: "create" },
});
expect(st.activeTabId).toBe(tab.id);
});
it("dedups create tabs per kind regardless of schema/name and does not clobber params", () => {
useDbViewerStore.getState().openFormTab(createOpts("first_seq"));
const first = useDbViewerStore.getState().tabs[0];
useDbViewerStore.getState().openFormTab(createOpts("second_seq"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.activeTabId).toBe(first.id);
// the existing tab keeps its original params (name not clobbered)
expect(st.tabs[0].form!.params.name).toBe("first_seq");
});
it("dedups edit tabs by kind+schema+name but a create tab of the same kind coexists", () => {
const editOpts = {
kind: "enum" as const,
schema: "public",
name: "role",
title: "Edit role",
description: "Edit public.role",
mode: "edit" as const,
params: { schema: "public", name: "role", action: { op: "update" } },
};
useDbViewerStore.getState().openFormTab(editOpts);
const first = useDbViewerStore.getState().tabs[0];
useDbViewerStore.getState().openFormTab(editOpts);
let st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.activeTabId).toBe(first.id);
// A create tab of the same kind is a distinct tab.
useDbViewerStore.getState().openFormTab(createOpts("new_enum", "Create enum"));
st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(2);
expect(st.tabs.every((t) => t.tabType === "objectForm")).toBe(true);
expect(st.tabs.some((t) => t.form!.mode === "edit")).toBe(true);
expect(st.tabs.some((t) => t.form!.mode === "create")).toBe(true);
});
it("does not collide with a table tab (distinct types)", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openFormTab(createOpts("users_id_seq"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(2);
expect(st.tabs[0].tabType).toBe("table");
expect(st.tabs[1].tabType).toBe("objectForm");
});
it("resolves an empty schema to the current schema", () => {
useDbViewerStore.setState({ currentSchema: "utils" });
useDbViewerStore
.getState()
.openFormTab({ ...createOpts("my_seq"), schema: "" });
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.schema).toBe("utils");
// params stay untouched — the form owns them
expect(tab.form!.params.schema).toBe("public");
});
it("falls back to public when no schema and no currentSchema", () => {
useDbViewerStore
.getState()
.openFormTab({ ...createOpts("my_seq"), schema: "" });
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.schema).toBe("public");
});
it("keeps an explicit schema even when currentSchema is set", () => {
useDbViewerStore.setState({ currentSchema: "utils" });
useDbViewerStore
.getState()
.openFormTab({ ...createOpts("my_seq"), schema: "public" });
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.schema).toBe("public");
});
it("updateFormTabParams updates params without touching title/description/mode/kind", () => {
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
const tabId = useDbViewerStore.getState().tabs[0].id;
const before = useDbViewerStore.getState().tabs[0].form;
expect(before).toBeDefined();
useDbViewerStore
.getState()
.updateFormTabParams(tabId, { schema: "other", name: "renamed", action: { op: "create" } });
const after = useDbViewerStore.getState().tabs[0].form;
expect(after?.params).toEqual({ schema: "other", name: "renamed", action: { op: "create" } });
expect(after?.title).toBe(before?.title);
expect(after?.description).toBe(before?.description);
expect(after?.mode).toBe(before?.mode);
expect(after?.kind).toBe(before?.kind);
});
});
describe("roles slice", () => {
it("setRoles stores roles", () => {
useDbViewerStore.getState().reset();
const r: RoleInfo = {
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
valid_until: null, memberships: [],
};
useDbViewerStore.getState().setRoles([r]);
expect(useDbViewerStore.getState().roles).toEqual([r]);
});
it("openFormTab accepts kind table and role", () => {
useDbViewerStore.getState().reset();
useDbViewerStore.getState().openFormTab({
kind: "table", schema: "public", name: "", title: "Create Table",
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
});
const t = useDbViewerStore.getState().tabs[0];
expect(t.tabType).toBe("objectForm");
expect(t.form?.kind).toBe("table");
// dedup: second create of same kind focuses existing
useDbViewerStore.getState().openFormTab({
kind: "table", schema: "public", name: "", title: "Create Table",
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
});
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
});
});
+650
View File
@@ -0,0 +1,650 @@
import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands";
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
// ─── Local types ────────────────────────────────────────────────
export type QueueStatus = "pending" | "cancelled" | "committed" | "failed";
export type FilterOperator = "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
export interface FilterRule {
id: string;
column: string;
operator: FilterOperator;
value: string;
}
export interface SortRule {
id: string;
column: string;
order: "asc" | "desc";
}
export interface QueueItem {
id: string;
type: ChangeItemType;
sql: string;
schema?: string;
table?: string;
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;
createdAt: number;
}
/** Payload carried by an "objectForm" tab — the kind + params for the
* object create/edit form and the SQL toggle it renders. */
export interface ViewerFormTabPayload {
kind: ObjectKind;
params: DdlParams;
title: string;
description: string;
mode: "create" | "edit";
}
export interface ViewerTab {
id: string;
schema: string;
table: string;
page: number;
pageSize: number;
loading: boolean;
error: string | null;
data: QueryResult | null;
columnFilter?: { column: string; value: string };
filterRules: FilterRule[];
sortRules: SortRule[];
hiddenColumns: string[];
smartSortApplied: boolean;
tabType: "table" | "query" | "object" | "objectForm";
query?: string;
objectType?: ObjectType | null;
objectItem?: unknown;
form?: ViewerFormTabPayload;
}
// ─── Auto-increment counters ───────────────────────────────────
let tabCounter = 0;
let changeCounter = 0;
const initialTab = (schema: string, table: string, defaultPageSize?: number): ViewerTab => ({
id: `tab-${++tabCounter}`,
schema,
table,
page: 1,
pageSize: defaultPageSize ?? 50,
loading: true,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "table",
query: undefined,
});
// ─── State interface ────────────────────────────────────────────
interface DbViewerState {
tabs: ViewerTab[];
activeTabId: string | null;
defaultPageSize: number;
changesQueue: QueueItem[];
changesPanelExpanded: boolean;
databases: string[];
schemas: string[];
tables: TableInfo[];
schemaTreeLoading: boolean;
currentDatabase: string | null;
currentSchema: string | null;
objectSearchOpen: boolean;
selectedObjectType: ObjectType | null;
/** View the DB viewer should switch to ("db-viewer" | "objects" | ...).
* Set by the search palette before closing; consumed (and cleared) by
* DbViewerScreen's navigation effect. */
requestedView: string | null;
functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null;
indexes: IndexInfo[] | null;
setSchemaTreeLoading: (loading: boolean) => void;
constraints: ConstraintInfo[] | null;
roles: RoleInfo[];
// Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void;
openQueryTab: () => void;
openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void;
openFormTab: (opts: {
kind: ObjectKind;
schema: string;
name: string;
title: string;
description: string;
mode: "create" | "edit";
params: DdlParams;
}) => void;
updateFormTabParams: (tabId: string, params: DdlParams) => void;
setDefaultPageSize: (size: number) => void;
closeTab: (tabId: string) => void;
reorderTab: (fromIndex: number, toIndex: number) => void;
closeTabsForTable: (schema: string, table: string) => void;
setActiveTab: (tabId: string) => void;
setPage: (tabId: string, page: number) => void;
setPageSize: (tabId: string, pageSize: number) => void;
setTabData: (tabId: string, data: QueryResult) => void;
setTabLoading: (tabId: string, loading: boolean) => void;
setTabError: (tabId: string, error: string) => void;
setColumnFilter: (tabId: string, column: string, value: string) => void;
clearColumnFilter: (tabId: string) => void;
setFilterRules: (tabId: string, rules: FilterRule[]) => void;
setSortRules: (tabId: string, rules: SortRule[]) => void;
setHiddenColumns: (tabId: string, columns: string[]) => void;
toggleHiddenColumn: (tabId: string, column: string) => void;
setSmartSortApplied: (tabId: string) => void;
addChange: (input: {
type: ChangeItemType;
sql?: string;
schema?: string;
table?: string;
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;
setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void;
setObjectSearchOpen: (open: boolean) => void;
setSelectedObjectType: (t: ObjectType | null) => void;
setRequestedView: (view: string | null) => void;
setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => void;
setExtensions: (extensions: ExtensionInfo[]) => void;
setIndexes: (indexes: IndexInfo[]) => void;
setConstraints: (constraints: ConstraintInfo[]) => void;
setRoles: (roles: RoleInfo[]) => void;
stageCellEdit: (input: {
tabId: string;
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
description?: string;
}) => void;
populate: (
databases: string[],
schemas: string[],
tables: TableInfo[],
) => void;
refreshTree: (connectionId: string, schema?: string) => Promise<void>;
reset: () => void;
}
// ─── Initial state ──────────────────────────────────────────────
const initialState = {
tabs: [] as ViewerTab[],
activeTabId: null as string | null,
defaultPageSize: 50,
changesQueue: [] as QueueItem[],
changesPanelExpanded: true,
databases: [] as string[],
schemas: [] as string[],
tables: [] as TableInfo[],
currentDatabase: null as string | null,
currentSchema: null as string | null,
objectSearchOpen: false,
selectedObjectType: null as ObjectType | null,
requestedView: null as string | null,
functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null,
indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null,
roles: [] as RoleInfo[],
schemaTreeLoading: false,
};
// ─── Store ──────────────────────────────────────────────────────
export const useDbViewerStore = create<DbViewerState>((set, get) => ({
...initialState,
openTab: (schema, table, forceNew = false) => {
const { tabs } = get();
// Dedup: if not forceNew and an identical tab exists, just activate it
if (!forceNew) {
const existing = tabs.find(
(t) => t.schema === schema && t.table === table,
);
if (existing) {
set({ activeTabId: existing.id });
return;
}
}
const tab = initialTab(schema, table, get().defaultPageSize);
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
openQueryTab: () => {
const { tabs, currentSchema } = get();
const queryCount = tabs.filter((t) => t.tabType === "query").length;
const tab: ViewerTab = {
id: `tab-${++tabCounter}`,
schema: currentSchema ?? "public",
table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`,
page: 1,
pageSize: get().defaultPageSize,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "query",
query: "",
};
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
openObjectTab: (objectType, schema, name, item) => {
const { tabs } = get();
// Dedup on the object's identity (objectType + schema + name); an object
// tab is distinct from a table tab of the same name.
const existing = tabs.find(
(t) =>
t.tabType === "object" &&
t.objectType === objectType &&
t.schema === schema &&
t.table === name,
);
if (existing) {
set({ activeTabId: existing.id });
return;
}
const tab: ViewerTab = {
id: `tab-${++tabCounter}`,
schema,
table: name,
page: 1,
pageSize: get().defaultPageSize,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "object",
objectType,
objectItem: item,
};
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
openFormTab: ({ kind, schema, name, title, description, mode, params }) => {
const { tabs } = get();
// Resolve the effective schema before building the tab: an explicit schema
// wins, otherwise fall back to the viewer's current schema, then "public".
// The form payload (params) is left untouched — the tab schema is used for
// dedup/identity only.
const effSchema = schema || get().currentSchema || "public";
// Dedup key semantics:
// create -> `create:<kind>` (one create form per kind;
// schema/name excluded because the user may rename while typing)
// edit -> `edit:<kind>:<schema>:<name>`
// The key is derived from the stored form payload (params carries
// { schema, name } per the objectCrud contract), so no extra field is
// needed on ViewerTab.
const existing = tabs.find(
(t) =>
t.tabType === "objectForm" &&
t.form?.kind === kind &&
t.form?.mode === mode &&
(mode === "create" ||
(t.schema === schema && t.form?.params?.name === name)),
);
if (existing) {
// Re-clicking the same form focuses the tab and never clobbers its
// in-progress params.
set({ activeTabId: existing.id });
return;
}
const tab: ViewerTab = {
id: `tab-${++tabCounter}`,
schema: effSchema,
table: title,
page: 1,
pageSize: get().defaultPageSize,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "objectForm",
objectType: null,
form: { kind, params, title, description, mode },
};
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
updateFormTabParams: (tabId, params) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId && t.tabType === "objectForm" && t.form
? { ...t, form: { ...t.form, params } }
: t,
),
})),
setDefaultPageSize: (size) => set({ defaultPageSize: size }),
closeTab: (tabId) => {
const { tabs, activeTabId } = get();
const remaining = tabs.filter((t) => t.id !== tabId);
const newActiveId =
activeTabId === tabId
? remaining.length > 0
? remaining[remaining.length - 1].id
: null
: activeTabId;
set({ tabs: remaining, activeTabId: newActiveId });
},
// Move a tab to a new index (drag-to-reorder). The active tab is tracked by
// id, so it follows the moved tab automatically.
reorderTab: (fromIndex, toIndex) => {
const { tabs } = get();
if (
fromIndex < 0 ||
fromIndex >= tabs.length ||
toIndex < 0 ||
toIndex >= tabs.length ||
fromIndex === toIndex
) {
return;
}
const next = [...tabs];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
set({ tabs: next });
},
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) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId
? { ...t, page, loading: true, error: null }
: t,
),
})),
setPageSize: (tabId, pageSize) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId
? { ...t, pageSize, page: 1, loading: true, error: null }
: t,
),
})),
setTabData: (tabId, data) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, data, loading: false, error: null } : t,
),
})),
setTabLoading: (tabId, loading) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, loading } : t,
),
})),
setTabError: (tabId, error) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, error, loading: false } : t,
),
})),
setColumnFilter: (tabId, column, value) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, columnFilter: { column, value } } : t,
),
})),
clearColumnFilter: (tabId) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, columnFilter: undefined } : t,
),
})),
setFilterRules: (tabId, rules) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, filterRules: rules, page: 1, loading: true, error: null } : t,
),
})),
setSortRules: (tabId, rules) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, sortRules: rules, page: 1, loading: true, error: null } : t,
),
})),
setHiddenColumns: (tabId, columns) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, hiddenColumns: columns } : t,
),
})),
toggleHiddenColumn: (tabId, column) =>
set((state) => ({
tabs: state.tabs.map((t) => {
if (t.id !== tabId) return t;
const exists = t.hiddenColumns.includes(column);
return {
...t,
hiddenColumns: exists
? t.hiddenColumns.filter((c) => c !== column)
: [...t.hiddenColumns, column],
};
}),
})),
setSmartSortApplied: (tabId) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, smartSortApplied: true } : t,
),
})),
addChange: (input) => {
const item: QueueItem = {
id: `ch-${++changeCounter}`,
type: input.type,
sql: input.sql ?? "",
schema: input.schema,
table: input.table,
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(),
};
set((state) => ({ changesQueue: [...state.changesQueue, item] }));
},
cancelChange: (changeId) =>
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === changeId ? { ...c, status: "cancelled" as const } : c,
),
})),
removeChange: (changeId) =>
set((state) => ({
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
})),
clearChanges: () => set({ changesQueue: [] }),
markChangeCommitted: (changeId) =>
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === changeId ? { ...c, status: "committed" as const } : c,
),
})),
markChangeFailed: (changeId, error) =>
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === changeId
? { ...c, status: "failed" as const, error }
: c,
),
})),
toggleChangesPanel: () =>
set((state) => ({ changesPanelExpanded: !state.changesPanelExpanded })),
setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }),
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
setSelectedObjectType: (t) => set({ selectedObjectType: t }),
setRequestedView: (view) => set({ requestedView: view }),
setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }),
setExtensions: (extensions) => set({ extensions }),
setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }),
setRoles: (roles) => set({ roles }),
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
stageCellEdit: (input) => {
// Re-staging the same cell replaces the existing pending entry (keeps the
// original oldData so revert restores the DB value) instead of stacking
// a second queue item.
const colName = Object.keys(input.newData)[0];
const existing = get().changesQueue.find(
(c) =>
c.status === "pending" &&
c.type === "update" &&
c.schema === input.schema &&
c.table === input.table &&
c.primaryKey &&
JSON.stringify(c.primaryKey) === JSON.stringify(input.primaryKey) &&
Object.keys(c.newData ?? {})[0] === colName,
);
if (existing) {
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === existing.id
? {
...c,
newData: input.newData,
description: input.description ?? c.description,
}
: c,
),
}));
return;
}
get().addChange({
type: "update",
schema: input.schema,
table: input.table,
primaryKey: input.primaryKey,
oldData: input.oldData,
newData: input.newData,
description: input.description ?? `Edit ${input.table}`,
});
},
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;
set({ ...initialState, tabs: [], changesQueue: [] });
},
}));
+27
View File
@@ -0,0 +1,27 @@
import { create } from "zustand";
export interface Notification {
id: string;
message: string;
type: "success" | "error" | "info";
}
interface NotificationState {
notifications: Notification[];
notify: (message: string, type?: Notification["type"]) => void;
dismiss: (id: string) => void;
}
let counter = 0;
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
notify: (message, type = "info") => {
const id = `notif-${++counter}`;
set((s) => ({ notifications: [...s.notifications, { id, message, type }] }));
setTimeout(() => {
set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) }));
}, 4000);
},
dismiss: (id) => set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })),
}));
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useQueryStore } from "./queryStore";
import * as commands from "../lib/commands";
// Mock the commands module
vi.mock("../lib/commands", () => ({
getQueryHistory: vi.fn().mockResolvedValue([]),
clearQueryHistory: vi.fn().mockResolvedValue(undefined),
setHistoryFavorite: vi.fn().mockResolvedValue(undefined),
getSavedQueries: vi.fn().mockResolvedValue([]),
saveQuery: vi.fn().mockResolvedValue({
id: "q-new",
connection_id: "c1",
name: "New",
query_text: "SELECT 1",
folder: "",
created_at: "2026-01-01",
updated_at: "2026-01-01",
}),
updateSavedQuery: vi.fn().mockResolvedValue(undefined),
deleteSavedQuery: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(() => {
// Reset the store to initial state before each test
useQueryStore.setState({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
savedQueries: null,
savedLoading: false,
savedError: null,
historyScope: null,
historySearch: "",
favoritesOnly: false,
});
vi.clearAllMocks();
});
describe("queryStore — history", () => {
it("loadHistory fetches and stores entries, clears stale flag", async () => {
const mockEntries = [
{
id: "h1", connection_id: "c1", query_text: "SELECT 1",
execution_time_ms: 5, row_count: 1, status: "success",
error_message: null, executed_at: "2026-01-01", favorite: false,
},
];
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
await useQueryStore.getState().loadHistory("c1");
const state = useQueryStore.getState();
expect(state.history).toEqual(mockEntries);
expect(state.historyStale).toBe(false);
expect(state.historyLoading).toBe(false);
expect(state.historyError).toBeNull();
});
it("loadHistory sets error on failure", async () => {
vi.mocked(commands.getQueryHistory).mockRejectedValueOnce(new Error("fail"));
await useQueryStore.getState().loadHistory("c1");
const state = useQueryStore.getState();
expect(state.historyError).toBe("fail");
expect(state.historyLoading).toBe(false);
});
it("clearHistory calls clearQueryHistory then resets", async () => {
await useQueryStore.getState().clearHistory("c1");
expect(commands.clearQueryHistory).toHaveBeenCalledWith("c1");
const state = useQueryStore.getState();
expect(state.history).toEqual([]);
expect(state.historyStale).toBe(false);
});
it('loadHistory("") maps the All-connections sentinel to null (global scope)', async () => {
await useQueryStore.getState().loadHistory("");
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
});
it("loadHistory(undefined) maps to null (global scope)", async () => {
await useQueryStore.getState().loadHistory(undefined);
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
});
it('clearHistory("") maps the All-connections sentinel to null (global scope)', async () => {
await useQueryStore.getState().clearHistory("");
expect(commands.clearQueryHistory).toHaveBeenCalledWith(null);
});
it("toggleFavorite optimistic-updates then refetches on failure", async () => {
const mockEntries = [
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
];
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
await useQueryStore.getState().loadHistory("c1");
// Toggle h1
vi.mocked(commands.setHistoryFavorite).mockResolvedValueOnce(undefined);
await useQueryStore.getState().toggleFavorite("h1", "c1");
let state = useQueryStore.getState();
expect(state.history![0].favorite).toBe(true); // optimistic set
// On failure, refetch
const reverted = [
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
];
vi.mocked(commands.setHistoryFavorite).mockRejectedValueOnce(new Error("boom"));
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(reverted);
// First toggle h2 — should fail and refetch
await useQueryStore.getState().toggleFavorite("h2", "c1");
state = useQueryStore.getState();
expect(state.history![1].favorite).toBe(false); // rolled back via refetch
});
});
describe("queryStore — saved queries", () => {
it("loadSavedQueries fetches and stores entries", async () => {
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
await useQueryStore.getState().loadSavedQueries("c1");
const state = useQueryStore.getState();
expect(state.savedQueries).toEqual(mockSaved);
expect(state.savedLoading).toBe(false);
expect(state.savedError).toBeNull();
});
it("saveCurrentQuery calls saveQuery with correct input", async () => {
vi.mocked(commands.saveQuery).mockResolvedValueOnce({
id: "new-q", connection_id: "c1", name: "MyQ", query_text: "SELECT 2", folder: "r", created_at: "", updated_at: "",
});
await useQueryStore.getState().saveCurrentQuery({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
expect(commands.saveQuery).toHaveBeenCalledWith({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
});
it("renameSavedQuery calls updateSavedQuery", async () => {
await useQueryStore.getState().renameSavedQuery("q1", { name: "Renamed" });
expect(commands.updateSavedQuery).toHaveBeenCalledWith("q1", { name: "Renamed" });
});
it("deleteSavedQuery calls deleteSavedQuery and removes from list", async () => {
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
await useQueryStore.getState().loadSavedQueries("c1");
vi.mocked(commands.deleteSavedQuery).mockResolvedValueOnce(undefined);
await useQueryStore.getState().deleteSavedQuery("q1");
expect(commands.deleteSavedQuery).toHaveBeenCalledWith("q1");
});
});
+132
View File
@@ -0,0 +1,132 @@
import { create } from "zustand";
import type { QueryHistoryEntry, SavedQuery, SaveQueryInput, UpdateSavedQueryPatch } from "../lib/commands";
import {
getQueryHistory,
clearQueryHistory,
setHistoryFavorite,
getSavedQueries,
saveQuery,
updateSavedQuery,
deleteSavedQuery,
} from "../lib/commands";
interface QueryState {
// History
history: QueryHistoryEntry[] | null;
historyLoading: boolean;
historyError: string | null;
historyStale: boolean;
historyScope: string | null; // null = all connections, string = connectionId
historySearch: string;
favoritesOnly: boolean;
// Saved queries
savedQueries: SavedQuery[] | null;
savedLoading: boolean;
savedError: string | null;
// Actions — History
loadHistory: (connectionId?: string) => Promise<void>;
clearHistory: (connectionId?: string) => Promise<void>;
toggleFavorite: (id: string, connectionId: string) => Promise<void>;
invalidateHistory: (connectionId: string) => void;
setHistoryScope: (scope: string | null) => void;
setHistorySearch: (search: string) => void;
setFavoritesOnly: (on: boolean) => void;
// Actions — Saved queries
loadSavedQueries: (connectionId?: string | null) => Promise<void>;
saveCurrentQuery: (input: SaveQueryInput) => Promise<SavedQuery>;
renameSavedQuery: (id: string, patch: UpdateSavedQueryPatch) => Promise<void>;
deleteSavedQuery: (id: string) => Promise<void>;
}
export const useQueryStore = create<QueryState>((set, get) => ({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
historyScope: null,
historySearch: "",
favoritesOnly: false,
savedQueries: null,
savedLoading: false,
savedError: null,
loadHistory: async (connectionId) => {
set({ historyLoading: true, historyError: null });
try {
const rows = await getQueryHistory(connectionId || null, 200, 0);
set({ history: rows, historyStale: false, historyLoading: false });
} catch (e) {
set({ historyError: e instanceof Error ? e.message : String(e), historyLoading: false });
}
},
clearHistory: async (connectionId) => {
await clearQueryHistory(connectionId || null);
set({ history: [], historyStale: false });
},
toggleFavorite: async (id, connectionId) => {
const prev = get().history;
if (prev) {
// Optimistic toggle
set({
history: prev.map((e) =>
e.id === id ? { ...e, favorite: !e.favorite } : e,
),
});
}
try {
await setHistoryFavorite(id, connectionId);
} catch {
// Roll back by refetching
await get().loadHistory(connectionId);
}
},
invalidateHistory: (connectionId) => {
// Only invalidate if the current scope includes this connection
const scope = get().historyScope;
if (scope === null || scope === connectionId) {
set({ historyStale: true });
}
},
setHistoryScope: (scope) => set({ historyScope: scope, historyStale: true }),
setHistorySearch: (search) => set({ historySearch: search }),
setFavoritesOnly: (on) => set({ favoritesOnly: on }),
loadSavedQueries: async (connectionId) => {
set({ savedLoading: true, savedError: null });
try {
const rows = await getSavedQueries(connectionId ?? null);
set({ savedQueries: rows, savedLoading: false });
} catch (e) {
set({ savedError: e instanceof Error ? e.message : String(e), savedLoading: false });
}
},
saveCurrentQuery: async (input) => {
const result = await saveQuery(input);
// Refresh the list
await get().loadSavedQueries(input.connectionId);
return result;
},
renameSavedQuery: async (id, patch) => {
await updateSavedQuery(id, patch);
// Refresh — we don't know which scope the panel was viewing, so reload all
await get().loadSavedQueries(null);
},
deleteSavedQuery: async (id) => {
await deleteSavedQuery(id);
// Optimistic: remove from local state
const prev = get().savedQueries;
if (prev) {
set({ savedQueries: prev.filter((q) => q.id !== id) });
}
},
}));
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useSettingsStore } from "./settingsStore";
import * as commands from "../lib/commands";
beforeEach(() => {
useSettingsStore.setState({ settings: null, loading: false, error: null });
vi.restoreAllMocks();
});
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", 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);
});
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", 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");
expect(useSettingsStore.getState().settings?.theme).toBe("light");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
import type { Settings } from "../lib/types";
import * as cmd from "../lib/commands";
interface SettingsState {
settings: Settings | null; loading: boolean; error: string | null;
load: () => Promise<void>;
updateSetting: (key: string, value: string) => Promise<void>;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
settings: null, loading: false, error: null,
load: async () => {
set({ loading: true, error: null });
try {
const settings = await cmd.getSettings();
set({ settings, loading: false });
} catch (e) {
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
},
updateSetting: async (key, value) => {
await cmd.updateSetting(key, value);
await get().load();
},
}));
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useUiStore } from "./uiStore";
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null }));
describe("uiStore", () => {
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
it("setActiveView changes view", () => {
useUiStore.getState().setActiveView("settings");
expect(useUiStore.getState().activeView).toBe("settings");
});
it("setSearchQuery updates query", () => {
useUiStore.getState().setSearchQuery("prod");
expect(useUiStore.getState().searchQuery).toBe("prod");
});
it("toggleTag adds and removes tag", () => {
useUiStore.getState().toggleTag("t1");
expect(useUiStore.getState().activeTagIds).toEqual(["t1"]);
useUiStore.getState().toggleTag("t1");
expect(useUiStore.getState().activeTagIds).toEqual([]);
});
it("toggleDbType adds and removes type", () => {
useUiStore.getState().toggleDbType("redis");
expect(useUiStore.getState().activeDbTypes).toEqual(["redis"]);
useUiStore.getState().toggleDbType("redis");
expect(useUiStore.getState().activeDbTypes).toEqual([]);
});
it("clearFilters resets search and filters but not view", () => {
useUiStore.getState().setSearchQuery("x");
useUiStore.getState().toggleTag("t1");
useUiStore.getState().setActiveView("settings");
useUiStore.getState().clearFilters();
expect(useUiStore.getState().searchQuery).toBe("");
expect(useUiStore.getState().activeTagIds).toEqual([]);
expect(useUiStore.getState().activeView).toBe("settings");
});
it("sets and clears prefilled connection string", () => {
useUiStore.getState().setPrefilledConnectionString("postgresql://a@b/c");
expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://a@b/c");
useUiStore.getState().clearPrefilledConnectionString();
expect(useUiStore.getState().prefilledConnectionString).toBeNull();
});
it("setEnvironment sets activeEnvironment", () => {
useUiStore.getState().setEnvironment("production");
expect(useUiStore.getState().activeEnvironment).toBe("production");
});
it("clearFilters resets activeEnvironment", () => {
useUiStore.getState().setEnvironment("staging");
useUiStore.getState().clearFilters();
expect(useUiStore.getState().activeEnvironment).toBeNull();
});
it("sets and clears activeConnectionId", () => {
useUiStore.getState().setActiveConnectionId("c1");
expect(useUiStore.getState().activeConnectionId).toBe("c1");
useUiStore.getState().setActiveConnectionId(null);
expect(useUiStore.getState().activeConnectionId).toBeNull();
});
it("openSettings from home records home and closeSettings returns to home", () => {
useUiStore.getState().openSettings();
expect(useUiStore.getState().settingsReturnView).toBe("home");
expect(useUiStore.getState().activeView).toBe("settings");
useUiStore.getState().closeSettings();
expect(useUiStore.getState().activeView).toBe("home");
expect(useUiStore.getState().settingsReturnView).toBeNull();
});
it("openSettings from db-viewer records db-viewer and closeSettings returns to it", () => {
useUiStore.getState().setActiveView("db-viewer");
useUiStore.getState().openSettings();
expect(useUiStore.getState().settingsReturnView).toBe("db-viewer");
expect(useUiStore.getState().activeView).toBe("settings");
useUiStore.getState().closeSettings();
expect(useUiStore.getState().activeView).toBe("db-viewer");
expect(useUiStore.getState().settingsReturnView).toBeNull();
});
it("double openSettings keeps the original return view", () => {
useUiStore.getState().setActiveView("db-viewer");
useUiStore.getState().openSettings();
useUiStore.getState().openSettings();
expect(useUiStore.getState().settingsReturnView).toBe("db-viewer");
expect(useUiStore.getState().activeView).toBe("settings");
useUiStore.getState().closeSettings();
expect(useUiStore.getState().activeView).toBe("db-viewer");
});
it("closeSettings falls back to home when no return view is recorded", () => {
useUiStore.getState().setActiveView("settings");
useUiStore.getState().closeSettings();
expect(useUiStore.getState().activeView).toBe("home");
expect(useUiStore.getState().settingsReturnView).toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { create } from "zustand";
import type { ActiveView, DbType } from "../lib/types";
interface UiState {
searchQuery: string;
activeFolderId: string | null;
activeTagIds: string[];
activeDbTypes: DbType[];
activeEnvironment: string | null;
activeView: ActiveView;
selectedItemIds: string[];
prefilledConnectionString: string | null;
activeConnectionId: string | null;
settingsReturnView: Exclude<ActiveView, "settings"> | null;
setActiveView: (view: ActiveView) => void;
openSettings: () => void;
closeSettings: () => void;
setSearchQuery: (q: string) => void;
setActiveFolderId: (id: string | null) => void;
toggleTag: (id: string) => void;
toggleDbType: (type: DbType) => void;
setEnvironment: (env: string | null) => void;
clearFilters: () => void;
toggleItemSelection: (id: string) => void;
selectAllItems: (ids: string[]) => void;
clearSelection: () => void;
setPrefilledConnectionString: (value: string) => void;
clearPrefilledConnectionString: () => void;
setActiveConnectionId: (id: string | null) => void;
}
export const useUiStore = create<UiState>((set) => ({
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null,
setActiveView: (view) => set({ activeView: view }),
openSettings: () => set((s) => ({
settingsReturnView:
s.activeView === "settings"
? s.settingsReturnView
: s.activeView === "home" || s.activeView === "db-viewer" || s.activeView === "new-connection"
? s.activeView
: null,
activeView: "settings",
})),
closeSettings: () => set((s) => ({
activeView: s.settingsReturnView ?? "home",
settingsReturnView: null,
})),
setSearchQuery: (q) => set({ searchQuery: q }),
setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }),
toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })),
toggleDbType: (type) => set((s) => ({ activeDbTypes: s.activeDbTypes.includes(type) ? s.activeDbTypes.filter((t) => t !== type) : [...s.activeDbTypes, type] })),
setEnvironment: (env) => set({ activeEnvironment: env }),
clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null, activeEnvironment: null }),
toggleItemSelection: (id) => set((s) => ({
selectedItemIds: s.selectedItemIds.includes(id)
? s.selectedItemIds.filter((i) => i !== id)
: [...s.selectedItemIds, id],
})),
selectAllItems: (ids) => set({ selectedItemIds: ids }),
clearSelection: () => set({ selectedItemIds: [] }),
setPrefilledConnectionString: (value) => set({ prefilledConnectionString: value }),
clearPrefilledConnectionString: () => set({ prefilledConnectionString: null }),
setActiveConnectionId: (id) => set({ activeConnectionId: id }),
}));