| 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; |
| |
| 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; |
|
|
| |
| 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; |
| }; |
| |
| }, []); |
|
|
| |
| useEffect(() => { |
| const view = viewRef.current; |
| if (!view) return; |
| view.dispatch({ |
| effects: dialectCompartment.reconfigure(getDialectExtension(dialect)), |
| }); |
| }, [dialect]); |
|
|
| |
| 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]); |
|
|
| |
| 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(); |
| }, []); |
|
|
| |
| 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 |
| /> |
| ); |
| } |
|
|