import { sql, PostgreSQL } from "@codemirror/lang-sql"; import { oneDark } from "@codemirror/theme-one-dark"; import { IconPlayerPlayFilled, IconHistory, IconBookmark, IconBookmarkPlus, IconDownload, IconAlertTriangle, IconLoader2, IconTrash, IconX, IconChevronDown, } from "@tabler/icons-react"; import CodeMirror, { type ReactCodeMirrorRef, EditorView, keymap, Prec, } from "@uiw/react-codemirror"; /** * Production-grade SQL editor for the dev-mode database admin. * * Layout: a CodeMirror editor pane on top, a resizable results panel below. * Features schema-aware autocomplete, keyboard run shortcuts, history, named * snippets, CSV/JSON export, and a confirm modal for destructive statements. * * Data access goes through `runQuery` from `./useDbAdmin.js` (the shared * contract). On a destructive statement without confirmation, `runQuery` throws * an Error whose `needsConfirm` flag is `true`; we catch that and re-run after * the user confirms in a locally-built modal. */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { DbAdminDialect } from "../../db-admin/types.js"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../components/ui/dropdown-menu.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { cn } from "../utils.js"; import { toCSV, toJSON, downloadFile } from "./export-utils.js"; import { ResultsGrid } from "./ResultsGrid.js"; import { loadHistory, pushHistory, clearHistory, loadSnippets, saveSnippet, deleteSnippet, type SqlSnippet, } from "./sql-storage.js"; import { runQuery, type DbAdminRequestConfig } from "./useDbAdmin.js"; export interface SqlEditorProps { dialect: DbAdminDialect; tableNames: string[]; columnsByTable: Record; requestConfig?: DbAdminRequestConfig; } interface QueryResult { columns: string[]; rows: Record[]; rowsAffected: number; durationMs: number; } // ─── Dark-mode detection ───────────────────────────────────────────────────── function useIsDark(): boolean { const [isDark, setIsDark] = useState(false); useEffect(() => { if (typeof document === "undefined") return; const root = document.documentElement; const update = () => setIsDark(root.classList.contains("dark")); update(); const observer = new MutationObserver(update); observer.observe(root, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); return isDark; } // ─── Statement-under-cursor helpers ────────────────────────────────────────── /** * Best-effort split of a SQL buffer into statements by top-level `;`, ignoring * semicolons inside single/double quotes or line/block comments. Returns each * statement with its character offsets so we can pick the one under the cursor. */ function splitStatements( text: string, ): { sql: string; start: number; end: number }[] { const out: { sql: string; start: number; end: number }[] = []; let start = 0; let inSingle = false; let inDouble = false; let inLineComment = false; let inBlockComment = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = text[i + 1]; if (inLineComment) { if (ch === "\n") inLineComment = false; continue; } if (inBlockComment) { if (ch === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inSingle) { if (ch === "'") inSingle = false; continue; } if (inDouble) { if (ch === '"') inDouble = false; continue; } if (ch === "-" && next === "-") { inLineComment = true; i++; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === ";") { out.push({ sql: text.slice(start, i + 1), start, end: i + 1 }); start = i + 1; } } if (start < text.length) { out.push({ sql: text.slice(start), start, end: text.length }); } return out; } /** Resolve which SQL to run given the current selection and cursor position. */ function resolveRunTarget( view: EditorView | undefined, buffer: string, ): string { if (!view) return buffer; const sel = view.state.selection.main; if (!sel.empty) { return view.state.sliceDoc(sel.from, sel.to); } const cursor = sel.head; const statements = splitStatements(buffer); const hit = statements.find((s) => cursor >= s.start && cursor <= s.end); return (hit?.sql ?? buffer).trim() || buffer; } // ─── Local modal primitive ─────────────────────────────────────────────────── function Modal({ title, children, onClose, }: { title: string; children: ReactNode; onClose: () => void; }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (

{title}

