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,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { applyTheme, applyFontSize, applyAccentColor, resolveTheme } from "./useAppearance";
|
||||
|
||||
const windowMocks = vi.hoisted(() => ({
|
||||
setTheme: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundColor: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({
|
||||
setTheme: windowMocks.setTheme,
|
||||
setBackgroundColor: windowMocks.setBackgroundColor,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useAppearance helpers", () => {
|
||||
const getRoot = () => document.documentElement;
|
||||
|
||||
const stubMatchMedia = (
|
||||
matches: boolean,
|
||||
addEventListener: ReturnType<typeof vi.fn>,
|
||||
removeEventListener: ReturnType<typeof vi.fn>,
|
||||
) => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({
|
||||
matches,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getRoot().classList.remove("light");
|
||||
delete getRoot().dataset.fontSize;
|
||||
getRoot().style.removeProperty("--color-accent");
|
||||
windowMocks.setTheme.mockClear();
|
||||
windowMocks.setBackgroundColor.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getRoot().classList.remove("light");
|
||||
delete getRoot().dataset.fontSize;
|
||||
getRoot().style.removeProperty("--color-accent");
|
||||
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
||||
});
|
||||
|
||||
it('applyTheme("dark") removes the light class', () => {
|
||||
getRoot().classList.add("light");
|
||||
applyTheme("dark");
|
||||
expect(getRoot().classList.contains("light")).toBe(false);
|
||||
});
|
||||
|
||||
it('applyTheme("light") adds the light class', () => {
|
||||
applyTheme("light");
|
||||
expect(getRoot().classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
it('applyTheme("system") keeps dark when the OS does not prefer light', () => {
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
stubMatchMedia(false, addEventListener, removeEventListener);
|
||||
const cleanup = applyTheme("system");
|
||||
expect(getRoot().classList.contains("light")).toBe(false);
|
||||
expect(addEventListener).toHaveBeenCalled();
|
||||
cleanup();
|
||||
expect(removeEventListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyTheme("system") adds the light class when the OS prefers light', () => {
|
||||
stubMatchMedia(true, vi.fn(), vi.fn());
|
||||
applyTheme("system");
|
||||
expect(getRoot().classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
it('resolveTheme("light") is "light"', () => {
|
||||
expect(resolveTheme("light")).toBe("light");
|
||||
});
|
||||
|
||||
it('resolveTheme("dark") is "dark"', () => {
|
||||
expect(resolveTheme("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it('resolveTheme("system") is "dark" when the OS does not prefer light', () => {
|
||||
stubMatchMedia(false, vi.fn(), vi.fn());
|
||||
expect(resolveTheme("system")).toBe("dark");
|
||||
});
|
||||
|
||||
it('resolveTheme("system") is "light" when the OS prefers light', () => {
|
||||
stubMatchMedia(true, vi.fn(), vi.fn());
|
||||
expect(resolveTheme("system")).toBe("light");
|
||||
});
|
||||
|
||||
it('resolveTheme("system") falls back to "dark" without matchMedia', () => {
|
||||
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
||||
expect(resolveTheme("system")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
it('applyFontSize("small") sets the data attribute', () => {
|
||||
applyFontSize("small");
|
||||
expect(getRoot().dataset.fontSize).toBe("small");
|
||||
});
|
||||
|
||||
it('applyFontSize("large") sets the data attribute', () => {
|
||||
applyFontSize("large");
|
||||
expect(getRoot().dataset.fontSize).toBe("large");
|
||||
});
|
||||
|
||||
it('applyFontSize("medium") removes the data attribute', () => {
|
||||
getRoot().dataset.fontSize = "large";
|
||||
applyFontSize("medium");
|
||||
expect(getRoot().dataset.fontSize).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applyAccentColor sets the custom property for a valid hex', () => {
|
||||
applyAccentColor("#22C55E");
|
||||
expect(
|
||||
getRoot().style.getPropertyValue("--color-accent").toLowerCase()
|
||||
).toBe("#22c55e");
|
||||
});
|
||||
|
||||
it('applyAccentColor accepts lowercase hex', () => {
|
||||
applyAccentColor("#2563eb");
|
||||
expect(
|
||||
getRoot().style.getPropertyValue("--color-accent").toLowerCase()
|
||||
).toBe("#2563eb");
|
||||
});
|
||||
|
||||
it('applyAccentColor removes the custom property for an invalid value', () => {
|
||||
getRoot().style.setProperty("--color-accent", "#22C55E");
|
||||
applyAccentColor("blue");
|
||||
expect(getRoot().style.getPropertyValue("--color-accent")).toBe("");
|
||||
});
|
||||
|
||||
it('applyAccentColor rejects malformed hex', () => {
|
||||
applyAccentColor("#22C5");
|
||||
expect(getRoot().style.getPropertyValue("--color-accent")).toBe("");
|
||||
});
|
||||
|
||||
it("syncs the native window (theme + background) for light", async () => {
|
||||
applyTheme("light");
|
||||
// Allow the fire-and-forget dynamic import + invoke to settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(windowMocks.setTheme).toHaveBeenCalledWith("light");
|
||||
expect(windowMocks.setBackgroundColor).toHaveBeenCalledWith("#FAFAFA");
|
||||
});
|
||||
|
||||
it("syncs the native window (theme + background) for dark", async () => {
|
||||
applyTheme("dark");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(windowMocks.setTheme).toHaveBeenCalledWith("dark");
|
||||
expect(windowMocks.setBackgroundColor).toHaveBeenCalledWith("#0A0A0B");
|
||||
});
|
||||
|
||||
it("resets the window to follow the OS when switching to system", async () => {
|
||||
stubMatchMedia(false, vi.fn(), vi.fn());
|
||||
applyTheme("system");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(windowMocks.setTheme).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Theme, FontSize } from "../lib/types";
|
||||
|
||||
const LIGHT_COLOR_SCHEME_QUERY = "(prefers-color-scheme: light)";
|
||||
|
||||
/**
|
||||
* Resolves a Theme setting to the concrete color scheme it maps to (pure).
|
||||
* For "system" this reads the webview's prefers-color-scheme, which correctly
|
||||
* mirrors the OS only while the native window is NOT forced to a specific
|
||||
* theme (see applyTheme's system handling).
|
||||
*/
|
||||
export function resolveTheme(theme: Theme): "light" | "dark" {
|
||||
if (theme === "dark") return "dark";
|
||||
if (theme === "light") return "light";
|
||||
// "system" — follow the OS preference. Guard for environments without matchMedia.
|
||||
if (typeof window.matchMedia !== "function") return "dark";
|
||||
return window.matchMedia(LIGHT_COLOR_SCHEME_QUERY).matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the native window chrome. Pass null to reset the window to follow the
|
||||
* OS theme — required for the "system" setting, because forcing the window
|
||||
* theme changes the webview's prefers-color-scheme (WKWebView follows the
|
||||
* window appearance), which would otherwise pollute matchMedia.
|
||||
*/
|
||||
async function syncWindowTheme(effective: "light" | "dark" | null): Promise<void> {
|
||||
try {
|
||||
// Dynamic import keeps the Tauri API out of the hot path for
|
||||
// non-Tauri bundles and non-Tauri test environments.
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const win = getCurrentWindow();
|
||||
if (effective === null) {
|
||||
await win.setTheme(null);
|
||||
return;
|
||||
}
|
||||
await win.setTheme(effective);
|
||||
// macOS "Overlay" title bar paints the WINDOW background color in the
|
||||
// title bar strip; keep it in sync with the theme.
|
||||
await win.setBackgroundColor(effective === "light" ? "#FAFAFA" : "#0A0A0B");
|
||||
} catch {
|
||||
// Outside Tauri — nothing to sync.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the theme to the document root and returns a cleanup that
|
||||
* stops tracking the system preference while the theme is "system".
|
||||
* Also syncs the native window chrome (fire-and-forget; noop outside Tauri).
|
||||
*/
|
||||
export function applyTheme(theme: Theme): () => void {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === "dark") {
|
||||
root.classList.remove("light");
|
||||
void syncWindowTheme("dark");
|
||||
return () => {};
|
||||
}
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
void syncWindowTheme("light");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// "system" — follow the OS preference and keep it in sync.
|
||||
// Guard for environments without matchMedia (e.g. jsdom).
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
root.classList.remove("light");
|
||||
void syncWindowTheme(null);
|
||||
return () => {};
|
||||
}
|
||||
const mq = window.matchMedia(LIGHT_COLOR_SCHEME_QUERY);
|
||||
const applySystem = () => {
|
||||
root.classList.toggle("light", mq.matches);
|
||||
void syncWindowTheme(null);
|
||||
};
|
||||
applySystem();
|
||||
// Reset the native window to follow the OS first, then re-read matchMedia
|
||||
// once it actually mirrors the OS — the immediate applySystem above may be
|
||||
// stale if the window was previously forced to the other theme.
|
||||
void syncWindowTheme(null).then(applySystem);
|
||||
mq.addEventListener("change", applySystem);
|
||||
return () => mq.removeEventListener("change", applySystem);
|
||||
}
|
||||
|
||||
/** Applies the font-size scale by toggling a data attribute on the root. */
|
||||
export function applyFontSize(fontSize: FontSize): void {
|
||||
const root = document.documentElement;
|
||||
if (fontSize === "medium") {
|
||||
delete root.dataset.fontSize;
|
||||
} else {
|
||||
root.dataset.fontSize = fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the accent color as a CSS custom property on the root. Invalid or
|
||||
* non-hex values fall back to the theme default (via removal).
|
||||
*/
|
||||
export function applyAccentColor(accent: string): void {
|
||||
const root = document.documentElement;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
||||
root.style.setProperty("--color-accent", accent);
|
||||
} else {
|
||||
root.style.removeProperty("--color-accent");
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the document appearance in sync with the theme/font-size settings. */
|
||||
export function useAppearance(theme: Theme, fontSize: FontSize, accentColor: string): void {
|
||||
useEffect(() => {
|
||||
const cleanupTheme = applyTheme(theme);
|
||||
applyFontSize(fontSize);
|
||||
applyAccentColor(accentColor);
|
||||
return cleanupTheme;
|
||||
}, [theme, fontSize, accentColor]);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useFilteredConnections } from "./useConnections";
|
||||
import { useConnectionStore } from "../stores/connectionStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import type { Connection } from "../lib/types";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
id: "c1",
|
||||
name: "Test DB",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
database: 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,
|
||||
favorite: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Root DB", folder_id: null }),
|
||||
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1" }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
|
||||
],
|
||||
folders: [
|
||||
{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "F2", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
],
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useUiStore.setState({
|
||||
searchQuery: "",
|
||||
activeFolderId: null,
|
||||
activeTagIds: [],
|
||||
activeDbTypes: [],
|
||||
activeEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilteredConnections", () => {
|
||||
it("returns all connections at root with no filters", () => {
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns folder-descendant connections when browsing a folder", () => {
|
||||
useUiStore.setState({ activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(2);
|
||||
expect(result.current.map((c) => c.id)).toEqual(["c2", "c3"]);
|
||||
});
|
||||
|
||||
it("returns ALL connections matching search regardless of active folder", () => {
|
||||
// c1 "Root DB" is at root (folder_id: null) — filtered out by
|
||||
// current folder-scope code when activeFolderId is set.
|
||||
useUiStore.setState({ searchQuery: "Root", activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c1");
|
||||
});
|
||||
|
||||
it("returns ALL connections matching tag filter regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Root DB", folder_id: null, tag_ids: ["t1"] }),
|
||||
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1", tag_ids: [] }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2", tag_ids: [] }),
|
||||
],
|
||||
tags: [{ id: "t1", name: "prod", color: "#f00", created_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeTagIds: ["t1"], activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c1");
|
||||
});
|
||||
|
||||
it("returns ALL connections matching environment regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
|
||||
makeConn({ id: "c2", name: "Dev", db_type: "sqlite", folder_id: "f1", environment: "development" }),
|
||||
],
|
||||
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeEnvironment: "development", activeFolderId: null });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c2");
|
||||
});
|
||||
|
||||
it("environment filter counts as active filter (bypasses folder scope)", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
|
||||
makeConn({ id: "c2", name: "Prod2", db_type: "postgresql", folder_id: "f1", environment: "production" }),
|
||||
],
|
||||
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeEnvironment: "production", activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns ALL connections matching DB type filter regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "PG", db_type: "postgresql", folder_id: null }),
|
||||
makeConn({ id: "c2", name: "SQLite", db_type: "sqlite", folder_id: "f1" }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
|
||||
],
|
||||
});
|
||||
useUiStore.setState({ activeDbTypes: ["sqlite"], activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useConnectionStore } from "../stores/connectionStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { filterConnections, getDescendantFolderIds } from "../lib/utils";
|
||||
import type { Connection } from "../lib/types";
|
||||
|
||||
export function useFilteredConnections(): Connection[] {
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const searchQuery = useUiStore((s) => s.searchQuery);
|
||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||
const activeTagIds = useUiStore((s) => s.activeTagIds);
|
||||
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
|
||||
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
|
||||
|
||||
const hasFilters =
|
||||
searchQuery.length > 0 ||
|
||||
activeTagIds.length > 0 ||
|
||||
activeDbTypes.length > 0 ||
|
||||
(activeEnvironment !== null && activeEnvironment !== undefined);
|
||||
|
||||
let filtered = filterConnections(connections, tags, {
|
||||
query: searchQuery,
|
||||
activeTagIds,
|
||||
activeDbTypes,
|
||||
activeEnvironment,
|
||||
});
|
||||
|
||||
if (!hasFilters && activeFolderId) {
|
||||
const allowed = new Set(getDescendantFolderIds(folders, activeFolderId));
|
||||
filtered = filtered.filter(
|
||||
(c) => c.folder_id !== null && allowed.has(c.folder_id),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { useDbConnection } from "./useDbConnection";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
vi.mock("../lib/commands", () => ({
|
||||
dbConnect: vi.fn().mockResolvedValue(undefined),
|
||||
dbDisconnect: vi.fn().mockResolvedValue(undefined),
|
||||
getDatabases: vi.fn().mockResolvedValue([]),
|
||||
getSchemas: vi.fn().mockResolvedValue([]),
|
||||
getTables: vi.fn().mockResolvedValue([]),
|
||||
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
|
||||
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const mockCommands = vi.mocked(commands);
|
||||
|
||||
const mockConnection = {
|
||||
id: "c1",
|
||||
name: "Conn",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
database: "mydb",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
ssh_host: null as string | null,
|
||||
ssh_port: null,
|
||||
ssh_user: null,
|
||||
ssh_auth_method: null as string | null,
|
||||
ssh_private_key_path: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
use_keychain: undefined as boolean | undefined,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
};
|
||||
|
||||
// Mutable stand-in for the connection store's session-password behavior: a
|
||||
// fallback password (default "pw", like a keychain hit) plus a per-connection
|
||||
// session map written by setSessionPassword (like the real store).
|
||||
const mockStoreState = vi.hoisted(() => {
|
||||
const sessionPasswords = new Map<string, string>();
|
||||
let fallbackPassword: string | null = "pw";
|
||||
return {
|
||||
sessionPasswords,
|
||||
setFallbackPassword: (p: string | null) => {
|
||||
fallbackPassword = p;
|
||||
},
|
||||
getConnectionPassword: vi.fn(
|
||||
async (id: string) => sessionPasswords.get(id) ?? fallbackPassword,
|
||||
),
|
||||
setSessionPassword: vi.fn((id: string, pw: string) => {
|
||||
sessionPasswords.set(id, pw);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../stores/connectionStore", () => ({
|
||||
useConnectionStore: {
|
||||
getState: () => ({
|
||||
connections: [mockConnection],
|
||||
getConnectionPassword: mockStoreState.getConnectionPassword,
|
||||
setSessionPassword: mockStoreState.setSessionPassword,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness() {
|
||||
const { connect } = useDbConnection("c1");
|
||||
return (
|
||||
<button type="button" onClick={() => connect()}>
|
||||
connect
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptHarness() {
|
||||
const { passwordPromptOpen } = useDbConnection("c1");
|
||||
return <div data-testid="prompt">{passwordPromptOpen ? "open" : "closed"}</div>;
|
||||
}
|
||||
|
||||
describe("useDbConnection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the mutable store stand-in + connection for isolation.
|
||||
mockStoreState.sessionPasswords.clear();
|
||||
mockStoreState.setFallbackPassword("pw");
|
||||
(mockConnection as any).use_keychain = undefined;
|
||||
useDbViewerStore.getState().reset();
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||
mockCommands.getTables.mockResolvedValue([]);
|
||||
mockCommands.getConnectionSshPassword.mockResolvedValue(null);
|
||||
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||
mockConnection.ssh_host = null;
|
||||
mockConnection.ssh_auth_method = null;
|
||||
});
|
||||
|
||||
it("sets schemaTreeLoading around the connect fetch", async () => {
|
||||
mockCommands.getSchemas.mockImplementation(
|
||||
() => new Promise((res) => setTimeout(() => res(["public"]), 50)),
|
||||
);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears schemaTreeLoading when connect throws", async () => {
|
||||
mockCommands.getSchemas.mockRejectedValue(new Error("boom"));
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("connects and smart-selects the public schema when available", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
// tables are fetched for the smart default schema
|
||||
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "public");
|
||||
});
|
||||
|
||||
it("falls back to the first schema when public is absent", async () => {
|
||||
mockCommands.getSchemas.mockResolvedValue(["analytics", "app"]);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("analytics");
|
||||
});
|
||||
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "analytics");
|
||||
});
|
||||
|
||||
it("reconnects and refreshes schemas when the database changes", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["analytics", "public"]);
|
||||
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "otherdb" });
|
||||
});
|
||||
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "otherdb" }),
|
||||
);
|
||||
expect(useDbViewerStore.getState().schemas).toEqual([
|
||||
"analytics",
|
||||
"public",
|
||||
]);
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
// tables refetched for the new database's smart schema
|
||||
expect(mockCommands.getTables).toHaveBeenLastCalledWith("c1", "public");
|
||||
});
|
||||
|
||||
it("refreshes schemas when switching back to the initial database", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
|
||||
// Switch away to otherdb
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "otherdb" });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "otherdb" }),
|
||||
),
|
||||
);
|
||||
|
||||
// Switch back to mydb — must reconnect and refresh schemas again
|
||||
mockCommands.getSchemas.mockResolvedValue(["public", "reporting"]);
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "mydb" });
|
||||
});
|
||||
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "mydb" }),
|
||||
);
|
||||
expect(useDbViewerStore.getState().schemas).toEqual([
|
||||
"public",
|
||||
"reporting",
|
||||
]);
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
});
|
||||
|
||||
it("opens the password prompt when use_keychain=false and no session password", async () => {
|
||||
mockConnection.use_keychain = false;
|
||||
mockStoreState.setFallbackPassword(null);
|
||||
render(<PromptHarness />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("prompt")).toHaveTextContent("open"),
|
||||
);
|
||||
// Early return: no Tauri call fires before the user submits a password.
|
||||
expect(mockCommands.dbConnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt when keychain is enabled (even without a stored password)", async () => {
|
||||
mockConnection.use_keychain = true;
|
||||
mockStoreState.setFallbackPassword(null);
|
||||
render(<PromptHarness />);
|
||||
await waitFor(() =>
|
||||
expect(mockCommands.dbConnect).toHaveBeenCalled(),
|
||||
);
|
||||
expect(screen.getByTestId("prompt")).toHaveTextContent("closed");
|
||||
});
|
||||
|
||||
it("submitPassword stores the session password and reconnects", async () => {
|
||||
mockConnection.use_keychain = false;
|
||||
mockStoreState.setFallbackPassword(null);
|
||||
let submit: ((pw: string) => void) | null = null;
|
||||
function SubmitHarness() {
|
||||
const { passwordPromptOpen, submitPassword } = useDbConnection("c1");
|
||||
submit = submitPassword;
|
||||
return (
|
||||
<div data-testid="prompt">
|
||||
{passwordPromptOpen ? "open" : "closed"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<SubmitHarness />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("prompt")).toHaveTextContent("open"),
|
||||
);
|
||||
act(() => submit!("secret"));
|
||||
expect(mockStoreState.setSessionPassword).toHaveBeenCalledWith("c1", "secret");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("prompt")).toHaveTextContent("closed"),
|
||||
);
|
||||
await waitFor(() => expect(mockCommands.dbConnect).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("fetches ssh secrets from keychain before connecting when ssh_host is set", async () => {
|
||||
mockConnection.ssh_host = "bastion.example.com";
|
||||
mockConnection.ssh_auth_method = "password";
|
||||
mockCommands.getConnectionSshPassword.mockResolvedValue("sshpw");
|
||||
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCommands.dbConnect).toHaveBeenCalled();
|
||||
});
|
||||
const config = mockCommands.dbConnect.mock.calls[0][1];
|
||||
expect(mockCommands.getConnectionSshPassword).toHaveBeenCalledWith("c1");
|
||||
expect(mockCommands.getConnectionSshPassphrase).toHaveBeenCalledWith("c1");
|
||||
expect(config.ssh_password).toBe("sshpw");
|
||||
expect(config.ssh_passphrase).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { useConnectionStore } from "../stores/connectionStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
import * as cmd from "../lib/commands";
|
||||
import { pickDefaultSchema } from "../lib/utils";
|
||||
import type { ConnectionInput, TableInfo } from "../lib/types";
|
||||
|
||||
export function useDbConnection(connectionId: string) {
|
||||
const reset = useDbViewerStore((s) => s.reset);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
const setSchemaTreeLoading = useDbViewerStore((s) => s.setSchemaTreeLoading);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
|
||||
const inputRef = useRef<ConnectionInput | null>(null);
|
||||
// The database the pool is currently connected to. Unlike the selected
|
||||
// `currentDatabase`, this lets us reconnect whenever the selection drifts
|
||||
// from the live connection (including switching back to the first DB).
|
||||
const connectedDbRef = useRef<string | null>(null);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
const conn = useConnectionStore
|
||||
.getState()
|
||||
.connections.find((c) => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setConnectionError("Connection not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
||||
// Keychain-off + no session password: prompt the user instead of
|
||||
// connecting with an empty password (early return, no Tauri call).
|
||||
if (conn.use_keychain === false && !password) {
|
||||
setPasswordPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
const sshPassword = conn.ssh_host
|
||||
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
||||
: null;
|
||||
const sshPassphrase = conn.ssh_host
|
||||
? await cmd.getConnectionSshPassphrase(conn.id).catch(() => null)
|
||||
: null;
|
||||
const input: ConnectionInput = {
|
||||
name: conn.name,
|
||||
db_type: conn.db_type,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
password,
|
||||
database: conn.database,
|
||||
folder_id: conn.folder_id,
|
||||
ssh_host: conn.ssh_host,
|
||||
ssh_port: conn.ssh_port,
|
||||
ssh_user: conn.ssh_user,
|
||||
ssh_auth_method:
|
||||
conn.ssh_auth_method as "password" | "key" | null | undefined,
|
||||
ssh_private_key_path: conn.ssh_private_key_path,
|
||||
ssh_password: sshPassword,
|
||||
ssh_passphrase: sshPassphrase,
|
||||
ssl_mode:
|
||||
conn.ssl_mode as
|
||||
| "disable"
|
||||
| "require"
|
||||
| "verify-ca"
|
||||
| "verify-full"
|
||||
| null
|
||||
| undefined,
|
||||
ssl_ca_path: conn.ssl_ca_path,
|
||||
ssl_cert_path: conn.ssl_cert_path,
|
||||
ssl_key_path: conn.ssl_key_path,
|
||||
};
|
||||
await cmd.dbConnect(connectionId, input);
|
||||
setConnectionError(null);
|
||||
inputRef.current = input;
|
||||
connectedDbRef.current =
|
||||
input.db_type === "sqlite"
|
||||
? "main"
|
||||
: (input.database ?? "postgres");
|
||||
|
||||
// Load initial data, smart-selecting the default schema (e.g. `public`)
|
||||
setSchemaTreeLoading(true);
|
||||
try {
|
||||
const databases = await cmd
|
||||
.getDatabases(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const defaultSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd.getTables(
|
||||
connectionId,
|
||||
defaultSchema ?? undefined,
|
||||
);
|
||||
populate(databases, schemas, tables);
|
||||
if (databases.length > 0) {
|
||||
// Prefer the connection's configured database, fall back to the first
|
||||
// available one so the dropdown matches what the pool is connected to.
|
||||
const preferred =
|
||||
input.database && databases.includes(input.database)
|
||||
? input.database
|
||||
: databases[0];
|
||||
setCurrentDatabase(preferred);
|
||||
}
|
||||
setCurrentSchema(defaultSchema);
|
||||
} finally {
|
||||
setSchemaTreeLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setConnectionError(msg);
|
||||
notify(`Failed to connect: ${msg}`, "error");
|
||||
}
|
||||
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, setSchemaTreeLoading, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
cmd.dbDisconnect(connectionId).catch(() => {});
|
||||
const hasPending = useDbViewerStore
|
||||
.getState()
|
||||
.changesQueue.some((c) => c.status === "pending");
|
||||
if (!hasPending) reset();
|
||||
};
|
||||
}, [connectionId, connect, reset]);
|
||||
|
||||
// Database switch effect: whenever the selected database drifts from the
|
||||
// pool's live connection, reconnect and refresh schemas/tables for that DB.
|
||||
useEffect(() => {
|
||||
if (!currentDatabase || !inputRef.current) return;
|
||||
if (currentDatabase === connectedDbRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const reconnect = async () => {
|
||||
const input = { ...inputRef.current!, database: currentDatabase };
|
||||
try {
|
||||
setSchemaTreeLoading(true);
|
||||
await cmd.dbConnect(connectionId, input);
|
||||
if (cancelled) return;
|
||||
connectedDbRef.current = currentDatabase;
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
if (cancelled) return;
|
||||
const newSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd
|
||||
.getTables(connectionId, newSchema ?? undefined)
|
||||
.catch(() => [] as TableInfo[]);
|
||||
if (cancelled) return;
|
||||
populate(useDbViewerStore.getState().databases, schemas, tables);
|
||||
setCurrentSchema(newSchema);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Revert the selection so the dropdown matches the live connection
|
||||
setCurrentDatabase(connectedDbRef.current);
|
||||
notify(`Failed to switch database: ${msg}`, "error");
|
||||
} finally {
|
||||
if (!cancelled) setSchemaTreeLoading(false);
|
||||
}
|
||||
};
|
||||
reconnect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
|
||||
|
||||
const submitPassword = useCallback(
|
||||
(pw: string) => {
|
||||
useConnectionStore.getState().setSessionPassword(connectionId, pw);
|
||||
setPasswordPromptOpen(false);
|
||||
void connect();
|
||||
},
|
||||
[connectionId, connect],
|
||||
);
|
||||
|
||||
const cancelPassword = useCallback(() => setPasswordPromptOpen(false), []);
|
||||
|
||||
return { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSearch } from "./useSearch";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useSearch", () => {
|
||||
it("returns initial empty value", () => {
|
||||
const { result } = renderHook(() => useSearch(""));
|
||||
expect(result.current.debounced).toBe("");
|
||||
});
|
||||
|
||||
it("debounces value updates by 150ms", () => {
|
||||
const { result, rerender } = renderHook(({ q }) => useSearch(q), {
|
||||
initialProps: { q: "" },
|
||||
});
|
||||
rerender({ q: "prod" });
|
||||
expect(result.current.debounced).toBe("");
|
||||
|
||||
act(() => vi.advanceTimersByTime(149));
|
||||
expect(result.current.debounced).toBe("");
|
||||
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(result.current.debounced).toBe("prod");
|
||||
});
|
||||
|
||||
it("resets debounce when value returns to empty", () => {
|
||||
const { result, rerender } = renderHook(({ q }) => useSearch(q), {
|
||||
initialProps: { q: "" },
|
||||
});
|
||||
rerender({ q: "x" });
|
||||
rerender({ q: "" });
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
expect(result.current.debounced).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useSearch(query: string): { debounced: string } {
|
||||
const [debounced, setDebounced] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query), 150);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
return { debounced };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
|
||||
// Default macOS shortcuts (used when no custom binding is set)
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
command_palette: "Meta+k",
|
||||
close_tab: "Meta+w",
|
||||
};
|
||||
|
||||
// Normalize key combo from settings (e.g. "Meta+K" → { metaKey: true, key: "k" })
|
||||
function parseCombo(combo: string): { metaKey: boolean; ctrlKey: boolean; key: string } | null {
|
||||
if (!combo) return null;
|
||||
const parts = combo.toLowerCase().split("+");
|
||||
const metaKey = parts.includes("meta") || parts.includes("cmd");
|
||||
const ctrlKey = parts.includes("ctrl");
|
||||
// The last part is the key
|
||||
const key = parts.filter((p) => !["meta", "cmd", "ctrl", "shift", "alt"].includes(p)).join("+");
|
||||
if (!key) return null;
|
||||
return { metaKey, ctrlKey, key };
|
||||
}
|
||||
|
||||
export function useShortcut(
|
||||
action: string,
|
||||
callback: () => void,
|
||||
) {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
useEffect(() => {
|
||||
// Get the combo from settings or use default
|
||||
const raw = settings?.shortcuts?.[action] ?? DEFAULTS[action];
|
||||
const combo = parseCombo(raw);
|
||||
if (!combo) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const metaMatch = combo.metaKey ? (e.metaKey || e.ctrlKey) : e.metaKey === combo.metaKey && e.ctrlKey === combo.ctrlKey;
|
||||
if (metaMatch && e.key.toLowerCase() === combo.key) {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [action, callback, settings?.shortcuts]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Tag } from "../lib/types";
|
||||
import { useConnectionStore } from "../stores/connectionStore";
|
||||
|
||||
export function useSortedTags(): Tag[] {
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
||||
|
||||
return useMemo(() => {
|
||||
if (tagOrder.length === 0) return tags;
|
||||
const orderMap = new Map(tagOrder.map((id, i) => [id, i]));
|
||||
const sorted = [...tags].sort((a, b) => {
|
||||
const ai = orderMap.get(a.id);
|
||||
const bi = orderMap.get(b.id);
|
||||
if (ai !== undefined && bi !== undefined) return ai - bi;
|
||||
if (ai !== undefined) return -1;
|
||||
if (bi !== undefined) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
}, [tags, tagOrder]);
|
||||
}
|
||||
Reference in New Issue
Block a user