From 4bee3cefa6b3c6dce13863f45c2ff5197e79290f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 13 Jul 2026 12:54:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(query-editor):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BE=93=E5=85=A5=E4=B8=8E=E9=80=89=E5=8C=BA?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=B5=81=E7=95=85=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 Monaco 编辑器及语言服务接入独立 Worker - 限制大规模对象元数据与列装饰扫描范围 - 仅在按下导航修饰键时更新悬停状态 - 避免同一数据库上下文重复重建装饰并补充回归测试 --- frontend/src/components/MonacoEditor.tsx | 44 +++++++++++++++- .../components/MonacoEditor.worker.test.ts | 28 +++++++++++ .../QueryEditor.external-sql-save.test.tsx | 50 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 21 ++++++-- .../queryEditor/QueryEditorHelpers.ts | 2 +- 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/MonacoEditor.worker.test.ts diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index 6383642a..abf60a3b 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -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, + 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 => { 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, { + editor: () => new editorWorker.default(), + json: () => new jsonWorker.default(), + css: () => new cssWorker.default(), + html: () => new htmlWorker.default(), + typescript: () => new typescriptWorker.default(), + }); loader.config({ monaco }); }); } diff --git a/frontend/src/components/MonacoEditor.worker.test.ts b/frontend/src/components/MonacoEditor.worker.test.ts new file mode 100644 index 00000000..8056dd19 --- /dev/null +++ b/frontend/src/components/MonacoEditor.worker.test.ts @@ -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 = {}; + + 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' }); + }); +}); diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index fa67ea36..c185b8fa 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + }); + + 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 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(); + }); + + 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; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 32e78139..221e2cf2 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2113,6 +2113,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const decorations: any[] = []; const seen = new Set(); 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; diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 1301a504..2796db66 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -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;