297 lines
12 KiB
TypeScript
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" />;
|
|
});
|