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:
@@ -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");
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
},
|
||||
}));
|
||||
@@ -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) })),
|
||||
}));
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user