import { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react'; import { EditorState, Compartment } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view'; import { sql, SQLDialect } from '@codemirror/lang-sql'; import { oneDark } from '@codemirror/theme-one-dark'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'; import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface SqlEditorProps { value: string; onChange: (val: string) => void; placeholder?: string; language?: 'sql' | 'redis' | 'mongodb'; height?: string; readOnly?: boolean; onExecute?: () => void; /** SQL schema: { [dbName]: { [tableName]: string[] (columns) } } or { [tableName]: string[] } */ schema?: Record | string[]>; } // --------------------------------------------------------------------------- // Language configs // --------------------------------------------------------------------------- const REDIS_KEYWORDS = [ 'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET', 'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN', 'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER', 'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL', 'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL', 'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT', 'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH', 'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE', 'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN', 'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT', 'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX', 'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE', 'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF', ]; const MONGODB_KEYWORDS = [ // CRUD 'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct', // Aggregation stages 'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup', 'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount', 'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count', // Operators 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not', 'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then', 'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays', 'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last', // Index / admin 'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint', // Collection / DB 'use', 'show', 'db', 'collection', 'collections', 'databases', // JS keywords commonly used 'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while', 'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof', 'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal', 'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef', ]; function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension { const lang = language ?? 'sql'; if (lang === 'redis') { const dialect = SQLDialect.define({ keywords: REDIS_KEYWORDS.join(' '), }); return sql({ dialect }); } if (lang === 'mongodb') { const dialect = SQLDialect.define({ keywords: MONGODB_KEYWORDS.join(' '), }); return sql({ dialect }); } // MySQL/PG — pass schema for autocomplete (tables + columns) if (schema && Object.keys(schema).length > 0) { // Flatten: if first value is an array, it's { table: columns[] } // if first value is an object, it's { db: { table: columns[] } } const sampleValue = Object.values(schema)[0]; let tables: Record = {}; if (Array.isArray(sampleValue)) { // Direct: { table: [columns] } tables = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, v as string[]])); } else { // Nested: { db: { table: [columns] } } // Register: db → table names (so "db." shows tables) // Register: table → columns (so "table." shows columns) for (const [db, tableMap] of Object.entries(schema)) { const entries = tableMap as Record; const tableNames = Object.keys(entries); // "shop_db" → shows table names as completions after "." tables[db] = tableNames; for (const [table, cols] of Object.entries(entries)) { // "goods" → shows column names after "." if (cols.length > 0) { tables[table] = cols; } } } } const dialect = SQLDialect.define({ keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze', ...(tables ? { schema: tables } : {}), } as any); return sql({ dialect }); } return sql(); } // --------------------------------------------------------------------------- // Custom theme to match app CSS variables // --------------------------------------------------------------------------- const MONO_FONT = "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace"; const appTheme = EditorView.theme({ '&': { fontSize: '13px', backgroundColor: 'var(--dg-bg-primary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)', borderRadius: '6px', // ★ 必须撑满外层容器:否则 .cm-editor 会随内容无限增高, // .cm-scroller 永远不会变成可滚动区域,外层高度一裁就只剩十几行, // 再多输入的内容既看不到也滚不动(用户反馈的「只有 13 行」就是这个)。 height: '100%', }, // ★ 行高统一写在 .cm-scroller / .cm-content / .cm-line / .cm-gutters 四处, // 并且用带单位的值(1.55em)。CodeMirror 靠读取 .cm-content 的 line-height // 来决定光标高度;若各处不一致或用了无单位数值,首次测量会拿到错的行高, // 表现为「空文档时光标是一根贯穿好几行的长竖线,敲个回车才恢复正常」。 '.cm-scroller': { overflow: 'auto', fontFamily: MONO_FONT, lineHeight: '1.55em', }, '.cm-content': { caretColor: 'var(--dg-text-primary)', fontFamily: MONO_FONT, lineHeight: '1.55em', padding: '4px 0', }, '.cm-line': { lineHeight: '1.55em', }, '.cm-gutters': { lineHeight: '1.55em', }, // 光标只改颜色/粗细,绝不要强改 height 与 marginTop: // CM 会按行高自己算光标高度,写死 height 会溢出到相邻行(光标两行同时闪)。 '.cm-cursor': { borderLeftColor: 'var(--dg-text-primary)', borderLeftWidth: '1.5px', }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { backgroundColor: 'rgba(66, 133, 244, 0.3) !important', }, '.cm-gutters': { backgroundColor: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)', border: 'none', borderRight: '1px solid var(--dg-border)', }, '.cm-activeLineGutter': { backgroundColor: 'var(--dg-bg-tertiary)', }, '.cm-activeLine': { backgroundColor: 'rgba(255, 255, 255, 0.04)', }, '.cm-placeholder': { color: 'var(--dg-text-muted)', fontStyle: 'italic', }, '.cm-scroller': { overflow: 'auto', }, }); // --------------------------------------------------------------------------- // formatSQL helper // --------------------------------------------------------------------------- const SQL_BREAK_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN', 'CROSS JOIN', 'LEFT OUTER JOIN', 'RIGHT OUTER JOIN', 'FULL OUTER JOIN', 'ON', 'UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', ]; const SQL_KEYWORDS_UPPER = new Set([ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS', 'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT', 'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION', 'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE', 'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON', 'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE', ]); export function formatSQL(input: string): string { // Normalise whitespace let formatted = input.replace(/\s+/g, ' ').trim(); // Uppercase SQL keywords (word-boundary aware) formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => { const upper = match.toUpperCase(); return SQL_KEYWORDS_UPPER.has(upper) ? upper : match; }); // Insert newlines before major clauses // We iterate in the order they might appear; use a single pass regex approach. for (const kw of SQL_BREAK_KEYWORDS) { // Only break on keyword that is not already at start of a line const escaped = kw.replace(/\s+/g, '\\s+'); const re = new RegExp(`(? void; /** 选区优先,否则光标所在语句;与全文相同时返回 null(调用方直接用全文) */ getSqlToRun: () => string | null; } /** 按分号切分语句(单/双引号与行注释感知),返回每段的 [start, end) 与文本 */ export function splitSqlStatements(text: string): Array<{ start: number; end: number; text: string }> { const out: Array<{ start: number; end: number; text: string }> = []; let start = 0; let quote: string | null = null; let lineComment = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = text[i + 1]; if (lineComment) { if (ch === '\n') lineComment = false; continue; } if (quote) { if (ch === '\\') { i++; continue; } if (ch === quote) quote = null; continue; } if (ch === '-' && next === '-') { lineComment = true; i++; continue; } if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; } if (ch === ';') { out.push({ start, end: i, text: text.slice(start, i) }); start = i + 1; } } if (start < text.length) out.push({ start, end: text.length, text: text.slice(start) }); return out; } const SqlEditor = forwardRef( ( { value, onChange, placeholder = 'Enter SQL query…', language = 'sql', height = '200px', readOnly = false, onExecute, schema, }, ref, ) => { const containerRef = useRef(null); const viewRef = useRef(null); const langCompartmentRef = useRef(new Compartment()); const readOnlyCompartmentRef = useRef(new Compartment()); const placeholderCompartmentRef = useRef(new Compartment()); const isExternalUpdateRef = useRef(false); // Expose insertText to parent via ref useImperativeHandle( ref, () => ({ insertText: (text: string) => { const view = viewRef.current; if (!view) return; const pos = view.state.selection.main.head; view.dispatch({ changes: { from: pos, insert: text }, selection: { anchor: pos + text.length }, }); view.focus(); }, getSqlToRun: (): string | null => { const view = viewRef.current; if (!view) return null; const sel = view.state.selection.main; const selected = view.state.sliceDoc(sel.from, sel.to).trim(); if (selected) return selected; const doc = view.state.doc.toString(); const statements = splitSqlStatements(doc).filter((st) => st.text.trim()); if (statements.length <= 1) return null; // 单语句/空:用全文 const cursor = sel.head; const stmt = statements.find((st) => cursor > st.start && cursor <= st.end + 1); if (!stmt) return null; const fragment = stmt.text.trim(); return fragment && fragment !== doc.trim() ? fragment : null; }, }), [], ); // Keep onChange in a stable ref so callbacks never go stale const onChangeRef = useRef(onChange); onChangeRef.current = onChange; // Ctrl+Enter handler const runKeymap = useCallback( (): Extension[] => [ keymap.of([ { key: 'Mod-Enter', run: () => { if (onExecute) { onExecute(); } else { window.dispatchEvent(new CustomEvent('sql-editor:execute')); } return true; }, }, { key: 'Mod-Shift-f', run: (view: EditorView) => { const current = view.state.doc.toString(); const formatted = formatSQL(current); if (formatted !== current) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: formatted, }, }); } return true; }, }, ]), ], [onExecute], ); // Create editor once useEffect(() => { if (!containerRef.current) return; const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged && !isExternalUpdateRef.current) { onChangeRef.current(update.state.doc.toString()); } }); const state = EditorState.create({ doc: value, extensions: [ lineNumbers(), history(), bracketMatching(), closeBrackets(), autocompletion({ activateOnTyping: true, icons: false, }), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // ★ 顺序很重要:oneDark 在前、appTheme 在后。 // CodeMirror 的 theme 是后者覆盖前者;反过来会让 oneDark 的 // 默认行高/内边距盖掉我们的设置,光标高度测量又会出问题。 oneDark, appTheme, langCompartmentRef.current.of(getSQLExtension(language, schema)), readOnlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)), placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)), updateListener, ...runKeymap(), keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]), EditorView.lineWrapping, ], }); const view = new EditorView({ state, parent: containerRef.current, }); viewRef.current = view; // ★ 强制重新测量:编辑器挂载瞬间若容器还没布局完成(或在隐藏面板里), // CM 会把行高/光标高度算错,表现为「首行光标是一根几行高的长竖线」。 // 这里在下一帧、以及等自定义字体加载完成后各测一次,把高度纠正回来。 view.requestMeasure(); const raf = requestAnimationFrame(() => view.requestMeasure()); let cancelled = false; if (typeof document !== 'undefined' && document.fonts?.ready) { document.fonts.ready.then(() => { if (!cancelled) view.requestMeasure(); }); } return () => { cancelled = true; cancelAnimationFrame(raf); view.destroy(); viewRef.current = null; }; // We intentionally only run this once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync external value changes into the editor (avoid loops) useEffect(() => { const view = viewRef.current; if (!view) return; const currentContent = view.state.doc.toString(); if (currentContent !== value) { isExternalUpdateRef.current = true; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, }); isExternalUpdateRef.current = false; } }, [value]); // Update language/schema when they change useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)), }); }, [language, schema]); // Update readOnly when it changes useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: readOnlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)), }); }, [readOnly]); // Update placeholder when it changes useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: placeholderCompartmentRef.current.reconfigure(cmPlaceholder(placeholder ?? '')), }); }, [placeholder]); return (