{children}
); } // ─── Toolbar button ────────────────────────────────────────────────────────── function ToolbarButton({ children, onClick, className, title, disabled, }: { children: ReactNode; onClick?: () => void; className?: string; title?: string; disabled?: boolean; }) { return ( ); } const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); const MOD = isMac ? "Cmd" : "Ctrl"; // ─── Main component ────────────────────────────────────────────────────────── export function SqlEditor({ dialect, tableNames, columnsByTable, requestConfig, }: SqlEditorProps) { const isDark = useIsDark(); const editorRef = useRef(null); const [value, setValue] = useState(""); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [status, setStatus] = useState(null); const [history, setHistory] = useState([]); const [snippets, setSnippets] = useState([]); const [confirmSql, setConfirmSql] = useState(null); const [saveModalOpen, setSaveModalOpen] = useState(false); const [snippetName, setSnippetName] = useState(""); // Editor height (px) — draggable splitter between editor and results. const [editorHeight, setEditorHeight] = useState(240); const dragState = useRef<{ startY: number; startHeight: number } | null>( null, ); // Keep the latest buffer accessible inside CodeMirror keymap closures. const valueRef = useRef(value); valueRef.current = value; useEffect(() => { setHistory(loadHistory()); setSnippets(loadSnippets()); }, []); // ─── Run logic ──────────────────────────────────────────────────────────── const execute = useCallback( async (sqlText: string, confirmDestructive?: boolean) => { const trimmed = sqlText.trim(); if (!trimmed) return; setRunning(true); setError(null); try { const res = await runQuery( trimmed, undefined, confirmDestructive, requestConfig, ); setResult({ columns: res.columns, rows: res.rows, rowsAffected: res.rowsAffected, durationMs: res.durationMs, }); if (res.columns.length > 0) { setStatus(`${res.rows.length} rows · ${res.durationMs}ms`); } else { setStatus( `Query OK · ${res.rowsAffected} rows affected · ${res.durationMs}ms`, ); } setHistory(pushHistory(trimmed)); } catch (err) { const e = err as Error & { needsConfirm?: boolean }; if (e.needsConfirm) { setConfirmSql(trimmed); } else { setError(e.message || "Query failed"); setStatus(null); } } finally { setRunning(false); } }, [requestConfig], ); const runActiveStatement = useCallback(() => { const view = editorRef.current?.view; const target = resolveRunTarget(view, valueRef.current); void execute(target); }, [execute]); const runWholeBuffer = useCallback(() => { void execute(valueRef.current); }, [execute]); const confirmAndRun = useCallback(() => { const sqlText = confirmSql; setConfirmSql(null); if (sqlText) void execute(sqlText, true); }, [confirmSql, execute]); // ─── CodeMirror extensions ────────────────────────────────────────────── const extensions = useMemo(() => { const langExt = sql({ dialect: dialect === "postgres" ? PostgreSQL : undefined, schema: columnsByTable, tables: tableNames.map((t) => ({ label: t })), upperCaseKeywords: true, }); const runKeymap = Prec.highest( keymap.of([ { key: "Mod-Enter", preventDefault: true, run: () => { runActiveStatement(); return true; }, }, { key: "Mod-Shift-Enter", preventDefault: true, run: () => { runWholeBuffer(); return true; }, }, ]), ); return [runKeymap, langExt, EditorView.lineWrapping]; // tableNames / columnsByTable identity is stable enough for our purposes; // re-derive when the dialect or schema reference changes. }, [dialect, columnsByTable, tableNames, runActiveStatement, runWholeBuffer]); // ─── Splitter drag ─────────────────────────────────────────────────────── useEffect(() => { const onMove = (e: MouseEvent) => { if (!dragState.current) return; const delta = e.clientY - dragState.current.startY; const next = Math.min( Math.max(dragState.current.startHeight + delta, 120), 640, ); setEditorHeight(next); }; const endDrag = () => { dragState.current = null; document.body.style.userSelect = ""; }; // mouseup covers the normal release-inside-the-page case; window blur // covers releasing the button outside the browser window/iframe, which // never delivers a mouseup to this document. The cleanup also resets // userSelect unconditionally so an unmount mid-drag can't leave // `document.body.style.userSelect` stuck at "none", which would // silently break text selection/copy everywhere in the app. window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", endDrag); window.addEventListener("blur", endDrag); return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", endDrag); window.removeEventListener("blur", endDrag); document.body.style.userSelect = ""; dragState.current = null; }; }, []); const startDrag = (e: React.MouseEvent) => { dragState.current = { startY: e.clientY, startHeight: editorHeight }; document.body.style.userSelect = "none"; }; // ─── Loading editor content from history / snippets ─────────────────────── const loadIntoEditor = useCallback((sqlText: string) => { setValue(sqlText); // Focus and place cursor at end after the controlled value updates. requestAnimationFrame(() => { const view = editorRef.current?.view; if (view) { view.focus(); view.dispatch({ selection: { anchor: view.state.doc.length } }); } }); }, []); // ─── Export ──────────────────────────────────────────────────────────────── const exportAs = (format: "csv" | "json") => { if (!result || result.columns.length === 0) return; const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); if (format === "csv") { downloadFile( `query-result-${stamp}.csv`, "text/csv;charset=utf-8", toCSV(result.columns, result.rows), ); } else { downloadFile( `query-result-${stamp}.json`, "application/json", toJSON(result.rows), ); } }; // ─── Snippet save ──────────────────────────────────────────────────────── const openSaveModal = () => { if (!value.trim()) return; setSnippetName(""); setSaveModalOpen(true); }; const commitSnippet = () => { const name = snippetName.trim(); if (!name || !value.trim()) return; setSnippets(saveSnippet({ name, sql: value })); setSaveModalOpen(false); }; const removeSnippet = (id: string) => { setSnippets(deleteSnippet(id)); }; // ─── Example queries (empty state) ───────────────────────────────────────── const firstTable = tableNames[0]; const examples = useMemo(() => { const list: { label: string; sql: string }[] = []; if (firstTable) { list.push({ label: `Select rows from ${firstTable}`, sql: `SELECT * FROM ${firstTable} LIMIT 100;`, }); } list.push({ label: "List tables", sql: dialect === "postgres" ? "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;" : "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;", }); if (firstTable) { list.push({ label: `Count rows in ${firstTable}`, sql: `SELECT COUNT(*) AS total FROM ${firstTable};`, }); } return list; }, [firstTable, dialect]); const hasResults = result !== null; const canExport = hasResults && result!.columns.length > 0; // ─── Render ────────────────────────────────────────────────────────────── return (
{/* Toolbar */}
{MOD}+Enter runs selection / statement · {MOD}+Shift+Enter runs all
{/* History */} History
Recent queries {history.length > 0 && ( )}
{history.length === 0 ? (
No queries yet
) : ( history.map((h, i) => ( )) )}
{/* Snippets */} Snippets
Saved snippets
{snippets.length === 0 ? (
No saved snippets
) : ( snippets.map((s) => (
)) )}
{/* Export */} Export exportAs("csv")}> Download CSV exportAs("json")}> Download JSON
{/* Editor pane */}
{/* Splitter */}
{/* Status / error bar */} {(status || error) && (
{error ? (
{error}
) : (
{status}
)}
)} {/* Results panel */}
{hasResults ? ( ) : (
Run a query to see results, or start with an example:
{examples.map((ex, i) => ( ))}
)}
{/* Destructive-confirm modal */} {confirmSql && ( setConfirmSql(null)} >

This looks like a destructive query (DROP / TRUNCATE / DELETE without WHERE). Run it?

              {confirmSql}
            
)} {/* Save-snippet modal */} {saveModalOpen && ( setSaveModalOpen(false)}>
setSnippetName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitSnippet(); }} placeholder="e.g. Active users this week" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary" />
              {value.trim()}
            
)}
); }