import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { sql } from "@codemirror/lang-sql"; import { autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete"; import { tags as t } from "@lezer/highlight"; import { oneDark } from "@codemirror/theme-one-dark"; import { getCachedSchema } from "./api"; import { REDIS_COMMANDS } from "./redis"; import { formatSqlText, statementAt } from "./sql"; /** §3.10:SQL 关键字静态补全表(通用 + MySQL/PG 常用) */ const SQL_KEYWORDS = [ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "VIEW", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ON", "AS", "DISTINCT", "GROUP", "BY", "ORDER", "ASC", "DESC", "LIMIT", "OFFSET", "HAVING", "UNION", "ALL", "CASE", "WHEN", "THEN", "ELSE", "END", "IN", "EXISTS", "BETWEEN", "LIKE", "IS", "COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "CAST", "WITH", "EXPLAIN", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "DEFAULT", "UNIQUE", "CHECK", "BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "TRUNCATE", "RETURNING", ].map((kw) => ({ label: kw, type: "keyword" }) satisfies Completion); /** * §3.10/C8:补全数据源 = 关键字 + schemaCache(对象树懒加载时写入的库/表/列)。 * 列名携带类型信息(detail),表/库/列分别标注类型。 */ function schemaOptions(connId?: string): Completion[] { const cache = getCachedSchema(connId); if (!cache) return []; const out: Completion[] = []; for (const [db, tables] of cache) { out.push({ label: db, type: "namespace", detail: "库" }); for (const [table, cols] of tables) { out.push({ label: table, type: "table", detail: db }); for (const col of cols) { out.push({ label: col.name, type: "property", detail: `${table} · ${col.type}` }); } } } return out; } function sqlComplete(connIdRef: React.RefObject) { return (ctx: CompletionContext): CompletionResult | null => { const word = ctx.matchBefore(/[\w$一-龥]*$/); if (!word || (word.from === word.to && !ctx.explicit)) return null; return { from: word.from, options: [...SQL_KEYWORDS, ...schemaOptions(connIdRef.current)] }; }; } function redisComplete(ctx: CompletionContext): CompletionResult | null { const word = ctx.matchBefore(/[\w$]*$/); if (!word || (word.from === word.to && !ctx.explicit)) return null; return { from: word.from, options: REDIS_COMMANDS }; } /** * SQL 高亮:DataGrip Dark 风格(用户点单对照截图)—— * 关键字 = 亮蓝加粗,字符串 = 红,数字 = 浅绿,标识符 = 浅灰白,注释 = 灰斜体。 * 注册在 oneDark 之后,覆盖其默认配色。 */ const sqlDarkHighlight = HighlightStyle.define([ { tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#39A5FF", fontWeight: "700" }, { tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#98C379" }, { tag: [t.string, t.special(t.string)], color: "#E06C75" }, { tag: [t.lineComment, t.blockComment, t.docComment], color: "#7A7A7A", fontStyle: "italic" }, { tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#A9B7C6" }, { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#E8E8E8" }, ]); /** 浅色主题四色方案:关键字蓝、字符串枣红、注释绿、标识符近黑 */ const sqlLightHighlight = HighlightStyle.define([ { tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#0033B3", fontWeight: "700" }, { tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#1750EB" }, { tag: [t.string, t.special(t.string)], color: "#067D17" }, { tag: [t.lineComment, t.blockComment, t.docComment], color: "#8C8C8C", fontStyle: "italic" }, { tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#000000" }, { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#000000" }, ]); export type SqlEditorHandle = { /** Selection trim if nonempty; else statement at cursor (statementAt). */ getRunSql: () => string; /** Format selection (if prefer + nonempty) or whole doc; single history-friendly change. */ format: (selectionPrefer?: boolean) => void; focus: () => void; hasSelection: () => boolean; selectedText: () => string; selectAll: () => void; cutSelection: () => Promise; copySelection: () => Promise; pasteClipboard: () => Promise; }; export type SqlEditorContextInfo = { x: number; y: number; hasSelection: boolean; }; export const SqlEditor = forwardRef< SqlEditorHandle, { value: string; theme: "dark" | "light"; onChange: (sql: string) => void; onRun: (sql: string) => void; /** Optional driver for dialect-aware format. */ driver?: string; /** §3.10:schema 补全按连接隔离(C8 schemaCache) */ connectionId?: string; /** 自绘右键:拦住系统菜单后回调 */ onContextMenu?: (info: SqlEditorContextInfo) => void; } >(function SqlEditor({ value, theme, onChange, onRun, driver, connectionId, onContextMenu }, ref) { const host = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); const onRunRef = useRef(onRun); const valueRef = useRef(value); const driverRef = useRef(driver); const connIdRef = useRef(connectionId); const onContextMenuRef = useRef(onContextMenu); onChangeRef.current = onChange; onRunRef.current = onRun; valueRef.current = value; driverRef.current = driver; connIdRef.current = connectionId; onContextMenuRef.current = onContextMenu; useImperativeHandle(ref, () => ({ getRunSql: () => { const view = viewRef.current; if (!view) return valueRef.current.trim(); const sel = view.state.selection.main; const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to); return statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current); }, format: (selectionPrefer = true) => { const view = viewRef.current; if (!view) return; const sel = view.state.selection.main; const useSel = selectionPrefer && !sel.empty; const from = useSel ? sel.from : 0; const to = useSel ? sel.to : view.state.doc.length; const raw = view.state.sliceDoc(from, to); if (!raw.trim()) return; const formatted = formatSqlText(raw, driverRef.current); if (formatted === raw) return; view.dispatch({ changes: { from, to, insert: formatted }, selection: useSel ? EditorSelection.range(from, from + formatted.length) : EditorSelection.cursor(Math.min(from + formatted.length, from + formatted.length)), }); view.focus(); }, focus: () => viewRef.current?.focus(), hasSelection: () => { const view = viewRef.current; if (!view) return false; return !view.state.selection.main.empty; }, selectedText: () => { const view = viewRef.current; if (!view) return ""; const sel = view.state.selection.main; if (sel.empty) return ""; return view.state.sliceDoc(sel.from, sel.to); }, selectAll: () => { const view = viewRef.current; if (!view) return; view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }); view.focus(); }, cutSelection: async () => { const view = viewRef.current; if (!view) return; const sel = view.state.selection.main; if (sel.empty) return; const text = view.state.sliceDoc(sel.from, sel.to); try { await navigator.clipboard.writeText(text); } catch { /* ignore */ } view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } }); view.focus(); }, copySelection: async () => { const view = viewRef.current; if (!view) return; const sel = view.state.selection.main; if (sel.empty) return; const text = view.state.sliceDoc(sel.from, sel.to); try { await navigator.clipboard.writeText(text); } catch { /* ignore */ } view.focus(); }, pasteClipboard: async () => { const view = viewRef.current; if (!view) return; let text = ""; try { text = await navigator.clipboard.readText(); } catch { return; } const sel = view.state.selection.main; view.dispatch({ changes: { from: sel.from, to: sel.to, insert: text }, selection: EditorSelection.cursor(sel.from + text.length), }); view.focus(); }, })); useEffect(() => { if (!host.current) return; const runKey = { key: "Mod-Enter", run: (view: EditorView) => { const sel = view.state.selection.main; const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to); onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current)); return true; }, }; const redis = (driverRef.current || "").toLowerCase() === "redis"; const state = EditorState.create({ doc: valueRef.current, extensions: [ lineNumbers(), highlightActiveLine(), history(), sql(), // §3.10:关键字 + schemaCache 补全(override 覆盖 lang-sql 默认源) autocompletion({ override: [redis ? redisComplete : sqlComplete(connIdRef)] }), placeholder(redis ? "写 Redis 命令,⌘/Ctrl+Enter 执行当前行或选区" : "写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"), keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]), EditorView.updateListener.of((u) => { if (u.docChanged) onChangeRef.current(u.state.doc.toString()); }), EditorView.domEventHandlers({ contextmenu: (e, view) => { e.preventDefault(); e.stopPropagation(); const sel = view.state.selection.main; onContextMenuRef.current?.({ x: e.clientX, y: e.clientY, hasSelection: !sel.empty, }); return true; }, }), EditorView.theme({ "&": { height: "100%", fontSize: "12.5px" }, // 字体栈走外层 .ops-code 的 tailwind font-mono 令牌(§2.1 修 V6),不再内联硬编码 ".cm-scroller": { lineHeight: "1.45" }, ".cm-content": { padding: "8px 0" }, ".cm-gutters": { border: "none" }, }), ...(theme === "dark" ? [oneDark] : []), syntaxHighlighting(theme === "dark" ? sqlDarkHighlight : sqlLightHighlight), ], }); const view = new EditorView({ state, parent: host.current }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; // recreate on theme / Redis↔SQL 方言切换(占位与补全不同) // eslint-disable-next-line react-hooks/exhaustive-deps }, [theme, driver === "redis"]); useEffect(() => { const view = viewRef.current; if (!view) return; if (view.state.doc.toString() !== value) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, }); } }, [value]); return
; });