Files
opsxdb/src/SqlEditor.tsx
T

297 lines
12 KiB
TypeScript

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<string | undefined>) {
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<void>;
copySelection: () => Promise<void>;
pasteClipboard: () => Promise<void>;
};
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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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 <div ref={host} className="ops-code h-full min-h-[140px] w-full overflow-hidden" />;
});