ewdlop's picture
App.tsx
42ae0b9
import { useEffect, useRef, useCallback } from "react";
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection, dropCursor } from "@codemirror/view";
import { EditorState, Compartment } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { sql, MySQL, PostgreSQL, StandardSQL, MSSQL, SQLite } from "@codemirror/lang-sql";
import { oneDark } from "@codemirror/theme-one-dark";
import { bracketMatching, indentOnInput, syntaxHighlighting, defaultHighlightStyle, foldGutter, foldKeymap } from "@codemirror/language";
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { lintKeymap } from "@codemirror/lint";
interface SqlEditorProps {
value: string;
onChange: (value: string) => void;
onAnalyze: () => void;
dialect: string;
}
const dialectMap: Record<string, unknown> = {
mysql: MySQL,
postgres: PostgreSQL,
tsql: MSSQL,
sqlite: SQLite,
ansi: StandardSQL,
};
const dialectCompartment = new Compartment();
function getDialectExtension(dialect: string) {
const schema = dialectMap[dialect] ?? StandardSQL;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return sql({ dialect: schema as any });
}
export function SqlEditor({ value, onChange, onAnalyze, dialect }: SqlEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onAnalyzeRef = useRef(onAnalyze);
onAnalyzeRef.current = onAnalyze;
// Create editor once
useEffect(() => {
if (!containerRef.current) return;
const analyzeKeymap = keymap.of([
{
key: "Ctrl-Enter",
mac: "Cmd-Enter",
run: () => {
onAnalyzeRef.current();
return true;
},
},
]);
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
history(),
drawSelection(),
dropCursor(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
foldGutter(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
dialectCompartment.of(getDialectExtension(dialect)),
oneDark,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
indentWithTab,
]),
analyzeKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}),
EditorView.theme({
"&": {
height: "100%",
backgroundColor: "oklch(0.10 0.008 264)",
},
".cm-content": {
padding: "12px 0",
caretColor: "oklch(0.68 0.16 210)",
},
".cm-line": {
padding: "0 16px 0 8px",
},
".cm-cursor": {
borderLeftColor: "oklch(0.68 0.16 210)",
borderLeftWidth: "2px",
},
}),
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync dialect changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: dialectCompartment.reconfigure(getDialectExtension(dialect)),
});
}, [dialect]);
// Sync external value changes (e.g. "apply formatted")
const lastValueRef = useRef(value);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value && value !== lastValueRef.current) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
lastValueRef.current = value;
}, [value]);
// Jump to line
const jumpToLine = useCallback((lineNo: number) => {
const view = viewRef.current;
if (!view) return;
const line = view.state.doc.line(Math.max(1, Math.min(lineNo, view.state.doc.lines)));
view.dispatch({
selection: { anchor: line.from },
scrollIntoView: true,
});
view.focus();
}, []);
// Expose jumpToLine via a custom event
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: Event) => {
const lineNo = (e as CustomEvent<number>).detail;
jumpToLine(lineNo);
};
el.addEventListener("jump-to-line", handler);
return () => el.removeEventListener("jump-to-line", handler);
}, [jumpToLine]);
return (
<div
ref={containerRef}
className="h-full w-full overflow-hidden"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
data-editor-container
/>
);
}