chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
@@ -0,0 +1,296 @@
|
||||
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 高亮走克制的四色方案(VS Code Dark+ 观感):
|
||||
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
|
||||
* 注册在 oneDark 之后,覆盖其默认的五彩配色。
|
||||
*/
|
||||
const sqlDarkHighlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#569CD6" },
|
||||
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#569CD6" },
|
||||
{ tag: [t.string, t.special(t.string)], color: "#CE9178" },
|
||||
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#6A9955" },
|
||||
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#D4D4D4" },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#D4D4D4" },
|
||||
]);
|
||||
|
||||
/** 浅色主题四色方案:关键字蓝、字符串枣红、注释绿、标识符近黑 */
|
||||
const sqlLightHighlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#0000FF" },
|
||||
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#0000FF" },
|
||||
{ tag: [t.string, t.special(t.string)], color: "#A31515" },
|
||||
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#008000" },
|
||||
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#24292F" },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#24292F" },
|
||||
]);
|
||||
|
||||
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" />;
|
||||
});
|
||||
Reference in new issue
Block a user