mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
⚡️ perf(query-editor): 优化输入与选区交互流畅度
- 为 Monaco 编辑器及语言服务接入独立 Worker - 限制大规模对象元数据与列装饰扫描范围 - 仅在按下导航修饰键时更新悬停状态 - 避免同一数据库上下文重复重建装饰并补充回归测试
This commit is contained in:
@@ -20,6 +20,32 @@ const isTestRuntime = (): boolean => {
|
||||
return env.MODE === 'test' || env.VITEST === true || env.VITEST === 'true';
|
||||
};
|
||||
|
||||
type MonacoWorkerFactory = () => Worker;
|
||||
|
||||
interface MonacoWorkerFactories {
|
||||
editor: MonacoWorkerFactory;
|
||||
json: MonacoWorkerFactory;
|
||||
css: MonacoWorkerFactory;
|
||||
html: MonacoWorkerFactory;
|
||||
typescript: MonacoWorkerFactory;
|
||||
}
|
||||
|
||||
export const installMonacoWorkerEnvironment = (
|
||||
scope: Record<string, any>,
|
||||
workers: MonacoWorkerFactories,
|
||||
) => {
|
||||
scope.MonacoEnvironment = {
|
||||
...(scope.MonacoEnvironment || {}),
|
||||
getWorker(_moduleId: string, label: string) {
|
||||
if (label === 'json') return workers.json();
|
||||
if (label === 'css' || label === 'scss' || label === 'less') return workers.css();
|
||||
if (label === 'html' || label === 'handlebars' || label === 'razor') return workers.html();
|
||||
if (label === 'typescript' || label === 'javascript') return workers.typescript();
|
||||
return workers.editor();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sameEditorPosition = (left: any, right: any): boolean => (
|
||||
Number(left?.lineNumber) === Number(right?.lineNumber)
|
||||
&& Number(left?.column) === Number(right?.column)
|
||||
@@ -539,8 +565,22 @@ const ensureMonacoConfigured = (): Promise<void> => {
|
||||
|
||||
if (!monacoConfiguredPromise) {
|
||||
monacoConfiguredPromise = import('monaco-editor/esm/nls.messages.zh-cn')
|
||||
.then(() => import('monaco-editor'))
|
||||
.then((monaco) => {
|
||||
.then(() => Promise.all([
|
||||
import('monaco-editor'),
|
||||
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
|
||||
]))
|
||||
.then(([monaco, editorWorker, jsonWorker, cssWorker, htmlWorker, typescriptWorker]) => {
|
||||
installMonacoWorkerEnvironment(globalThis as unknown as Record<string, any>, {
|
||||
editor: () => new editorWorker.default(),
|
||||
json: () => new jsonWorker.default(),
|
||||
css: () => new cssWorker.default(),
|
||||
html: () => new htmlWorker.default(),
|
||||
typescript: () => new typescriptWorker.default(),
|
||||
});
|
||||
loader.config({ monaco });
|
||||
});
|
||||
}
|
||||
|
||||
28
frontend/src/components/MonacoEditor.worker.test.ts
Normal file
28
frontend/src/components/MonacoEditor.worker.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { installMonacoWorkerEnvironment } from './MonacoEditor';
|
||||
|
||||
describe('MonacoEditor worker environment', () => {
|
||||
it('routes Monaco languages to bundled workers', () => {
|
||||
const createWorker = (name: string) => vi.fn(() => ({ name } as unknown as Worker));
|
||||
const workers = {
|
||||
editor: createWorker('editor'),
|
||||
json: createWorker('json'),
|
||||
css: createWorker('css'),
|
||||
html: createWorker('html'),
|
||||
typescript: createWorker('typescript'),
|
||||
};
|
||||
const scope: Record<string, any> = {};
|
||||
|
||||
installMonacoWorkerEnvironment(scope, workers);
|
||||
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'json')).toEqual({ name: 'json' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'css')).toEqual({ name: 'css' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'scss')).toEqual({ name: 'css' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'html')).toEqual({ name: 'html' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'handlebars')).toEqual({ name: 'html' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'typescript')).toEqual({ name: 'typescript' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'javascript')).toEqual({ name: 'typescript' });
|
||||
expect(scope.MonacoEnvironment.getWorker('', 'sql')).toEqual({ name: 'editor' });
|
||||
});
|
||||
});
|
||||
@@ -6017,6 +6017,27 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not churn decorations while selecting text without a navigation modifier', async () => {
|
||||
editorState.value = 'select users.id from users';
|
||||
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: editorState.value })} />);
|
||||
});
|
||||
|
||||
editorState.editor.updateOptions.mockClear();
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
editorState.mouseMoveListeners[0]?.({
|
||||
target: { position: { lineNumber: 1, column: 10 } },
|
||||
event: { ctrlKey: false, metaKey: false },
|
||||
});
|
||||
});
|
||||
|
||||
expect(editorState.editor.updateOptions).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores candidate number keys while a composition session is active', async () => {
|
||||
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
|
||||
vi.stubGlobal('window', {
|
||||
@@ -6654,6 +6675,35 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not rescan object decorations after repeated edits in the same database context', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
|
||||
});
|
||||
|
||||
const emitChange = async (value: string) => {
|
||||
editorState.value = value;
|
||||
editorState.latestOnChange?.(value);
|
||||
editorState.modelContentListeners.forEach((listener) => listener({
|
||||
changes: [{ text: value }],
|
||||
}));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(450);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
await emitChange('select users.id from users;');
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
await emitChange('select users.id, users.name from users;');
|
||||
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores focused local tab query echoes so IME candidate commits are not overwritten', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
|
||||
@@ -2113,6 +2113,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const decorations: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
const candidates = collectQueryEditorObjectDecorationCandidates(text);
|
||||
const objectMetadataCount = tablesRef.current.length
|
||||
+ viewsRef.current.length
|
||||
+ materializedViewsRef.current.length
|
||||
+ triggersRef.current.length
|
||||
+ routinesRef.current.length
|
||||
+ sequencesRef.current.length
|
||||
+ packagesRef.current.length;
|
||||
if (objectMetadataCount > 5_000) {
|
||||
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []);
|
||||
return;
|
||||
}
|
||||
const decorationColumns = allColumnsRef.current.length <= 2_000
|
||||
? allColumnsRef.current
|
||||
: [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const hoverTarget = resolveQueryEditorHoverTarget(
|
||||
@@ -2122,7 +2136,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
currentDbRef.current,
|
||||
visibleDbsRef.current,
|
||||
tablesRef.current,
|
||||
allColumnsRef.current,
|
||||
decorationColumns,
|
||||
viewsRef.current,
|
||||
materializedViewsRef.current,
|
||||
triggersRef.current,
|
||||
@@ -3958,6 +3972,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const applyNavigationHoverState = (event: any) => {
|
||||
const targetPosition = normalizeEditorPosition(event?.target?.position);
|
||||
lastHoverTargetPositionRef.current = targetPosition;
|
||||
if (!ctrlMetaPressedRef.current) {
|
||||
return;
|
||||
}
|
||||
applyNavigationHoverStateAtPosition(targetPosition);
|
||||
};
|
||||
|
||||
@@ -4298,8 +4315,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
...referencedDbs.map((dbName) => String(dbName || '').toLowerCase()).sort(),
|
||||
].join('\u0000');
|
||||
if (nextKey === lastSqlReferencedMetadataKeyRef.current) {
|
||||
// 库集合未变:仍刷新装饰(可能表列表已异步到位)
|
||||
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
|
||||
return;
|
||||
}
|
||||
lastSqlReferencedMetadataKeyRef.current = nextKey;
|
||||
|
||||
@@ -1109,7 +1109,7 @@ export const QUERY_EDITOR_SQL_ALIAS_REFERENCE_REGEX = new RegExp(
|
||||
export const QUERY_EDITOR_SQL_LEADING_IDENTIFIER_PATH_REGEX = new RegExp(`^(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})([\\s\\S]*)$`);
|
||||
export const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
|
||||
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000;
|
||||
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800;
|
||||
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 200;
|
||||
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_LINES = 1_000;
|
||||
export const QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH = 50_000;
|
||||
export const QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH = 50_000;
|
||||
|
||||
Reference in New Issue
Block a user