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

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DestructiveQueryDialog } from "./DestructiveQueryDialog";
describe("DestructiveQueryDialog", () => {
it("renders warning message with the SQL shown", () => {
render(
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={() => {}} />
);
expect(screen.getByText(/destructive/i)).toBeInTheDocument();
expect(screen.getByText(/DROP TABLE users/)).toBeInTheDocument();
});
it("calls onConfirm when 'Execute' is clicked", () => {
const onConfirm = vi.fn();
render(
<DestructiveQueryDialog open={true} query="DELETE FROM users" onConfirm={onConfirm} onCancel={() => {}} />
);
fireEvent.click(screen.getByText("Execute"));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("calls onCancel when 'Cancel' is clicked", () => {
const onCancel = vi.fn();
render(
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={onCancel} />
);
fireEvent.click(screen.getByText("Cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("truncates long queries in the dialog", () => {
const longQuery = "DROP TABLE " + "x".repeat(500);
render(
<DestructiveQueryDialog open={true} query={longQuery} onConfirm={() => {}} onCancel={() => {}} />
);
const displayed = screen.getByText(/DROP TABLE/);
expect(displayed.textContent!.length).toBeLessThan(longQuery.length + 20);
});
});
@@ -0,0 +1,40 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "../ui/Button";
interface DestructiveQueryDialogProps {
open: boolean;
query: string;
onConfirm: () => void;
onCancel: () => void;
}
export function DestructiveQueryDialog({ open, query, onConfirm, onCancel }: DestructiveQueryDialogProps) {
if (!open) return null;
const truncated = query.length > 200 ? query.slice(0, 200) + "..." : query;
return (
<div className="fixed inset-0 bg-canvas/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 shadow-2xl ring-1 ring-white/10 max-w-md w-full">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle size={20} className="text-amber-400 shrink-0 mt-0.5" />
<div>
<h3 className="font-heading text-text text-lg">Destructive Query</h3>
<p className="text-sm text-text-muted mt-1">
This query will modify your database. This action cannot be undone.
</p>
</div>
</div>
<div className="bg-canvas border border-border rounded-lg p-3 mb-4">
<code className="text-xs text-text-muted font-mono break-all">{truncated}</code>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button onClick={onConfirm} className="!bg-red-500 hover:!bg-red-600 !border-red-500">Execute</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { QueryEditor } from "./QueryEditor";
import { editor as monacoEditor } from "monaco-editor";
import { useSettingsStore } from "../../stores/settingsStore";
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
const { registeredActions, updateOptions } = vi.hoisted(() => ({
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
updateOptions: vi.fn(),
}));
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
// monaco-editor's global re-measure (font metrics) — stub so tests stay light
vi.mock("monaco-editor", () => ({
editor: { remeasureFonts: vi.fn() },
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value, onChange, onMount, options }: any) => {
editorOptions.push(options);
if (onMount) {
onMount({
addAction: (action: any) => registeredActions.push(action),
getValue: () => value,
setValue: (v: string) => onChange?.(v),
focus: vi.fn(),
updateOptions,
});
}
return (
<div data-testid="monaco-editor">
<textarea
data-testid="monaco-textarea"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</div>
);
},
}));
describe("QueryEditor", () => {
beforeEach(() => {
registeredActions.length = 0;
editorOptions.length = 0;
updateOptions.mockClear();
vi.mocked(monacoEditor.remeasureFonts).mockClear();
useSettingsStore.setState({ settings: null });
});
it("renders a textarea editor", () => {
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={() => {}} />);
expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
});
it("displays the provided value", () => {
render(<QueryEditor value="SELECT * FROM users" onChange={() => {}} onRun={() => {}} />);
const textarea = screen.getByTestId("monaco-textarea");
expect(textarea).toHaveValue("SELECT * FROM users");
});
it("calls onChange when text changes", () => {
const onChange = vi.fn();
render(<QueryEditor value="" onChange={onChange} onRun={() => {}} />);
fireEvent.change(screen.getByTestId("monaco-textarea"), { target: { value: "SELECT 1" } });
expect(onChange).toHaveBeenCalledWith("SELECT 1");
});
it("registers a Cmd+Enter action that runs the query", () => {
const onRun = vi.fn();
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={onRun} />);
expect(registeredActions).toHaveLength(1);
expect(registeredActions[0].keybindings).toEqual([2048 | 3]);
registeredActions[0].run();
expect(onRun).toHaveBeenCalledTimes(1);
});
it("passes a placeholder option to the editor", () => {
editorOptions.length = 0;
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
expect(editorOptions[0]?.placeholder).toMatch(/Enter your SQL query/i);
});
it("re-measures fonts after mount so the cursor stays aligned", () => {
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
});
it("calls updateOptions with editor settings when settings change", () => {
const settings = {
confirm_before_delete: true,
default_folder_id: null,
theme: "dark" as const,
font_size: "medium" as const,
default_ports: {},
tag_order: null,
table_refresh_rate: 5,
table_page_size: 50,
shortcuts: {},
accent_color: "blue",
editor_font_size: 13,
editor_font_family: "Space Mono",
editor_word_wrap: "off" as const,
editor_minimap: false,
editor_tab_size: 4,
};
useSettingsStore.setState({ settings });
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
updateOptions.mockClear();
act(() => {
useSettingsStore.setState({
settings: { ...settings, editor_font_size: 16, editor_word_wrap: "on" as const },
});
});
expect(updateOptions).toHaveBeenCalledWith(
expect.objectContaining({ fontSize: 16, wordWrap: "on" }),
);
});
it("wraps the editor without padding, border, or rounding", () => {
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
const wrapper = screen.getByTestId("query-editor");
expect(wrapper.className).not.toMatch(/rounded|border|p-\d/);
});
});
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef } from "react";
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
import { useSettingsStore } from "../../stores/settingsStore";
interface QueryEditorProps {
value: string;
onChange: (value: string) => void;
onRun: () => void;
readOnly?: boolean;
}
export function QueryEditor({
value,
onChange,
onRun,
readOnly = false,
}: QueryEditorProps) {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const editorFontFamily = useSettingsStore(
(s) => s.settings?.editor_font_family ?? "Space Mono",
);
const editorFontSize = useSettingsStore(
(s) => s.settings?.editor_font_size ?? 13,
);
const editorWordWrap = useSettingsStore(
(s) => s.settings?.editor_word_wrap ?? "off",
);
const editorMinimap = useSettingsStore(
(s) => s.settings?.editor_minimap ?? false,
);
const editorTabSize = useSettingsStore(
(s) => s.settings?.editor_tab_size ?? 4,
);
useEffect(() => {
editorRef.current?.updateOptions?.({
fontFamily: editorFontFamily,
fontSize: editorFontSize,
wordWrap: editorWordWrap === "on" ? "on" : "off",
minimap: { enabled: editorMinimap },
tabSize: editorTabSize,
});
monaco.editor.remeasureFonts();
}, [editorFontFamily, editorFontSize, editorWordWrap, editorMinimap, editorTabSize]);
const handleMount: OnMount = useCallback(
(editor) => {
editorRef.current = editor;
editor.addAction({
id: "run-query",
label: "Run Query",
keybindings: [2048 | 3], // Cmd/Ctrl+Enter
run: () => onRun(),
});
editor.focus();
// Custom fonts (@fontsource Space Mono) load asynchronously. Monaco
// measures glyph widths at creation, so if the font lands after that the
// cursor/selection drift rightward the further along the line you are.
// Re-measure now (fonts may already be ready) and again once fonts load.
const reMeasure = () => monaco.editor.remeasureFonts();
reMeasure();
try {
void document.fonts?.load('13px "Space Mono"').then(() => {
requestAnimationFrame(reMeasure);
// WebKit can settle a frame late; re-measure once more to be safe
setTimeout(reMeasure, 200);
});
} catch {
// fonts API unavailable — nothing more we can do
}
},
[onRun],
);
// Transparent editor background so the app's canvas shows through
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
monaco.editor.defineTheme("gridline-sql", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#00000000",
"editorGutter.background": "#00000000",
"editor.lineHighlightBackground": "#ffffff08",
"editorLineNumber.foreground": "#5b5b5e",
"editorLineNumber.activeForeground": "#a1a1a6",
},
});
}, []);
return (
<div className="h-full min-h-0" data-testid="query-editor">
<Editor
height="100%"
language="sql"
theme="gridline-sql"
beforeMount={handleBeforeMount}
value={value}
onChange={(v) => onChange(v ?? "")}
onMount={handleMount}
options={{
minimap: { enabled: editorMinimap },
fontSize: editorFontSize,
fontFamily: editorFontFamily,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: editorWordWrap === "on" ? "on" : "off",
readOnly,
placeholder: "Enter your SQL query…",
automaticLayout: true,
tabSize: editorTabSize,
}}
/>
</div>
);
}
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
import { useQueryStore } from "../../stores/queryStore";
import { TooltipProvider } from "../ui/Tooltip";
// Mock queryStore
vi.mock("../../stores/queryStore", () => ({
useQueryStore: vi.fn(),
}));
const mockLoadHistory = vi.fn();
const mockClearHistory = vi.fn();
const mockToggleFavorite = vi.fn();
function setStoreState(overrides: Record<string, unknown> = {}) {
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
loadHistory: mockLoadHistory,
clearHistory: mockClearHistory,
toggleFavorite: mockToggleFavorite,
...overrides,
}),
);
}
function renderDropdown(props: {
connectionId: string;
onRestore?: (sql: string) => void;
onRun?: (sql: string) => void;
}) {
return render(
<TooltipProvider>
<QueryHistoryDropdown
connectionId={props.connectionId}
onRestore={props.onRestore ?? (() => {})}
onRun={props.onRun ?? (() => {})}
/>
</TooltipProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
setStoreState();
});
describe("QueryHistoryDropdown", () => {
it("opens dropdown on button click and loads history if stale", async () => {
const user = userEvent.setup();
renderDropdown({ connectionId: "conn-1" });
const btn = screen.getByLabelText("Query history");
await user.click(btn);
// Should trigger loadHistory because stale
expect(mockLoadHistory).toHaveBeenCalledWith("conn-1");
// Dropdown menu should appear (check for empty state)
await waitFor(() => {
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
});
});
it("displays history items with metadata", async () => {
const user = userEvent.setup();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT * FROM users",
execution_time_ms: 42,
row_count: 10,
status: "success",
error_message: null,
executed_at: "2026-01-01T12:00:00Z",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByText(/SELECT \* FROM users/)).toBeInTheDocument();
expect(screen.getByText(/42ms/)).toBeInTheDocument();
expect(screen.getByText(/10 rows/)).toBeInTheDocument();
});
});
it("calls onRestore when Load button clicked", async () => {
const user = userEvent.setup();
const onRestore = vi.fn();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT 1",
execution_time_ms: 1,
row_count: 1,
status: "success",
error_message: null,
executed_at: "",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1", onRestore });
await user.click(screen.getByLabelText("Query history"));
await user.click(screen.getByLabelText("Load query into editor"));
expect(onRestore).toHaveBeenCalledWith("SELECT 1");
});
it("calls onRun when Run button clicked", async () => {
const user = userEvent.setup();
const onRun = vi.fn();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT 1",
execution_time_ms: 1,
row_count: 1,
status: "success",
error_message: null,
executed_at: "",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1", onRun });
await user.click(screen.getByLabelText("Query history"));
await user.click(screen.getByLabelText("Run query from history"));
expect(onRun).toHaveBeenCalledWith("SELECT 1");
});
it("shows loading spinner while fetching", async () => {
const user = userEvent.setup();
setStoreState({ history: null, historyLoading: true, historyStale: true });
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByTestId("query-history-spinner")).toBeInTheDocument();
});
});
it("shows error state on fetch failure", async () => {
const user = userEvent.setup();
setStoreState({
history: null,
historyLoading: false,
historyError: "Fetch failed",
historyStale: false,
});
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByText(/fetch failed/i)).toBeInTheDocument();
});
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
// Menu is open
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
// Press Escape
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByText(/no queries/i)).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,180 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Clock, Trash2, Download, Play, Star } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { useQueryStore } from "../../stores/queryStore";
interface QueryHistoryDropdownProps {
connectionId: string;
onRestore: (sql: string) => void;
onRun: (sql: string) => void;
}
export function QueryHistoryDropdown({
connectionId,
onRestore,
onRun,
}: QueryHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const history = useQueryStore((s) => s.history);
const loading = useQueryStore((s) => s.historyLoading);
const historyError = useQueryStore((s) => s.historyError);
const stale = useQueryStore((s) => s.historyStale);
const loadHistory = useQueryStore((s) => s.loadHistory);
const clearHistory = useQueryStore((s) => s.clearHistory);
const toggleFavorite = useQueryStore((s) => s.toggleFavorite);
// Lazy fetch on open
const handleToggle = useCallback(() => {
const willOpen = !open;
setOpen(willOpen);
if (willOpen && stale) {
loadHistory(connectionId);
}
}, [open, stale, connectionId, loadHistory]);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div className="relative" ref={menuRef}>
<Tooltip content="Query history" side="bottom">
<button
type="button"
onClick={handleToggle}
aria-label="Query history"
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer text-text-muted"
>
<Clock className="h-3.5 w-3.5" />
</button>
</Tooltip>
{open && (
<div className="absolute left-0 top-full mt-1 rounded-xl bg-surface border border-border py-1 z-20 w-80 shadow-lg max-h-80 overflow-y-auto">
{loading && history === null && (
<div className="flex items-center justify-center gap-2 px-3 py-4 text-center text-sm text-text-muted">
<span
data-testid="query-history-spinner"
aria-hidden="true"
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-text-muted/30 border-t-text-muted"
/>
Loading...
</div>
)}
{historyError && !loading && (
<div className="px-3 py-4 text-center text-sm text-red-400">
{historyError}
</div>
)}
{!loading && !historyError && (!history || history.length === 0) && (
<div className="px-3 py-4 text-center text-sm text-text-muted">
No queries yet
</div>
)}
{!loading && !historyError && history && history.length > 0 && (
<>
{history.slice(0, 50).map((entry) => (
<div
key={entry.id}
className="group flex items-start gap-2 px-3 py-2 hover:bg-surface-raised border-b border-border/50 last:border-b-0"
>
<div className="flex-1 min-w-0">
<div className="text-xs text-text font-mono truncate max-w-[220px]">
{entry.query_text}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
{entry.status === "error" ? (
<span className="text-red-400">Error</span>
) : (
<>
{entry.execution_time_ms != null && (
<span>{entry.execution_time_ms}ms</span>
)}
{entry.row_count != null && (
<span>{entry.row_count} rows</span>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Tooltip content="Load into editor" side="top">
<button
type="button"
aria-label="Load query into editor"
onClick={() => {
setOpen(false);
onRestore(entry.query_text);
}}
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Download className="h-3 w-3" />
</button>
</Tooltip>
<Tooltip content="Run query" side="top">
<button
type="button"
aria-label="Run query from history"
onClick={() => {
setOpen(false);
onRun(entry.query_text);
}}
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Play className="h-3 w-3" />
</button>
</Tooltip>
<button
type="button"
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
onClick={() => toggleFavorite(entry.id, connectionId)}
className={`rounded p-1 cursor-pointer ${
entry.favorite
? "text-amber-400 hover:text-amber-300"
: "text-text-muted hover:text-amber-400 opacity-0 group-hover:opacity-100 transition-opacity"
}`}
>
<Star
className="h-3 w-3"
fill={entry.favorite ? "currentColor" : "none"}
/>
</button>
</div>
</div>
))}
{/* Clear History footer */}
<button
type="button"
onClick={() => {
setOpen(false);
clearHistory(connectionId);
}}
className="flex items-center gap-1.5 w-full px-3 py-2 text-xs text-text-muted hover:text-red-400 hover:bg-surface-raised transition-colors cursor-pointer border-t border-border"
>
<Trash2 className="h-3 w-3" />
Clear History
</button>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryToolbar, queryShortcut } from "./QueryToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
function renderToolbar(props: {
onRun?: () => void;
onFormat?: () => void;
dbType?: "postgresql" | "mysql" | "sqlite" | "redis";
connectionId?: string;
onRestore?: (sql: string) => void;
onRunFromHistory?: (sql: string) => void;
}) {
return render(
<TooltipProvider>
<QueryToolbar
onRun={props.onRun ?? (() => {})}
onFormat={props.onFormat ?? (() => {})}
dbType={props.dbType}
connectionId={props.connectionId ?? "conn-1"}
onRestore={props.onRestore ?? (() => {})}
onRunFromHistory={props.onRunFromHistory ?? (() => {})}
/>
</TooltipProvider>,
);
}
describe("queryShortcut", () => {
afterEach(() => {
Object.defineProperty(navigator, "platform", {
value: "",
configurable: true,
});
});
it("uses the command symbol and enter glyph on mac", () => {
expect(queryShortcut("MacIntel")).toEqual({ mod: "⌘", enter: "⏎" });
});
it("uses Ctrl + Enter on other platforms", () => {
expect(queryShortcut("Win32")).toEqual({ mod: "Ctrl", enter: "Enter" });
expect(queryShortcut("Linux x86_64")).toEqual({
mod: "Ctrl",
enter: "Enter",
});
});
it("renders the mac shortcut in the run tooltip", async () => {
Object.defineProperty(navigator, "platform", {
value: "MacIntel",
configurable: true,
});
renderToolbar({});
fireEvent.mouseEnter(
screen.getByRole("button", { name: /run query/i }).parentElement!,
);
await waitFor(() => expect(screen.getByText("⌘")).toBeInTheDocument());
expect(screen.getByText("⏎")).toBeInTheDocument();
});
it("renders Ctrl + Enter in the run tooltip on non-mac", async () => {
Object.defineProperty(navigator, "platform", {
value: "Linux x86_64",
configurable: true,
});
renderToolbar({});
fireEvent.mouseEnter(
screen.getByRole("button", { name: /run query/i }).parentElement!,
);
await waitFor(() => expect(screen.getByText("Ctrl")).toBeInTheDocument());
expect(screen.getByText("Enter")).toBeInTheDocument();
});
});
describe("QueryToolbar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
const baseProps = {
onRun: () => {},
onFormat: () => {},
connectionId: "conn-1",
onRestore: () => {},
onRunFromHistory: () => {},
};
it("shows a Cancel button when isRunning is true", () => {
render(
<TooltipProvider>
<QueryToolbar {...baseProps} isRunning={true} onCancel={() => {}} />
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
});
it("hides the Cancel button when not running", () => {
render(
<TooltipProvider>
<QueryToolbar {...baseProps} isRunning={false} onCancel={() => {}} />
</TooltipProvider>,
);
expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
});
it("renders Run Query and the format icon button", () => {
renderToolbar({});
expect(
screen.getByRole("button", { name: /run query/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /auto format/i }),
).toBeInTheDocument();
});
it("shows the SQL dialect on the right", () => {
renderToolbar({ dbType: "postgresql" });
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
renderToolbar({ dbType: "mysql" });
expect(screen.getByText("MySQL")).toBeInTheDocument();
});
it("hides the dialect badge when no db type is known", () => {
renderToolbar({});
expect(screen.queryByText(/postgresql|mysql|sqlite|redis/i)).toBeNull();
});
it("runs the query when Run Query is clicked", () => {
const onRun = vi.fn();
renderToolbar({ onRun });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
expect(onRun).toHaveBeenCalledTimes(1);
});
it("calls onFormat when the format icon is clicked", () => {
const onFormat = vi.fn();
renderToolbar({ onFormat });
fireEvent.click(screen.getByRole("button", { name: /auto format/i }));
expect(onFormat).toHaveBeenCalledTimes(1);
});
it("starts with an unfilled play icon", () => {
renderToolbar({});
const playSvg = screen
.getByRole("button", { name: /run query/i })
.querySelector("svg");
expect(playSvg).toHaveAttribute("fill", "none");
});
it("shows the pulse while the active query tab is loading", () => {
useDbViewerStore.getState().openQueryTab();
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) => ({ ...t, loading: true })),
}));
renderToolbar({});
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument();
});
it("hides the pulse when the query is idle", () => {
useDbViewerStore.getState().openQueryTab();
renderToolbar({});
expect(screen.queryByTestId("query-run-pulse")).toBeNull();
});
it("renders the format action as an icon only (no text label)", () => {
renderToolbar({});
const button = screen.getByRole("button", { name: /auto format/i });
expect(button.textContent?.trim()).toBe("");
});
it("renders History icon button", () => {
renderToolbar({});
expect(screen.getByLabelText("Query history")).toBeInTheDocument();
});
it("renders Save Query icon button", () => {
renderToolbar({});
expect(screen.getByLabelText("Save query")).toBeInTheDocument();
});
it("orders toolbar actions: Run, History, Format, Save", () => {
renderToolbar({});
const container = screen.getByLabelText("Query toolbar actions");
const buttons = Array.from(container.querySelectorAll("button"));
const btnLabels = buttons.map((b) => b.getAttribute("aria-label"));
// Run Query first, then History, then Auto Format, then Save
expect(btnLabels[0]).toBe("Run query");
expect(btnLabels[1]).toBe("Query history");
expect(btnLabels[2]).toBe("Auto format query");
expect(btnLabels[3]).toBe("Save query");
});
});
@@ -0,0 +1,177 @@
import { useState } from "react";
import { Play, Square, Wand2, Save } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
import { SaveQueryDialog } from "./SaveQueryDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DbType } from "../../lib/types";
const DB_TYPE_LABELS: Record<DbType, string> = {
postgresql: "PostgreSQL",
mysql: "MySQL",
sqlite: "SQLite",
redis: "Redis",
};
/**
* Resolve the run-query modifier key for the given platform string
* (from `navigator.platform`). Mac platforms show ⌘ + the return glyph,
* everything else shows Ctrl + Enter.
*/
export function queryShortcut(platform: string): {
mod: string;
enter: string;
} {
const isMac = /Mac|iPhone|iPad/.test(platform);
return isMac
? { mod: "⌘", enter: "⏎" }
: { mod: "Ctrl", enter: "Enter" };
}
interface QueryToolbarProps {
onRun: () => void;
onFormat: () => void;
connectionId: string;
onRestore: (sql: string) => void;
onRunFromHistory: (sql: string) => void;
dbType?: DbType;
readOnly?: boolean;
isRunning?: boolean;
onCancel?: () => void;
}
export function QueryToolbar({
onRun,
onFormat,
connectionId,
onRestore,
onRunFromHistory,
dbType,
readOnly = false,
isRunning: isRunningProp,
onCancel,
}: QueryToolbarProps) {
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const isRunning =
isRunningProp ?? tabs.find((t) => t.id === activeTabId)?.loading ?? false;
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const activeTab = tabs.find((t) => t.id === activeTabId);
const currentQueryText = activeTab?.query ?? "";
const shortcut = queryShortcut(
typeof navigator !== "undefined" ? navigator.platform : "",
);
return (
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* run pulse: mirrors the table toolbar refresh pulse, absolutely positioned so it never shifts layout */}
{isRunning && (
<div
data-testid="query-run-pulse"
aria-hidden="true"
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
/>
)}
<div
className="flex items-center gap-1"
aria-label="Query toolbar actions"
>
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */}
<Tooltip
content={
<span className="inline-flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
{shortcut.mod}
</kbd>
<span className="text-text-muted">+</span>
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
{shortcut.enter}
</kbd>
</span>
}
side="bottom"
>
<button
type="button"
onClick={onRun}
disabled={readOnly}
className="group flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Run query"
>
<Play className="h-3.5 w-3.5 transition-[fill] duration-150 group-hover:fill-current" />
<span>Run Query</span>
</button>
</Tooltip>
{/* Cancel Query */}
{isRunning && onCancel && (
<Tooltip content="Cancel running query" side="bottom">
<button
type="button"
onClick={onCancel}
className="flex items-center gap-1.5 rounded-md border border-red-500/50 bg-red-500/10 px-2.5 py-1 font-medium text-red-400 transition-colors hover:bg-red-500/20 cursor-pointer"
aria-label="Cancel query"
>
<Square className="h-3 w-3 fill-current" />
<span>Cancel</span>
</button>
</Tooltip>
)}
{/* History dropdown */}
<QueryHistoryDropdown
connectionId={connectionId}
onRestore={onRestore}
onRun={onRunFromHistory}
/>
{/* Auto format: icon only with tooltip */}
<Tooltip content="Auto format query" side="bottom">
<button
type="button"
onClick={onFormat}
disabled={readOnly}
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Auto format query"
>
<Wand2 className="h-3.5 w-3.5" />
</button>
</Tooltip>
{/* Save Query icon */}
<Tooltip content="Save query" side="bottom">
<button
type="button"
aria-label="Save query"
onClick={() => {
if (currentQueryText.trim()) {
setSaveDialogOpen(true);
}
}}
disabled={readOnly || !currentQueryText.trim()}
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
{dbType && (
<div className="ml-auto">
<span className="rounded-md border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-text-muted">
{DB_TYPE_LABELS[dbType]}
</span>
</div>
)}
<SaveQueryDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
connectionId={connectionId}
queryText={currentQueryText}
/>
</div>
);
}
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SaveQueryDialog } from "./SaveQueryDialog";
import { useQueryStore } from "../../stores/queryStore";
vi.mock("../../stores/queryStore", () => ({
useQueryStore: vi.fn(),
}));
const mockSaveCurrentQuery = vi.fn();
function setStoreMock() {
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({
saveCurrentQuery: mockSaveCurrentQuery,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
setStoreMock();
mockSaveCurrentQuery.mockResolvedValue({ id: "q-new" });
});
describe("SaveQueryDialog", () => {
it("renders nothing when closed", () => {
render(
<SaveQueryDialog
open={false}
onClose={() => {}}
connectionId="c1"
queryText="SELECT 1"
/>,
);
expect(screen.queryByText(/save query/i)).not.toBeInTheDocument();
});
it("shows form when open", () => {
render(
<SaveQueryDialog
open={true}
onClose={() => {}}
connectionId="c1"
queryText="SELECT 1"
/>,
);
expect(screen.getByText(/save query/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/query name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/folder/i)).toBeInTheDocument();
});
it("blocks save when name is empty", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
expect(mockSaveCurrentQuery).not.toHaveBeenCalled();
});
it("saves with name + folder and closes", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.type(screen.getByPlaceholderText(/query name/i), "My Query");
await user.type(screen.getByPlaceholderText(/folder/i), "reports");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockSaveCurrentQuery).toHaveBeenCalledWith({
connectionId: "c1",
name: "My Query",
queryText: "SELECT 1",
folder: "reports",
});
});
expect(onClose).toHaveBeenCalled();
});
it("closes on cancel", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.click(screen.getByText(/cancel/i));
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,101 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { useQueryStore } from "../../stores/queryStore";
interface SaveQueryDialogProps {
open: boolean;
onClose: () => void;
connectionId: string;
queryText: string;
}
export function SaveQueryDialog({
open,
onClose,
connectionId,
queryText,
}: SaveQueryDialogProps) {
const [name, setName] = useState("");
const [folder, setFolder] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const saveCurrentQuery = useQueryStore((s) => s.saveCurrentQuery);
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Name is required");
return;
}
setSaving(true);
setError("");
try {
await saveCurrentQuery({
connectionId,
name: trimmed,
queryText,
folder: folder.trim(),
});
// Reset form on success
setName("");
setFolder("");
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleClose = () => {
setError("");
setName("");
setFolder("");
onClose();
};
if (!open) return null;
return (
<AnimatedModal open={open} onClose={handleClose}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-1">Save Query</h3>
<p className="text-sm text-text-muted mb-4">Save the current query for later use.</p>
<div className="flex flex-col gap-3 mb-4">
<div>
<label className="block text-xs text-text-muted mb-1">Name</label>
<Input
value={name}
placeholder="Query name"
onChange={(v) => { setName(v); if (error) setError(""); }}
aria-label="Query name"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Folder (optional)</label>
<Input
value={folder}
placeholder="Folder"
onChange={setFolder}
aria-label="Folder"
/>
</div>
</div>
{error && (
<p className="text-red-400 text-xs mb-3">{error}</p>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,66 @@
import { useCallback } from "react";
import Editor, { type BeforeMount } from "@monaco-editor/react";
import { useSettingsStore } from "../../stores/settingsStore";
interface SqlEditorFieldProps {
value: string;
onChange: (value: string) => void;
height?: number;
readOnly?: boolean;
}
export function SqlEditorField({
value,
onChange,
height = 140,
readOnly = false,
}: SqlEditorFieldProps) {
const editorFontFamily = useSettingsStore(
(s) => s.settings?.editor_font_family ?? "Space Mono",
);
const editorFontSize = useSettingsStore(
(s) => s.settings?.editor_font_size ?? 13,
);
const editorTabSize = useSettingsStore(
(s) => s.settings?.editor_tab_size ?? 4,
);
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
monaco.editor.defineTheme("gridline-sql", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#00000000",
"editorGutter.background": "#00000000",
"editor.lineHighlightBackground": "#ffffff08",
"editorLineNumber.foreground": "#5b5b5e",
"editorLineNumber.activeForeground": "#a1a1a6",
},
});
}, []);
return (
<div className="h-full min-h-0" data-testid="sql-editor-field">
<Editor
height={height}
language="sql"
theme="gridline-sql"
beforeMount={handleBeforeMount}
value={value}
onChange={(v) => onChange(v ?? "")}
options={{
minimap: { enabled: false },
fontSize: editorFontSize,
fontFamily: editorFontFamily,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
readOnly,
automaticLayout: true,
tabSize: editorTabSize,
}}
/>
</div>
);
}