"use client"; import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { tokenize } from "./sql"; import "./query-cell.css"; export type ResultColumn = { name: string; type: string }; export type QueryResult = { columns: ResultColumn[]; rows: unknown[][] }; /** Throw one of these (or anything with a message and optional line) to mark the failing line. */ export type QueryError = Error & { line?: number }; export type QueryCellProps = { defaultSql: string; /** Run the query against your engine. The signal aborts when the user cancels. */ run: (sql: string, signal: AbortSignal) => Promise; /** Shown in the cell's header, e.g. the cell or dataset name. */ title?: string; maxRows?: number; locale?: string; theme?: "light" | "dark"; className?: string; }; type State = | { kind: "idle" } | { kind: "running"; started: number } | { kind: "done"; result: QueryResult; ms: number } | { kind: "error"; message: string; line?: number } | { kind: "cancelled" }; const numeric = /^(BIGINT|INTEGER|INT|SMALLINT|TINYINT|HUGEINT|DOUBLE|FLOAT|REAL|DECIMAL|NUMERIC|UBIGINT|UINTEGER)/i; /** * Query Cell * A notebook SQL cell: a plain textarea with a highlighted copy behind it, * ⌘/Ctrl + Enter to run, a status strip that says how many rows and how * long, and a result grid with typed column headers. Errors mark their line. */ export function QueryCell({ defaultSql, run, title = "query", maxRows = 200, locale, theme = "light", className = "" }: QueryCellProps) { const id = useId(); const [sql, setSql] = useState(defaultSql); const [state, setState] = useState({ kind: "idle" }); const [elapsed, setElapsed] = useState(0); const controller = useRef(null); const field = useRef(null); const highlight = useRef(null); const gutter = useRef(null); const tokens = useMemo(() => tokenize(sql + (sql.endsWith("\n") ? " " : "")), [sql]); const lines = sql.split("\n").length; const num = new Intl.NumberFormat(locale); useEffect(() => { if (state.kind !== "running") return; const t = setInterval(() => setElapsed(Math.round(performance.now() - state.started)), 47); return () => clearInterval(t); }, [state]); useEffect(() => () => controller.current?.abort(), []); const execute = async () => { if (state.kind === "running" || !sql.trim()) return; const ctrl = new AbortController(); controller.current = ctrl; const started = performance.now(); setElapsed(0); setState({ kind: "running", started }); try { const result = await run(sql, ctrl.signal); if (ctrl.signal.aborted) return; setState({ kind: "done", result, ms: Math.max(1, Math.round(performance.now() - started)) }); } catch (e) { if (ctrl.signal.aborted) return; const err = e as QueryError; setState({ kind: "error", message: err?.message || "The query failed.", line: typeof err?.line === "number" ? err.line : undefined }); } }; const cancel = () => { controller.current?.abort(); setState({ kind: "cancelled" }); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); execute(); return; } if (e.key === "Escape" && state.kind === "running") { e.preventDefault(); cancel(); return; } }; const sync = () => { const el = field.current; if (!el) return; if (highlight.current) { highlight.current.scrollTop = el.scrollTop; highlight.current.scrollLeft = el.scrollLeft; } if (gutter.current) gutter.current.scrollTop = el.scrollTop; }; const errorLine = state.kind === "error" ? state.line : undefined; const result = state.kind === "done" ? state.result : null; const shown = result ? result.rows.slice(0, maxRows) : []; return (

{title}

{state.kind === "running" && }