=== 一、点表生成的语句带分号 → 查询必报 1064 ===
用户报错:
Error 1064 (42000): You have an error in your SQL syntax; ...
near 'LIMIT 100 OFFSET 0' at line 2
复现语句(点表自动生成的原文,天然带分号):
SELECT * FROM `cex_activity`.`concert_goods_language`;
根因在 instance_detail.go 的自动补 LIMIT:
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
原语句已以 ';' 结尾 → 拼完变成
SELECT * FROM ...; LIMIT 100 OFFSET 0
→ 两条语句,报错位置就是第 2 行的 LIMIT(与报错文案完全吻合)。
修复:新增 normalizeSQLForExec(),去掉首尾空白并反复剥掉结尾分号,
在两个「会拼 LIMIT」的入口统一调用:
① executeSQLQuery(查询)—— 归一化后再生效 COUNT(*) 统计与 LIMIT 注入
② exportSQLCSV(导出 CSV)—— 同样先归一化再拼 LIMIT
顺带收益:COUNT(*) 统计以前也会带上分号(虽然 MySQL 容忍),现在干净了。
安全边界未被放松:多语句仍被 isReadOnlySQL 拦住
SELECT 1; SELECT 2 → 拒绝「不允许一次执行多条语句」✅
SELECT 1; LIMIT 5 → 拒绝 ✅(这是一次性两条语句,不是尾分号)
双分号 ;; → 归一化后正常执行 ✅
=== 二、SQL / Redis / Mongo 编辑器:光标异常 + 只有 13 行且滚不动 ===
用户反馈:
「默认还是有一个很长的竖线 一闪一闪,需要回车后第 2 行才正常」
「输入框下面还有空白,最后只有 13 行可以输入,至少需要加一个滚动」
根因 1(滚不动,13 行):appTheme 的 '&' 没有 height:100%。
.cm-editor 会随内容无限增高,而外层 div 固定 200px 且裁切,
CodeMirror 内部的 .cm-scroller 永远拿不到受限高度 → 不产生滚动条。
13 行 = 200px ÷ 15px 行高,正好对上。
根因 2(光标拉长):appTheme 在 extensions 里排在 oneDark 【之前】。
CodeMirror theme 是后者覆盖前者,oneDark 的默认行高/内边距盖掉了我们的
line-height,首次测量拿到的行高不对 → 空文档时光标高度算成好几行。
修复:
① appTheme '&' 增加 height:'100%';外层容器 style 改为
height + minHeight:120px + resize:vertical + overflow:hidden
(.cm-scroller 立刻有确定高度 → 内部滚动生效;右下角还能拖动拉高)
② appTheme 移到 oneDark 之后(顺序即优先级)
③ 行高统一写成 1.55em,并在 .cm-scroller / .cm-content / .cm-line /
.cm-gutters 四处保持一致(CM 靠读 .cm-content 的 line-height 算光标高度)
④ 挂载后 requestMeasure() + 下一帧再测一次 + document.fonts.ready 后再测一次,
修掉「首帧测量错乱」这一大类问题
⑤ SqlEditorSection 默认高度 200px → 240px(约 15 行可见)
注意:仍然不要给 .cm-cursor 写死 height / marginTop ——
CM 自己按行高算,写死会溢出到相邻行(历史上已经踩过一次)。
=== 三、实测 ===
表本身为空:cex_activity.concert_goods_language COUNT(*)=0
→ SELECT * 返回 0 行是正确结果,不是故障
分号归一化:带分号 / 不带分号 / 双分号 → rows=100 total=17447 完全一致,无 1064 ✅
安全边界:两条语句、分号后接 LIMIT 均被拒 ✅
编辑器产物:SqlEditor-*.js 内含 1.55em 与 resize:"vertical" ✅
镜像:lghost/opsxsql:latest + v0.1 = sha256:72a00dda2419
796 lines
20 KiB
TypeScript
796 lines
20 KiB
TypeScript
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, Record<string, string[]> | 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<string, readonly string[]> = {};
|
||
|
||
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<string, string[]>;
|
||
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(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
|
||
formatted = formatted.replace(re, '\n$1');
|
||
}
|
||
|
||
// Clean up any double newlines
|
||
formatted = formatted.replace(/\n{2,}/g, '\n');
|
||
|
||
return formatted.trim();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SqlEditor component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface SqlEditorHandle {
|
||
/** Insert text at cursor position and move cursor after inserted text */
|
||
insertText: (text: string) => 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<SqlEditorHandle, SqlEditorProps>(
|
||
(
|
||
{
|
||
value,
|
||
onChange,
|
||
placeholder = 'Enter SQL query…',
|
||
language = 'sql',
|
||
height = '200px',
|
||
readOnly = false,
|
||
onExecute,
|
||
schema,
|
||
},
|
||
ref,
|
||
) => {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const viewRef = useRef<EditorView | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
// height 固定 → 内层 .cm-scroller 才有确定的可用高度,从而出现滚动条;
|
||
// resize: vertical → 用户可以自己把编辑区拖高(右下角有拖拽柄),
|
||
// 比写死 200px 灵活;overflow hidden 保证内容不会溢出到卡片外。
|
||
style={{
|
||
height,
|
||
minHeight: '120px',
|
||
resize: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
className="sql-editor-wrapper"
|
||
/>
|
||
);
|
||
},
|
||
);
|
||
|
||
export default SqlEditor;
|