From faaf7169daac2f8d11bcbbaeda5c537aa816f74d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Jul 2026 18:11:41 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20SQL=20=E7=89=87=E6=AE=B5=E8=A1=A5=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 插入 SQL 片段后主动关闭片段弹窗,保持右键插入行为一致 - 提升 Monaco 补全详情区最小高度,放大片段说明展示区域 - 列补全解析改为基于当前语句引用上下文,修复当前语句前半段补全丢列问题 - 为 SQL 草稿持久化补齐 window 定时器兜底,避免测试环境 clearTimeout 缺失 - 更新 SQL 片段弹窗关闭和补全说明高度相关回归测试 --- frontend/src/App.css | 8 +++++ .../QueryEditor.external-sql-save.test.tsx | 22 ++++++++++++-- frontend/src/components/QueryEditor.tsx | 26 ++++++++++++---- frontend/src/utils/sqlFileTabDrafts.ts | 30 +++++++++++++++---- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index df35257c..74ad92ec 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -239,6 +239,14 @@ body[data-theme='light'] ::-webkit-scrollbar-thumb:hover { background-clip: content-box; } +.gn-query-monaco-stage .monaco-editor .suggest-details-container { + min-height: 260px; +} + +.gn-query-monaco-stage .monaco-editor .suggest-details { + min-height: 260px; +} + /* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */ body { transition: color 0.3s; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 85592d64..d62f7e2e 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -3642,7 +3642,7 @@ describe('QueryEditor external SQL save', () => { })], ); expect(editorState.value).toBe('SELECT id FROM user_table;'); - expect(renderer.root.findByProps({ 'data-query-editor-snippet-picker': 'true' })).toBeTruthy(); + expect(renderer.root.findAllByProps({ 'data-query-editor-snippet-picker': 'true' })).toHaveLength(0); }); it('prefers Monaco snippet controller insertion when the controller is available', async () => { @@ -3695,7 +3695,7 @@ describe('QueryEditor external SQL save', () => { ); expect(editorState.editor.executeEdits).not.toHaveBeenCalled(); expect(editorState.value).toBe('ALTER TABLE demo_table\nADD COLUMN user_name VARCHAR(255);'); - expect(renderer.root.findByProps({ 'data-query-editor-snippet-picker': 'true' })).toBeTruthy(); + expect(renderer.root.findAllByProps({ 'data-query-editor-snippet-picker': 'true' })).toHaveLength(0); }); it('keeps the SQL snippet picker modal non-mask-closable to avoid immediate close after context-menu click', () => { @@ -8892,6 +8892,24 @@ describe('QueryEditor external SQL save', () => { expect(css).not.toContain('body[data-ui-version="v2"] .gn-v2-query-monaco-stage .monaco-editor .find-widget {'); }); + it('raises QueryEditor suggest docs height for SQL snippet completion without widening global Monaco defaults', () => { + const appCss = readFileSync(new URL('../App.css', import.meta.url), 'utf8'); + + expect(queryEditorSource).toContain('QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT = 260'); + expect(queryEditorSource).toContain("editor.getContribution?.('editor.contrib.suggestController')"); + expect(queryEditorSource).toContain('const originalSuggestDetailsLayout = suggestDetailsWidget.layout.bind(suggestDetailsWidget);'); + expect(queryEditorSource).toContain('suggestDetailsWidget.layout = (width: number, height: number) => {'); + expect(queryEditorSource).toContain('Math.max(height, QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT)'); + expect(queryEditorSource).toContain("className={isV2Ui ? 'gn-v2-query-monaco-stage gn-query-monaco-stage' : 'gn-query-monaco-stage'}"); + expect(appCss).toContain('.gn-query-monaco-stage .monaco-editor .suggest-details-container {'); + expect(appCss).toContain('min-height: 260px;'); + expect(appCss).toContain('.gn-query-monaco-stage .monaco-editor .suggest-details {'); + expect(appCss).toContain('min-height: 260px;'); + expect(appCss).not.toContain('.gn-query-monaco-stage .monaco-editor .suggest-widget {'); + expect(appCss).not.toContain('width: 680px;'); + expect(appCss).not.toContain('min-width: 560px;'); + }); + it('keeps the v2 query editor toolbar grouped and compact', () => { const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ec471936..f874b633 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -742,6 +742,7 @@ const clearRecord = (record: Record) => { delete record[key]; }); }; +const QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT = 260; const buildSqlSnippetVariableMap = (now: Date): Record => { const pad = (value: number) => String(value).padStart(2, '0'); @@ -1197,6 +1198,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (typeof nextValue === 'string') { applyQueryState(nextValue); } + handleCloseSqlSnippetPicker(); editor.focus?.(); return; } @@ -1246,8 +1248,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (typeof nextValue === 'string') { applyQueryState(nextValue); } + handleCloseSqlSnippetPicker(); editor.focus?.(); - }, [applyQueryState]); + }, [applyQueryState, handleCloseSqlSnippetPicker]); useEffect(() => { persistQueryTabDraftSnapshot(draftSnapshotTab, query, { @@ -2600,6 +2603,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const handleEditorDidMount: OnMount = (editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + + const suggestController = editor.getContribution?.('editor.contrib.suggestController') as { + widget?: { value?: { _details?: { widget?: { layout?: (width: number, height: number) => void } } } }; + } | null; + const suggestDetailsWidget = suggestController?.widget?.value?._details?.widget; + if (suggestDetailsWidget?.layout) { + const originalSuggestDetailsLayout = suggestDetailsWidget.layout.bind(suggestDetailsWidget); + suggestDetailsWidget.layout = (width: number, height: number) => { + originalSuggestDetailsLayout(width, Math.max(height, QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT)); + }; + } lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.()); editor.updateOptions?.({ @@ -3415,6 +3429,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ? fullText.slice(currentStatementRange.start, cursorOffset) : fullText.slice(0, cursorOffset); const completionScopeText = currentStatementPrefix || linePrefix; + const currentStatementText = currentStatementRange?.text || ''; + const completionReferenceText = currentStatementText || completionScopeText; // 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列 const threePartMatch = linePrefix.match(QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX); @@ -3552,7 +3568,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } // 否则检查是否是表别名或表名,提示列 - const aliasMap = buildQueryEditorAliasMap(completionScopeText, sharedCurrentDb || ''); + const aliasMap = buildQueryEditorAliasMap(completionReferenceText, sharedCurrentDb || ''); const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { @@ -3586,7 +3602,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc tableRegex.lastIndex = 0; const foundTables = new Set(); let match; - while ((match = tableRegex.exec(completionScopeText)) !== null) { + while ((match = tableRegex.exec(completionReferenceText)) !== null) { const t = normalizeQualifiedName(match[1] || ''); if (!t) continue; // 存储完整标识 db.table 或 table @@ -3685,7 +3701,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const referencedColumns: CompletionColumnMeta[] = []; if (!expectsTableName) { - const aliasMapForReferencedTables = buildQueryEditorAliasMap(completionScopeText, currentDatabase); + const aliasMapForReferencedTables = buildQueryEditorAliasMap(completionReferenceText, currentDatabase); const seenReferencedTables = new Set(); for (const tableInfo of Object.values(aliasMapForReferencedTables)) { const key = `${String(tableInfo.dbName || '').toLowerCase()}.${String(tableInfo.tableName || '').toLowerCase()}`; @@ -6223,7 +6239,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
{ + if (typeof window === 'undefined') { + return null; + } + const setTimeoutImpl = typeof window.setTimeout === 'function' ? window.setTimeout.bind(window) : globalThis.setTimeout; + const clearTimeoutImpl = typeof window.clearTimeout === 'function' ? window.clearTimeout.bind(window) : globalThis.clearTimeout; + if (typeof setTimeoutImpl !== 'function' || typeof clearTimeoutImpl !== 'function') { + return null; + } + return { + setTimeout: setTimeoutImpl, + clearTimeout: clearTimeoutImpl, + }; +}; + const toTabId = (value: unknown): string => String(value ?? '').trim(); const toTrimmedString = (value: unknown, fallback = ''): string => { @@ -108,8 +126,9 @@ const ensurePersistedDraftsHydrated = (): void => { }; const flushPersistedDrafts = (): void => { - if (persistTimer !== null && typeof window !== 'undefined') { - window.clearTimeout(persistTimer); + const timerApi = getWindowTimerApi(); + if (persistTimer !== null && timerApi) { + timerApi.clearTimeout(persistTimer); persistTimer = null; } const storage = getDraftSnapshotStorage(); @@ -155,14 +174,15 @@ const bindFlushListeners = (): void => { const schedulePersistedDraftFlush = (): void => { bindFlushListeners(); - if (typeof window === 'undefined') { + const timerApi = getWindowTimerApi(); + if (!timerApi) { flushPersistedDrafts(); return; } if (persistTimer !== null) { - window.clearTimeout(persistTimer); + timerApi.clearTimeout(persistTimer); } - persistTimer = window.setTimeout(() => { + persistTimer = timerApi.setTimeout(() => { flushPersistedDrafts(); }, QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS); };