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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user