import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; import { message, Input, Form, MenuProps, Button, Segmented } from 'antd'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, type SavedQuery, type SqlSnippet } from '../types'; import { type SqlLog, useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiInTransaction, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import { GONAVI_ROW_KEY } from './DataGrid'; import { EventsOn } from '../../wailsjs/runtime'; import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, normalizeShortcutCombo, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isPostgresSchemaDialect } from '../utils/connectionDriverType'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { buildQueryResultPageSql, createInitialQueryResultPagination, resolveQueryResultPaginationTotal, } from '../utils/queryResultPagination'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { formatSqlExecutionError, hasLocalizedSqlTimeoutKeyword } from '../utils/sqlErrorSemantics'; import { canReusePendingSqlEditorTransactionForType, shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction'; import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { t as translate } from '../i18n'; import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab'; import { isLocalizedUntitledQueryTitle } from '../utils/queryTabTitle'; import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; import { clampQueryEditorEditorHeight, resolveQueryEditorEditorHeightFromRatio, resolveQueryEditorEditorHeightRatio, sanitizeQueryEditorEditorHeightRatio, } from '../utils/queryEditorSplitLayout'; import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator, } from '../utils/rowLocator'; import { clearQueryTabDraft, getQueryTabDraft, hasQueryTabDraft, persistQueryTabDraftSnapshot, } from '../utils/sqlFileTabDrafts'; import { clearQueryEditorResultSession, saveQueryEditorResultSession, takeQueryEditorResultSession, } from '../utils/queryEditorResultSessionCache'; import { buildEditableTriggerSql } from '../utils/triggerEditSql'; import { getColumnDefinitionComment, getColumnDefinitionKey, getColumnDefinitionName, getColumnDefinitionType, } from '../utils/columnDefinition'; import QueryEditorResultsPanel, { QUERY_EDITOR_SQL_LOG_TAB_KEY, type QueryEditorResultSet, } from './QueryEditorResultsPanel'; import ResultDiffWizard from './resultDiff/ResultDiffWizard'; import ResultDiffPanel from './resultDiff/ResultDiffPanel'; import ViewDataVerifyWizard from './resultDiff/ViewDataVerifyWizard'; import type { ResultDiffColumnMeta, ResultDiffComparableResult, ResultDiffSummary, } from '../utils/resultDiff/types'; import { isViewEditSql, resolveViewNameForVerify, } from '../utils/resultDiff/viewDataVerify'; import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings'; import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar'; import QueryEditorToolbar from './QueryEditorToolbar'; import { useSqlEditorTransactionController } from './useSqlEditorTransactionController'; import { type CompletionColumnMeta, type CompletionPackageMeta, type CompletionRoutineMeta, type CompletionSequenceMeta, type CompletionTableMeta, type CompletionTriggerMeta, type CompletionViewMeta, type QueryEditorNavigationTarget, type QueryStatementPlan, QUERY_EDITOR_HOVER_DELAY_MS, QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH, QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH, QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH, QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX, QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX, QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX, appendCommentToDetail, areSqlStatementListsEqual, buildCompletionDocumentation, buildColumnCompletionDetail, buildColumnCompletionDocumentation, buildCompletionFunctionsMetadataQuerySpecs, buildCompletionMaterializedViewsMetadataQuerySpecs, buildCompletionPackagesMetadataQuerySpecs, buildCompletionSequencesMetadataQuerySpecs, buildCompletionTableCommentSQL, buildCompletionTriggersMetadataQuerySpecs, buildCompletionViewsMetadataQuerySpecs, buildQueryEditorAliasMap, buildQueryEditorHoverMarkdown, buildQualifiedCompletionName, clearQueryEditorLinkDecorations, clearQueryEditorObjectDecorations, collectQueryEditorObjectDecorationCandidates, collectQueryEditorReferencedDatabaseNames, getCaseInsensitiveValue, getFirstRowValue, getNormalizedPositionAtOffset, hasQueryEditorCtrlMetaModifier, getInitialEditorQuery, getMySQLShowTablesName, getNormalizedOffsetAtPosition, getQueryEditorDecorationModelTextIfLightweight, getQueryEditorObjectResolveText, getTabQueryValue, isDocumentLevelShortcutTarget, isQueryEditorPrimaryMouseButton, normalizeCommentText, normalizeQueryResultMessages, normalizeCompletionQualifiedName, normalizeEditorPosition, normalizeExecutedSqlKey, normalizeMetadataDialect, queryCompletionMetadataRowsBySpecs, readSidebarSqlDropText, matchLeadingSelectTableReference, resolveNewQueryDefaultTemplate, resolveEventTargetNode, resolveNextResultSetIndex, resolveOracleExactCaseTableReference, resolveOracleLikeDefaultSchemaName, resolveOracleLikeExecutionSchemaName, resolveOracleLikeLookupSchemaCandidates, resolveQueryEditorFormatterLanguage, resolveQueryEditorHoverTarget, resolveQueryEditorNavigationDecorations, resolveQueryEditorNavigationTarget, resolveQueryLocatorPlan, rewriteLeadingSelectTableReference, splitCompletionSchemaAndTable, splitQueryIdentifierPathSegments, stripCompletionIdentifierQuotes, } from './queryEditor/QueryEditorHelpers'; import { buildQueryEditorAiInlineSuggestOptions, getQueryEditorAiService, requestQueryEditorInlineCompletion, requestQueryEditorTextToSql, resolveInlineSqlGhostPreviewText, resolveQueryEditorInlineMemoryInsertText, resolveQueryEditorInlineCompletionIntentDetails, shouldTriggerQueryEditorInlineObjectSuggestFallback, shouldRequestQueryEditorInlineCompletion, type QueryEditorAiApplyMode, type QueryEditorAiContext, type QueryEditorAiEditorSnapshot, } from './queryEditor/QueryEditorAiAssist'; export { collectQueryEditorObjectDecorationCandidates, resolveQueryEditorNavigationDecorations, resolveQueryEditorNavigationTarget, } from './queryEditor/QueryEditorHelpers'; const buildQueryEditorMonacoActionLabel = (key: string): string => `GoNavi: ${translate(key)}`; const QUERY_EDITOR_MONACO_FIND_OPTIONS = { addExtraSpaceOnTop: true, } as const; const QUERY_EDITOR_NATIVE_SELECT_CURRENT_LINE_EVENT = 'gonavi:native-select-current-line'; const QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO = 'Meta+E'; const QUERY_EDITOR_MAC_FIND_WITH_SELECTION_GUARD_ACTION_ID = 'gonavi.suppressMacFindWithSelection'; const QUERY_EDITOR_AI_INLINE_DEBOUNCE_MS = 90; const QUERY_EDITOR_AI_INLINE_CONTEXT_KEY = 'gonaviAiInlineSuggestionVisible'; const normalizeQueryEditorInlineMemorySqlKey = (sql: string): string => ( String(sql || '') .replace(/\r\n?/g, '\n') .replace(/\s+/g, ' ') .trim() .toLowerCase() ); const matchesQueryEditorInlineMemoryDb = (currentDb: string, candidateDb?: string): boolean => { const normalizedCurrentDb = String(currentDb || '').trim().toLowerCase(); const normalizedCandidateDb = String(candidateDb || '').trim().toLowerCase(); if (!normalizedCurrentDb || !normalizedCandidateDb) { return true; } return normalizedCurrentDb === normalizedCandidateDb; }; const buildQueryEditorInlineMemoryEntries = ({ currentConnectionId, currentDb, savedQueries, sqlLogs, }: { currentConnectionId: string; currentDb: string; savedQueries: SavedQuery[]; sqlLogs: SqlLog[]; }): Array<{ sql: string }> => { const ranked = new Map(); const addCandidate = (sql: string, score: number, latestAt: number) => { const text = String(sql || '').trim(); if (!text) { return; } const key = normalizeQueryEditorInlineMemorySqlKey(text); if (!key) { return; } const existing = ranked.get(key); if (!existing) { ranked.set(key, { sql: text, score, latestAt }); return; } existing.score += score; if (latestAt >= existing.latestAt) { existing.latestAt = latestAt; existing.sql = text; } }; savedQueries.forEach((query) => { if (currentConnectionId && String(query.connectionId || '').trim() !== currentConnectionId) { return; } if (!matchesQueryEditorInlineMemoryDb(currentDb, query.dbName)) { return; } addCandidate(query.sql, 600, Number(query.createdAt || 0)); }); sqlLogs.forEach((log) => { if (log.status !== 'success') { return; } if (!matchesQueryEditorInlineMemoryDb(currentDb, log.dbName)) { return; } addCandidate(log.sql, 80, Number(log.timestamp || 0)); }); return [...ranked.values()] .sort((left, right) => ( right.score - left.score || right.latestAt - left.latestAt || left.sql.length - right.sql.length )) .slice(0, 16) .map((entry) => ({ sql: entry.sql })); }; const buildQueryEditorMonacoOptions = (isObjectEditQueryTab: boolean) => ({ minimap: { enabled: false }, automaticLayout: true, fixedOverflowWidgets: true, find: QUERY_EDITOR_MONACO_FIND_OPTIONS, hover: { enabled: true, delay: QUERY_EDITOR_HOVER_DELAY_MS, above: false, }, scrollBeyondLastLine: false, quickSuggestions: { other: true, comments: false, strings: false }, suggestOnTriggerCharacters: true, inlineSuggest: buildQueryEditorAiInlineSuggestOptions(), ...(isObjectEditQueryTab ? { fontSize: 14, lineHeight: 24, lineNumbersMinChars: 4, stickyScroll: { enabled: false }, } : {}), }); const QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER = '{SQL}'; const escapeQueryEditorObjectEditSqlLiteral = (value: unknown): string => ( String(value || '').replace(/'/g, "''") ); const CLIPBOARD_WRITE_TIMEOUT_MS = 2000; const copyQueryEditorTextToClipboard = async (text: string): Promise => { const tryAsyncClipboardWrite = async (): Promise => { if (typeof navigator?.clipboard?.writeText !== 'function') { return false; } try { const written = await Promise.race([ navigator.clipboard.writeText(text).then(() => true as const), new Promise((resolve) => setTimeout(() => resolve(false), CLIPBOARD_WRITE_TIMEOUT_MS)), ]); return written; } catch { return false; } }; if (typeof document?.createElement === 'function' && typeof document?.execCommand === 'function') { const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.setAttribute('aria-hidden', 'true'); Object.assign(textarea.style, { position: 'fixed', top: '0', left: '-9999px', opacity: '0', pointerEvents: 'none', }); try { document.body?.appendChild?.(textarea); textarea.focus?.(); textarea.select?.(); textarea.setSelectionRange?.(0, text.length); if (document.execCommand('copy')) { return true; } } catch { // Fall through to async clipboard APIs when execCommand is unavailable. } finally { textarea.remove?.(); } } if (await tryAsyncClipboardWrite()) { return true; } return false; }; const getQueryEditorObjectEditRawValue = (row: Record, candidateKeys: string[]): any => { const keyMap = new Map(); Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); for (const key of candidateKeys) { if (keyMap.has(key.toLowerCase())) { const value = keyMap.get(key.toLowerCase()); if (value !== undefined && value !== null) return value; } } return undefined; }; const normalizeQueryEditorRoutineDefinitionForEdit = ( definition: string, routineName: string, routineType: string, ): string => { const text = String(definition || '').trim(); if (!text) return ''; if (/^\s*create\b/i.test(text)) return text; if (/^\s*(function|procedure)\b/i.test(text)) { return `CREATE OR REPLACE ${text}`; } const normalizedType = String(routineType || 'FUNCTION').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; return `CREATE OR REPLACE ${normalizedType} ${routineName}\n${text}`; }; const buildQueryEditorRoutineEditFallbackSql = ( routineName: string, routineType: string, ): string => { const normalizedType = String(routineType || 'FUNCTION').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; if (normalizedType === 'PROCEDURE') { return `CREATE OR REPLACE PROCEDURE ${routineName}()\nBEGIN\n -- TODO: edit procedure body\nEND;`; } return `CREATE OR REPLACE FUNCTION ${routineName}()\nRETURNS INTEGER\nBEGIN\n -- TODO: edit function body\n RETURN 0;\nEND;`; }; const normalizeQueryEditorMySQLViewDDL = (rawDefinition: unknown): string => { const text = String(rawDefinition || '').trim(); if (!text) return ''; const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, ''); const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i; if (createViewPrefixPattern.test(normalized)) { return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`; } if (/^\s*(select|with)\b/i.test(normalized)) { return normalized; } return `${normalized};`; }; const normalizeQueryEditorSqlPlusSlashTerminator = (sql: string): string => ( String(sql || '').trim().replace(/(^|\n)([ \t]*\/[ \t]*);+([ \t]*(?:--[^\n]*)?)\s*$/i, '$1$2$3') ); const hasQueryEditorSqlPlusSlashTerminator = (sql: string): boolean => ( /(?:^|\n)[ \t]*\/[ \t]*(?:--[^\n]*)?\s*$/i.test(String(sql || '').trim()) ); const ensureQueryEditorObjectEditSqlTerminator = (sql: string): string => { const normalized = normalizeQueryEditorSqlPlusSlashTerminator(sql); if (!normalized) return ''; if (hasQueryEditorSqlPlusSlashTerminator(normalized)) return normalized; return /;\s*$/.test(normalized) ? normalized : `${normalized};`; }; const isQueryEditorCommentOnlyDefinition = (definition: string): boolean => { const normalized = String(definition || '').replace(/\r\n/g, '\n').trim(); if (!normalized) return false; const lines = normalized .split('\n') .map((line) => line.trim()) .filter(Boolean); return lines.length > 0 && lines.every((line) => line.startsWith('--')); }; const withQueryEditorCreateOrReplacePackageHeaders = (definition: string): string => { const normalized = String(definition || '').replace(/\r\n/g, '\n').trim(); if (!normalized) return ''; return normalized .split(/(?=^\s*PACKAGE(?:\s+BODY)?\b)/gim) .map((part) => part.trim()) .filter(Boolean) .map((part) => (/^\s*CREATE\b/i.test(part) ? part : `CREATE OR REPLACE ${part}`)) .join('\n/\n'); }; const buildQueryEditorQualifiedObjectName = (objectName: string, schemaName?: string): string => { const normalizedObjectName = String(objectName || '').trim(); const normalizedSchemaName = String(schemaName || '').trim(); if (!normalizedObjectName || !normalizedSchemaName || normalizedObjectName.includes('.')) { return normalizedObjectName; } return `${normalizedSchemaName}.${normalizedObjectName}`; }; const buildQueryEditorEditableDefinitionSql = ( objectType: 'view-def' | 'sequence-def' | 'package-def', definition: string, objectName: string, objectLabel: string, ): string => { const normalizedDefinition = String(definition || '').trim(); const header = [ `-- ${translate('definition_viewer.edit.comment_title', { object: objectLabel, name: objectName })}`, `-- ${translate('definition_viewer.edit.comment_compatibility')}`, ].join('\n') + '\n'; if (!normalizedDefinition) { return `${header}-- ${translate('definition_viewer.edit.comment_empty_definition', { name: objectName })}\n`; } if (isQueryEditorCommentOnlyDefinition(normalizedDefinition)) { return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; } if (objectType === 'view-def' && !/^\s*create\b/i.test(normalizedDefinition)) { if (/^\s*view\b/i.test(normalizedDefinition)) { return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition.replace(/^\s*view\b/i, 'CREATE OR REPLACE VIEW'))}`; } return `${header}CREATE OR REPLACE VIEW ${objectName} AS\n${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; } if (objectType === 'sequence-def' && !/^\s*create\b/i.test(normalizedDefinition)) { return `${header}${ensureQueryEditorObjectEditSqlTerminator(`CREATE SEQUENCE ${objectName}\n${normalizedDefinition}`)}`; } if ( objectType === 'package-def' && !/^\s*create\b/i.test(normalizedDefinition) && /^\s*package\b/i.test(normalizedDefinition) ) { return `${header}${withQueryEditorCreateOrReplacePackageHeaders(normalizedDefinition)}`; } return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; }; const buildQueryEditorObjectDefinitionConnectionConfig = (conn: any): Record => ({ ...conn.config, port: Number(conn.config?.port), password: conn.config?.password || '', database: conn.config?.database || '', useSSH: conn.config?.useSSH || false, ssh: conn.config?.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }); const runQueryEditorObjectDefinitionCandidates = async ( config: Record, dbName: string, queries: string[], collectAll = false, ): Promise => { const collectedRows: any[] = []; for (const query of queries) { const sql = String(query || '').trim(); if (!sql || sql.startsWith('--')) continue; try { const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql); if (!result.success || !Array.isArray(result.data) || result.data.length === 0) { continue; } if (!collectAll) { return result.data; } collectedRows.push(...result.data); } catch { // 元数据定义读取失败时保留编辑模板。 } } return collectedRows; }; const buildQueryEditorViewDefinitionQueries = ( dialect: string, viewName: string, dbName: string, schemaName?: string, viewKind?: 'view' | 'materialized', ): string[] => { const parsed = splitSidebarQualifiedName(viewName); const objectName = parsed.objectName || viewName; const schema = String(schemaName || parsed.schemaName || '').trim(); const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); switch (dialect) { case 'mysql': case 'starrocks': { const viewRef = schema ? `\`${schema.replace(/`/g, '``')}\`.\`${objectName.replace(/`/g, '``')}\`` : `\`${objectName.replace(/`/g, '``')}\``; if (dialect === 'starrocks' && viewKind === 'materialized') { return [ `SHOW CREATE MATERIALIZED VIEW ${viewRef}`, `SHOW CREATE TABLE ${viewRef}`, ]; } return [ `SHOW CREATE VIEW ${viewRef}`, safeDbName ? `SELECT VIEW_DEFINITION AS view_definition FROM information_schema.views WHERE table_schema = '${safeDbName}' AND table_name = '${safeName}' LIMIT 1` : '', `SHOW CREATE TABLE ${viewRef}`, ].filter(Boolean); } case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { const schemaRef = schema || 'public'; return [`SELECT pg_get_viewdef('${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`]; } case 'sqlserver': return buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition'); case 'oracle': { const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); if (owner) { return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${owner}' AND VIEW_NAME = '${safeName.toUpperCase()}'`]; } return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`]; } case 'sqlite': return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`]; case 'duckdb': { const schemaRef = schema || 'main'; return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`]; } default: return []; } }; const extractQueryEditorViewDefinition = (dialect: string, data: any[]): string => { if (!Array.isArray(data) || data.length === 0) return ''; const row = data[0] as Record; if (dialect === 'mysql' || dialect === 'starrocks') { const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'VIEW_DEFINITION']); if (direct !== undefined && direct !== null && String(direct).trim()) { return normalizeQueryEditorMySQLViewDDL(direct); } const sqlKey = Object.keys(row).find((key) => { const lowerKey = key.toLowerCase(); return lowerKey.includes('create view') || lowerKey === 'create view' || lowerKey.includes('create table'); }); if (sqlKey) { return normalizeQueryEditorMySQLViewDDL(row[sqlKey]); } const createValue = Object.values(row).find((value) => { const text = String(value || '').toUpperCase(); return text.includes('CREATE') && (text.includes('VIEW') || text.includes('TABLE')); }); return createValue ? normalizeQueryEditorMySQLViewDDL(createValue) : ''; } if (dialect === 'sqlserver') { const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'definition']); if (direct !== undefined && direct !== null && String(direct).trim()) { return String(direct); } return data .map((item) => getQueryEditorObjectEditRawValue(item, ['Text', 'text'])) .filter((value) => value !== undefined && value !== null) .map((value) => String(value)) .join(''); } const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'definition', 'sql', 'text', 'TEXT', 'SQL']); return direct !== undefined && direct !== null ? String(direct) : String(Object.values(row)[0] || ''); }; const buildQueryEditorSequenceDefinitionQueries = ( dialect: string, sequenceName: string, dbName: string, schemaName?: string, ): string[] => { const parsed = splitSidebarQualifiedName(sequenceName); const objectName = parsed.objectName || sequenceName; const schema = String(schemaName || parsed.schemaName || '').trim(); const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); switch (dialect) { case 'oracle': if (owner) { return [`SELECT SEQUENCE_OWNER, SEQUENCE_NAME, MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '${owner}' AND SEQUENCE_NAME = '${safeName.toUpperCase()}'`]; } return [`SELECT SEQUENCE_NAME, MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER FROM USER_SEQUENCES WHERE SEQUENCE_NAME = '${safeName.toUpperCase()}'`]; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { const schemaRef = schema || 'public'; return [`SELECT sequence_schema, sequence_name, data_type, start_value, minimum_value, maximum_value, increment FROM information_schema.sequences WHERE sequence_schema = '${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}' AND sequence_name = '${safeName}' LIMIT 1`]; } default: return []; } }; const buildQueryEditorSequenceDefinitionFromRow = ( row: Record, fallbackSequenceName: string, fallbackSchemaName?: string, ): string => { const sequenceName = String(getQueryEditorObjectEditRawValue(row, ['sequence_name']) || splitSidebarQualifiedName(fallbackSequenceName).objectName || fallbackSequenceName).trim(); const owner = String(getQueryEditorObjectEditRawValue(row, ['sequence_owner', 'owner', 'sequence_schema']) || fallbackSchemaName || splitSidebarQualifiedName(fallbackSequenceName).schemaName || '').trim(); const name = buildQueryEditorQualifiedObjectName(sequenceName, owner); if (!name) return ''; const clauses: string[] = []; const increment = getQueryEditorObjectEditRawValue(row, ['increment_by', 'increment']); const minValue = getQueryEditorObjectEditRawValue(row, ['min_value', 'minimum_value']); const maxValue = getQueryEditorObjectEditRawValue(row, ['max_value', 'maximum_value']); const cacheSize = Number(getQueryEditorObjectEditRawValue(row, ['cache_size'])); const cycleFlag = String(getQueryEditorObjectEditRawValue(row, ['cycle_flag']) || '').trim().toUpperCase(); const orderFlag = String(getQueryEditorObjectEditRawValue(row, ['order_flag']) || '').trim().toUpperCase(); if (increment !== undefined && increment !== null && String(increment).trim() !== '') { clauses.push(`INCREMENT BY ${increment}`); } if (minValue !== undefined && minValue !== null && String(minValue).trim() !== '') { clauses.push(`MINVALUE ${minValue}`); } if (maxValue !== undefined && maxValue !== null && String(maxValue).trim() !== '') { clauses.push(`MAXVALUE ${maxValue}`); } if (Number.isFinite(cacheSize)) { clauses.push(cacheSize > 0 ? `CACHE ${cacheSize}` : 'NOCACHE'); } if (cycleFlag) clauses.push(cycleFlag === 'Y' ? 'CYCLE' : 'NOCYCLE'); if (orderFlag) clauses.push(orderFlag === 'Y' ? 'ORDER' : 'NOORDER'); return [`CREATE SEQUENCE ${name}`, ...clauses.map((clause) => ` ${clause}`)].join('\n'); }; const extractQueryEditorSequenceDefinition = ( data: any[], sequenceName: string, schemaName?: string, ): string => { if (!Array.isArray(data) || data.length === 0) return ''; return buildQueryEditorSequenceDefinitionFromRow(data[0] as Record, sequenceName, schemaName); }; const buildQueryEditorPackageDefinitionQueries = ( dialect: string, packageName: string, dbName: string, schemaName?: string, ): string[] => { const parsed = splitSidebarQualifiedName(packageName); const objectName = parsed.objectName || packageName; const schema = String(schemaName || parsed.schemaName || '').trim(); const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); if (dialect !== 'oracle') { return []; } const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); if (owner) { return [ `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE' ORDER BY LINE`, `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE BODY' ORDER BY LINE`, ]; } return [ `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE' ORDER BY LINE`, `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE BODY' ORDER BY LINE`, ]; }; const extractQueryEditorPackageDefinition = (data: any[]): string => { if (!Array.isArray(data) || data.length === 0) return ''; return data .map((row: any) => getQueryEditorObjectEditRawValue(row, ['text', 'TEXT']) ?? Object.values(row || {})[0] ?? '') .map((value) => String(value)) .join(''); }; const buildQueryEditorTriggerDefinitionQueries = ( dialect: string, triggerName: string, dbName: string, schemaName?: string, ): string[] => { const parsed = splitSidebarQualifiedName(triggerName); const objectName = parsed.objectName || triggerName; const schema = String(schemaName || parsed.schemaName || '').trim(); const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); switch (dialect) { case 'mysql': case 'starrocks': { const triggerRef = schema ? `\`${schema.replace(/`/g, '``')}\`.\`${objectName.replace(/`/g, '``')}\`` : `\`${objectName.replace(/`/g, '``')}\``; return [ `SHOW CREATE TRIGGER ${triggerRef}`, safeDbName ? `SELECT TRIGGER_NAME, TRIGGER_SCHEMA, EVENT_OBJECT_SCHEMA, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORIENTATION, ACTION_STATEMENT FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeName}' LIMIT 1` : '', ].filter(Boolean); } case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid WHERE t.tgname = '${safeName}' AND NOT t.tgisinternal LIMIT 1`]; case 'sqlserver': return buildSqlServerObjectDefinitionQueries('trigger', triggerName, dbName, 'trigger_definition'); case 'oracle': { if (schema) { return [ `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}', '${escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase()}') AS trigger_definition FROM DUAL`, `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase()}' AND TRIGGER_NAME = '${safeName.toUpperCase()}'`, ]; } if (safeDbName) { return [ `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}', '${safeDbName.toUpperCase()}') AS trigger_definition FROM DUAL`, `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeName.toUpperCase()}'`, ]; } return [ `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}') AS trigger_definition FROM DUAL`, `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeName.toUpperCase()}'`, ]; } case 'sqlite': return [`SELECT sql AS trigger_definition FROM sqlite_master WHERE type = 'trigger' AND name = '${safeName}'`]; default: return []; } }; const extractQueryEditorTriggerDefinition = (dialect: string, data: any[]): string => { if (!Array.isArray(data) || data.length === 0) return ''; const row = data[0] as Record; const direct = getQueryEditorObjectEditRawValue(row, ['trigger_definition', 'definition', 'sql', 'SQL']); if (direct !== undefined && direct !== null && String(direct).trim()) { return String(direct); } if (dialect === 'mysql' || dialect === 'starrocks') { const statementKey = Object.keys(row).find((key) => { const lowerKey = key.toLowerCase(); return lowerKey.includes('statement') || lowerKey.includes('create trigger'); }); if (statementKey) return String(row[statementKey] || ''); const createValue = Object.values(row).find((value) => String(value || '').toUpperCase().includes('CREATE TRIGGER')); return createValue ? String(createValue) : String(getQueryEditorObjectEditRawValue(row, ['ACTION_STATEMENT', 'action_statement']) || ''); } return String(getQueryEditorObjectEditRawValue(row, ['TRIGGER_BODY', 'trigger_body', 'TEXT', 'text']) || Object.values(row)[0] || ''); }; const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => { if (!connection) { return ''; } const sourceLabel = String(connection.config?.type || '').trim() || translate('query_editor.ai_prompt.default_source'); const databaseLabel = String(database || '').trim() || translate('query_editor.ai_prompt.default_database'); return translate('query_editor.ai_prompt.context', { type: sourceLabel, name: `"${connection.name}"`, database: `"${databaseLabel}"`, }); }; const resolveQueryEditorAiConnectionHost = (connection: any): string => { const config = connection?.config || {}; if (Array.isArray(config.hosts)) { const hosts = config.hosts .map((item: any) => String(typeof item === 'string' ? item : item?.host || item?.hostname || item?.address || '').trim()) .filter(Boolean); if (hosts.length > 0) { return hosts.join(', '); } } return String(config.host || config.hostname || config.server || config.address || '').trim(); }; // HMR 重载时释放旧注册避免补全和 hover 内容重复 const _g = globalThis as any; const SQL_COMPLETION_PROVIDER_VERSION = '20260702-ai-inline-sql-v1'; if (!_g.__gonaviSqlCompletionState) { _g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] }; } if (!Array.isArray(_g.__gonaviSqlCompletionState.disposables)) { _g.__gonaviSqlCompletionState.disposables = []; } let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered; let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables; // 模块级共享变量:completion provider 从这些变量读取当前活跃 Tab 的状态。 // 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。 let sharedCurrentDb = ''; let sharedCurrentConnectionId = ''; let sharedConnections: any[] = []; let sharedTablesData: CompletionTableMeta[] = []; let sharedAllColumnsData: CompletionColumnMeta[] = []; // AI 补全的元数据预热可能把整库列(数十万条)灌入 sharedAllColumnsData,普通补全逐列全量 // 扫描会阻塞主线程;按 (库, 表名末段) 建索引,并以数组身份为键缓存,数组重新赋值时自动失效。 const sharedColumnsIndexCache = new WeakMap>(); const findSharedPreloadedColumns = (dbName: string, tableName: string): CompletionColumnMeta[] => { const columns = sharedAllColumnsData; let index = sharedColumnsIndexCache.get(columns); if (!index) { index = new Map(); columns.forEach((column) => { const dbLower = String(column.dbName || '').toLowerCase(); const tableLower = String(column.tableName || '').toLowerCase(); const lastPartLower = String(splitCompletionSchemaAndTable(column.tableName || '').table || '').toLowerCase(); const keys = lastPartLower && lastPartLower !== tableLower ? [`${dbLower}\u0000${tableLower}`, `${dbLower}\u0000${lastPartLower}`] : [`${dbLower}\u0000${tableLower}`]; keys.forEach((key) => { const list = index!.get(key); if (list) { list.push(column); } else { index!.set(key, [column]); } }); }); sharedColumnsIndexCache.set(columns, index); } const key = `${String(dbName || '').toLowerCase()}\u0000${String(tableName || '').toLowerCase()}`; return index.get(key) || []; }; // 普通建议的“相关列”按 SQL 中引用的表标识符(db.table / table / 纯表名)匹配, // 同样避免对全量列做逐条正则扫描;索引以列数组身份为键缓存。 const sharedColumnsByIdentCache = new WeakMap>(); const collectSharedColumnsForTableIdents = ( columns: CompletionColumnMeta[], idents: ReadonlySet, ): CompletionColumnMeta[] => { let index = sharedColumnsByIdentCache.get(columns); if (!index) { index = new Map(); columns.forEach((column) => { const tableLower = String(column.tableName || '').toLowerCase(); const fullLower = `${String(column.dbName || '').toLowerCase()}.${tableLower}`; const pureLower = String(splitCompletionSchemaAndTable(column.tableName || '').table || '').toLowerCase(); new Set([fullLower, tableLower, pureLower]).forEach((key) => { if (!key) { return; } const list = index!.get(key); if (list) { list.push(column); } else { index!.set(key, [column]); } }); }); sharedColumnsByIdentCache.set(columns, index); } const seen = new Set(); const result: CompletionColumnMeta[] = []; idents.forEach((ident) => { (index!.get(ident) || []).forEach((column) => { if (seen.has(column)) { return; } seen.add(column); result.push(column); }); }); return result; }; let sharedVisibleDbs: string[] = []; let sharedViewsData: CompletionViewMeta[] = []; let sharedMaterializedViewsData: CompletionViewMeta[] = []; let sharedTriggersData: CompletionTriggerMeta[] = []; let sharedRoutinesData: CompletionRoutineMeta[] = []; let sharedSequencesData: CompletionSequenceMeta[] = []; let sharedPackagesData: CompletionPackageMeta[] = []; let sharedColumnsCacheData: Record = {}; let sharedActiveEditorModelUri = ''; const sharedLazyTablesCache: Record = {}; const sharedLazyTablesInFlight: Record | undefined> = {}; const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] }); const isSqlCompletionRequestCancelled = (token?: { isCancellationRequested?: boolean } | null) => Boolean(token?.isCancellationRequested); const clearRecord = (record: Record) => { Object.keys(record).forEach((key) => { delete record[key]; }); }; const QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT = 260; const getCompletionTableNameFromRow = (row: any): string => ( normalizeCommentText( getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Table', 'table', 'name', 'Name']) ?? Object.values(row || {})[0], ) ); const getCompletionTableCommentFromRow = (row: any): string => ( normalizeCommentText(getCaseInsensitiveValue(row, [ 'table_comment', 'TABLE_COMMENT', 'comment', 'comments', 'Comment', 'COMMENTS', 'description', 'Description', ])) ); const getCompletionTableComment = ( tableComments: Map, tableName: string, rowComment = '', ): string => { const parsed = splitCompletionSchemaAndTable(String(tableName || '')); return tableComments.get(String(tableName || '').toLowerCase()) || (parsed.table ? tableComments.get(parsed.table.toLowerCase()) : '') || normalizeCommentText(rowComment); }; const buildCompletionTableMeta = ( dbName: string, row: any, tableComments: Map, ): CompletionTableMeta | null => { const tableName = getCompletionTableNameFromRow(row); if (!tableName) return null; return { dbName, tableName, comment: getCompletionTableComment(tableComments, tableName, getCompletionTableCommentFromRow(row)) || undefined, }; }; const fetchCompletionTableCommentMap = async ( config: any, dbName: string, metadataDialect: string, ): Promise> => { const tableComments = new Map(); const tableCommentSQL = buildCompletionTableCommentSQL(metadataDialect, dbName); if (!tableCommentSQL) return tableComments; try { const resTableComments = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, tableCommentSQL); if (resTableComments.success && Array.isArray(resTableComments.data)) { resTableComments.data.forEach((row: any) => { const tableName = normalizeCommentText(getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'name', 'Name'])); if (!tableName) return; tableComments.set(tableName.toLowerCase(), getCompletionTableCommentFromRow(row)); }); } } catch { // 表备注只是补全增强,失败时保留原有表名补全。 } return tableComments; }; const buildSqlSnippetVariableMap = (now: Date): Record => { const pad = (value: number) => String(value).padStart(2, '0'); return { CURRENT_YEAR: String(now.getFullYear()), CURRENT_MONTH: pad(now.getMonth() + 1), CURRENT_DATE: pad(now.getDate()), CURRENT_HOUR: pad(now.getHours()), CURRENT_MINUTE: pad(now.getMinutes()), CURRENT_SECOND: pad(now.getSeconds()), CURRENT_SECONDS_UNIX: String(Math.floor(now.getTime() / 1000)), UUID: uuidv4(), RANDOM: String(Math.floor(100000 + Math.random() * 900000)), }; }; const materializeSqlSnippetText = (body: string): string => { const tabstopValues = new Map(); const variableMap = buildSqlSnippetVariableMap(new Date()); return String(body || '') .replace(/\$\{(\d+)\|([^}]+)\|\}/g, (_match, index: string, rawChoices: string) => { const choice = String(rawChoices || '') .split(',') .map((item) => item.trim()) .find(Boolean) || ''; if (index !== '0') { tabstopValues.set(index, choice); } return choice; }) .replace(/\$\{([A-Z_]+)\}/g, (match, variableName: string) => ( Object.prototype.hasOwnProperty.call(variableMap, variableName) ? variableMap[variableName] : match )) .replace(/\$\{(\d+):([^}]+)\}/g, (_match, index: string, placeholder: string) => { const value = String(placeholder || ''); if (index !== '0') { tabstopValues.set(index, value); } return value; }) .replace(/\$(\d+)/g, (_match, index: string) => ( index === '0' ? '' : (tabstopValues.get(index) ?? '') )); }; const resetSharedQueryEditorMetadata = () => { sharedCurrentDb = ''; sharedTablesData = []; sharedAllColumnsData = []; sharedVisibleDbs = []; sharedViewsData = []; sharedMaterializedViewsData = []; sharedTriggersData = []; sharedRoutinesData = []; sharedSequencesData = []; sharedPackagesData = []; sharedColumnsCacheData = {}; sharedActiveEditorModelUri = ''; clearRecord(sharedLazyTablesCache); clearRecord(sharedLazyTablesInFlight); }; const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { const appearance = useStore(state => state.appearance); const [query, setQuery] = useState(() => getInitialEditorQuery( tab, resolveNewQueryDefaultTemplate(appearance.newQuerySqlTemplate), )); const isExternalSQLFileTab = Boolean(String(tab.filePath || '').trim()); const isObjectEditQueryTab = tab.type === 'query' && tab.queryMode === 'object-edit'; const queryEditorMonacoOptions = useMemo( () => buildQueryEditorMonacoOptions(isObjectEditQueryTab), [isObjectEditQueryTab], ); type ResultSet = QueryEditorResultSet; // Result Sets (session cache survives detach/attach remounts) const restoredResultSessionRef = useRef(takeQueryEditorResultSession(tab.id)); const [resultSets, setResultSets] = useState( () => restoredResultSessionRef.current?.resultSets || [], ); const [activeResultKey, setActiveResultKey] = useState( () => restoredResultSessionRef.current?.activeResultKey || '', ); const resultSetsRef = useRef(resultSets); const activeResultKeyRef = useRef(activeResultKey); resultSetsRef.current = resultSets; activeResultKeyRef.current = activeResultKey; const [loading, setLoading] = useState(false); const [executionError, setExecutionError] = useState(''); const [, setCurrentQueryId] = useState(''); const [isSqlSnippetPickerOpen, setIsSqlSnippetPickerOpen] = useState(false); const [sqlSnippetPickerKeyword, setSqlSnippetPickerKeyword] = useState(''); const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save'); const [saveForm] = Form.useForm(); // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); const [dbList, setDbList] = useState([]); const [isTextToSqlModalOpen, setIsTextToSqlModalOpen] = useState(false); const [textToSqlInstruction, setTextToSqlInstruction] = useState(''); const [textToSqlApplyMode, setTextToSqlApplyMode] = useState('insert'); const [textToSqlGenerating, setTextToSqlGenerating] = useState(false); const [resultDiffWizardOpen, setResultDiffWizardOpen] = useState(false); const [resultDiffAnchorKey, setResultDiffAnchorKey] = useState(''); const [resultDiffSession, setResultDiffSession] = useState<{ jobId: string; summary: ResultDiffSummary; leftLabel: string; rightLabel: string; columnMeta?: Record; } | null>(null); const [viewDataVerifyOpen, setViewDataVerifyOpen] = useState(false); // Resizing state const [editorHeight, setEditorHeight] = useState(300); const editorStageRef = useRef(null); const editorShellRef = useRef(null); const editorRef = useRef(null); const monacoRef = useRef(null); const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); const macFindWithSelectionGuardActionRef = useRef(null); const duplicateCurrentLineActionRef = useRef(null); const saveQueryActionRef = useRef(null); const findInEditorActionRef = useRef(null); const formatSqlActionRef = useRef(null); const triggerSqlAiCompletionActionRef = useRef(null); const triggerSqlAiCompletionKeydownDisposableRef = useRef(null); const insertSqlSnippetActionRef = useRef(null); const aiContextMenuActionDisposablesRef = useRef([]); const toggleQueryResultsPanelActionRef = useRef(null); const lastExternalQueryRef = useRef(getTabQueryValue(tab)); const lastLocalQueryRef = useRef(query); const imeCompositionFallbackRef = useRef<{ editor: any; valueBefore: string; selectionBefore: any; positionBefore: { lineNumber: number; column: number } | null; committedText: string; } | null>(null); const imeCompositionFallbackTimerRef = useRef | null>(null); const lastEditorCursorPositionRef = useRef(null); const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null); const lastExecutedEditorQueryRef = useRef(''); const linkDecorationIdsRef = useRef([]); const ctrlMetaPressedRef = useRef(false); const objectDecorationIdsRef = useRef([]); const aiInlineGhostDecorationIdsRef = useRef([]); const aiInlineGhostOverlayRef = useRef(null); const aiInlineGhostVisibleContextKeyRef = useRef(null); const aiInlineGhostTimerRef = useRef | null>(null); const aiInlineGhostRequestSeqRef = useRef(0); const triggerAiInlineCompletionRef = useRef<(() => void) | null>(null); const aiContextMetadataWarmupRef = useRef | undefined>>({}); const aiContextCacheRef = useRef<{ deps: unknown[]; value: QueryEditorAiContext } | null>(null); const triggerSqlAiCompletionAltPressedRef = useRef(false); const triggerSqlAiCompletionAltGestureAtRef = useRef(0); const triggerSqlAiCompletionFallbackRef = useRef<{ observedAt: number } | null>(null); const triggerSqlAiCompletionFallbackApplyingRef = useRef(false); const aiInlineGhostRef = useRef<{ insertText: string; modelUri: string; position: { lineNumber: number; column: number }; snapshot: QueryEditorAiEditorSnapshot; } | null>(null); const aiInlineGhostAcceptingRef = useRef(false); const objectHoverActionRef = useRef(null); const dragRef = useRef<{ startY: number, startHeight: number, currentHeight: number } | null>(null); const pendingEditorHeightRef = useRef(editorHeight); const resizeFrameRef = useRef(null); const queryEditorRootRef = useRef(null); const editorPaneRef = useRef(null); const tablesRef = useRef([]); // Store tables for autocomplete (cross-db) const allColumnsRef = useRef([]); // Store all columns (cross-db) const viewsRef = useRef([]); const materializedViewsRef = useRef([]); const triggersRef = useRef([]); const routinesRef = useRef([]); const sequencesRef = useRef([]); const packagesRef = useRef([]); const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const metadataFetchKeyRef = useRef(''); const metadataContextKeyRef = useRef(''); /** SQL 中引用到的库集合变化时触发跨库元数据补拉(供超链接/补全) */ const [sqlReferencedMetadataKey, setSqlReferencedMetadataKey] = useState(''); const sqlReferencedMetadataTimerRef = useRef(null); const lastSqlReferencedMetadataKeyRef = useRef(''); const connections = useStore(state => state.connections); const queryCapableConnections = useMemo( () => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor), [connections] ); const addSqlLog = useStore(state => state.addSqlLog); const sqlLogCount = useStore(state => state.sqlLogs.length); const sqlLogs = useStore(state => state.sqlLogs); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft); const activeTabId = useStore(state => state.activeTabId); const savedQueries = useStore(state => state.savedQueries); const sqlSnippets = useStore(state => state.sqlSnippets); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); const inlineSqlMemoryEntries = useMemo(() => buildQueryEditorInlineMemoryEntries({ currentConnectionId, currentDb, savedQueries, sqlLogs, }), [currentConnectionId, currentDb, savedQueries, sqlLogs]); const draftSnapshotTab = useMemo(() => ({ id: tab.id, title: tab.title, connectionId: tab.connectionId, dbName: tab.dbName, filePath: tab.filePath, savedQueryId: tab.savedQueryId, readOnly: tab.readOnly, }), [tab.connectionId, tab.dbName, tab.filePath, tab.id, tab.readOnly, tab.savedQueryId, tab.title]); const connectionsRef = useRef(connections); const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); const theme = useStore(state => state.theme); const languagePreference = useStore((state) => state.languagePreference); void languagePreference; const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; const sqlFormatOptions = useStore(state => state.sqlFormatOptions); const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); const queryEditorEditorHeightRatio = sanitizeQueryEditorEditorHeightRatio( queryOptions?.queryEditorEditorHeightRatio, ); const sqlEditorTransactionOptions = useStore(state => state.sqlEditorTransactionOptions); const setSqlEditorTransactionOptions = useStore(state => state.setSqlEditorTransactionOptions); const [isResultPanelVisible, setIsResultPanelVisible] = useState( () => restoredResultSessionRef.current?.isResultPanelVisible ?? (tab.resultPanelVisible === true) ); const isResultPanelVisibleRef = useRef(isResultPanelVisible); isResultPanelVisibleRef.current = isResultPanelVisible; useEffect(() => { // Keep result panel state across detach/attach remounts of the same tab. return () => { saveQueryEditorResultSession(tab.id, { resultSets: resultSetsRef.current, activeResultKey: activeResultKeyRef.current, isResultPanelVisible: isResultPanelVisibleRef.current, }); }; }, [tab.id]); const shortcutOptions = useStore(state => state.shortcutOptions); const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); const runQueryShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); // SQL 诊断 / 慢 SQL 历史的快捷键绑定(从 store 读取,用户可在快捷键管理面板自定义) const diagnoseQueryShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'diagnoseQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const showSlowQueriesShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'showSlowQueries', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const sortedSqlSnippets = useMemo( () => [...sqlSnippets].sort((left, right) => ( left.prefix.localeCompare(right.prefix) || left.name.localeCompare(right.name) )), [sqlSnippets], ); const filteredSqlSnippets = useMemo(() => { const keyword = String(sqlSnippetPickerKeyword || '').trim().toLowerCase(); if (!keyword) { return sortedSqlSnippets; } return sortedSqlSnippets.filter((snippet) => ( [ snippet.prefix, snippet.name, snippet.description, snippet.syntaxHelp, snippet.body, ].some((field) => String(field || '').toLowerCase().includes(keyword)) )); }, [sortedSqlSnippets, sqlSnippetPickerKeyword]); const sqlSnippetPickerEmptyLabel = useMemo( () => ( String(sqlSnippetPickerKeyword || '').trim() ? translate('query_editor.snippet_picker.empty_filtered') : translate('query_editor.snippet_picker.empty') ), [sqlSnippetPickerKeyword], ); const openSqlAnalysisWorkbench = useCallback( (view: 'diagnose' | 'slow-query', nextSql?: string) => { const connectionId = String(currentConnectionId || '').trim(); if (!connectionId) { message.warning(translate('query_editor.message.connection_not_found')); return; } const dbName = String(currentDb || tab.dbName || '').trim(); addTab(buildSqlAnalysisWorkbenchTab({ connectionId, dbName: dbName || undefined, query: typeof nextSql === 'string' && nextSql.trim() ? nextSql : undefined, view, })); }, [addTab, currentConnectionId, currentDb, tab.dbName], ); const handleCloseSqlSnippetPicker = useCallback(() => { setIsSqlSnippetPickerOpen(false); setSqlSnippetPickerKeyword(''); }, []); const handleOpenSqlSnippetPicker = useCallback(() => { setSqlSnippetPickerKeyword(''); setIsSqlSnippetPickerOpen(true); }, []); const handleOpenSnippetSettingsFromPicker = useCallback(() => { handleCloseSqlSnippetPicker(); window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')); }, [handleCloseSqlSnippetPicker]); const registerInsertSqlSnippetContextMenuAction = useCallback((editor: any) => { if (insertSqlSnippetActionRef.current) { insertSqlSnippetActionRef.current.dispose(); insertSqlSnippetActionRef.current = null; } if (!editor) { return; } insertSqlSnippetActionRef.current = editor.addAction({ id: 'gonavi.insertSqlSnippet', label: translate('query_editor.action.insert_sql_snippet'), contextMenuGroupId: '8_snippet', contextMenuOrder: 1, run: handleOpenSqlSnippetPicker, }); }, [handleOpenSqlSnippetPicker]); // SQL 诊断 / 慢 SQL 历史的快捷键监听(必须在 binding 声明之后) useEffect(() => { if (!isActive) return; const handler = (e: KeyboardEvent) => { if (diagnoseQueryShortcutBinding?.enabled && isShortcutMatch(e, diagnoseQueryShortcutBinding.combo)) { e.preventDefault(); openSqlAnalysisWorkbench('diagnose', getCurrentQuery()); return; } if (showSlowQueriesShortcutBinding?.enabled && isShortcutMatch(e, showSlowQueriesShortcutBinding.combo)) { e.preventDefault(); openSqlAnalysisWorkbench('slow-query'); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [diagnoseQueryShortcutBinding, isActive, openSqlAnalysisWorkbench, showSlowQueriesShortcutBinding]); const selectCurrentStatementShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const duplicateCurrentLineShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'duplicateCurrentLine', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const saveQueryShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const formatSqlShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'formatSql', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const triggerSqlAiCompletionShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'triggerSqlAiCompletion', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const toggleQueryResultsPanelShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'toggleQueryResultsPanel', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); const findInEditorShortcutCombo = useMemo( () => activeShortcutPlatform === 'mac' ? 'Meta+F' : 'Ctrl+F', [activeShortcutPlatform], ); const primaryShortcutModifierLabel = useMemo( () => getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform), [activeShortcutPlatform], ); const isTriggerSqlAiCompletionShortcutEvent = useCallback((event: any): boolean => { const binding = triggerSqlAiCompletionShortcutBinding; if (!binding?.enabled || !binding.combo) { return false; } if (isShortcutMatch(event, binding.combo)) { return true; } if (normalizeShortcutCombo(binding.combo) !== 'Alt+\\') { return false; } const key = String( event?.key || event?.nativeEvent?.key || event?.browserEvent?.key || '', ).trim(); const code = String( event?.code || event?.nativeEvent?.code || event?.browserEvent?.code || '', ).trim(); const keyCode = Number( event?.keyCode ?? event?.which ?? event?.nativeEvent?.keyCode ?? event?.nativeEvent?.which ?? event?.browserEvent?.keyCode ?? event?.browserEvent?.which ?? 0, ); const isBackslashKey = key === '\\' || code === 'Backslash' || code === 'IntlBackslash' || keyCode === 220 || keyCode === 226; return isBackslashKey && triggerSqlAiCompletionAltPressedRef.current; }, [triggerSqlAiCompletionShortcutBinding]); const isPossibleTriggerSqlAiCompletionFallbackEvent = useCallback((event: any): boolean => { const binding = triggerSqlAiCompletionShortcutBinding; if (!binding?.enabled || normalizeShortcutCombo(binding.combo) !== 'Alt+\\') { return false; } const key = String( event?.key || event?.nativeEvent?.key || event?.browserEvent?.key || '', ).trim(); const code = String( event?.code || event?.nativeEvent?.code || event?.browserEvent?.code || '', ).trim(); const keyCode = Number( event?.keyCode ?? event?.which ?? event?.nativeEvent?.keyCode ?? event?.nativeEvent?.which ?? event?.browserEvent?.keyCode ?? event?.browserEvent?.which ?? 0, ); const isLikelyBackslashKey = key === '\\' || key === 'Process' || code === 'Backslash' || code === 'IntlBackslash' || keyCode === 220 || keyCode === 226; const hasAltIntent = Boolean( event?.altKey || event?.nativeEvent?.altKey || event?.browserEvent?.altKey || triggerSqlAiCompletionAltPressedRef.current ); return isLikelyBackslashKey && hasAltIntent; }, [triggerSqlAiCompletionShortcutBinding]); const registerTriggerSqlAiCompletionAction = useCallback((editor: any, monaco: any) => { if (triggerSqlAiCompletionActionRef.current) { triggerSqlAiCompletionActionRef.current.dispose(); triggerSqlAiCompletionActionRef.current = null; } if (!editor || !monaco) { return; } const binding = triggerSqlAiCompletionShortcutBinding; const keyBinding = binding?.enabled && binding.combo ? comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode) : null; triggerSqlAiCompletionActionRef.current = editor.addAction({ id: 'gonavi.triggerSqlAiCompletion', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.triggerSqlAiCompletion.label'), keybindings: keyBinding ? [keyBinding.keyMod | keyBinding.keyCode] : [], contextMenuGroupId: '7_ai', contextMenuOrder: 0, run: () => { triggerAiInlineCompletionRef.current?.(); }, }); }, [triggerSqlAiCompletionShortcutBinding]); useEffect(() => { // Prefer remount session cache (detach/attach); otherwise follow tab draft flag. if (restoredResultSessionRef.current && restoredResultSessionRef.current.isResultPanelVisible !== undefined) { setIsResultPanelVisible(restoredResultSessionRef.current.isResultPanelVisible === true); return; } setIsResultPanelVisible(tab.resultPanelVisible === true); }, [tab.id, tab.resultPanelVisible]); const updateResultPanelVisibility = useCallback((visible: boolean) => { setIsResultPanelVisible(visible); updateQueryTabDraft(tab.id, { resultPanelVisible: visible }); }, [tab.id, updateQueryTabDraft]); const toggleResultPanelVisibility = useCallback(() => { setIsResultPanelVisible((previousVisible) => { const nextVisible = !previousVisible; updateQueryTabDraft(tab.id, { resultPanelVisible: nextVisible }); return nextVisible; }); }, [tab.id, updateQueryTabDraft]); const handleOpenEditorFind = useCallback(() => { const editor = editorRef.current; if (!editor) { return; } editor.focus?.(); try { const findAction = editor.getAction?.('actions.find'); if (findAction?.run) { void findAction.run(); return; } } catch { // Fall back to Monaco's built-in command id if the action lookup fails. } editor.trigger?.('keyboard', 'actions.find', null); }, []); const handleShowSqlExecutionLog = useCallback((mode: 'open' | 'toggle' = 'toggle') => { if (!isActive) { return; } if (mode !== 'open' && isResultPanelVisible && activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) { updateResultPanelVisibility(false); return; } updateResultPanelVisibility(true); setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); }, [activeResultKey, isActive, isResultPanelVisible, updateResultPanelVisibility]); const sqlEditorCommitMode = sqlEditorTransactionOptions?.commitMode === 'auto' ? 'auto' : 'manual'; const sqlEditorAutoCommitDelayMs = SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS.some((item) => item.value === sqlEditorTransactionOptions?.autoCommitDelayMs) ? Number(sqlEditorTransactionOptions?.autoCommitDelayMs) : 0; const { activatePendingSqlTransaction, autoCommitRemainingSeconds: sqlEditorAutoCommitRemainingSeconds, finishPendingSqlTransaction, pendingSqlTransaction, pendingSqlTransactionRef, } = useSqlEditorTransactionController({ tabId: tab.id, translate: (key, params) => translate(key, params), }); const autoFetchVisible = useAutoFetchVisibility(); useEffect(() => { const nextContextKey = [ String(currentConnectionId || '').trim(), String(currentDb || '').trim().toLowerCase(), ].join('\u0000'); if (metadataContextKeyRef.current === nextContextKey) { return; } metadataContextKeyRef.current = nextContextKey; metadataFetchKeyRef.current = ''; tablesRef.current = []; allColumnsRef.current = []; viewsRef.current = []; materializedViewsRef.current = []; triggersRef.current = []; routinesRef.current = []; columnsCacheRef.current = {}; if (isActive) { resetSharedQueryEditorMetadata(); } }, [currentConnectionId, currentDb, isActive]); const currentSavedQuery = useMemo(() => { const savedId = String(tab.savedQueryId || '').trim(); if (savedId) { return savedQueries.find((item) => item.id === savedId) || null; } const tabId = String(tab.id || '').trim(); if (!tabId) { return null; } return savedQueries.find((item) => item.id === tabId) || null; }, [savedQueries, tab.id, tab.savedQueryId]); const syncQueryDraft = useCallback((nextQuery: string) => { const next = String(nextQuery ?? ''); lastLocalQueryRef.current = next; persistQueryTabDraftSnapshot(draftSnapshotTab, next, { connectionId: currentConnectionIdRef.current, dbName: currentDbRef.current, }); }, [draftSnapshotTab]); const applyQueryState = useCallback((nextQuery: string) => { const next = String(nextQuery ?? ''); syncQueryDraft(next); if (!isExternalSQLFileTab || next.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH) { setQuery(next); } }, [isExternalSQLFileTab, syncQueryDraft]); const handleInsertSqlSnippet = useCallback((snippet: SqlSnippet) => { const editor = editorRef.current; const monaco = monacoRef.current; if (!editor) { return; } const snippetController = editor.getContribution?.('snippetController2'); if (snippetController && typeof snippetController.insert === 'function') { editor.focus?.(); snippetController.insert(snippet.body); const nextValue = editor.getValue?.(); if (typeof nextValue === 'string') { applyQueryState(nextValue); } handleCloseSqlSnippetPicker(); editor.focus?.(); return; } const model = editor.getModel?.(); if (!model || !monaco?.Range) { return; } const selection = editor.getSelection?.(); const position = editor.getPosition?.() || { lineNumber: model.getLineCount?.() || 1, column: model.getLineMaxColumn?.(model.getLineCount?.() || 1) || 1 }; const hasSelection = selection && ( selection.startLineNumber !== selection.endLineNumber || selection.startColumn !== selection.endColumn ); const range = hasSelection ? selection : new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); const startOffset = model.getOffsetAt?.({ lineNumber: range.startLineNumber, column: range.startColumn, }); const plainText = materializeSqlSnippetText(snippet.body); editor.pushUndoStop?.(); editor.executeEdits?.('gonavi-insert-sql-snippet', [{ range, text: plainText, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); if (Number.isFinite(Number(startOffset)) && typeof model.getPositionAt === 'function') { const nextPosition = model.getPositionAt(Number(startOffset) + plainText.length); editor.setPosition?.(nextPosition); editor.setSelection?.(new monaco.Range( nextPosition.lineNumber, nextPosition.column, nextPosition.lineNumber, nextPosition.column, )); } const nextValue = editor.getValue?.(); if (typeof nextValue === 'string') { applyQueryState(nextValue); } handleCloseSqlSnippetPicker(); editor.focus?.(); }, [applyQueryState, handleCloseSqlSnippetPicker]); useEffect(() => { persistQueryTabDraftSnapshot(draftSnapshotTab, query, { connectionId: currentConnectionIdRef.current || currentConnectionId, dbName: currentDbRef.current || currentDb, }); }, [currentConnectionId, currentDb, draftSnapshotTab, query]); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; }, [currentConnectionId]); useEffect(() => { if (!queryCapableConnections.some(c => c.id === currentConnectionId)) { const fallback = queryCapableConnections[0]?.id || ''; if (fallback && fallback !== currentConnectionId) { setCurrentConnectionId(fallback); setCurrentDb(''); } } }, [queryCapableConnections, currentConnectionId]); useEffect(() => { currentDbRef.current = currentDb; }, [currentDb]); useEffect(() => { const nextConnectionId = String(tab.connectionId || '').trim(); const nextDb = String(tab.dbName || '').trim(); if (nextConnectionId !== currentConnectionIdRef.current) { currentConnectionIdRef.current = nextConnectionId; setCurrentConnectionId(nextConnectionId); } if (nextDb !== currentDbRef.current) { currentDbRef.current = nextDb; setCurrentDb(nextDb); } }, [tab.id, tab.connectionId, tab.dbName]); useEffect(() => { if (isExternalSQLFileTab) return; const currentDraft = getQueryTabDraft(tab.id, query); const shouldPersistQuery = currentDraft.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH; updateQueryTabDraft(tab.id, { ...(shouldPersistQuery ? { query: currentDraft } : {}), connectionId: currentConnectionId, dbName: currentDb, }); }, [currentConnectionId, currentDb, isExternalSQLFileTab, query, tab.id, updateQueryTabDraft]); useEffect(() => { if (!isExternalSQLFileTab) return; updateQueryTabDraft(tab.id, { connectionId: currentConnectionId, dbName: currentDb, }); }, [currentConnectionId, currentDb, isExternalSQLFileTab, tab.id, updateQueryTabDraft]); const getCurrentQuery = useCallback(() => { const val = editorRef.current?.getValue?.(); if (typeof val === 'string') return val; return query || ''; }, [query]); const buildQueryEditorAiEditorSnapshot = useCallback((): QueryEditorAiEditorSnapshot => { const editor = editorRef.current; const model = editor?.getModel?.(); const position = normalizeEditorPosition(editor?.getPosition?.()); const value = String(model?.getValue?.() ?? getCurrentQuery() ?? ''); if (!model || !position || typeof model.getOffsetAt !== 'function') { return { prefix: value, suffix: '', currentLineBeforeCursor: value.split(/\r?\n/).pop() || '', currentLineAfterCursor: '', }; } const offset = Number(model.getOffsetAt(position)); const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.min(offset, value.length)) : value.length; const lineContent = String(model.getLineContent?.(position.lineNumber) || ''); const lineColumnIndex = Math.max(0, Math.min(position.column - 1, lineContent.length)); return { prefix: value.slice(0, safeOffset), suffix: value.slice(safeOffset), currentLineBeforeCursor: lineContent.slice(0, lineColumnIndex), currentLineAfterCursor: lineContent.slice(lineColumnIndex), }; }, [getCurrentQuery]); const buildQueryEditorAiContext = useCallback((): QueryEditorAiContext => { const resolvedConnectionId = String( currentConnectionIdRef.current || currentConnectionId || tab.connectionId || '', ).trim(); const conn = connectionsRef.current.find(c => c.id === resolvedConnectionId); const currentDbName = String( currentDbRef.current || currentDb || tab.dbName || '', ).trim(); const lazyTablesEntry = sharedLazyTablesCache[`${resolvedConnectionId}|${currentDbName}`]; // 大库下全量合并可达数十万条且每次补全请求都会调用;依赖引用未变时复用上次结果, // 同时保持 tables/columns 数组身份稳定,让下游按数组身份缓存的索引也能跨请求复用。 const cacheDeps: unknown[] = [ resolvedConnectionId, conn, currentDbName, lazyTablesEntry, sharedTablesData, tablesRef.current, sharedAllColumnsData, allColumnsRef.current, visibleDbsRef.current, ]; const cached = aiContextCacheRef.current; if (cached && cached.deps.every((dep, index) => dep === cacheDeps[index])) { return cached.value; } const lazyTables = lazyTablesEntry || []; const mergedTablesByKey = new Map(); [...sharedTablesData, ...tablesRef.current, ...lazyTables].forEach((table) => { const tableKey = `${String(table?.dbName || '').trim().toLowerCase()}\u0000${String(table?.tableName || '').trim().toLowerCase()}`; if (!tableKey.trim()) { return; } mergedTablesByKey.set(tableKey, table); }); const mergedColumnsByKey = new Map(); [...sharedAllColumnsData, ...allColumnsRef.current].forEach((column) => { const columnKey = `${String(column?.dbName || '').trim().toLowerCase()}\u0000${String(column?.tableName || '').trim().toLowerCase()}\u0000${String(column?.name || '').trim().toLowerCase()}`; if (!columnKey.trim()) { return; } mergedColumnsByKey.set(columnKey, column); }); const value: QueryEditorAiContext = { connectionName: conn?.name, host: resolveQueryEditorAiConnectionHost(conn), port: conn?.config?.port, sourceType: conn?.config?.type, currentDb: currentDbName, visibleDbs: visibleDbsRef.current, tables: [...mergedTablesByKey.values()], columns: [...mergedColumnsByKey.values()], }; aiContextCacheRef.current = { deps: cacheDeps, value }; return value; }, [currentConnectionId, currentDb, tab.connectionId, tab.dbName]); const ensureQueryEditorAiContextMetadata = useCallback(async ( editorSnapshot: QueryEditorAiEditorSnapshot, ): Promise => { const connectionId = String( currentConnectionIdRef.current || currentConnectionId || tab.connectionId || '', ).trim(); const dbName = String( currentDbRef.current || currentDb || tab.dbName || '', ).trim(); if (!connectionId || !dbName) { return; } const intent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot); const normalizedDbName = dbName.toLowerCase(); const needsTables = intent.intent === 'table_name' || !tablesRef.current.some((table) => String(table.dbName || '').trim().toLowerCase() === normalizedDbName); const needsColumns = intent.intent === 'column_name' && !allColumnsRef.current.some((column) => String(column.dbName || '').trim().toLowerCase() === normalizedDbName); if (!needsTables && !needsColumns) { return; } const warmupKey = `${connectionId}\u0000${normalizedDbName}\u0000${needsTables ? 'tables' : ''}\u0000${needsColumns ? 'columns' : ''}`; const existingWarmup = aiContextMetadataWarmupRef.current[warmupKey]; if (existingWarmup) { await existingWarmup; return; } const warmupPromise = (async (): Promise => { const conn = connectionsRef.current.find((item) => item.id === connectionId); if (!conn) { return false; } let warmupSucceeded = true; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || '', database: conn.config.database || '', useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }; if (needsTables) { try { const metadataDialect = normalizeMetadataDialect(conn); const [tableComments, resTables] = await Promise.all([ fetchCompletionTableCommentMap(config, dbName, metadataDialect).catch(() => new Map()), DBGetTables(buildRpcConnectionConfig(config) as any, dbName), ]); if (!resTables?.success) { warmupSucceeded = false; } if (resTables?.success && Array.isArray(resTables.data)) { const fetchedTables = resTables.data .map((row: any) => buildCompletionTableMeta(dbName, row, tableComments)) .filter((table): table is CompletionTableMeta => !!table); if (fetchedTables.length > 0) { const nextTableByKey = new Map( tablesRef.current.map((table) => [ `${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim().toLowerCase()}`, table, ]), ); fetchedTables.forEach((table) => { nextTableByKey.set( `${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim().toLowerCase()}`, table, ); }); tablesRef.current = [...nextTableByKey.values()]; sharedTablesData = tablesRef.current; sharedLazyTablesCache[`${connectionId}|${dbName}`] = fetchedTables; } } } catch (error) { warmupSucceeded = false; console.warn('GoNavi AI inline table metadata warmup failed', error); } } if (needsColumns) { try { const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName); if (!resCols?.success) { warmupSucceeded = false; } if (resCols?.success && Array.isArray(resCols.data)) { const fetchedColumns = resCols.data.map((col: any) => ({ dbName, tableName: col.tableName, name: col.name, type: col.type, comment: normalizeCommentText(col.comment ?? col.Comment ?? col.COLUMN_COMMENT ?? col.column_comment ?? ''), })); if (fetchedColumns.length > 0) { const nextColumnByKey = new Map( allColumnsRef.current.map((column) => [ `${String(column.dbName || '').trim().toLowerCase()}\u0000${String(column.tableName || '').trim().toLowerCase()}\u0000${String(column.name || '').trim().toLowerCase()}`, column, ]), ); fetchedColumns.forEach((column) => { nextColumnByKey.set( `${String(column.dbName || '').trim().toLowerCase()}\u0000${String(column.tableName || '').trim().toLowerCase()}\u0000${String(column.name || '').trim().toLowerCase()}`, column, ); }); allColumnsRef.current = [...nextColumnByKey.values()]; sharedAllColumnsData = allColumnsRef.current; } } } catch (error) { warmupSucceeded = false; console.warn('GoNavi AI inline column metadata warmup failed', error); } } return warmupSucceeded; })(); // 成功的 warmup 结果整个会话内复用,避免每次内联补全都真实查库;失败时删除缓存以便重试。 aiContextMetadataWarmupRef.current[warmupKey] = warmupPromise; let warmupSucceeded = false; try { warmupSucceeded = await warmupPromise; } finally { if (!warmupSucceeded) { delete aiContextMetadataWarmupRef.current[warmupKey]; } } }, [currentConnectionId, currentDb, tab.connectionId, tab.dbName]); useEffect(() => { if (!isExternalSQLFileTab) return; persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { connectionId: currentConnectionIdRef.current, dbName: currentDbRef.current, }); return () => { persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { connectionId: currentConnectionIdRef.current, dbName: currentDbRef.current, }); }; }, [draftSnapshotTab, getCurrentQuery, isExternalSQLFileTab]); // 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量 // 确保 completion provider 始终使用当前活跃 Tab 的上下文 useEffect(() => { if (!isActive) return; sharedCurrentDb = currentDb; sharedCurrentConnectionId = currentConnectionId; sharedConnections = connections; sharedTablesData = tablesRef.current; sharedAllColumnsData = allColumnsRef.current; sharedVisibleDbs = visibleDbsRef.current; sharedViewsData = viewsRef.current; sharedMaterializedViewsData = materializedViewsRef.current; sharedTriggersData = triggersRef.current; sharedRoutinesData = routinesRef.current; sharedSequencesData = sequencesRef.current; sharedPackagesData = packagesRef.current; sharedColumnsCacheData = columnsCacheRef.current; sharedActiveEditorModelUri = String(editorRef.current?.getModel?.()?.uri?.toString?.() || ''); }, [isActive, currentDb, currentConnectionId, connections]); useEffect(() => { connectionsRef.current = connections; }, [connections]); const refreshObjectDecorations = useCallback((maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH) => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); if (!editor || !monaco || !model) { return; } if (isObjectEditQueryTab) { objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []); return; } const text = getQueryEditorDecorationModelTextIfLightweight(model, maxTextLength); if (text === null) { objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []); return; } const decorations: any[] = []; const seen = new Set(); const candidates = collectQueryEditorObjectDecorationCandidates(text); for (const candidate of candidates) { const hoverTarget = resolveQueryEditorHoverTarget( text, candidate.lineContent, candidate.positionColumn, currentDbRef.current, visibleDbsRef.current, tablesRef.current, allColumnsRef.current, viewsRef.current, materializedViewsRef.current, triggersRef.current, routinesRef.current, sequencesRef.current, packagesRef.current, ); if (!hoverTarget) continue; const inlineClassName = hoverTarget.kind === 'column' ? 'gonavi-query-editor-column-token' : hoverTarget.kind === 'database' ? 'gonavi-query-editor-db-token' : 'gonavi-query-editor-object-token'; const key = `${candidate.lineNumber}:${hoverTarget.range.startColumn}:${hoverTarget.range.endColumn}:${inlineClassName}`; if (seen.has(key)) continue; seen.add(key); decorations.push({ range: new monaco.Range( candidate.lineNumber, hoverTarget.range.startColumn, candidate.lineNumber, hoverTarget.range.endColumn, ), options: { inlineClassName }, }); } objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations); }, [isObjectEditQueryTab]); const showObjectInfoAtPosition = useCallback((position?: { lineNumber: number; column: number } | null) => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); const normalizedPosition = normalizeEditorPosition(position || editor?.getPosition?.()); if (!editor || !model || !normalizedPosition) { return false; } const lineContent = String(model.getLineContent?.(normalizedPosition.lineNumber) || ''); const resolveText = getQueryEditorObjectResolveText(model, lineContent); const hoverTarget = resolveQueryEditorHoverTarget( resolveText, lineContent, normalizedPosition.column, currentDbRef.current, visibleDbsRef.current, tablesRef.current, allColumnsRef.current, viewsRef.current, materializedViewsRef.current, triggersRef.current, routinesRef.current, sequencesRef.current, packagesRef.current, ); if (!hoverTarget) { return false; } editor.focus?.(); const hoverRange = monaco ? new monaco.Range( normalizedPosition.lineNumber, hoverTarget.range.startColumn, normalizedPosition.lineNumber, hoverTarget.range.endColumn, ) : { startLineNumber: normalizedPosition.lineNumber, startColumn: hoverTarget.range.startColumn, endLineNumber: normalizedPosition.lineNumber, endColumn: hoverTarget.range.endColumn, }; const contentHoverController = editor.getContribution?.('editor.contrib.contentHover'); if (contentHoverController?.showContentHover) { contentHoverController.showContentHover(hoverRange, 1, 2, false); return true; } editor.setPosition?.({ lineNumber: normalizedPosition.lineNumber, column: hoverTarget.range.startColumn, }); editor.trigger?.('gonavi-hover', 'editor.action.showHover', null); return true; }, []); const registerShowObjectInfoAction = useCallback(() => { const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) { return; } objectHoverActionRef.current?.dispose?.(); const showObjectInfoKeybinding = monaco.KeyMod?.CtrlCmd && monaco.KeyCode?.KeyQ ? [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyQ] : undefined; objectHoverActionRef.current = editor.addAction({ id: 'gonavi.queryEditor.showObjectInfo', label: buildQueryEditorMonacoActionLabel('query_editor.action.show_object_info'), keybindings: showObjectInfoKeybinding, run: () => { const preferredPosition = lastHoverTargetPositionRef.current || editor.getPosition?.(); const shown = showObjectInfoAtPosition(preferredPosition); if (!shown) { void message.info({ key: 'gonavi-query-editor-object-info-miss', content: translate('query_editor.message.object_info_target_not_found'), }); } }, }); }, [showObjectInfoAtPosition]); useEffect(() => { refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); }, [currentDb, refreshObjectDecorations]); const insertTextIntoEditorAtPosition = useCallback((text: string, position?: { lineNumber: number; column: number } | null) => { const editor = editorRef.current; const monaco = monacoRef.current; const targetPosition = normalizeEditorPosition(position || editor?.getPosition?.() || lastEditorCursorPositionRef.current); if (!editor || !monaco?.Range || !targetPosition || !text) { return false; } editor.focus?.(); editor.setPosition?.(targetPosition); editor.executeEdits?.('gonavi-sidebar-drop', [{ range: new monaco.Range( targetPosition.lineNumber, targetPosition.column, targetPosition.lineNumber, targetPosition.column, ), text, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); return true; }, []); const mergeSidebarDropObjectMetadata = useCallback((payload: ReturnType) => { if (!payload?.text || !payload.dbName) { return; } const nodeType = String(payload.nodeType || '').trim().toLowerCase(); if (nodeType && nodeType !== 'table') { return; } const dbName = String(payload.dbName || '').trim(); const tableName = normalizeCompletionQualifiedName(payload.text); if (!dbName || !tableName) { return; } const visibleKey = dbName.toLowerCase(); if (!visibleDbsRef.current.some((db) => String(db || '').toLowerCase() === visibleKey)) { visibleDbsRef.current = [...visibleDbsRef.current, dbName]; } const tableKey = `${visibleKey}\u0000${tableName.toLowerCase()}`; if (!tablesRef.current.some((table) => `${String(table.dbName || '').toLowerCase()}\u0000${String(table.tableName || '').toLowerCase()}` === tableKey)) { tablesRef.current = [...tablesRef.current, { dbName, tableName }]; } if (isActive) { sharedVisibleDbs = visibleDbsRef.current; sharedTablesData = tablesRef.current; } }, [isActive]); const handleSidebarObjectDrop = useCallback((event: DragEvent) => { if (!hasSidebarSqlEditorDragPayload(event.dataTransfer)) { return; } event.preventDefault(); event.stopPropagation(); const payload = decodeSidebarSqlEditorDragPayload(String(event.dataTransfer?.getData(SIDEBAR_SQL_EDITOR_DRAG_MIME) || '')); const dragText = readSidebarSqlDropText(event, currentConnectionIdRef.current, currentDbRef.current); if (!dragText) { return; } const editor = editorRef.current; const dropTarget = editor?.getTargetAtClientPoint?.(event.clientX, event.clientY); if (insertTextIntoEditorAtPosition(dragText, normalizeEditorPosition(dropTarget?.position))) { mergeSidebarDropObjectMetadata(payload); refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); } }, [insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations]); const handleSelectCurrentStatement = async () => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); if (!editor || !monaco?.Range || !model) { return; } const normalizedPosition = normalizeEditorPosition(editor.getPosition?.()) || normalizeEditorPosition(lastEditorCursorPositionRef.current); if (!normalizedPosition) { return; } lastEditorCursorPositionRef.current = normalizedPosition; const lineNumber = normalizedPosition.lineNumber; const lineText = String(model.getLineContent?.(lineNumber) || ''); if (!lineText.trim()) { void message.info(translate('query_editor.message.current_line_no_copyable_content')); return; } const maxColumn = Number(model.getLineMaxColumn?.(lineNumber) || 1); const selection = new monaco.Range(lineNumber, 1, lineNumber, maxColumn); editor.setPosition?.(normalizedPosition); editor.setSelection(selection); editor.revealRangeInCenterIfOutsideViewport?.(selection); const copied = await copyQueryEditorTextToClipboard(lineText); editor.setSelection(selection); editor.focus?.(); if (copied) { void message.success(translate('data_grid.message.copied_to_clipboard')); return; } void message.error(translate('connection_modal.message.copy_failed')); }; const handleDuplicateCurrentLine = () => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); const normalizedPosition = normalizeEditorPosition(editor?.getPosition?.()); if (!editor || !monaco?.Range || !model || !normalizedPosition) { return; } const lineNumber = normalizedPosition.lineNumber; const lineText = String(model.getLineContent?.(lineNumber) || ''); const maxColumn = Number(model.getLineMaxColumn?.(lineNumber) || (lineText.length + 1)); const modelValue = String(model.getValue?.() || ''); const lineBreak = typeof model.getEOL?.() === 'string' ? model.getEOL() : (modelValue.includes('\r\n') ? '\r\n' : '\n'); const insertRange = new monaco.Range(lineNumber, maxColumn, lineNumber, maxColumn); const nextColumn = Math.min(normalizedPosition.column, lineText.length + 1); editor.executeEdits?.('gonavi-duplicate-current-line', [{ range: insertRange, text: `${lineBreak}${lineText}`, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); const nextPosition = { lineNumber: lineNumber + 1, column: nextColumn }; const cursorSelection = new monaco.Range( nextPosition.lineNumber, nextPosition.column, nextPosition.lineNumber, nextPosition.column, ); editor.setSelections?.([cursorSelection]); editor.setSelection?.(cursorSelection); editor.setPosition?.(nextPosition); editor.revealLineInCenterIfOutsideViewport?.(nextPosition.lineNumber); editor.focus?.(); const nextValue = editor.getValue?.(); if (typeof nextValue === 'string') { applyQueryState(nextValue); } }; const buildQueryEditorAiContextMenuActions = useCallback(() => ([ { id: 'ai.generateSQL', label: `AI ${translate('query_editor.action.ai_generate_sql_menu')}`, prompt: translate('query_editor.ai_prompt.generate'), }, { id: 'ai.explainSQL', label: `AI ${translate('query_editor.action.ai_explain_sql_menu')}`, useSelection: true, prompt: translate('query_editor.ai_prompt.explain', { sql: QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER }), }, { id: 'ai.optimizeSQL', label: `AI ${translate('query_editor.action.ai_optimize_sql_menu')}`, useSelection: true, prompt: translate('query_editor.ai_prompt.optimize', { sql: QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER }), }, ]), []); const disposeQueryEditorAiContextMenuActions = useCallback(() => { aiContextMenuActionDisposablesRef.current.forEach((disposable) => disposable?.dispose?.()); aiContextMenuActionDisposablesRef.current = []; }, []); const registerQueryEditorAiContextMenuActions = useCallback((editor: any) => { disposeQueryEditorAiContextMenuActions(); aiContextMenuActionDisposablesRef.current = buildQueryEditorAiContextMenuActions().map((action) => ( editor.addAction({ id: action.id, label: action.label, contextMenuGroupId: '9_ai', contextMenuOrder: 1, run: (ed: any) => { const selection = ed.getModel()?.getValueInRange(ed.getSelection()); const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); const ctxText = buildQueryEditorAiContextPrompt(conn, currentDbRef.current); let prompt = ctxText + action.prompt; if (action.useSelection && selection) { prompt = prompt.replace(QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER, selection); } const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); }, }) )); }, [buildQueryEditorAiContextMenuActions, disposeQueryEditorAiContextMenuActions]); const buildQueryEditorSlashCommandDefs = useCallback(() => ([ { cmd: '/query', label: `🔍 ${translate('query_editor.slash_command.query.label')}`, desc: translate('query_editor.slash_command.query.description'), prompt: translate('query_editor.slash_command.query.prompt'), }, { cmd: '/sql', label: `📝 ${translate('query_editor.slash_command.sql.label')}`, desc: translate('query_editor.slash_command.sql.description'), prompt: translate('query_editor.slash_command.sql.prompt'), }, { cmd: '/explain', label: `💡 ${translate('query_editor.slash_command.explain.label')}`, desc: translate('query_editor.slash_command.explain.description'), prompt: translate('query_editor.slash_command.explain.prompt', { sql: QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER }), useSelection: true, }, { cmd: '/optimize', label: `⚡ ${translate('query_editor.slash_command.optimize.label')}`, desc: translate('query_editor.slash_command.optimize.description'), prompt: translate('query_editor.slash_command.optimize.prompt', { sql: QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER }), useSelection: true, }, { cmd: '/schema', label: `🏗️ ${translate('query_editor.slash_command.schema.label')}`, desc: translate('query_editor.slash_command.schema.description'), prompt: translate('query_editor.slash_command.schema.prompt'), }, { cmd: '/index', label: `📊 ${translate('query_editor.slash_command.index.label')}`, desc: translate('query_editor.slash_command.index.description'), prompt: translate('query_editor.slash_command.index.prompt'), }, { cmd: '/diff', label: `🔄 ${translate('query_editor.slash_command.diff.label')}`, desc: translate('query_editor.slash_command.diff.description'), prompt: translate('query_editor.slash_command.diff.prompt'), }, { cmd: '/mock', label: `🎲 ${translate('query_editor.slash_command.mock.label')}`, desc: translate('query_editor.slash_command.mock.description'), prompt: translate('query_editor.slash_command.mock.prompt'), }, ]), []); const refreshQueryEditorSlashCommandDefs = useCallback(() => { (window as any).__gonaviSlashCmdDefs = buildQueryEditorSlashCommandDefs(); }, [buildQueryEditorSlashCommandDefs]); const syncQueryToEditor = (sql: string) => { const next = sql || ''; applyQueryState(next); const editor = editorRef.current; if (editor && editor.getValue?.() !== next) { editor.setValue(next); } }; const openTextToSqlModal = useCallback(() => { const editor = editorRef.current; const selection = editor?.getSelection?.(); const selectedText = selection ? String(editor?.getModel?.()?.getValueInRange?.(selection) || '') : ''; setTextToSqlApplyMode(selectedText.trim() ? 'replaceSelection' : 'insert'); setIsTextToSqlModalOpen(true); }, []); const applyTextToSqlResult = useCallback((sql: string, applyMode: QueryEditorAiApplyMode) => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); const nextSql = String(sql || '').trim(); if (!nextSql) { return false; } if (!editor || !monaco?.Range || !model) { syncQueryToEditor(nextSql); refreshObjectDecorations(); return true; } const selection = editor.getSelection?.(); const hasSelection = !!selection && !(typeof selection.isEmpty === 'function' ? selection.isEmpty() : selection.startLineNumber === selection.endLineNumber && selection.startColumn === selection.endColumn); const lineCount = Number(model.getLineCount?.() || 1); const range = applyMode === 'replaceAll' ? ( model.getFullModelRange?.() || new monaco.Range(1, 1, lineCount, Number(model.getLineMaxColumn?.(lineCount) || 1)) ) : applyMode === 'replaceSelection' && hasSelection ? selection : (() => { const position = normalizeEditorPosition(editor.getPosition?.()) || normalizeEditorPosition(lastEditorCursorPositionRef.current) || { lineNumber: lineCount, column: Number(model.getLineMaxColumn?.(lineCount) || 1) }; return new monaco.Range( position.lineNumber, position.column, position.lineNumber, position.column, ); })(); editor.focus?.(); editor.pushUndoStop?.(); editor.executeEdits?.('gonavi-text-to-sql', [{ range, text: nextSql, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); const nextValue = String(editor.getValue?.() || nextSql); applyQueryState(nextValue); refreshObjectDecorations(); return true; }, [applyQueryState, refreshObjectDecorations]); const showTextToSqlReadinessWarning = useCallback((reason?: string) => { const key = reason === 'service_unavailable' ? 'query_editor.message.ai_service_unavailable' : reason === 'model_missing' ? 'query_editor.message.ai_model_missing' : 'query_editor.message.ai_provider_missing'; void message.warning(translate(key)); }, []); const handleGenerateTextToSql = useCallback(async () => { const instruction = textToSqlInstruction.trim(); if (!instruction) { void message.warning(translate('query_editor.message.text_to_sql_empty_instruction')); return; } setTextToSqlGenerating(true); try { const { sql, readiness } = await requestQueryEditorTextToSql({ service: getQueryEditorAiService(), aiContext: buildQueryEditorAiContext(), editorSnapshot: buildQueryEditorAiEditorSnapshot(), instruction, }); if (!readiness.ready) { showTextToSqlReadinessWarning(readiness.reason); return; } if (!sql.trim()) { void message.warning(translate('query_editor.message.text_to_sql_empty_result')); return; } if (applyTextToSqlResult(sql, textToSqlApplyMode)) { setIsTextToSqlModalOpen(false); setTextToSqlInstruction(''); void message.success(translate('query_editor.message.text_to_sql_success')); } } catch (error: any) { void message.error(translate('query_editor.message.text_to_sql_failed', { error: error?.message || String(error || ''), })); } finally { setTextToSqlGenerating(false); } }, [ applyTextToSqlResult, buildQueryEditorAiContext, buildQueryEditorAiEditorSnapshot, showTextToSqlReadinessWarning, textToSqlApplyMode, textToSqlInstruction, ]); // If opening a saved query, load its SQL useEffect(() => { const incoming = getTabQueryValue(tab); if (incoming === lastExternalQueryRef.current) { return; } lastExternalQueryRef.current = incoming; const editorHasFocus = editorRef.current?.hasTextFocus?.() === true; if (editorHasFocus && incoming === lastLocalQueryRef.current) { setQuery(incoming); return; } syncQueryToEditor(incoming); }, [tab.id, tab.query]); // Fetch Database List useEffect(() => { if (!autoFetchVisible) { return; } const fetchDbs = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); if (res.success && Array.isArray(res.data)) { let dbs = res.data.map((row: any) => row.Database || row.database); // 过滤只显示 includeDatabases 中配置的数据库 const includeDbs = conn.includeDatabases; if (includeDbs && includeDbs.length > 0) { dbs = dbs.filter((db: string) => includeDbs.includes(db)); } // 存储可见数据库列表用于跨库智能提示 visibleDbsRef.current = dbs; if (isActive) { sharedVisibleDbs = dbs; } setDbList(dbs); if (!currentDbRef.current) { const configuredDb = String(conn.config.database || '').trim(); const fallbackDb = dbs.find((db: string) => String(db || '').toLowerCase() !== 'information_schema') || dbs[0] || ''; const nextDb = configuredDb && dbs.includes(configuredDb) ? configuredDb : fallbackDb; if (nextDb) { currentDbRef.current = nextDb; setCurrentDb(nextDb); } } } else { visibleDbsRef.current = []; if (isActive) { sharedVisibleDbs = []; } setDbList([]); } }; void fetchDbs(); }, [autoFetchVisible, currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { if (!autoFetchVisible || isObjectEditQueryTab) { return; } let cancelled = false; // 仅在本次 effect 成功完成后写入;中途 cancel 不得留下 key,否则同 key 永远不再拉取 → 超链接全灭 let activeFetchKey = ''; const fetchMetadata = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const visibleDbs = visibleDbsRef.current; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const metadataDbName = String(currentDbRef.current || currentDb || '').trim(); if (!metadataDbName) return; if (isActive) { sharedCurrentDb = metadataDbName; } const metadataDbNames = collectQueryEditorReferencedDatabaseNames( getCurrentQuery(), metadataDbName, visibleDbs, ); const metadataFetchKey = [ currentConnectionId, ...metadataDbNames.map((dbName) => String(dbName || '').toLowerCase()).sort(), ].join('\u0000'); const hasCurrentDbTables = tablesRef.current.some( (table) => String(table.dbName || '').trim().toLowerCase() === metadataDbName.toLowerCase(), ); if (metadataFetchKeyRef.current === metadataFetchKey && hasCurrentDbTables) { // 已成功拉过同一批库且当前库表仍在:只刷新装饰 refreshObjectDecorations(); return; } // key 相同但表为空(中途 cancel / 异常):允许重拉 activeFetchKey = metadataFetchKey; const allTables: CompletionTableMeta[] = []; const allColumns: CompletionColumnMeta[] = []; const allViews: CompletionViewMeta[] = []; const allMaterializedViews: CompletionViewMeta[] = []; const allTriggers: CompletionTriggerMeta[] = []; const allRoutines: CompletionRoutineMeta[] = []; const allSequences: CompletionSequenceMeta[] = []; const allPackages: CompletionPackageMeta[] = []; const metadataDialect = normalizeMetadataDialect(conn); const syncMetadataSnapshot = () => { if (cancelled) { return false; } tablesRef.current = [...allTables]; allColumnsRef.current = [...allColumns]; viewsRef.current = [...allViews]; materializedViewsRef.current = [...allMaterializedViews]; triggersRef.current = [...allTriggers]; routinesRef.current = [...allRoutines]; sequencesRef.current = [...allSequences]; packagesRef.current = [...allPackages]; if (isActive) { sharedCurrentDb = metadataDbName; sharedTablesData = tablesRef.current; sharedAllColumnsData = allColumnsRef.current; sharedViewsData = viewsRef.current; sharedMaterializedViewsData = materializedViewsRef.current; sharedTriggersData = triggersRef.current; sharedRoutinesData = routinesRef.current; sharedSequencesData = sequencesRef.current; sharedPackagesData = packagesRef.current; } return true; }; for (const dbName of metadataDbNames) { if (cancelled) return; const tableComments = await fetchCompletionTableCommentMap(config, dbName, metadataDialect); if (cancelled) return; // 获取表 const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName); if (cancelled) return; if (resTables.success && Array.isArray(resTables.data)) { resTables.data.forEach((row: any) => { const tableMeta = buildCompletionTableMeta(dbName, row, tableComments); if (tableMeta) { allTables.push(tableMeta); } }); } if (!syncMetadataSnapshot()) return; // 获取列 (所有数据库类型都支持 DBGetAllColumns) const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName); if (cancelled) return; if (resCols.success && Array.isArray(resCols.data)) { resCols.data.forEach((col: any) => { allColumns.push({ dbName, tableName: col.tableName, name: col.name, type: col.type, comment: normalizeCommentText(col.comment ?? col.Comment ?? col.COLUMN_COMMENT ?? col.column_comment ?? '') }); }); } if (!syncMetadataSnapshot()) return; const viewResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionViewsMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenViews = new Set(); viewResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']); if (!isSidebarViewTableType(tableType)) return; const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']) || '').trim(); const rawViewName = String(getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || '').trim() || getMySQLShowTablesName(row) || getFirstRowValue(row); const normalizedViewName = normalizeSidebarViewName(metadataDialect, dbName, schemaName, rawViewName); if (!normalizedViewName) return; const uniqueKey = `${dbName.toLowerCase()}@@${normalizedViewName.toLowerCase()}`; if (seenViews.has(uniqueKey)) return; seenViews.add(uniqueKey); const parsed = splitSidebarQualifiedName(normalizedViewName); allViews.push({ dbName, viewName: normalizedViewName, schemaName: schemaName || parsed.schemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; const materializedViewResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionMaterializedViewsMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenMaterializedViews = new Set(); materializedViewResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']) || '').trim(); const rawViewName = String(getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) || '').trim() || getFirstRowValue(row); const normalizedViewName = normalizeSidebarViewName(metadataDialect, dbName, schemaName, rawViewName); if (!normalizedViewName) return; const uniqueKey = `${dbName.toLowerCase()}@@${normalizedViewName.toLowerCase()}`; if (seenMaterializedViews.has(uniqueKey)) return; seenMaterializedViews.add(uniqueKey); const parsed = splitSidebarQualifiedName(normalizedViewName); allMaterializedViews.push({ dbName, viewName: normalizedViewName, schemaName: schemaName || parsed.schemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; const triggerResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionTriggersMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenTriggers = new Set(); triggerResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const rawTriggerName = String(getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || '').trim() || getFirstRowValue(row); if (!rawTriggerName) return; const rawSchemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']) || '').trim(); const rawTableName = String(getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']) || '').trim(); const triggerParts = splitSidebarQualifiedName(rawTriggerName); const tableParts = splitSidebarQualifiedName(rawTableName); const resolvedSchemaName = String(rawSchemaName || tableParts.schemaName || triggerParts.schemaName || '').trim(); const resolvedTriggerName = String(triggerParts.objectName || rawTriggerName).trim(); const resolvedTableName = buildQualifiedCompletionName(resolvedSchemaName, tableParts.objectName || rawTableName); const uniqueKey = (metadataDialect === 'mysql' || metadataDialect === 'starrocks') ? `${dbName.toLowerCase()}@@${resolvedSchemaName.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` : `${dbName.toLowerCase()}@@${resolvedSchemaName.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; if (seenTriggers.has(uniqueKey)) return; seenTriggers.add(uniqueKey); allTriggers.push({ dbName, triggerName: buildQualifiedCompletionName(resolvedSchemaName, resolvedTriggerName) || resolvedTriggerName, tableName: resolvedTableName || rawTableName, schemaName: resolvedSchemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; const routineResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionFunctionsMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenRoutines = new Set(); routineResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const rawRoutineName = String(getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']) || '').trim(); if (!rawRoutineName) return; const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']) || '').trim(); const rawType = String(getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION').trim(); const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; const qualifiedRoutineName = buildQualifiedCompletionName(schemaName, rawRoutineName); if (!qualifiedRoutineName) return; const uniqueKey = `${dbName.toLowerCase()}@@${qualifiedRoutineName.toLowerCase()}@@${normalizedType}`; if (seenRoutines.has(uniqueKey)) return; seenRoutines.add(uniqueKey); allRoutines.push({ dbName, routineName: qualifiedRoutineName, routineType: normalizedType, schemaName: schemaName || splitSidebarQualifiedName(qualifiedRoutineName).schemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; const sequenceResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionSequencesMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenSequences = new Set(); sequenceResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const rawSequenceName = String(getCaseInsensitiveValue(row, ['sequence_name', 'name']) || '').trim() || getFirstRowValue(row); if (!rawSequenceName) return; const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'sequence_owner', 'owner', 'db', 'database']) || '').trim(); const sequenceParts = splitSidebarQualifiedName(rawSequenceName); const resolvedSchemaName = String(schemaName || sequenceParts.schemaName || '').trim(); const resolvedSequenceName = String(sequenceParts.objectName || rawSequenceName).trim(); const qualifiedSequenceName = buildQualifiedCompletionName(resolvedSchemaName, resolvedSequenceName); if (!qualifiedSequenceName) return; const uniqueKey = `${dbName.toLowerCase()}@@${qualifiedSequenceName.toLowerCase()}`; if (seenSequences.has(uniqueKey)) return; seenSequences.add(uniqueKey); allSequences.push({ dbName, sequenceName: qualifiedSequenceName, schemaName: resolvedSchemaName || splitSidebarQualifiedName(qualifiedSequenceName).schemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; const packageResults = await queryCompletionMetadataRowsBySpecs( config, dbName, buildCompletionPackagesMetadataQuerySpecs(metadataDialect, dbName), ); if (cancelled) return; const seenPackages = new Set(); packageResults.forEach((queryResult) => { queryResult.rows.forEach((row) => { const rawPackageName = String(getCaseInsensitiveValue(row, ['package_name', 'object_name', 'name']) || '').trim() || getFirstRowValue(row); if (!rawPackageName) return; const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'owner', 'db', 'database']) || '').trim(); const packageParts = splitSidebarQualifiedName(rawPackageName); const resolvedSchemaName = String(schemaName || packageParts.schemaName || '').trim(); const resolvedPackageName = String(packageParts.objectName || rawPackageName).trim(); const qualifiedPackageName = buildQualifiedCompletionName(resolvedSchemaName, resolvedPackageName); if (!qualifiedPackageName) return; const uniqueKey = `${dbName.toLowerCase()}@@${qualifiedPackageName.toLowerCase()}`; if (seenPackages.has(uniqueKey)) return; seenPackages.add(uniqueKey); allPackages.push({ dbName, packageName: qualifiedPackageName, schemaName: resolvedSchemaName || splitSidebarQualifiedName(qualifiedPackageName).schemaName || undefined, }); }); }); if (!syncMetadataSnapshot()) return; } if (!syncMetadataSnapshot()) return; // 成功完成后才固化 key,避免 cancel 后同 key 被误判为「已完成」 metadataFetchKeyRef.current = activeFetchKey; lastSqlReferencedMetadataKeyRef.current = activeFetchKey; refreshObjectDecorations(); }; void fetchMetadata(); return () => { cancelled = true; }; }, [ autoFetchVisible, currentConnectionId, currentDb, connections, isActive, isObjectEditQueryTab, refreshObjectDecorations, sqlReferencedMetadataKey, ]); // Query ID management helpers const setQueryId = (id: string) => { currentQueryIdRef.current = id; setCurrentQueryId(id); }; const clearQueryId = () => { currentQueryIdRef.current = ''; setCurrentQueryId(''); }; const resolveEditorSplitAvailableHeight = useCallback(() => { const rootRect = queryEditorRootRef.current?.getBoundingClientRect?.(); const paneRect = editorPaneRef.current?.getBoundingClientRect?.(); const editorContainerRect = (editorStageRef.current || editorShellRef.current)?.getBoundingClientRect?.(); const rootHeight = Number(rootRect?.height || 0); const paneHeight = Number(paneRect?.height || 0); const editorContainerHeight = Number(editorContainerRect?.height || 0); if (!Number.isFinite(rootHeight) || rootHeight <= 0) { return 0; } const nonEditorPaneHeight = paneHeight > 0 && editorContainerHeight > 0 ? Math.max(0, paneHeight - editorContainerHeight) : 0; const availableHeight = rootHeight - nonEditorPaneHeight; return Number.isFinite(availableHeight) && availableHeight > 0 ? availableHeight : 0; }, []); const clampEditorHeight = useCallback((height: number) => { const availableHeight = resolveEditorSplitAvailableHeight(); if (availableHeight > 0) { return clampQueryEditorEditorHeight(height, availableHeight); } const viewportHeight = Number.isFinite(window.innerHeight) ? window.innerHeight : 800; const maxHeight = Math.max(100, viewportHeight - 200); return Math.max(100, Math.min(maxHeight, height)); }, [resolveEditorSplitAvailableHeight]); const applyEditorHeightRatio = useCallback(() => { const availableHeight = resolveEditorSplitAvailableHeight(); if (availableHeight <= 0 || dragRef.current) return; const nextHeight = resolveQueryEditorEditorHeightFromRatio( queryEditorEditorHeightRatio, availableHeight, ); pendingEditorHeightRef.current = nextHeight; setEditorHeight(previousHeight => previousHeight === nextHeight ? previousHeight : nextHeight); }, [queryEditorEditorHeightRatio, resolveEditorSplitAvailableHeight]); useEffect(() => { if (!isResultPanelVisible || !isActive) return; let frame: number | null = null; const requestFrame = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16); const cancelFrame = typeof window.cancelAnimationFrame === 'function' ? window.cancelAnimationFrame.bind(window) : window.clearTimeout.bind(window); const scheduleApply = () => { if (frame !== null) return; frame = requestFrame(() => { frame = null; applyEditorHeightRatio(); }); }; scheduleApply(); const ResizeObserverCtor = typeof ResizeObserver === 'function' ? ResizeObserver : null; const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(scheduleApply) : null; if (resizeObserver) { if (queryEditorRootRef.current) resizeObserver.observe(queryEditorRootRef.current); if (editorPaneRef.current) resizeObserver.observe(editorPaneRef.current); } window.addEventListener('resize', scheduleApply); return () => { if (frame !== null) { cancelFrame(frame); frame = null; } resizeObserver?.disconnect(); window.removeEventListener('resize', scheduleApply); }; }, [applyEditorHeightRatio, isActive, isResultPanelVisible, tab.id]); const applyEditorHeightToDom = useCallback(() => { const nextHeight = pendingEditorHeightRef.current; const editorContainer = editorStageRef.current || editorShellRef.current; if (editorContainer) { editorContainer.style.height = `${nextHeight}px`; } editorRef.current?.layout?.(); }, []); const cancelEditorResizeFrame = useCallback(() => { if (resizeFrameRef.current === null) return; if (typeof window.cancelAnimationFrame === 'function') { window.cancelAnimationFrame(resizeFrameRef.current); } else { window.clearTimeout(resizeFrameRef.current); } resizeFrameRef.current = null; }, []); const scheduleEditorHeightDomUpdate = useCallback((height: number) => { pendingEditorHeightRef.current = height; if (resizeFrameRef.current !== null) return; const requestFrame = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16); resizeFrameRef.current = requestFrame(() => { resizeFrameRef.current = null; applyEditorHeightToDom(); }); }, [applyEditorHeightToDom]); // Handle Resizing const handleMouseMove = useCallback((e: MouseEvent) => { if (!dragRef.current) return; const delta = e.clientY - dragRef.current.startY; const newHeight = clampEditorHeight(dragRef.current.startHeight + delta); dragRef.current.currentHeight = newHeight; scheduleEditorHeightDomUpdate(newHeight); }, [clampEditorHeight, scheduleEditorHeightDomUpdate]); const handleMouseUp = useCallback(() => { const finalHeight = dragRef.current?.currentHeight; dragRef.current = null; cancelEditorResizeFrame(); if (typeof finalHeight === 'number') { pendingEditorHeightRef.current = finalHeight; applyEditorHeightToDom(); setEditorHeight(finalHeight); const availableHeight = resolveEditorSplitAvailableHeight(); if (availableHeight > 0) { setQueryOptions({ queryEditorEditorHeightRatio: resolveQueryEditorEditorHeightRatio( finalHeight, availableHeight, ), }); } } document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }, [applyEditorHeightToDom, cancelEditorResizeFrame, handleMouseMove, resolveEditorSplitAvailableHeight, setQueryOptions]); const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); const currentEditorHeight = Number((editorStageRef.current || editorShellRef.current)?.getBoundingClientRect?.().height || editorHeight); const startHeight = Number.isFinite(currentEditorHeight) && currentEditorHeight > 0 ? currentEditorHeight : editorHeight; dragRef.current = { startY: e.clientY, startHeight, currentHeight: startHeight }; pendingEditorHeightRef.current = startHeight; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }, [editorHeight, handleMouseMove, handleMouseUp]); useEffect(() => { return () => { dragRef.current = null; cancelEditorResizeFrame(); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [cancelEditorResizeFrame, handleMouseMove, handleMouseUp]); const openRoutineObjectEditTab = useCallback(async ( navigationTarget: Extract, connectionId: string, targetDbName: string, ) => { const targetRoutineName = String(navigationTarget.routineName || '').trim(); if (!targetRoutineName) return; const normalizedRoutineType = String(navigationTarget.routineType || 'FUNCTION').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; const routineTypeLabel = normalizedRoutineType === 'PROCEDURE' ? translate('sidebar.object.procedure') : translate('sidebar.object.function'); const sqlTemplateHeader = `-- ${translate('sidebar.sql_template.edit_routine', { type: routineTypeLabel, name: targetRoutineName, })}`; let editSql = `${sqlTemplateHeader}\n-- ${translate('sidebar.sql_template.modify_then_execute')}\n${buildQueryEditorRoutineEditFallbackSql(targetRoutineName, normalizedRoutineType)}`; const conn = connectionsRef.current.find((item) => item.id === connectionId); if (conn) { const dialect = normalizeMetadataDialect(conn); const parsedRoutine = splitSidebarQualifiedName(targetRoutineName); const routineObjectName = parsedRoutine.objectName || targetRoutineName; const routineSchemaName = String(navigationTarget.schemaName || parsedRoutine.schemaName || '').trim(); const safeName = escapeQueryEditorObjectEditSqlLiteral(routineObjectName); const safeSchema = escapeQueryEditorObjectEditSqlLiteral(routineSchemaName); const safeDbName = escapeQueryEditorObjectEditSqlLiteral(targetDbName); const config = { ...conn.config, port: Number(conn.config?.port), password: conn.config?.password || '', database: conn.config?.database || '', useSSH: conn.config?.useSSH || false, ssh: conn.config?.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }; const queries = (() => { switch (dialect) { case 'mysql': case 'starrocks': return [ `SHOW CREATE ${normalizedRoutineType} \`${routineObjectName.replace(/`/g, '``')}\``, safeDbName ? `SELECT ROUTINE_DEFINITION AS routine_definition FROM information_schema.routines WHERE routine_schema = '${safeDbName}' AND routine_name = '${safeName}' AND UPPER(routine_type) = '${normalizedRoutineType}' LIMIT 1` : '', ].filter(Boolean); case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { const schemaRef = safeSchema || 'public'; return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${schemaRef}' AND p.proname = '${safeName}' LIMIT 1`]; } case 'sqlserver': return buildSqlServerObjectDefinitionQueries('routine', targetRoutineName, targetDbName, 'routine_definition'); case 'oracle': case 'dm': case 'dameng': { const owner = safeSchema || safeDbName; return [ owner ? `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner.toUpperCase()}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${normalizedRoutineType}' ORDER BY LINE` : `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${normalizedRoutineType}' ORDER BY LINE`, ]; } case 'duckdb': { const schemaRef = safeSchema || 'main'; return [ `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${schemaRef}' AND function_name = '${safeName}' LIMIT 1`, ]; } default: return []; } })(); for (const queryText of queries) { try { const result = await DBQuery(buildRpcConnectionConfig(config) as any, targetDbName, queryText); if (!result.success || !Array.isArray(result.data) || result.data.length === 0) { continue; } let definition = ''; if (dialect === 'oracle' || dialect === 'dm' || dialect === 'dameng') { definition = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join(''); } else if (dialect === 'duckdb') { const row = result.data[0] as Record; const schemaName = String(getQueryEditorObjectEditRawValue(row, ['schema_name']) || routineSchemaName || '').trim(); const functionName = String(getQueryEditorObjectEditRawValue(row, ['function_name', 'routine_name', 'name']) || routineObjectName || '').trim(); const parametersRaw = getQueryEditorObjectEditRawValue(row, ['parameters']); const macroDefinition = String(getQueryEditorObjectEditRawValue(row, ['macro_definition']) || '').trim(); const parameters = Array.isArray(parametersRaw) ? parametersRaw.map((item) => String(item ?? '').trim()).filter(Boolean).join(', ') : String(parametersRaw ?? '').replace(/^\[|\]$/g, '').trim(); const qualifiedName = schemaName ? `${schemaName}.${functionName}` : functionName; if (qualifiedName && macroDefinition) { definition = macroDefinition.startsWith('(') ? `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};` : `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; } } else if (dialect === 'sqlserver') { definition = result.data .map((row: any) => getQueryEditorObjectEditRawValue(row, ['routine_definition', 'definition', 'text', 'Text']) ?? '') .map((value) => String(value)) .join(''); } else { const row = result.data[0] as Record; const direct = getQueryEditorObjectEditRawValue(row, ['routine_definition', 'definition']); if (direct !== undefined && direct !== null && String(direct).trim()) { definition = String(direct); } else { const createKey = Object.keys(row).find((key) => /create\s+(function|procedure)/i.test(key)); definition = createKey ? String(row[createKey] || '') : ''; } } const normalizedDefinition = normalizeQueryEditorRoutineDefinitionForEdit( definition, targetRoutineName, normalizedRoutineType, ); if (normalizedDefinition) { editSql = `${sqlTemplateHeader}\n${normalizedDefinition}`; break; } } catch { // 查询最新定义失败时保留可编辑模板。 } } } addTab({ id: `query-edit-routine-${connectionId}-${targetDbName}-${targetRoutineName}-${Date.now()}`, title: translate('sidebar.tab.edit_routine', { type: routineTypeLabel, name: targetRoutineName, }), type: 'query', connectionId, dbName: targetDbName, query: editSql, queryMode: 'object-edit', returnToTabId: activeTabId || undefined, }); }, [activeTabId, addTab]); const openDefinitionObjectEditTab = useCallback(async ( navigationTarget: Extract, connectionId: string, targetDbName: string, ) => { const targetSchemaName = String(navigationTarget.schemaName || '').trim(); const conn = connectionsRef.current.find((item) => item.id === connectionId); const dialect = conn ? normalizeMetadataDialect(conn) : ''; let targetObjectName = ''; let objectEditName = ''; let objectLabel = ''; let definitionTabType: 'view-def' | 'sequence-def' | 'package-def' = 'view-def'; let definitionQueries: string[] = []; let collectAllDefinitionRows = false; let latestDefinition = ''; if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') { targetObjectName = String(navigationTarget.viewName || '').trim(); if (!targetObjectName) return; definitionTabType = 'view-def'; objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); objectLabel = navigationTarget.type === 'materialized-view' ? translate('definition_viewer.object.materialized_view') : translate('definition_viewer.object.view'); definitionQueries = conn ? buildQueryEditorViewDefinitionQueries( dialect, targetObjectName, targetDbName, targetSchemaName, navigationTarget.type === 'materialized-view' ? 'materialized' : 'view', ) : []; } else if (navigationTarget.type === 'sequence') { targetObjectName = String(navigationTarget.sequenceName || '').trim(); if (!targetObjectName) return; definitionTabType = 'sequence-def'; objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); objectLabel = translate('definition_viewer.object.sequence'); definitionQueries = conn ? buildQueryEditorSequenceDefinitionQueries(dialect, targetObjectName, targetDbName, targetSchemaName) : []; } else { targetObjectName = String(navigationTarget.packageName || '').trim(); if (!targetObjectName) return; definitionTabType = 'package-def'; objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); objectLabel = translate('definition_viewer.object.package'); collectAllDefinitionRows = true; definitionQueries = conn ? buildQueryEditorPackageDefinitionQueries(dialect, targetObjectName, targetDbName, targetSchemaName) : []; } if (conn && definitionQueries.length > 0) { const rows = await runQueryEditorObjectDefinitionCandidates( buildQueryEditorObjectDefinitionConnectionConfig(conn), targetDbName, definitionQueries, collectAllDefinitionRows, ); if (definitionTabType === 'view-def') { latestDefinition = extractQueryEditorViewDefinition(dialect, rows); } else if (definitionTabType === 'sequence-def') { latestDefinition = extractQueryEditorSequenceDefinition(rows, targetObjectName, targetSchemaName); } else { latestDefinition = extractQueryEditorPackageDefinition(rows); } } addTab({ id: `query-edit-object-${connectionId}-${targetDbName}-${objectEditName}-${Date.now()}`, title: translate('definition_viewer.edit.tab_title', { object: objectLabel, name: objectEditName, }), type: 'query', connectionId, dbName: targetDbName, query: buildQueryEditorEditableDefinitionSql( definitionTabType, latestDefinition, objectEditName, objectLabel, ), queryMode: 'object-edit', returnToTabId: activeTabId || undefined, }); }, [activeTabId, addTab]); const openTriggerObjectEditTab = useCallback(async ( navigationTarget: Extract, connectionId: string, targetDbName: string, ) => { const targetTriggerName = String(navigationTarget.triggerName || '').trim(); if (!targetTriggerName) return; const conn = connectionsRef.current.find((item) => item.id === connectionId); let latestDefinition = ''; if (conn) { const dialect = normalizeMetadataDialect(conn); const rows = await runQueryEditorObjectDefinitionCandidates( buildQueryEditorObjectDefinitionConnectionConfig(conn), targetDbName, buildQueryEditorTriggerDefinitionQueries( dialect, targetTriggerName, targetDbName, navigationTarget.schemaName, ), ); latestDefinition = extractQueryEditorTriggerDefinition(dialect, rows); } addTab({ id: `query-edit-trigger-${connectionId}-${targetDbName}-${targetTriggerName}-${Date.now()}`, title: translate('trigger_viewer.tab.edit_trigger_title', { name: targetTriggerName }), type: 'query', connectionId, dbName: targetDbName, query: buildEditableTriggerSql(targetTriggerName, latestDefinition, { translate }), queryMode: 'object-edit', returnToTabId: activeTabId || undefined, }); }, [activeTabId, addTab]); // Setup Autocomplete and Editor 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?.()); if (isActive) { sharedActiveEditorModelUri = String(editor.getModel?.()?.uri?.toString?.() || ''); } const mountedModel = editor.getModel?.(); if (mountedModel && typeof monaco?.editor?.setModelLanguage === 'function') { monaco.editor.setModelLanguage(mountedModel, 'sql'); } editor.updateOptions?.(buildQueryEditorMonacoOptions(isObjectEditQueryTab)); aiInlineGhostVisibleContextKeyRef.current = editor.createContextKey?.( QUERY_EDITOR_AI_INLINE_CONTEXT_KEY, false, ) || null; const clearAiInlineGhostTimer = () => { if (aiInlineGhostTimerRef.current !== null) { clearTimeout(aiInlineGhostTimerRef.current); aiInlineGhostTimerRef.current = null; } }; const clearAiInlineGhostDecorations = () => { if (aiInlineGhostDecorationIdsRef.current.length === 0) { return; } const nextDecorationIds = editor.deltaDecorations?.( aiInlineGhostDecorationIdsRef.current, [], ); aiInlineGhostDecorationIdsRef.current = Array.isArray(nextDecorationIds) ? nextDecorationIds : []; }; const clearAiInlineGhost = (cancelRequest = true) => { clearAiInlineGhostTimer(); if (cancelRequest) { aiInlineGhostRequestSeqRef.current += 1; } aiInlineGhostRef.current = null; aiInlineGhostVisibleContextKeyRef.current?.set?.(false); if (aiInlineGhostOverlayRef.current) { aiInlineGhostOverlayRef.current.remove(); aiInlineGhostOverlayRef.current = null; } clearAiInlineGhostDecorations(); }; const triggerStructuredSqlSuggest = (source: string, defer = false) => { const run = () => { if (editorRef.current !== editor) { return; } editor.trigger?.(source, 'editor.action.triggerSuggest', undefined); }; if (defer) { window.setTimeout(run, 0); return; } run(); }; const didModelContentAcceptCurrentAiInlineGhost = (event: any): boolean => { const ghost = aiInlineGhostRef.current; if (!ghost?.insertText) { return false; } const changes = Array.isArray(event?.changes) ? event.changes : []; return changes.some((change: any) => String(change?.text ?? '') === ghost.insertText); }; const buildInlineGhostEditorSnapshot = (model: any, position: { lineNumber: number; column: number }): QueryEditorAiEditorSnapshot => { const lineContent = String(model.getLineContent?.(position.lineNumber) || ''); const lineColumnIndex = Math.max(0, Math.min(Number(position.column || 1) - 1, lineContent.length)); const lineCount = Number(model.getLineCount?.() || position.lineNumber || 1); return { prefix: String(model.getValueInRange?.(new monaco.Range(1, 1, position.lineNumber, position.column)) || ''), suffix: String(model.getValueInRange?.(new monaco.Range( position.lineNumber, position.column, lineCount, Number(model.getLineMaxColumn?.(lineCount) || position.column), )) || ''), currentLineBeforeCursor: lineContent.slice(0, lineColumnIndex), currentLineAfterCursor: lineContent.slice(lineColumnIndex), }; }; const buildInlineGhostEditorSnapshotFromInsertedTextRemoval = ( modelText: string, rangeOffset: number, removedTextLength: number, ): QueryEditorAiEditorSnapshot | null => { if (!Number.isFinite(rangeOffset)) { return null; } const safeStart = Math.max(0, Math.min(Math.trunc(rangeOffset), modelText.length)); const safeEnd = Math.max(safeStart, Math.min(safeStart + Math.max(0, removedTextLength), modelText.length)); const textBeforeInsertion = `${modelText.slice(0, safeStart)}${modelText.slice(safeEnd)}`; const prefix = textBeforeInsertion.slice(0, safeStart); const suffix = textBeforeInsertion.slice(safeStart); const lineStart = Math.max(0, prefix.lastIndexOf('\n') + 1); const nextLineBreak = textBeforeInsertion.indexOf('\n', safeStart); const lineEnd = nextLineBreak === -1 ? textBeforeInsertion.length : nextLineBreak; return { prefix, suffix, currentLineBeforeCursor: prefix.slice(lineStart).replace(/\r/g, ''), currentLineAfterCursor: textBeforeInsertion.slice(safeStart, lineEnd).replace(/\r/g, ''), }; }; const recoverStrayManualSqlCompletionMarker = ( model: any, position: { lineNumber: number; column: number }, snapshot: QueryEditorAiEditorSnapshot, ): { position: { lineNumber: number; column: number }; snapshot: QueryEditorAiEditorSnapshot; recovered: boolean; } => { const prefix = String(snapshot.prefix || ''); const lineBeforeCursor = String(snapshot.currentLineBeforeCursor || ''); if (!prefix.endsWith('\\') || !lineBeforeCursor.endsWith('\\')) { return { position, snapshot, recovered: false }; } const sanitizedSnapshot: QueryEditorAiEditorSnapshot = { prefix: prefix.slice(0, -1), suffix: String(snapshot.suffix || ''), currentLineBeforeCursor: lineBeforeCursor.slice(0, -1), currentLineAfterCursor: String(snapshot.currentLineAfterCursor || ''), }; const intent = resolveQueryEditorInlineCompletionIntentDetails(sanitizedSnapshot); if (intent.intent !== 'table_name' && intent.intent !== 'column_name') { return { position, snapshot, recovered: false }; } const startColumn = Math.max(1, position.column - 1); const startPosition = { lineNumber: position.lineNumber, column: startColumn }; editor.executeEdits?.('gonavi-manual-sql-ai-strip-marker', [{ range: new monaco.Range( position.lineNumber, startColumn, position.lineNumber, position.column, ), text: '', forceMoveMarkers: true, }]); editor.setPosition?.(startPosition); syncQueryDraft(getEditorText()); return { position: startPosition, snapshot: buildInlineGhostEditorSnapshot(model, startPosition), recovered: true, }; }; const isInlineGhostSnapshotCurrent = ( model: any, position: { lineNumber: number; column: number }, snapshot: QueryEditorAiEditorSnapshot, ): boolean => { const currentSnapshot = buildInlineGhostEditorSnapshot(model, position); return currentSnapshot.prefix === snapshot.prefix && currentSnapshot.suffix === snapshot.suffix && currentSnapshot.currentLineBeforeCursor === snapshot.currentLineBeforeCursor && currentSnapshot.currentLineAfterCursor === snapshot.currentLineAfterCursor; }; const renderAiInlineGhost = ( model: any, position: { lineNumber: number; column: number }, insertText: string, snapshot: QueryEditorAiEditorSnapshot, ) => { const previewText = resolveInlineSqlGhostPreviewText(insertText); if (!previewText) { clearAiInlineGhost(false); return; } const modelUri = String(model?.uri?.toString?.() || ''); aiInlineGhostRef.current = { insertText, modelUri, position, snapshot, }; clearAiInlineGhostDecorations(); const visiblePosition = editor.getScrolledVisiblePosition?.(position); const editorDomNode = editor.getDomNode?.(); if (!visiblePosition || !editorDomNode) { clearAiInlineGhost(false); return; } const overlay = aiInlineGhostOverlayRef.current || document.createElement('span'); if (!aiInlineGhostOverlayRef.current) { overlay.className = 'gonavi-query-editor-ai-inline-ghost-overlay'; editorDomNode.appendChild(overlay); aiInlineGhostOverlayRef.current = overlay; } const fontInfoOption = monaco.editor?.EditorOption?.fontInfo; const fontInfo = fontInfoOption !== undefined ? editor.getOption?.(fontInfoOption) : null; overlay.textContent = previewText; overlay.style.left = `${Math.max(0, visiblePosition.left)}px`; overlay.style.top = `${Math.max(0, visiblePosition.top)}px`; overlay.style.height = `${Math.max(1, visiblePosition.height || fontInfo?.lineHeight || 20)}px`; overlay.style.lineHeight = `${Math.max(1, visiblePosition.height || fontInfo?.lineHeight || 20)}px`; if (fontInfo) { overlay.style.fontFamily = String(fontInfo.fontFamily || ''); overlay.style.fontSize = `${Number(fontInfo.fontSize || 14)}px`; overlay.style.fontWeight = String(fontInfo.fontWeight || 'normal'); } aiInlineGhostVisibleContextKeyRef.current?.set?.(true); }; const acceptAiInlineGhost = (): boolean => { const ghost = aiInlineGhostRef.current; const model = editor.getModel?.(); const position = normalizeEditorPosition(editor.getPosition?.()); if (!ghost || !model || !position) { return false; } const modelUri = String(model?.uri?.toString?.() || ''); if ( ghost.modelUri !== modelUri || ghost.position.lineNumber !== position.lineNumber || ghost.position.column !== position.column || !isInlineGhostSnapshotCurrent(model, position, ghost.snapshot) ) { clearAiInlineGhost(); return false; } aiInlineGhostAcceptingRef.current = true; try { editor.pushUndoStop?.(); const startOffset = typeof model.getOffsetAt === 'function' ? Number(model.getOffsetAt(position)) : Number.NaN; editor.executeEdits?.('gonavi-ai-inline-sql-completion', [{ range: new monaco.Range( position.lineNumber, position.column, position.lineNumber, position.column, ), text: ghost.insertText, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); syncQueryDraft(String(editor.getValue?.() ?? model.getValue?.() ?? '')); if (Number.isFinite(startOffset) && typeof model.getPositionAt === 'function') { const nextPosition = normalizeEditorPosition(model.getPositionAt(startOffset + ghost.insertText.length)); if (nextPosition) { editor.setPosition?.(nextPosition); } } } finally { aiInlineGhostAcceptingRef.current = false; clearAiInlineGhost(); } requestAiInlineGhost(0); return true; }; const requestAiInlineGhost = (delayMs: number, focusEditor = false, manualTrigger = false) => { clearAiInlineGhost(); if (aiInlineGhostAcceptingRef.current || editorRef.current !== editor) { return; } if (focusEditor) { editor.focus?.(); } const model = editor.getModel?.(); let position = normalizeEditorPosition(editor.getPosition?.()); if (!model || !position) { return; } const modelUri = String(model?.uri?.toString?.() || ''); if (modelUri && sharedActiveEditorModelUri && modelUri !== sharedActiveEditorModelUri) { return; } let editorSnapshot = buildInlineGhostEditorSnapshot(model, position); if (manualTrigger) { const normalizedState = recoverStrayManualSqlCompletionMarker(model, position, editorSnapshot); position = normalizedState.position; editorSnapshot = normalizedState.snapshot; } const intent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot); const shouldUseInlineMemory = manualTrigger || intent.intent !== 'general_sql'; if (shouldUseInlineMemory) { const memoryInsertText = resolveQueryEditorInlineMemoryInsertText({ editorSnapshot, memoryEntries: inlineSqlMemoryEntries, }); if (memoryInsertText.trim()) { renderAiInlineGhost(model, position, memoryInsertText, editorSnapshot); return; } } if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) { return; } const requestId = ++aiInlineGhostRequestSeqRef.current; const runRequest = () => { if (aiInlineGhostTimerRef.current !== null) { aiInlineGhostTimerRef.current = null; } void (async () => { if ( requestId !== aiInlineGhostRequestSeqRef.current || editorRef.current !== editor ) { return; } try { await ensureQueryEditorAiContextMetadata(editorSnapshot); const insertText = await requestQueryEditorInlineCompletion({ service: getQueryEditorAiService(), aiContext: buildQueryEditorAiContext(), editorSnapshot, }); const currentPosition = normalizeEditorPosition(editor.getPosition?.()); if ( requestId !== aiInlineGhostRequestSeqRef.current || !currentPosition || currentPosition.lineNumber !== position.lineNumber || currentPosition.column !== position.column || !isInlineGhostSnapshotCurrent(model, currentPosition, editorSnapshot) ) { return; } if (!insertText.trim()) { // Keep the manual AI action on the AI path; silently downgrading to plain suggest is misleading. if (!manualTrigger && (intent.intent === 'table_name' || intent.intent === 'column_name')) { const shouldTriggerStructuredSuggest = shouldTriggerQueryEditorInlineObjectSuggestFallback({ aiContext: buildQueryEditorAiContext(), editorSnapshot, }); if (shouldTriggerStructuredSuggest) { triggerStructuredSqlSuggest('gonavi-ai-inline-auto', true); } } return; } renderAiInlineGhost(model, position, insertText, editorSnapshot); } catch (error) { console.warn('GoNavi AI inline SQL ghost failed', error); } })(); }; if (delayMs > 0) { aiInlineGhostTimerRef.current = setTimeout(runRequest, delayMs); return; } runRequest(); }; const scheduleAiInlineGhost = () => { requestAiInlineGhost(QUERY_EDITOR_AI_INLINE_DEBOUNCE_MS); }; triggerAiInlineCompletionRef.current = () => { requestAiInlineGhost(0, true, true); }; if (monaco?.KeyCode?.RightArrow) { editor.addCommand?.( monaco.KeyCode.RightArrow, () => { acceptAiInlineGhost(); }, QUERY_EDITOR_AI_INLINE_CONTEXT_KEY, ); editor.addCommand?.( monaco.KeyCode.RightArrow, () => { void editor.getAction?.('editor.action.inlineSuggest.commit')?.run?.(); }, 'inlineSuggestionVisible', ); } const repositionAiInlineGhost = () => { const ghost = aiInlineGhostRef.current; const model = editor.getModel?.(); if (!ghost || !model) { return; } const modelUri = String(model?.uri?.toString?.() || ''); if (ghost.modelUri !== modelUri) { clearAiInlineGhost(); return; } renderAiInlineGhost(model, ghost.position, ghost.insertText, ghost.snapshot); }; const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => { if (!ctrlMetaPressedRef.current) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); return; } if (!targetPosition) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); return; } const model = editor.getModel?.(); const lineContent = String(model?.getLineContent?.(targetPosition.lineNumber) || ''); const decorations = resolveQueryEditorNavigationDecorations( lineContent, targetPosition.column, currentDbRef.current, visibleDbsRef.current, tablesRef.current, viewsRef.current, materializedViewsRef.current, triggersRef.current, routinesRef.current, sequencesRef.current, packagesRef.current, primaryShortcutModifierLabel, ); if (decorations.length === 0) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); return; } linkDecorationIdsRef.current = editor.deltaDecorations( linkDecorationIdsRef.current, decorations.map((item) => ({ range: new monaco.Range( targetPosition.lineNumber, item.startColumn, targetPosition.lineNumber, item.endColumn, ), options: { inlineClassName: 'gonavi-query-editor-link-hint', }, })), ); setQueryEditorMouseCursor(editor, 'pointer'); }; const applyNavigationHoverState = (event: any) => { const targetPosition = normalizeEditorPosition(event?.target?.position); lastHoverTargetPositionRef.current = targetPosition; applyNavigationHoverStateAtPosition(targetPosition); }; const syncModifierState = (keyboardEvent?: KeyboardEvent | MouseEvent | null) => { const wasPressed = ctrlMetaPressedRef.current; const isKeyboardLikeEvent = keyboardEvent && typeof keyboardEvent === 'object' && ('key' in keyboardEvent || 'code' in keyboardEvent || 'repeat' in keyboardEvent); if (isKeyboardLikeEvent && isImeComposingKeyEvent(keyboardEvent as KeyboardEvent)) { return; } const keyboardEventType = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).type || '').toLowerCase() : ''; const keyboardKey = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).key || '').trim().toLowerCase() : ''; const keyboardCode = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).code || '').trim().toLowerCase() : ''; const isModifierKeyDown = isKeyboardLikeEvent && keyboardEventType !== 'keyup' && ( keyboardKey === 'control' || keyboardKey === 'ctrl' || keyboardKey === 'meta' || keyboardKey === 'os' || keyboardKey === 'command' || keyboardCode.startsWith('control') || keyboardCode.startsWith('meta') || keyboardCode.startsWith('os') ); const eventHasModifierFlag = hasQueryEditorCtrlMetaModifier(keyboardEvent); const nextPressed = isKeyboardLikeEvent ? !!(eventHasModifierFlag || isModifierKeyDown) : !!(eventHasModifierFlag || wasPressed); ctrlMetaPressedRef.current = nextPressed; if (!nextPressed && !wasPressed) { return; } if (!nextPressed) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); return; } if (!wasPressed || isKeyboardLikeEvent) { const keyboardFallbackPosition = isKeyboardLikeEvent ? normalizeEditorPosition(editor.getPosition?.()) || lastEditorCursorPositionRef.current : null; applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current || keyboardFallbackPosition); } }; const handleWindowBlur = () => { ctrlMetaPressedRef.current = false; clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); }; const editorDomNode = editor.getDomNode?.(); const clearImeCompositionFallbackTimer = () => { if (imeCompositionFallbackTimerRef.current !== null) { clearTimeout(imeCompositionFallbackTimerRef.current); imeCompositionFallbackTimerRef.current = null; } }; const getEditorText = () => String( editor.getValue?.() ?? editor.getModel?.()?.getValue?.() ?? '', ); const buildImeFallbackRange = (snapshot: NonNullable) => { const selection = snapshot.selectionBefore; const startFromSelection = typeof selection?.getStartPosition === 'function' ? normalizeEditorPosition(selection.getStartPosition()) : null; const endFromSelection = typeof selection?.getEndPosition === 'function' ? normalizeEditorPosition(selection.getEndPosition()) : null; const startPosition = startFromSelection || normalizeEditorPosition({ lineNumber: selection?.startLineNumber ?? selection?.selectionStartLineNumber, column: selection?.startColumn ?? selection?.selectionStartColumn, }) || snapshot.positionBefore || lastEditorCursorPositionRef.current || { lineNumber: 1, column: 1 }; const endPosition = endFromSelection || normalizeEditorPosition({ lineNumber: selection?.endLineNumber ?? selection?.positionLineNumber, column: selection?.endColumn ?? selection?.positionColumn, }) || startPosition; return new monaco.Range( startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endPosition.column, ); }; const handleImeCompositionStart = () => { clearImeCompositionFallbackTimer(); imeCompositionFallbackRef.current = { editor, valueBefore: getEditorText(), selectionBefore: editor.getSelection?.() || null, positionBefore: normalizeEditorPosition(editor.getPosition?.()) || lastEditorCursorPositionRef.current || null, committedText: '', }; }; const handleImeBeforeInput = (rawEvent: Event) => { const snapshot = imeCompositionFallbackRef.current; if (!snapshot || snapshot.editor !== editor) { return; } const inputEvent = rawEvent as InputEvent; const nextText = String(inputEvent.data ?? ''); if (nextText && (inputEvent.isComposing || String(inputEvent.inputType || '').includes('Composition'))) { snapshot.committedText = nextText; } }; const handleImeCompositionEnd = (rawEvent: Event) => { const snapshot = imeCompositionFallbackRef.current; imeCompositionFallbackRef.current = null; const committedText = String((rawEvent as CompositionEvent).data ?? '') || snapshot?.committedText || ''; if (!committedText || !snapshot || snapshot.editor !== editor) { return; } const fallbackRange = buildImeFallbackRange(snapshot); clearImeCompositionFallbackTimer(); imeCompositionFallbackTimerRef.current = setTimeout(() => { imeCompositionFallbackTimerRef.current = null; if (editorRef.current !== editor) { return; } const currentValue = getEditorText(); if (currentValue !== snapshot.valueBefore) { syncQueryDraft(currentValue); return; } editor.executeEdits?.('gonavi-ime-composition-fallback', [{ range: fallbackRange, text: committedText, forceMoveMarkers: true, }]); const nextValue = getEditorText(); syncQueryDraft(nextValue); const model = editor.getModel?.(); const startOffset = Number(model?.getOffsetAt?.({ lineNumber: fallbackRange.startLineNumber, column: fallbackRange.startColumn, })); const nextPosition = Number.isFinite(startOffset) ? normalizeEditorPosition(model?.getPositionAt?.(startOffset + committedText.length)) : null; if (nextPosition) { editor.setPosition?.(nextPosition); } }, 0); }; const handleEditorDragOver = (rawEvent: Event) => { const event = rawEvent as DragEvent; if (!hasSidebarSqlEditorDragPayload(event.dataTransfer)) return; event.preventDefault(); event.stopPropagation(); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } }; const handleEditorDrop = (rawEvent: Event) => { handleSidebarObjectDrop(rawEvent as DragEvent); }; // 应用透明主题(主题由 MonacoEditor 包装组件按需注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); objectHoverActionRef.current?.dispose?.(); const showObjectInfoKeybinding = monaco.KeyMod?.CtrlCmd && monaco.KeyCode?.KeyQ ? [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyQ] : undefined; objectHoverActionRef.current = editor.addAction({ id: 'gonavi.queryEditor.showObjectInfo', label: buildQueryEditorMonacoActionLabel('query_editor.action.show_object_info'), keybindings: showObjectInfoKeybinding, run: () => { const preferredPosition = lastHoverTargetPositionRef.current || editor.getPosition?.(); const shown = showObjectInfoAtPosition(preferredPosition); if (!shown) { void message.info({ key: 'gonavi-query-editor-object-info-miss', content: translate('query_editor.message.object_info_target_not_found'), }); } }, }); editor.onDidChangeCursorPosition?.((event: any) => { const position = normalizeEditorPosition(event?.position); if (position) { lastEditorCursorPositionRef.current = position; } const ghost = aiInlineGhostRef.current; if ( ghost && (!position || ghost.position.lineNumber !== position.lineNumber || ghost.position.column !== position.column) ) { clearAiInlineGhost(); } }); const recoverTriggerSqlAiCompletionFallback = (event: any): boolean => { if (triggerSqlAiCompletionFallbackApplyingRef.current) { return true; } const pending = triggerSqlAiCompletionFallbackRef.current; const altGestureAge = Date.now() - Number(triggerSqlAiCompletionAltGestureAtRef.current || 0); const hasRecentAltGesture = altGestureAge >= 0 && altGestureAge <= 1200; const changes = Array.isArray(event?.changes) ? event.changes : []; const backslashChange = changes.find((change: any) => String(change?.text ?? '') === '\\'); if (!backslashChange) { if (pending && (Date.now() - pending.observedAt) > 1200) { triggerSqlAiCompletionFallbackRef.current = null; } return false; } const model = editor.getModel?.(); if (!model || typeof model.getOffsetAt !== 'function' || typeof model.getValue !== 'function') { return false; } let markerOffset = Number.NaN; let startPosition = normalizeEditorPosition(backslashChange?.range ? { lineNumber: Number(backslashChange.range.startLineNumber || 1), column: Number(backslashChange.range.startColumn || 1), } : null); let endPosition = normalizeEditorPosition(backslashChange?.range ? { lineNumber: Number(backslashChange.range.endLineNumber || 1), column: Number(backslashChange.range.endColumn || 1), } : null); const rangeOffset = Number(backslashChange?.rangeOffset); if (Number.isFinite(rangeOffset)) { markerOffset = rangeOffset; } else if (startPosition) { markerOffset = Number(model.getOffsetAt(startPosition)); } else { const currentPosition = normalizeEditorPosition(editor.getPosition?.()); const currentOffset = currentPosition ? Number(model.getOffsetAt(currentPosition)) : Number.NaN; if (Number.isFinite(currentOffset) && currentOffset > 0) { markerOffset = currentOffset - 1; } } if (!Number.isFinite(markerOffset) || markerOffset < 0) { return false; } const currentModelText = String(model?.getValue?.() ?? ''); if (currentModelText.slice(markerOffset, markerOffset + 1) !== '\\') { return false; } startPosition = normalizeEditorPosition(model?.getPositionAt?.(markerOffset)); endPosition = normalizeEditorPosition(model?.getPositionAt?.(markerOffset + 1)); const fallbackSnapshot = buildInlineGhostEditorSnapshotFromInsertedTextRemoval( currentModelText, markerOffset, 1, ); const fallbackIntent = fallbackSnapshot ? resolveQueryEditorInlineCompletionIntentDetails(fallbackSnapshot) : null; const hasStructuredSqlCompletionContext = fallbackIntent?.intent === 'table_name' || fallbackIntent?.intent === 'column_name'; if (!pending && !hasRecentAltGesture && !hasStructuredSqlCompletionContext) { return false; } if (pending && (Date.now() - pending.observedAt) > 1200) { triggerSqlAiCompletionFallbackRef.current = null; if (!hasRecentAltGesture && !hasStructuredSqlCompletionContext) { return false; } } if (!startPosition || !endPosition) { return false; } triggerSqlAiCompletionFallbackRef.current = null; triggerSqlAiCompletionFallbackApplyingRef.current = true; try { editor.executeEdits?.('gonavi-trigger-sql-ai-completion-fallback', [{ range: new monaco.Range( startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endPosition.column, ), text: '', forceMoveMarkers: true, }]); editor.setPosition?.(startPosition); syncQueryDraft(getEditorText()); } finally { triggerSqlAiCompletionFallbackApplyingRef.current = false; } triggerAiInlineCompletionRef.current?.(); return true; }; editor.onDidChangeModelContent?.((event: any) => { if (recoverTriggerSqlAiCompletionFallback(event)) { return; } const hasSlashCommandMarker = Array.isArray(event?.changes) && event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || ''))); if (hasSlashCommandMarker) { refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); } // SQL 文本变更后,按引用库集合防抖触发跨库元数据拉取(db.table / schema.table / db.schema.table) if (sqlReferencedMetadataTimerRef.current !== null) { window.clearTimeout(sqlReferencedMetadataTimerRef.current); } sqlReferencedMetadataTimerRef.current = window.setTimeout(() => { sqlReferencedMetadataTimerRef.current = null; if (editorRef.current !== editor) { return; } const modelText = String(editor.getModel?.()?.getValue?.() || ''); const referencedDbs = collectQueryEditorReferencedDatabaseNames( modelText, currentDbRef.current || '', visibleDbsRef.current, ); const nextKey = [ String(currentConnectionIdRef.current || '').trim(), ...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; setSqlReferencedMetadataKey(nextKey); }, 450); const acceptedCurrentAiGhost = !aiInlineGhostAcceptingRef.current && didModelContentAcceptCurrentAiInlineGhost(event); if (acceptedCurrentAiGhost) { clearAiInlineGhost(false); window.setTimeout(() => { if (editorRef.current !== editor) { return; } requestAiInlineGhost(0); }, 0); return; } if (!aiInlineGhostAcceptingRef.current) { scheduleAiInlineGhost(); } }); // 滚动/布局事件可达每帧多次,rAF 合并避免高频 DOM 重排。 let repositionAiInlineGhostRafId: number | null = null; const scheduleRepositionAiInlineGhost = () => { if (!aiInlineGhostRef.current || repositionAiInlineGhostRafId !== null) { return; } repositionAiInlineGhostRafId = window.requestAnimationFrame(() => { repositionAiInlineGhostRafId = null; if (editorRef.current !== editor) { return; } repositionAiInlineGhost(); }); }; editor.onDidScrollChange?.(() => { scheduleRepositionAiInlineGhost(); }); editor.onDidLayoutChange?.(() => { scheduleRepositionAiInlineGhost(); }); editor.onMouseMove?.((event: any) => { syncModifierState(event?.event || null); applyNavigationHoverState(event); }); editor.onMouseLeave?.(() => { lastHoverTargetPositionRef.current = null; clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); editor.updateOptions?.({ mouseStyle: 'text' }); setQueryEditorMouseCursor(editor, ''); }); window.addEventListener('keydown', syncModifierState); window.addEventListener('keyup', syncModifierState); window.addEventListener('blur', handleWindowBlur); editorDomNode?.addEventListener('beforeinput', handleImeBeforeInput, true); editorDomNode?.addEventListener('compositionstart', handleImeCompositionStart, true); editorDomNode?.addEventListener('compositionend', handleImeCompositionEnd, true); editorDomNode?.addEventListener('dragover', handleEditorDragOver, true); editorDomNode?.addEventListener('drop', handleEditorDrop, true); editor.onMouseDown?.((event: any) => { const browserEvent = event?.event; const targetPosition = normalizeEditorPosition(event?.target?.position); if (!browserEvent || !targetPosition) { return; } if (!isQueryEditorPrimaryMouseButton(browserEvent)) { return; } if (!hasQueryEditorCtrlMetaModifier(browserEvent) && !ctrlMetaPressedRef.current) { return; } const model = editor.getModel?.(); const lineContent = String(model?.getLineContent?.(targetPosition.lineNumber) || ''); const navigationTarget = resolveQueryEditorNavigationTarget( lineContent, targetPosition.column, currentDbRef.current, visibleDbsRef.current, tablesRef.current, viewsRef.current, materializedViewsRef.current, triggersRef.current, routinesRef.current, sequencesRef.current, packagesRef.current, ); if (!navigationTarget) { return; } browserEvent.preventDefault?.(); browserEvent.stopPropagation?.(); const connectionId = String(currentConnectionIdRef.current || '').trim(); if (!connectionId) { return; } if (navigationTarget.type === 'database') { const nextDbName = String(navigationTarget.dbName || '').trim(); if (!nextDbName) { return; } setCurrentDb(nextDbName); currentDbRef.current = nextDbName; setActiveContext({ connectionId, dbName: nextDbName }); return; } const targetDbName = String(navigationTarget.dbName || '').trim(); if (!targetDbName) { return; } if (navigationTarget.type === 'table') { const targetTableName = String(navigationTarget.tableName || '').trim(); if (!targetTableName) return; addTab({ id: `${connectionId}-${targetDbName}-table-${targetTableName}`, title: targetTableName, type: 'table', connectionId, dbName: targetDbName, tableName: targetTableName, initialViewMode: 'fields', initialViewModeRequestId: String(Date.now()), objectType: 'table', returnToTabId: activeTabId || undefined, }); return; } if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') { void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'trigger') { void openTriggerObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'sequence') { void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'package') { void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } void openRoutineObjectEditTab(navigationTarget, connectionId, targetDbName); }); editor.onDidDispose?.(() => { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef); setQueryEditorMouseCursor(editor, ''); objectHoverActionRef.current?.dispose?.(); objectHoverActionRef.current = null; triggerSqlAiCompletionActionRef.current?.dispose?.(); triggerSqlAiCompletionActionRef.current = null; macFindWithSelectionGuardActionRef.current?.dispose?.(); macFindWithSelectionGuardActionRef.current = null; triggerSqlAiCompletionKeydownDisposableRef.current?.dispose?.(); triggerSqlAiCompletionKeydownDisposableRef.current = null; triggerAiInlineCompletionRef.current = null; const disposedModelUri = String(editor.getModel?.()?.uri?.toString?.() || ''); if (disposedModelUri && sharedActiveEditorModelUri === disposedModelUri) { sharedActiveEditorModelUri = ''; } disposeQueryEditorAiContextMenuActions(); window.removeEventListener('keydown', syncModifierState); window.removeEventListener('keyup', syncModifierState); window.removeEventListener('blur', handleWindowBlur); clearImeCompositionFallbackTimer(); editorDomNode?.removeEventListener('beforeinput', handleImeBeforeInput, true); editorDomNode?.removeEventListener('compositionstart', handleImeCompositionStart, true); editorDomNode?.removeEventListener('compositionend', handleImeCompositionEnd, true); editorDomNode?.removeEventListener('dragover', handleEditorDragOver, true); editorDomNode?.removeEventListener('drop', handleEditorDrop, true); }); refreshObjectDecorations(); // 注册 AI 右键菜单操作 registerQueryEditorAiContextMenuActions(editor); registerInsertSqlSnippetContextMenuAction(editor); registerTriggerSqlAiCompletionAction(editor, monaco); // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding const runBinding = runQueryShortcutBinding; if (runBinding?.enabled && runBinding.combo) { const keyBinding = comboToMonacoKeyBinding( runBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { runQueryActionRef.current = editor.addAction({ id: 'gonavi.runQuery', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.runQuery.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:run-active-query', { detail: { requireSelection: true }, })); }, }); } } const selectStatementBinding = selectCurrentStatementShortcutBinding; if (selectStatementBinding?.enabled && selectStatementBinding.combo) { const keyBinding = comboToMonacoKeyBinding( selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { selectCurrentStatementActionRef.current = editor.addAction({ id: 'gonavi.selectCurrentStatement', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.selectCurrentStatement.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: handleSelectCurrentStatement, }); } } const macFindWithSelectionGuardKeyBinding = activeShortcutPlatform === 'mac' ? comboToMonacoKeyBinding( QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO, monaco.KeyMod, monaco.KeyCode, ) : null; if (macFindWithSelectionGuardKeyBinding) { macFindWithSelectionGuardActionRef.current = editor.addAction({ id: QUERY_EDITOR_MAC_FIND_WITH_SELECTION_GUARD_ACTION_ID, label: 'GoNavi: Suppress macOS Cmd+E Find with Selection', keybindings: [ macFindWithSelectionGuardKeyBinding.keyMod | macFindWithSelectionGuardKeyBinding.keyCode, ], run: () => { if ( selectStatementBinding?.enabled && normalizeShortcutCombo(selectStatementBinding.combo) === QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO ) { void handleSelectCurrentStatement(); } }, }); } const duplicateLineBinding = duplicateCurrentLineShortcutBinding; if (duplicateLineBinding?.enabled && duplicateLineBinding.combo) { const keyBinding = comboToMonacoKeyBinding( duplicateLineBinding.combo, monaco.KeyMod, monaco.KeyCode, ); if (keyBinding) { duplicateCurrentLineActionRef.current = editor.addAction({ id: 'gonavi.duplicateCurrentLine', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.duplicateCurrentLine.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: handleDuplicateCurrentLine, }); } } const saveBinding = saveQueryShortcutBinding; if (saveBinding?.enabled && saveBinding.combo) { const keyBinding = comboToMonacoKeyBinding( saveBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { saveQueryActionRef.current = editor.addAction({ id: 'gonavi.saveQuery', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.saveQuery.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:save-active-query')); }, }); } } const findInEditorKeyBinding = comboToMonacoKeyBinding( findInEditorShortcutCombo, monaco.KeyMod, monaco.KeyCode ); if (findInEditorKeyBinding) { findInEditorActionRef.current = editor.addAction({ id: 'gonavi.findInEditor', label: buildQueryEditorMonacoActionLabel('query_editor.action.find_in_editor'), keybindings: [findInEditorKeyBinding.keyMod | findInEditorKeyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:find-active-query')); }, }); } const formatBinding = formatSqlShortcutBinding; if (formatBinding?.enabled && formatBinding.combo) { const keyBinding = comboToMonacoKeyBinding( formatBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { formatSqlActionRef.current = editor.addAction({ id: 'gonavi.formatSql', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.formatSql.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:format-active-query')); }, }); } } // 注册 / 斜杠命令 AI 快捷补全 refreshQueryEditorSlashCommandDefs(); const toggleResultsBinding = toggleQueryResultsPanelShortcutBinding; if (toggleResultsBinding?.enabled && toggleResultsBinding.combo) { const keyBinding = comboToMonacoKeyBinding( toggleResultsBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { toggleQueryResultsPanelActionRef.current = editor.addAction({ id: 'gonavi.toggleQueryResultsPanel', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.toggleQueryResultsPanel.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: toggleResultPanelVisibility, }); } } // HMR 重载或测试重置时,以全局状态为准,避免本地闭包状态和 provider 列表不同步。 sqlCompletionRegistered = Boolean(_g.__gonaviSqlCompletionState.registered); sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables; const shouldRegisterSqlCompletion = !sqlCompletionRegistered || _g.__gonaviSqlCompletionState.version !== SQL_COMPLETION_PROVIDER_VERSION; // HMR 重载时释放旧注册避免补全项重复 if (shouldRegisterSqlCompletion) { sqlCompletionRegistered = true; _g.__gonaviSqlCompletionState.registered = true; _g.__gonaviSqlCompletionState.version = SQL_COMPLETION_PROVIDER_VERSION; sqlCompletionDisposables.forEach((d: any) => d?.dispose?.()); sqlCompletionDisposables.length = 0; sqlCompletionDisposables.push(monaco.languages.registerHoverProvider('sql', { provideHover: (model: any, position: any) => { const normalizedPosition = normalizeEditorPosition(position); if (!normalizedPosition) { return null; } const lineContent = String(model?.getLineContent?.(normalizedPosition.lineNumber) || ''); const resolveText = getQueryEditorObjectResolveText(model, lineContent); const hoverTarget = resolveQueryEditorHoverTarget( resolveText, lineContent, normalizedPosition.column, sharedCurrentDb, sharedVisibleDbs, sharedTablesData, sharedAllColumnsData, sharedViewsData, sharedMaterializedViewsData, sharedTriggersData, sharedRoutinesData, sharedSequencesData, sharedPackagesData, ); if (!hoverTarget) { return null; } return { range: new monaco.Range( normalizedPosition.lineNumber, hoverTarget.range.startColumn, normalizedPosition.lineNumber, hoverTarget.range.endColumn, ), contents: [{ value: buildQueryEditorHoverMarkdown(hoverTarget) }], }; }, })); sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.'], provideCompletionItems: async (model: any, position: any, _context?: any, token?: { isCancellationRequested?: boolean }) => { if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; const activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId); const activeDialect = resolveSqlDialect( String(activeConnection?.config?.type || ''), String(activeConnection?.config?.driver || ''), { oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol }, ); const shouldQuoteCompletionIdentifiers = isPostgresSchemaDialect(activeDialect); const quoteCompletionPart = (ident: string) => { const raw = String(ident || '').trim(); if (!raw) return raw; return shouldQuoteCompletionIdentifiers ? quoteIdentPart(activeDialect, raw) : raw; }; const quoteCompletionPath = (ident: string) => { const raw = String(ident || '').trim(); if (!raw) return raw; return shouldQuoteCompletionIdentifiers ? quoteQualifiedIdent(activeDialect, raw) : raw; }; const getActiveCompletionDbName = () => String(sharedCurrentDb || currentDbRef.current || currentDb || tab.dbName || '').trim(); const dialectKeywords = resolveSqlKeywords(activeDialect); const dialectFunctions = resolveSqlFunctions(activeDialect); const stripQuotes = stripCompletionIdentifierQuotes; const normalizeQualifiedName = normalizeCompletionQualifiedName; const splitSchemaAndTable = splitCompletionSchemaAndTable; const buildDbQualifiedTableSuggestionMeta = (dbName: string, tableName: string) => { const rawDbName = String(dbName || '').trim(); const rawTableName = String(tableName || '').trim(); const parsed = splitSchemaAndTable(rawTableName); const schemaMatchesDb = !!parsed.schema && !!parsed.table && parsed.schema.toLowerCase() === rawDbName.toLowerCase(); const displayName = schemaMatchesDb ? parsed.table : rawTableName; const insertText = schemaMatchesDb ? quoteCompletionPart(parsed.table) : quoteCompletionPath(rawTableName); const dbQualifiedLabel = rawDbName ? `${rawDbName}.${displayName || rawTableName}` : (displayName || rawTableName); return { displayName: displayName || rawTableName, insertText, dbQualifiedLabel, }; }; const normalizeRoutineType = (routineType: string) => ( String(routineType || '').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION' ); const getRoutineTypeLabel = (routineType: string) => ( normalizeRoutineType(routineType) === 'PROCEDURE' ? translate('sidebar.object.procedure') : translate('sidebar.object.function') ); const buildRoutineSuggestionMeta = (routine: CompletionRoutineMeta) => { const rawDbName = String(routine.dbName || '').trim(); const rawRoutineName = String(routine.routineName || '').trim(); const parsed = splitSchemaAndTable(rawRoutineName); const schemaName = String(routine.schemaName || parsed.schema || '').trim(); const objectName = String(parsed.table || rawRoutineName).trim(); const schemaMatchesDb = !!schemaName && !!rawDbName && schemaName.toLowerCase() === rawDbName.toLowerCase(); const isCurrentDb = rawDbName.toLowerCase() === getActiveCompletionDbName().toLowerCase(); const displayName = isCurrentDb && schemaMatchesDb ? objectName : (parsed.schema ? rawRoutineName : objectName); const dbQualifiedLabel = rawDbName && !isCurrentDb ? `${rawDbName}.${displayName}` : displayName; const insertName = rawDbName && !isCurrentDb ? dbQualifiedLabel : displayName; return { displayName, dbQualifiedLabel, insertText: `${quoteCompletionPath(insertName)}($0)`, objectName, schemaName, routineType: normalizeRoutineType(routine.routineType), }; }; const buildConnConfig = () => { const connId = sharedCurrentConnectionId; const conn = sharedConnections.find(c => c.id === connId); if (!conn) return null; return { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; }; const getLazyTablesByDB = async (dbName: string) => { const connId = sharedCurrentConnectionId; if (!connId || !dbName) return [] as CompletionTableMeta[]; const key = `${connId}|${dbName}`; if (sharedLazyTablesCache[key]) { return sharedLazyTablesCache[key]; } if (sharedLazyTablesInFlight[key]) { return sharedLazyTablesInFlight[key]; } const config = buildConnConfig(); if (!config) return [] as CompletionTableMeta[]; const conn = sharedConnections.find(c => c.id === connId); sharedLazyTablesInFlight[key] = Promise.all([ fetchCompletionTableCommentMap(config, dbName, normalizeMetadataDialect(conn)), DBGetTables(buildRpcConnectionConfig(config) as any, dbName), ]) .then(([tableComments, res]) => { const tables = res?.success && Array.isArray(res.data) ? res.data .map((row: any) => buildCompletionTableMeta(dbName, row, tableComments)) .filter((table): table is CompletionTableMeta => !!table) : []; sharedLazyTablesCache[key] = tables; if (tables.length > 0) { const lazyTableByKey = new Map(tables.map((table) => [ `${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`, table, ])); const existingKeys = new Set(); let changed = false; const nextSharedTables = sharedTablesData.map((table) => { const tableKey = `${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`; existingKeys.add(tableKey); const lazyTable = lazyTableByKey.get(tableKey); if (lazyTable?.comment && lazyTable.comment !== table.comment) { changed = true; return { ...table, comment: lazyTable.comment }; } return table; }); const missingTables = tables.filter((table) => !existingKeys.has(`${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`)); if (missingTables.length > 0) { changed = true; nextSharedTables.push(...missingTables); } if (changed) { sharedTablesData = nextSharedTables; } } return tables; }) .catch(() => []) .finally(() => { delete sharedLazyTablesInFlight[key]; }); return sharedLazyTablesInFlight[key]; }; const toCompletionColumns = ( columns: ColumnDefinition[], dbName: string, tableName: string, ): CompletionColumnMeta[] => columns .map((column) => ({ dbName, tableName, name: getColumnDefinitionName(column), type: getColumnDefinitionType(column), comment: getColumnDefinitionComment(column), })) .filter((column) => !!column.name); const findPreloadedColumns = (dbName: string, tableName: string) => findSharedPreloadedColumns(dbName, tableName); const mergeSharedCompletionColumns = (columns: CompletionColumnMeta[]) => { if (columns.length === 0) return; const existingKeys = new Set(sharedAllColumnsData.map((column) => [ String(column.dbName || '').toLowerCase(), String(column.tableName || '').toLowerCase(), String(column.name || '').toLowerCase(), ].join('\u0000'))); const missing = columns.filter((column) => { const key = [ String(column.dbName || '').toLowerCase(), String(column.tableName || '').toLowerCase(), String(column.name || '').toLowerCase(), ].join('\u0000'); if (existingKeys.has(key)) return false; existingKeys.add(key); return true; }); if (missing.length > 0) { sharedAllColumnsData = [...sharedAllColumnsData, ...missing]; } }; const getCompletionColumnsByTable = async (dbName: string, tableIdent: string) => { const connId = sharedCurrentConnectionId; const targetDb = String(dbName || '').trim(); const targetTable = String(tableIdent || '').trim(); if (!connId || !targetDb || !targetTable) return [] as CompletionColumnMeta[]; const preloaded = findPreloadedColumns(targetDb, targetTable); if (preloaded.length > 0) { return preloaded; } const key = `${connId}|${targetDb}|${targetTable}`; const cached = sharedColumnsCacheData[key] as ColumnDefinition[] | undefined; if (cached) { const cachedColumns = toCompletionColumns(cached, targetDb, targetTable); mergeSharedCompletionColumns(cachedColumns); return cachedColumns; } const config = buildConnConfig(); if (!config) return [] as CompletionColumnMeta[]; const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, targetDb, targetTable); if (res?.success && Array.isArray(res.data)) { const cols = res.data as ColumnDefinition[]; sharedColumnsCacheData[key] = cols; const completionColumns = toCompletionColumns(cols, targetDb, targetTable); mergeSharedCompletionColumns(completionColumns); return completionColumns; } return [] as CompletionColumnMeta[]; }; const fullText = model.getValue(); const cursorOffset = getNormalizedOffsetAtPosition(fullText, { lineNumber: Number(position?.lineNumber || 1), column: Number(position?.column || 1), }); const currentStatementRange = resolveCurrentSqlStatementRange(fullText, cursorOffset); // 获取当前行光标前的内容 const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1); const currentStatementPrefix = currentStatementRange ? 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); if (threePartMatch) { const dbPart = stripQuotes(threePartMatch[1]); const tablePart = stripQuotes(threePartMatch[2]); const colPrefix = (threePartMatch[3] || '').toLowerCase(); const cols = await getCompletionColumnsByTable(dbPart, tablePart); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } const filtered = colPrefix ? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix)) : cols; const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: quoteCompletionPart(c.name), detail: buildColumnCompletionDetail(c), documentation: buildColumnCompletionDocumentation(c), range, sortText: '0' + c.name })); return { suggestions }; } // 1) 两段式 qualifier.xxx 格式 const qualifierMatch = linePrefix.match(QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX); if (qualifierMatch) { const qualifier = stripQuotes(qualifierMatch[1]); const prefix = (qualifierMatch[2] || '').toLowerCase(); const qualifierLower = qualifier.toLowerCase(); // 首先检查 qualifier 是否是数据库名(跨库表提示) const visibleDbs = sharedVisibleDbs; if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 let tables = sharedTablesData.filter(t => (t.dbName || '').toLowerCase() === qualifierLower ); if (tables.length === 0) { tables = await getLazyTablesByDB(qualifier); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } } const filtered = prefix ? tables.filter(t => { const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || ''); return String(suggestionMeta.displayName || '').toLowerCase().startsWith(prefix) || String(t.tableName || '').toLowerCase().startsWith(prefix); }) : tables; const suggestions = filtered.map(t => { const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || ''); return { label: suggestionMeta.displayName, kind: monaco.languages.CompletionItemKind.Class, insertText: suggestionMeta.insertText, detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, sortText: '0' + suggestionMeta.displayName }; }); const routineSuggestions = sharedRoutinesData .filter((routine) => String(routine.dbName || '').toLowerCase() === qualifierLower) .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) .filter(({ routine, meta }) => ( !prefix || meta.displayName.toLowerCase().startsWith(prefix) || meta.objectName.toLowerCase().startsWith(prefix) || String(routine.routineName || '').toLowerCase().startsWith(prefix) )) .map(({ routine, meta }) => ({ label: meta.displayName, kind: monaco.languages.CompletionItemKind.Function, insertText: meta.insertText, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName})`, range, sortText: '1' + meta.displayName, })); return { suggestions: [...suggestions, ...routineSuggestions] }; } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table const schemaTables = sharedTablesData .map(t => { const parsed = splitSchemaAndTable(t.tableName || ''); return { dbName: t.dbName || '', schema: parsed.schema, table: parsed.table, comment: t.comment, }; }) .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); if (schemaTables.length > 0) { const filtered = prefix ? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix)) : schemaTables; const suggestions = filtered.map(t => ({ label: t.table, kind: monaco.languages.CompletionItemKind.Class, insertText: quoteCompletionPart(t.table), detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, sortText: '0' + t.table })); const routineSuggestions = sharedRoutinesData .filter((routine) => { const meta = buildRoutineSuggestionMeta(routine); return meta.schemaName.toLowerCase() === qualifierLower; }) .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) .filter(({ meta }) => !prefix || meta.objectName.toLowerCase().startsWith(prefix)) .map(({ routine, meta }) => ({ label: meta.objectName, kind: monaco.languages.CompletionItemKind.Function, insertText: `${quoteCompletionPart(meta.objectName)}($0)`, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName}${meta.schemaName ? '.' + meta.schemaName : ''})`, range, sortText: '1' + meta.objectName, })); return { suggestions: [...suggestions, ...routineSuggestions] }; } // 否则检查是否是表别名或表名,提示列 const aliasMap = buildQueryEditorAliasMap(completionReferenceText, sharedCurrentDb || ''); const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } const filtered = prefix ? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix)) : cols; const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: quoteCompletionPart(c.name), detail: buildColumnCompletionDetail(c), documentation: buildColumnCompletionDocumentation(c), range, sortText: '0' + c.name })); return { suggestions }; } } // 2) global/table/column completion const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX; tableRegex.lastIndex = 0; const foundTables = new Set(); let match; while ((match = tableRegex.exec(completionReferenceText)) !== null) { const t = normalizeQualifiedName(match[1] || ''); if (!t) continue; // 存储完整标识 db.table 或 table foundTables.add(t.toLowerCase()); } const currentDatabase = getActiveCompletionDbName(); const isCurrentCompletionDatabase = (dbName: string) => String(dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const wordPrefix = (word.word || '').toLowerCase(); const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix); const includesWordPrefix = (candidate: string) => !wordPrefix || String(candidate || '').toLowerCase().includes(wordPrefix); const getPrefixMatchRank = (...candidates: string[]) => { if (!wordPrefix) return '0'; const normalized = candidates .map((candidate) => String(candidate || '').toLowerCase()) .filter(Boolean); if (normalized.some((candidate) => candidate.startsWith(wordPrefix))) return '0'; if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1'; return '9'; }; const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix); const expectsRoutineName = /\bCALL\s+[`"]?[\w.]*$/i.test(linePrefix); const matchesKeywordPrefix = wordPrefix.length > 0 && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); const statementPrefixBeforeWord = currentStatementPrefix.slice( 0, Math.max(0, currentStatementPrefix.length - String(word.word || '').length), ); const isNewStatementKeywordContext = !expectsTableName && !expectsRoutineName && matchesKeywordPrefix && !statementPrefixBeforeWord.trim(); const shouldBoostKeywords = !expectsTableName && !expectsRoutineName && matchesKeywordPrefix; const sortGroups = isNewStatementKeywordContext ? { keyword: '00', func: '10', routineCurrent: '11', routineOther: '12', tableCurrent: '20', tableOther: '21', columnCurrent: '30', columnOther: '31', db: '40' } : shouldBoostKeywords ? { keyword: '00', func: '05', routineCurrent: '06', routineOther: '07', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } : expectsRoutineName ? { keyword: '30', func: '40', routineCurrent: '00', routineOther: '01', columnCurrent: '20', columnOther: '21', tableCurrent: '10', tableOther: '11', db: '35' } : expectsTableName ? { keyword: '20', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' } : { keyword: '30', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' }; let completionTables = sharedTablesData; if ( expectsTableName && currentDatabase && !sharedTablesData.some((t) => isCurrentCompletionDatabase(t.dbName || '')) ) { const lazyTables = await getLazyTablesByDB(currentDatabase); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } if (lazyTables.length > 0) { const seenTableKeys = new Set(); completionTables = [...sharedTablesData, ...lazyTables].filter((table) => { const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`; if (seenTableKeys.has(key)) return false; seenTableKeys.add(key); return true; }); } } if ( expectsTableName && currentDatabase && sharedTablesData.some((table) => isCurrentCompletionDatabase(table.dbName || '')) && sharedTablesData.some((table) => isCurrentCompletionDatabase(table.dbName || '') && !normalizeCommentText(table.comment)) ) { const enrichedTables = await getLazyTablesByDB(currentDatabase); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } if (enrichedTables.length > 0) { completionTables = sharedTablesData; } } if (expectsTableName && currentDatabase) { completionTables = completionTables.filter((table) => isCurrentCompletionDatabase(table.dbName || '')); } const referencedColumns: CompletionColumnMeta[] = []; if (!expectsTableName) { 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()}`; if (!tableInfo.dbName || !tableInfo.tableName || seenReferencedTables.has(key)) continue; seenReferencedTables.add(key); const preloaded = findPreloadedColumns(tableInfo.dbName, tableInfo.tableName); if (preloaded.length > 0) continue; const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } referencedColumns.push(...cols); } } // 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等) // 权重最高,输入 WHERE 条件时优先显示 // 先用索引把候选收敛到被引用表的列,避免整库列全量扫描。 const relevantColumnCandidates = expectsTableName || foundTables.size === 0 ? [] : [ ...collectSharedColumnsForTableIdents(sharedAllColumnsData, foundTables), ...referencedColumns, ]; const relevantColumns = relevantColumnCandidates .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); // 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users) const parsed = splitSchemaAndTable(c.tableName || ''); const pureIdent = (parsed.table || '').toLowerCase(); return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 const isCurrentDb = isCurrentCompletionDatabase(c.dbName || ''); return { label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: quoteCompletionPart(c.name), detail: buildColumnCompletionDetail(c), documentation: buildColumnCompletionDocumentation(c), range, sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name, }; }); // 表提示:当前库智能处理 schema.table 格式 // 1. 构建纯表名到 schema 列表的映射,检测同名表 const currentDbTables = completionTables.filter(t => isCurrentCompletionDatabase(t.dbName || '') ); const tableNameToSchemas = new Map(); for (const t of currentDbTables) { const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = (parsed.table || t.tableName || '').toLowerCase(); const schemas = tableNameToSchemas.get(pureTable) || []; schemas.push(parsed.schema || ''); tableNameToSchemas.set(pureTable, schemas); } const tableSuggestions = completionTables .filter(t => { const isCurrentDb = isCurrentCompletionDatabase(t.dbName || ''); const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; if (!isCurrentDb) { const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || ''); const label = suggestionMeta.dbQualifiedLabel; // 跨库:用 db.table 格式匹配 return includesWordPrefix(label) || includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable); } // 当前库:同时用完整名和纯表名匹配 return includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable); }) .map(t => { const isCurrentDb = isCurrentCompletionDatabase(t.dbName || ''); const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; if (!isCurrentDb) { const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || ''); const label = suggestionMeta.dbQualifiedLabel; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText: quoteCompletionPath(label), detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, sortText: sortGroups.tableOther + getPrefixMatchRank(label, t.tableName || '', pureTable) + label, }; } // 当前库:检查是否有跨 schema 同名表 const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || []; const hasDuplicate = schemas.length > 1; // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 const label = hasDuplicate ? t.tableName : pureTable; const insertText = quoteCompletionPath(hasDuplicate ? t.tableName : pureTable); const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, detail: appendCommentToDetail(`${translate('query_editor.object_info.table')}${schemaInfo}`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, sortText: sortGroups.tableCurrent + getPrefixMatchRank(t.tableName || '', pureTable) + pureTable, }; }); const routineSuggestions = sharedRoutinesData .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) .filter(({ routine, meta }) => { if (expectsRoutineName && meta.routineType !== 'PROCEDURE') return false; return includesWordPrefix(meta.dbQualifiedLabel) || includesWordPrefix(meta.displayName) || includesWordPrefix(meta.objectName) || includesWordPrefix(routine.routineName || ''); }) .map(({ routine, meta }) => { const isCurrentDb = isCurrentCompletionDatabase(routine.dbName || ''); const schemaInfo = meta.schemaName && meta.schemaName.toLowerCase() !== String(routine.dbName || '').toLowerCase() ? `.${meta.schemaName}` : ''; return { label: meta.dbQualifiedLabel, kind: monaco.languages.CompletionItemKind.Function, insertText: meta.insertText, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName}${schemaInfo})`, range, sortText: (isCurrentDb ? sortGroups.routineCurrent : sortGroups.routineOther) + getPrefixMatchRank(meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName || '') + meta.dbQualifiedLabel, }; }); // 数据库提示 const dbSuggestions = sharedVisibleDbs .filter((db) => startsWithPrefix(db)) .map(db => ({ label: db, kind: monaco.languages.CompletionItemKind.Module, insertText: db, detail: translate('query_editor.object_info.database'), range, sortText: sortGroups.db + db, })); // 关键字提示 const keywordSuggestions = dialectKeywords .filter((k) => startsWithPrefix(k)) .map(k => ({ label: k, kind: monaco.languages.CompletionItemKind.Keyword, insertText: k, range, sortText: sortGroups.keyword + k, })); // 内置函数提示 const funcSuggestions = dialectFunctions .filter((f) => startsWithPrefix(f.name)) .map(f => ({ label: f.name, kind: monaco.languages.CompletionItemKind.Function, insertText: f.name + '($0)', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: f.detail, range, sortText: sortGroups.func + f.name, })); const suggestions = isNewStatementKeywordContext ? [ ...keywordSuggestions, ...funcSuggestions, ...tableSuggestions, ...dbSuggestions, ...routineSuggestions, ...relevantColumns, ] : [ ...relevantColumns, // FROM 表的列最优先 ...tableSuggestions, // 表次之 ...dbSuggestions, // 数据库 ...routineSuggestions, // 存储过程/函数 ...funcSuggestions, // 内置函数 ...keywordSuggestions // 关键字最后 ]; return { suggestions }; } })); sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['/'], provideCompletionItems: (model: any, position: any) => { const lineContent = model.getLineContent(position.lineNumber); const textBefore = lineContent.substring(0, position.column - 1).trimStart(); if (!textBefore.startsWith('/')) { return { suggestions: [] }; } const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: position.column - textBefore.length, endColumn: position.column, }; return { suggestions: ((window as any).__gonaviSlashCmdDefs || []).map((c: any, i: number) => ({ label: `${c.cmd} ${c.label}`, kind: monaco.languages.CompletionItemKind.Event, detail: c.desc, insertText: `__AI_${c.cmd.slice(1).toUpperCase()}__`, range, sortText: String(i).padStart(2, '0'), })), }; }, })); // SQL snippet completion provider sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { provideCompletionItems: (model: any, position: any) => { const word = model.getWordUntilPosition(position); const prefix = word.word.toLowerCase(); if (!prefix) return createEmptySqlCompletionResult(); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; const allSnippets = useStore.getState().sqlSnippets || []; const matched = allSnippets.filter(s => s.prefix.toLowerCase().startsWith(prefix) || s.name.toLowerCase().includes(prefix) ); return { suggestions: matched.map(s => ({ label: s.prefix, kind: monaco.languages.CompletionItemKind.Snippet, insertText: s.body, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: s.name, documentation: s.syntaxHelp || s.description || s.body, range, sortText: '04' + s.prefix, })), }; }, })); } // end sqlCompletionRegistered guard // 每个编辑器实例都注册内容变化监听(检测斜杠命令标记) let _handlingSlash = false; editor.onDidChangeModelContent((event: any) => { if (_handlingSlash) return; const hasSlashCommandMarker = Array.isArray(event?.changes) && event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || ''))); if (!hasSlashCommandMarker) return; const model = editor.getModel(); if (!model) return; const content = model.getValue(); const markerMatch = content.match(/__AI_(\w+)__/); if (!markerMatch) return; const cmdKey = markerMatch[1].toLowerCase(); const defs = (window as any).__gonaviSlashCmdDefs || []; const cmdDef = defs.find((c: any) => c.cmd === `/${cmdKey}`); if (!cmdDef) return; // 清除标记文本(带递归保护) _handlingSlash = true; const fullText = model.getValue(); const newText = fullText.replace(markerMatch[0], '').replace(/^\s*\n/, ''); model.setValue(newText); _handlingSlash = false; // 组装 prompt const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); const ctxText = buildQueryEditorAiContextPrompt(conn, currentDbRef.current); let finalPrompt = ctxText + cmdDef.prompt; if (cmdDef.useSelection) { const sel = editor.getSelection(); const selText = sel ? model.getValueInRange(sel) : ''; finalPrompt = finalPrompt.replace(QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER, selText || getCurrentQuery()); } // 打开 AI 面板并注入 prompt const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: finalPrompt } })); }, store.aiPanelVisible ? 0 : 350); }); }; const handleFormat = () => { try { const activeConnectionId = String(currentConnectionIdRef.current || '').trim(); const tabConnectionId = String(tab.connectionId || '').trim(); const conn = connectionsRef.current.find(c => c.id === activeConnectionId) || (tabConnectionId && tabConnectionId !== activeConnectionId ? connectionsRef.current.find(c => c.id === tabConnectionId) : undefined); const formatterLanguage = resolveQueryEditorFormatterLanguage(conn); const sourceSql = getCurrentQuery(); const formatted = format(sourceSql, { language: formatterLanguage, keywordCase: sqlFormatOptions.keywordCase }); if (sourceSql === formatted) { return; } updateQueryTabDraft(tab.id, { formatRestoreSnapshot: { query: sourceSql, createdAt: Date.now(), }, }); const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); if (editor && monaco && model) { const currentValue = String(model.getValue?.() || sourceSql); if (currentValue === formatted) { return; } const fullRange = model.getFullModelRange?.() || new monaco.Range(1, 1, model.getLineCount?.() || 1, model.getLineMaxColumn?.(model.getLineCount?.() || 1) || 1); editor.pushUndoStop?.(); editor.executeEdits?.('gonavi-format-sql', [{ range: fullRange, text: formatted, forceMoveMarkers: true, }]); editor.pushUndoStop?.(); const nextValue = editor.getValue?.(); applyQueryState(typeof nextValue === 'string' ? nextValue : formatted); refreshObjectDecorations(); return; } syncQueryToEditor(formatted); } catch (e) { void message.error(translate('query_editor.message.format_failed')); } }; const handleFormatRef = useRef(handleFormat); useEffect(() => { handleFormatRef.current = handleFormat; }); useEffect(() => { const handleFormatActiveQuery = () => { if (!isActive) { return; } handleFormatRef.current(); }; window.addEventListener('gonavi:format-active-query', handleFormatActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:format-active-query', handleFormatActiveQuery as EventListener); }; }, [isActive]); const handleRestoreLastFormat = () => { const previousQuery = tab.formatRestoreSnapshot?.query; if (!previousQuery) { void message.info(translate('query_editor.message.no_format_restore_snapshot')); return; } syncQueryToEditor(previousQuery); updateQueryTabDraft(tab.id, { query: previousQuery, formatRestoreSnapshot: undefined, }); refreshObjectDecorations(); void message.success(translate('query_editor.message.format_restore_success')); }; const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => { if (action === 'generate') { openTextToSqlModal(); return; } const editor = editorRef.current; const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || ''; const fullSQL = getCurrentQuery(); const conn = connections.find(c => c.id === currentConnectionId); const ctxText = buildQueryEditorAiContextPrompt(conn, currentDb); const prompts: Record = { generate: `${ctxText}${translate('query_editor.ai_prompt.generate')}`, explain: `${ctxText}${translate('query_editor.ai_prompt.explain', { sql: selection || fullSQL || QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER })}`, optimize: `${ctxText}${translate('query_editor.ai_prompt.optimize', { sql: selection || fullSQL || QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER })}`, schema: `${ctxText}${translate('query_editor.ai_prompt.schema')}`, }; const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } })); }; const formatSettingsMenu: MenuProps['items'] = [ { key: 'upper', label: translate('query_editor.format.keyword_upper'), icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'upper' }) }, { key: 'lower', label: translate('query_editor.format.keyword_lower'), icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }) }, { type: 'divider' }, { key: 'restore-last-format', label: translate('query_editor.format.restore_last_format'), disabled: !tab.formatRestoreSnapshot?.query, onClick: handleRestoreLastFormat, }, { type: 'divider' }, { key: 'snippet-settings', label: translate('query_editor.format.snippet_settings'), onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')), }, { key: 'shortcut-settings', label: translate('query_editor.format.shortcut_settings'), onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')), }, ]; const splitSQLStatements = (sql: string): string[] => { return findSqlStatementRanges(sql).map((range) => range.text); }; const containsOraclePlsqlDefinition = (statements: string[]): boolean => ( statements.some((statement) => /^\s*(?:(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)*CREATE\s+(?:OR\s+REPLACE\s+)?(?:EDITIONABLE\s+|NONEDITIONABLE\s+)?(?:PROCEDURE|FUNCTION|PACKAGE|TRIGGER)\b/i.test(statement)) ); const normalizeOracleSqlPlusSlashTerminators = (sql: string): string => ( String(sql || '').replace(/(^|\n)([ \t]*\/[ \t]*);+([ \t]*(?:--[^\n]*)?)(?=\n|$)/g, '$1$2$3') ); const getSelectedSQL = (): string => { const editor = editorRef.current; if (!editor) return ''; const model = editor.getModel?.(); const selection = editor.getSelection?.(); if (!model || !selection) return ''; const selected = model.getValueInRange?.(selection) || ''; if (typeof selected !== 'string') return ''; if (!selected.trim()) return ''; return selected; }; const buildResultSetMergeKey = (result: ResultSet): string => { const sqlKey = normalizeExecutedSqlKey(result.exportSql || result.sql); const sourceStatementIndex = Number(result.sourceStatementIndex || 1); const statementResultIndex = Number(result.statementResultIndex || 1); return `${sqlKey}::${sourceStatementIndex}::${statementResultIndex}`; }; const mergeResultSets = (previous: ResultSet[], next: ResultSet[], replaceAll: boolean): ResultSet[] => { if (replaceAll || previous.length === 0) { return next.map((result, index) => ({ ...result, key: `result-${index + 1}` })); } const merged = [...previous]; next.forEach((result) => { const incomingKey = buildResultSetMergeKey(result); const existingIndex = merged.findIndex((item) => buildResultSetMergeKey(item) === incomingKey); if (existingIndex >= 0) { merged[existingIndex] = { ...result, key: merged[existingIndex].key }; return; } merged.push({ ...result, key: `result-${resolveNextResultSetIndex(merged)}` }); }); return merged; }; const isDisplayableResultSet = (result?: ResultSet | null): boolean => { if (!result) { return false; } if (Array.isArray(result.messages) && result.messages.length > 0) { return true; } if (Array.isArray(result.columns) && result.columns.length > 0) { return true; } if (Array.isArray(result.rows) && result.rows.length > 0) { return true; } return false; }; const isAffectedRowsResultSet = (result?: ResultSet | null): boolean => Boolean( result && Array.isArray(result.columns) && result.columns.length === 1 && result.columns[0] === 'affectedRows', ); const isAffectedRowsResultSetData = (result?: any): boolean => Boolean( result && Array.isArray(result.rows) && result.rows.length === 1 && Array.isArray(result.columns) && result.columns.length === 1 && result.columns[0] === 'affectedRows', ); const hasConcreteQueryResultSetData = (result: any, messages: string[]): boolean => { if (!result || isAffectedRowsResultSetData(result)) return false; if (messages.length > 0) return true; if (Array.isArray(result.columns) && result.columns.length > 0) return true; if (Array.isArray(result.rows) && result.rows.length > 0) return true; return false; }; const isMessageLikeResultSet = (result?: ResultSet | null): boolean => Boolean( result && Array.isArray(result.messages) && result.messages.length > 0 && result.resultType !== 'grid', ); const isConcreteGridResultSet = (result?: ResultSet | null): boolean => Boolean( result && result.resultType !== 'message' && !isAffectedRowsResultSet(result) && ( (Array.isArray(result.columns) && result.columns.length > 0) || (Array.isArray(result.rows) && result.rows.length > 0) ), ); const resolveActiveResultKeyAfterMerge = (merged: ResultSet[], executed: ResultSet[]): string => { const firstExecutedResult = executed.find((result) => isConcreteGridResultSet(result)) || executed.find((result) => isMessageLikeResultSet(result)) || executed.find((result) => isDisplayableResultSet(result) && !isAffectedRowsResultSet(result)) || executed.find((result) => isDisplayableResultSet(result)) || executed[0]; if (!firstExecutedResult) { return ''; } const executedSqlKey = buildResultSetMergeKey(firstExecutedResult); return merged.find((item) => buildResultSetMergeKey(item) === executedSqlKey)?.key || firstExecutedResult.key || merged[0]?.key || ''; }; const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any): string => { const normalizedPosition = normalizeEditorPosition(position); if (!normalizedPosition) return ''; const cursorOffset = getNormalizedOffsetAtPosition(sqlText, normalizedPosition); const resolved = resolveExecutableSql(sqlText, cursorOffset, ''); return resolved?.sql || ''; }; const getExecutableSQLAtCurrentCursor = (model: any, sqlText: string): string => { const editor = editorRef.current; const liveSelection = normalizeEditorPosition(editor?.getSelection?.()); if (liveSelection) { return resolveExecutableSQLAtEditorPosition(model, sqlText, liveSelection); } const livePosition = normalizeEditorPosition(editor?.getPosition?.()); const cachedPosition = normalizeEditorPosition(lastEditorCursorPositionRef.current); const candidates: Array<{ lineNumber: number; column: number }> = []; if (cachedPosition) candidates.push(cachedPosition); if (livePosition) candidates.push(livePosition); const seen = new Set(); for (const position of candidates) { const key = `${position.lineNumber}:${position.column}`; if (seen.has(key)) continue; seen.add(key); const sql = resolveExecutableSQLAtEditorPosition(model, sqlText, position); if (sql.trim()) return sql; } const fallbackPosition = cachedPosition || livePosition; return resolveExecutableSQLAtEditorPosition(model, sqlText, fallbackPosition); }; const getExecutableSQL = (): string => { const editor = editorRef.current; const model = editor?.getModel?.(); const currentQuery = getCurrentQuery(); const selectedSQL = getSelectedSQL(); const selected = selectedSQL.trim(); if (!selected && resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current)) { const appendedSQL = currentQuery.slice(lastExecutedEditorQueryRef.current.length); if (appendedSQL.trim()) { return appendedSQL; } } if (!model || !editor) { return selectedSQL || currentQuery; } if (selected) { return selectedSQL; } return getExecutableSQLAtCurrentCursor(model, String(model.getValue?.() ?? currentQuery)); }; const captureEditorCursorPosition = (event?: React.MouseEvent) => { event?.preventDefault(); const editor = editorRef.current; const position = normalizeEditorPosition(editor?.getSelection?.()) || normalizeEditorPosition(editor?.getPosition?.()); if (position) { lastEditorCursorPositionRef.current = position; } }; const executeSqlEditorMultiQuery = useCallback(( config: Record, dbName: string, sql: string, queryId: string, sourceStatements: string[], ) => { const pendingTransaction = pendingSqlTransactionRef.current; if (pendingTransaction && canReusePendingSqlEditorTransactionForType(String(config.type || ''), sourceStatements)) { return DBQueryMultiInTransaction(pendingTransaction.id, sql, queryId); } return DBQueryMulti(buildRpcConnectionConfig(config) as any, dbName, sql, queryId); }, []); // 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL const handleReloadResult = async (resultKey: string, sql: string) => { if (!sql?.trim() || !currentDb) return; const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const currentResult = resultSets.find((item) => item.key === resultKey); const statementResultIndex = Math.max(1, Number(currentResult?.statementResultIndex || 1)); const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; try { setLoading(true); // 保持与首次执行一致的后端路径,必要时复用挂起事务 let queryId: string; try { queryId = await GenerateQueryID(); } catch { queryId = 'reload-' + Date.now(); } const res = await executeSqlEditorMultiQuery( config, currentDb, sql, queryId, splitSQLStatements(sql), ); if (!res?.success) { message.error(translate('query_editor.message.refresh_failed', { error: formatSqlExecutionError(res?.message || translate('common.unknown'), { translate }), })); return; } const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; const rsData = resultSetDataArray[Math.max(0, statementResultIndex - 1)]; if (!rsData) return; const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 && rsData.columns && rsData.columns.length === 1 && rsData.columns[0] === 'affectedRows'; if (isAffectedResult) return; // 不应该出现,但保险起见 let rows = Array.isArray(rsData.rows) ? rsData.rows : []; const maxRows = Number(queryOptions?.maxRows) || 0; let truncated = false; if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; rows = rows.slice(0, maxRows); } const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : []); const refreshedMessages = normalizeQueryResultMessages(rsData?.messages); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); // 只更新匹配的结果集的 rows 和 columns,保留 tableName/pkColumns/readOnly 等元数据 setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, rows, columns: cols, messages: refreshedMessages, resultType: ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && refreshedMessages.length > 0) ? 'message' : 'grid', truncated, } : rs )); } catch (err: any) { message.error(translate('query_editor.message.refresh_failed', { error: formatSqlExecutionError(err?.message || err || translate('common.unknown'), { translate }), })); } finally { setLoading(false); } }; const handleResultPageChange = async (resultKey: string, page: number, pageSize: number) => { const target = resultSets.find((item) => item.key === resultKey); if (!target?.page?.baseSql || !currentDb) return; const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const safePage = Math.max(1, Math.floor(Number(page) || 1)); const safePageSize = Math.max(1, Math.floor(Number(pageSize) || target.page.pageSize || 1)); const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const dbType = String(config.type || 'mysql'); const driver = String((config as any).driver || ''); const normalizedDbType = String(resolveSqlDialect(dbType, driver, { oceanBaseProtocol: String((config as any).oceanBaseProtocol || ''), })).toLowerCase(); const pageSql = buildQueryResultPageSql({ baseSql: target.page.baseSql, dbType: normalizedDbType, driver, page: safePage, pageSize: safePageSize, lookahead: true, }); try { setLoading(true); setResultSets(prev => prev.map(rs => rs.key === resultKey && rs.page ? { ...rs, page: { ...rs.page, loading: true } } : rs )); let queryId: string; try { queryId = await GenerateQueryID(); } catch { queryId = 'query-page-' + Date.now(); } const res = await executeSqlEditorMultiQuery( config, currentDb, pageSql, queryId, splitSQLStatements(pageSql), ); if (!res?.success) { message.error(translate('query_editor.message.page_query_failed', { error: formatSqlExecutionError(res?.message || translate('common.unknown'), { translate }), })); return; } const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; const rsData = resultSetDataArray[0]; if (!rsData) { message.warning(translate('query_editor.message.page_query_empty')); return; } const rawRows = Array.isArray(rsData.rows) ? rsData.rows : []; const hasNext = rawRows.length > safePageSize; const rows = rawRows.slice(0, safePageSize); const rowKeyOffset = (safePage - 1) * safePageSize; rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = rowKeyOffset + i; }); const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : target.columns); const pageMessages = normalizeQueryResultMessages(rsData?.messages); const totalState = resolveQueryResultPaginationTotal({ current: safePage, pageSize: safePageSize, rowCount: rows.length, hasNext, }); setResultSets(prev => prev.map(rs => rs.key === resultKey && rs.page ? { ...rs, rows, columns: cols, messages: pageMessages, resultType: 'grid', truncated: false, page: { ...rs.page, current: safePage, pageSize: safePageSize, ...totalState, loading: false, }, } : rs )); } catch (err: any) { message.error(translate('query_editor.message.page_query_failed', { error: formatSqlExecutionError(err?.message || err || translate('common.unknown'), { translate }), })); } finally { setLoading(false); setResultSets(prev => prev.map(rs => rs.key === resultKey && rs.page?.loading ? { ...rs, page: { ...rs.page, loading: false } } : rs )); } }; const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; const executableSQL = getExecutableSQL(); if (!executableSQL.trim()) { message.info(translate('query_editor.message.no_executable_sql')); setResultSets([]); setActiveResultKey(''); return; } if (!currentDb) { message.error(translate('query_editor.message.select_database_first')); return; } // 如果已有查询在运行,先取消它 if (currentQueryIdRef.current) { try { await CancelQuery(currentQueryIdRef.current); } catch (error) { // 忽略取消错误,可能查询已完成 } // 清除旧查询ID clearQueryId(); } const runSeq = ++runSeqRef.current; setLoading(true); setExecutionError(''); const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error(translate('query_editor.message.connection_not_found')); if (runSeqRef.current === runSeq) setLoading(false); return; } const connCaps = getDataSourceCapabilities(conn.config); if (!connCaps.supportsQueryEditor) { message.error(translate('query_editor.message.unsupported_source')); if (runSeqRef.current === runSeq) setLoading(false); return; } if (findConnectionMutatingStatements(conn.config, executableSQL).length > 0) { message.warning(translate('query_editor.message.connection_readonly_blocked')); if (runSeqRef.current === runSeq) setLoading(false); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, timeout: Math.max(Number(conn.config.timeout) || 30, 120), }; try { const rawSQL = executableSQL; const rpcConfig = buildRpcConnectionConfig(config) as any; const dbType = String(rpcConfig.type || 'mysql'); const driver = String((config as any).driver || ''); const normalizedDbType = String(resolveSqlDialect(dbType, driver, { oceanBaseProtocol: (config as any).oceanBaseProtocol, })).trim().toLowerCase(); const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); // MongoDB 仍走逐条执行的旧路径 const isMongoDB = normalizedDbType === 'mongodb'; if (isMongoDB) { // MongoDB: 保持逐条执行 const splitInput = normalizedRawSQL .replace(/^\s*\/\/.*$/gm, '') .replace(/^\s*#.*$/gm, ''); const statements = splitSQLStatements(splitInput); const didExecuteAppendedSql = resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current) && normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(/;/g, ';').trim(); const didExecuteWholeEditor = areSqlStatementListsEqual( splitSQLStatements(currentQuery.replace(/;/g, ';')), statements, ); if (statements.length === 0) { message.info(translate('query_editor.message.no_executable_sql')); setResultSets([]); setActiveResultKey(''); return; } const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; let anyTruncated = false; for (let idx = 0; idx < statements.length; idx++) { const rawStatement = statements[idx]; let executedSql = rawStatement; const shellConvert = convertMongoShellToJsonCommand(executedSql); if (shellConvert.recognized) { if (shellConvert.error) { const prefix = statements.length > 1 ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 }) : ''; updateResultPanelVisibility(true); setExecutionError(formatSqlExecutionError(shellConvert.error, { prefix, translate })); setResultSets([]); setActiveResultKey(''); return; } if (shellConvert.command) { executedSql = shellConvert.command; } } if (wantsLimitProbe) { const limitResult = applyMongoQueryAutoLimit(executedSql, maxRows); if (limitResult.applied) { executedSql = limitResult.command; } } const startTime = Date.now(); let queryId: string; try { queryId = await GenerateQueryID(); } catch (error) { console.warn('GenerateQueryID failed, using local UUID fallback:', error); queryId = 'query-' + uuidv4(); } setQueryId(queryId); const res = await DBQueryWithCancel(buildRpcConnectionConfig(config) as any, currentDb, executedSql, queryId); const legacyResultMessages = normalizeQueryResultMessages(res?.messages); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-${idx + 1}`, timestamp: Date.now(), sql: executedSql, status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), dbName: currentDb }); if (!res.success) { const prefix = statements.length > 1 ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 }) : ''; updateResultPanelVisibility(true); setExecutionError(formatSqlExecutionError(res.message, { prefix, translate })); setResultSets([]); setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); return; } if (Array.isArray(res.data)) { let rows = (res.data as any[]) || []; let truncated = false; if (wantsLimitProbe && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); } const cols = (res.fields && res.fields.length > 0) ? (res.fields as string[]) : (rows.length > 0 ? Object.keys(rows[0]) : []); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, sourceStatementIndex: idx + 1, statementResultIndex: 1, rows, columns: cols, messages: legacyResultMessages, pkColumns: [], readOnly: true, truncated }); } else if (legacyResultMessages.length > 0) { nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, sourceStatementIndex: idx + 1, statementResultIndex: 1, rows: [], columns: [], messages: legacyResultMessages, resultType: 'message', pkColumns: [], readOnly: true, }); } else { const affected = Number((res.data as any)?.affectedRows); if (Number.isFinite(affected)) { const row = { affectedRows: affected }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, sourceStatementIndex: idx + 1, statementResultIndex: 1, rows: [row], columns: ['affectedRows'], messages: legacyResultMessages, pkColumns: [], readOnly: true }); } } } if (nextResultSets.length > 0) { updateResultPanelVisibility(true); } const shouldReplaceAllResults = didExecuteWholeEditor; const mergedResultSets = mergeResultSets(resultSets, nextResultSets, shouldReplaceAllResults); setResultSets(mergedResultSets); setActiveResultKey(resolveActiveResultKeyAfterMerge(mergedResultSets, nextResultSets)); if (didExecuteAppendedSql || didExecuteWholeEditor) { lastExecutedEditorQueryRef.current = currentQuery; } if (statements.length > 1) { message.success(translate('query_editor.message.execution_multi_success', { statements: statements.length, results: nextResultSets.length, })); } else if (nextResultSets.length === 0) { message.success(translate('query_editor.message.execution_success')); } } else { // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 const sourceStatements = splitSQLStatements(normalizedRawSQL); const didExecuteAppendedSql = resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current) && normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(/;/g, ';').trim(); const didExecuteWholeEditor = areSqlStatementListsEqual( splitSQLStatements(currentQuery.replace(/;/g, ';')), sourceStatements, ); if (sourceStatements.length === 0) { message.info(translate('query_editor.message.no_executable_sql')); setResultSets([]); setActiveResultKey(''); return; } const useManagedTransaction = shouldUseSqlEditorManagedTransactionForType(connCaps.type, sourceStatements); if (useManagedTransaction && pendingSqlTransactionRef.current) { message.warning(translate('query_editor.transaction.message.pending_managed_transaction')); return; } const managedTransactionStatementCount = sourceStatements .filter((statement) => shouldUseSqlEditorManagedTransactionForType(connCaps.type, [statement])) .length || sourceStatements.length; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; const defaultOracleSchema = isOracleLikeDialect(normalizedDbType) ? resolveOracleLikeDefaultSchemaName(config) : ''; const oracleTableCache = new Map(); const getOracleTablesForDb = async (dbName: string): Promise => { const normalizedDbName = String(dbName || '').trim(); if (!normalizedDbName) return []; const cacheKey = normalizedDbName.toLowerCase(); const cached = oracleTableCache.get(cacheKey); if (cached) return cached; const existing = tablesRef.current.filter((table) => String(table.dbName || '').trim().toLowerCase() === cacheKey); if (existing.length > 0) { oracleTableCache.set(cacheKey, existing); return existing; } try { const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, normalizedDbName); if (!resTables?.success || !Array.isArray(resTables.data)) { oracleTableCache.set(cacheKey, []); return []; } const fetchedTables = resTables.data .map((row: any) => { const tableName = String(Object.values(row || {})[0] || '').trim(); if (!tableName) return null; return { dbName: normalizedDbName, tableName, } as CompletionTableMeta; }) .filter(Boolean) as CompletionTableMeta[]; if (fetchedTables.length > 0) { const knownKeys = new Set(tablesRef.current.map((table) => `${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim()}`)); const missing = fetchedTables.filter((table) => !knownKeys.has(`${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim()}`)); if (missing.length > 0) { tablesRef.current = [...tablesRef.current, ...missing]; if (isActive) { sharedTablesData = tablesRef.current; } } } oracleTableCache.set(cacheKey, fetchedTables); return fetchedTables; } catch { oracleTableCache.set(cacheKey, []); return []; } }; const executedSourceStatements: string[] = []; for (const statement of sourceStatements) { let executableStatement = statement; if (isOracleLikeDialect(normalizedDbType)) { const leadingTable = matchLeadingSelectTableReference(statement); if (leadingTable) { const leadingSegments = splitQueryIdentifierPathSegments(leadingTable.tableText); const oracleLookupDbCandidates = leadingSegments.length >= 2 ? [String(leadingSegments[0]?.value || '').trim()].filter(Boolean) : resolveOracleLikeLookupSchemaCandidates(config, currentDb); let exactQualifiedTable: string | undefined; for (const oracleLookupDbName of oracleLookupDbCandidates) { const oracleTables = oracleLookupDbName ? await getOracleTablesForDb(oracleLookupDbName) : []; exactQualifiedTable = resolveOracleExactCaseTableReference(statement, oracleLookupDbName, oracleTables, { qualifyUnqualified: Boolean( leadingSegments.length === 1 && oracleLookupDbName && oracleLookupDbName.toLowerCase() !== String(defaultOracleSchema || '').trim().toLowerCase(), ), }); if (exactQualifiedTable) { break; } } if (exactQualifiedTable) { executableStatement = rewriteLeadingSelectTableReference(statement, exactQualifiedTable) || statement; } } } executedSourceStatements.push(executableStatement); } const statementPlans: QueryStatementPlan[] = []; for (let index = 0; index < sourceStatements.length; index += 1) { const statementForPlan = executedSourceStatements[index] || sourceStatements[index]; try { statementPlans.push(await resolveQueryLocatorPlan({ statement: statementForPlan, originalStatement: sourceStatements[index], dbType: normalizedDbType, currentDb, config, forceReadOnly: forceReadOnlyResult, })); } catch (planError) { // 行定位计划失败绝不能阻断查询执行,兜底裸计划保证结果页始终呈现。 console.warn('resolveQueryLocatorPlan failed; falling back to a bare statement plan', planError); statementPlans.push({ originalSql: sourceStatements[index], executedSql: statementForPlan, pkColumns: [], }); } } // 自动给 SELECT 语句注入行数限制(防止大结果集卡死) const maxRowsForLimit = Number(queryOptions?.maxRows) || 0; let anyLimitApplied = false; const executablePlans = statementPlans.map((plan) => { if (!Number.isFinite(maxRowsForLimit) || maxRowsForLimit <= 0) return plan; const result = applyQueryAutoLimit(plan.executedSql, normalizedDbType, maxRowsForLimit, driver); if (result.applied) anyLimitApplied = true; return { ...plan, executedSql: result.sql }; }); const executableStatements = executablePlans.map((plan) => plan.executedSql); const shouldPreserveOraclePlsqlBatch = isOracleLikeDialect(normalizedDbType) && containsOraclePlsqlDefinition(sourceStatements); const fullSQL = shouldPreserveOraclePlsqlBatch ? normalizeOracleSqlPlusSlashTerminators(normalizedRawSQL) : executableStatements.join(';\n'); const startTime = Date.now(); let queryId: string; try { queryId = await GenerateQueryID(); } catch (error) { console.warn('GenerateQueryID failed, using local UUID fallback:', error); queryId = 'query-' + uuidv4(); } setQueryId(queryId); const res = useManagedTransaction ? await DBQueryMultiTransactional(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId) : await executeSqlEditorMultiQuery( config, currentDb, fullSQL, queryId, executableStatements, ); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-multi`, timestamp: Date.now(), sql: sourceStatements.join(';\n'), status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, dbName: currentDb }); if (!res.success) { const errorMsg = res.message.toLowerCase(); const isCancelledError = errorMsg.includes('context canceled') || errorMsg.includes('查询已取消') || errorMsg.includes('canceled') || errorMsg.includes('cancelled') || errorMsg.includes('statement canceled') || errorMsg.includes('sql: statement canceled'); const isTimeoutError = errorMsg.includes('context deadline exceeded') || errorMsg.includes('timeout') || hasLocalizedSqlTimeoutKeyword(errorMsg) || errorMsg.includes('deadline exceeded'); if (isCancelledError && !isTimeoutError) { setResultSets([]); setActiveResultKey(''); if (currentQueryIdRef.current) { clearQueryId(); } return; } updateResultPanelVisibility(true); setExecutionError(formatSqlExecutionError(res.message, { translate })); setResultSets([]); setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); return; } if (useManagedTransaction && res.transactionPending && res.transactionId) { activatePendingSqlTransaction({ id: String(res.transactionId), commitMode: sqlEditorCommitMode, autoCommitDelayMs: sqlEditorAutoCommitDelayMs, createdAt: Date.now(), statementCount: managedTransactionStatementCount, }); } // res.data 是 ResultSetData[] 数组 const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; const topLevelMessages = normalizeQueryResultMessages(res.messages); const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; let anyTruncated = false; const statementResultCounts = new Map(); const resolveSourceStatementIndex = (rsData: any, idx: number): number => { const explicitStatementIndex = Number(rsData?.statementIndex || 0); if (explicitStatementIndex > 0) { return explicitStatementIndex; } if (normalizedDbType === 'sqlserver' && sourceStatements.length === 1) { return 1; } return idx + 1; }; const sqlServerStatementsWithConcreteResults = new Set(); if (normalizedDbType === 'sqlserver') { resultSetDataArray.forEach((rsData, idx) => { const sourceStatementIndex = resolveSourceStatementIndex(rsData, idx); const resultMessages = normalizeQueryResultMessages(rsData?.messages); if (hasConcreteQueryResultSetData(rsData, resultMessages)) { sqlServerStatementsWithConcreteResults.add(sourceStatementIndex); } }); } const shouldUseTopLevelSqlServerMessages = normalizedDbType === 'sqlserver' && topLevelMessages.length > 0 && sqlServerStatementsWithConcreteResults.size === 0; for (let idx = 0; idx < resultSetDataArray.length; idx++) { const rsData = resultSetDataArray[idx]; const sourceStatementIndex = resolveSourceStatementIndex(rsData, idx); const plan = executablePlans[Math.max(0, sourceStatementIndex - 1)]; const originalSql = plan?.originalSql || ''; const executedSql = plan?.executedSql || originalSql; const resultMessages = normalizeQueryResultMessages(rsData?.messages); // 检查是否为 affectedRows 类结果集 const isAffectedResult = isAffectedRowsResultSetData(rsData); const shouldHideSqlServerAffectedResult = normalizedDbType === 'sqlserver' && isAffectedResult && ( sqlServerStatementsWithConcreteResults.has(sourceStatementIndex) || shouldUseTopLevelSqlServerMessages ); if (shouldHideSqlServerAffectedResult) { continue; } const statementResultIndex = (statementResultCounts.get(sourceStatementIndex) || 0) + 1; statementResultCounts.set(sourceStatementIndex, statementResultIndex); if (isAffectedResult) { const affected = Number(rsData.rows[0]?.affectedRows); const row = { affectedRows: Number.isFinite(affected) ? affected : 0 }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${nextResultSets.length + 1}`, sql: executedSql, exportSql: originalSql, sourceStatementIndex, statementResultIndex, rows: [row], columns: ['affectedRows'], messages: resultMessages, pkColumns: [], readOnly: true }); } else if ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && resultMessages.length > 0) { nextResultSets.push({ key: `result-${nextResultSets.length + 1}`, sql: executedSql, exportSql: originalSql, sourceStatementIndex, statementResultIndex, rows: [], columns: [], messages: resultMessages, resultType: 'message', pkColumns: [], readOnly: true, }); } else { let rows = Array.isArray(rsData.rows) ? rsData.rows : []; let truncated = false; // 仅当前端自动注入了 LIMIT 时才做兜底截断;用户手写 LIMIT 时尊重原始结果 if (anyLimitApplied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); } const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : []); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); const tableRef = plan?.tableRef; const editLocator = plan?.editLocator; const page = createInitialQueryResultPagination({ executedSql, exportSql: originalSql, dbType: normalizedDbType, driver, returnedRowCount: rows.length, fallbackPageSize: maxRows, }); nextResultSets.push({ key: `result-${nextResultSets.length + 1}`, sql: executedSql, exportSql: originalSql, sourceStatementIndex, statementResultIndex, rows, columns: cols, messages: resultMessages, tableName: tableRef?.tableName, // 跨库/跨 schema 查询时,列类型与注释必须从真实表所在库加载 metadataDbName: tableRef?.metadataDbName, metadataTableName: tableRef?.metadataTableName, pkColumns: plan?.pkColumns || [], editLocator, readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly, truncated, page, }); } } if (topLevelMessages.length > 0 && !nextResultSets.some((result) => Array.isArray(result.messages) && result.messages.length > 0)) { nextResultSets.push({ key: `result-${nextResultSets.length + 1}`, sql: fullSQL, exportSql: sourceStatements.join(';\n'), sourceStatementIndex: 1, statementResultIndex: (statementResultCounts.get(1) || 0) + 1, rows: [], columns: [], messages: topLevelMessages, resultType: 'message', pkColumns: [], readOnly: true, }); } if (nextResultSets.length > 0) { updateResultPanelVisibility(true); } const shouldReplaceAllResults = didExecuteWholeEditor; const mergedResultSets = mergeResultSets(resultSets, nextResultSets, shouldReplaceAllResults); setResultSets(mergedResultSets); setActiveResultKey(resolveActiveResultKeyAfterMerge(mergedResultSets, nextResultSets)); if (didExecuteAppendedSql || didExecuteWholeEditor) { lastExecutedEditorQueryRef.current = currentQuery; } executablePlans.forEach((plan) => { if (plan.warning) message.warning(plan.warning); }); // 后端附带的提示信息(如本次改走逐条执行的多语句回退提示) if (res.message) { message.info(res.message); } if (resultSetDataArray.length > 1) { message.success(translate('query_editor.message.execution_result_sets_success', { results: nextResultSets.length, })); } else if (nextResultSets.length === 0) { message.success(translate('query_editor.message.execution_success')); } } } catch (e: any) { const formattedError = formatSqlExecutionError(e?.message || e, { translate }); message.error(translate('query_editor.message.execution_failed_with_error', { error: formattedError })); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), sql: executableSQL || getExecutableSQL() || getCurrentQuery(), status: 'error', duration: Date.now() - runStartTime, message: e.message, dbName: currentDb }); updateResultPanelVisibility(true); setExecutionError(formattedError); setResultSets([]); setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); } finally { if (runSeqRef.current === runSeq) setLoading(false); // Clear query ID after execution completes clearQueryId(); } }; const handleRunSelectedShortcut = async () => { await handleRun(); }; const handleCancel = async () => { if (!currentQueryIdRef.current) { message.warning(translate('query_editor.message.cancel_no_running')); return; } const queryIdToCancel = currentQueryIdRef.current; try { const res = await CancelQuery(queryIdToCancel); if (res.success) { message.success(translate('query_editor.message.cancel_success')); // Clear query ID after successful cancellation if (currentQueryIdRef.current === queryIdToCancel) { clearQueryId() } } else { message.warning(res.message); } } catch (error: any) { message.error(translate('query_editor.message.cancel_failed', { error: error.message })); } }; useEffect(() => { const handleSelectAllInEditor = (event: KeyboardEvent) => { if (!isActive) { return; } if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { return; } const editor = editorRef.current; if (!editor) { return; } const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (isEditableElement(event.target) && !inEditorPane) { return; } if (!editorHasFocus && !inEditorPane) { return; } if (!editorHasFocus && !inQueryEditor) { return; } event.preventDefault(); event.stopPropagation(); editor.focus?.(); editor.trigger('keyboard', 'editor.action.selectAll', null); }; window.addEventListener('keydown', handleSelectAllInEditor, true); return () => { window.removeEventListener('keydown', handleSelectAllInEditor, true); }; }, [isActive]); useEffect(() => { const binding = runQueryShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleRunShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editorHasFocus = !!editorRef.current?.hasTextFocus?.(); if (!editorHasFocus && !isEditableElement(event.target)) { return; } event.preventDefault(); event.stopPropagation(); void handleRunSelectedShortcut(); }; window.addEventListener('keydown', handleRunShortcut, true); return () => { window.removeEventListener('keydown', handleRunShortcut, true); }; }, [isActive, runQueryShortcutBinding, handleRun]); // Re-register Monaco internal keybinding when runQuery shortcut changes useEffect(() => { if (objectHoverActionRef.current) { objectHoverActionRef.current.dispose(); objectHoverActionRef.current = null; } if (!editorRef.current || !monacoRef.current) return; registerShowObjectInfoAction(); return () => { if (objectHoverActionRef.current) { objectHoverActionRef.current.dispose(); objectHoverActionRef.current = null; } }; }, [languagePreference, registerShowObjectInfoAction]); useEffect(() => { const editor = editorRef.current; if (!editor) return; registerInsertSqlSnippetContextMenuAction(editor); return () => { if (insertSqlSnippetActionRef.current) { insertSqlSnippetActionRef.current.dispose(); insertSqlSnippetActionRef.current = null; } }; }, [languagePreference, registerInsertSqlSnippetContextMenuAction]); useEffect(() => { const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; registerTriggerSqlAiCompletionAction(editor, monaco); return () => { if (triggerSqlAiCompletionActionRef.current) { triggerSqlAiCompletionActionRef.current.dispose(); triggerSqlAiCompletionActionRef.current = null; } }; }, [languagePreference, registerTriggerSqlAiCompletionAction]); useEffect(() => { triggerSqlAiCompletionKeydownDisposableRef.current?.dispose?.(); triggerSqlAiCompletionKeydownDisposableRef.current = null; const editor = editorRef.current; const binding = triggerSqlAiCompletionShortcutBinding; if (!editor?.onKeyDown || !binding?.enabled || !binding.combo) { return; } triggerSqlAiCompletionKeydownDisposableRef.current = editor.onKeyDown((event: any) => { if (!isActive) { return; } const browserEvent = event?.browserEvent || event?.event || event; if (!browserEvent) { return; } if (!isTriggerSqlAiCompletionShortcutEvent(browserEvent)) { if (isPossibleTriggerSqlAiCompletionFallbackEvent(browserEvent)) { triggerSqlAiCompletionFallbackRef.current = { observedAt: Date.now() }; } return; } triggerSqlAiCompletionFallbackRef.current = null; event?.preventDefault?.(); event?.stopPropagation?.(); browserEvent.preventDefault?.(); browserEvent.stopPropagation?.(); triggerAiInlineCompletionRef.current?.(); }); return () => { triggerSqlAiCompletionKeydownDisposableRef.current?.dispose?.(); triggerSqlAiCompletionKeydownDisposableRef.current = null; }; }, [isActive, isPossibleTriggerSqlAiCompletionFallbackEvent, isTriggerSqlAiCompletionShortcutEvent, triggerSqlAiCompletionShortcutBinding]); useEffect(() => { if (runQueryActionRef.current) { runQueryActionRef.current.dispose(); runQueryActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = runQueryShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { runQueryActionRef.current = editor.addAction({ id: 'gonavi.runQuery', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.runQuery.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:run-active-query', { detail: { requireSelection: true }, })); }, }); } return () => { if (runQueryActionRef.current) { runQueryActionRef.current.dispose(); runQueryActionRef.current = null; } }; }, [languagePreference, runQueryShortcutBinding]); useEffect(() => { if (selectCurrentStatementActionRef.current) { selectCurrentStatementActionRef.current.dispose(); selectCurrentStatementActionRef.current = null; } if (macFindWithSelectionGuardActionRef.current) { macFindWithSelectionGuardActionRef.current.dispose(); macFindWithSelectionGuardActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = selectCurrentStatementShortcutBinding; if (binding?.enabled && binding.combo) { const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { selectCurrentStatementActionRef.current = editor.addAction({ id: 'gonavi.selectCurrentStatement', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.selectCurrentStatement.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: handleSelectCurrentStatement, }); } } const macFindWithSelectionGuardKeyBinding = activeShortcutPlatform === 'mac' ? comboToMonacoKeyBinding( QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO, monaco.KeyMod, monaco.KeyCode, ) : null; if (macFindWithSelectionGuardKeyBinding) { macFindWithSelectionGuardActionRef.current = editor.addAction({ id: QUERY_EDITOR_MAC_FIND_WITH_SELECTION_GUARD_ACTION_ID, label: 'GoNavi: Suppress macOS Cmd+E Find with Selection', keybindings: [ macFindWithSelectionGuardKeyBinding.keyMod | macFindWithSelectionGuardKeyBinding.keyCode, ], run: () => { if ( binding?.enabled && normalizeShortcutCombo(binding.combo) === QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO ) { void handleSelectCurrentStatement(); } }, }); } return () => { if (selectCurrentStatementActionRef.current) { selectCurrentStatementActionRef.current.dispose(); selectCurrentStatementActionRef.current = null; } if (macFindWithSelectionGuardActionRef.current) { macFindWithSelectionGuardActionRef.current.dispose(); macFindWithSelectionGuardActionRef.current = null; } }; }, [activeShortcutPlatform, languagePreference, selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]); useEffect(() => { if (duplicateCurrentLineActionRef.current) { duplicateCurrentLineActionRef.current.dispose(); duplicateCurrentLineActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = duplicateCurrentLineShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { duplicateCurrentLineActionRef.current = editor.addAction({ id: 'gonavi.duplicateCurrentLine', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.duplicateCurrentLine.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: handleDuplicateCurrentLine, }); } return () => { if (duplicateCurrentLineActionRef.current) { duplicateCurrentLineActionRef.current.dispose(); duplicateCurrentLineActionRef.current = null; } }; }, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, languagePreference]); useEffect(() => { if (saveQueryActionRef.current) { saveQueryActionRef.current.dispose(); saveQueryActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = saveQueryShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { saveQueryActionRef.current = editor.addAction({ id: 'gonavi.saveQuery', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.saveQuery.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:save-active-query')); }, }); } return () => { if (saveQueryActionRef.current) { saveQueryActionRef.current.dispose(); saveQueryActionRef.current = null; } }; }, [languagePreference, saveQueryShortcutBinding]); useEffect(() => { if (findInEditorActionRef.current) { findInEditorActionRef.current.dispose(); findInEditorActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const keyBinding = comboToMonacoKeyBinding( findInEditorShortcutCombo, monaco.KeyMod, monaco.KeyCode, ); if (keyBinding) { findInEditorActionRef.current = editor.addAction({ id: 'gonavi.findInEditor', label: buildQueryEditorMonacoActionLabel('query_editor.action.find_in_editor'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:find-active-query')); }, }); } return () => { if (findInEditorActionRef.current) { findInEditorActionRef.current.dispose(); findInEditorActionRef.current = null; } }; }, [findInEditorShortcutCombo, languagePreference]); useEffect(() => { if (formatSqlActionRef.current) { formatSqlActionRef.current.dispose(); formatSqlActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = formatSqlShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { formatSqlActionRef.current = editor.addAction({ id: 'gonavi.formatSql', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.formatSql.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:format-active-query')); }, }); } return () => { if (formatSqlActionRef.current) { formatSqlActionRef.current.dispose(); formatSqlActionRef.current = null; } }; }, [languagePreference, formatSqlShortcutBinding]); useEffect(() => { const editor = editorRef.current; if (!editor) return; registerQueryEditorAiContextMenuActions(editor); return () => { disposeQueryEditorAiContextMenuActions(); }; }, [languagePreference, disposeQueryEditorAiContextMenuActions, registerQueryEditorAiContextMenuActions]); useEffect(() => { refreshQueryEditorSlashCommandDefs(); }, [languagePreference, refreshQueryEditorSlashCommandDefs]); useEffect(() => { if (toggleQueryResultsPanelActionRef.current) { toggleQueryResultsPanelActionRef.current.dispose(); toggleQueryResultsPanelActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = toggleQueryResultsPanelShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { toggleQueryResultsPanelActionRef.current = editor.addAction({ id: 'gonavi.toggleQueryResultsPanel', label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.toggleQueryResultsPanel.label'), keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: toggleResultPanelVisibility, }); } return () => { if (toggleQueryResultsPanelActionRef.current) { toggleQueryResultsPanelActionRef.current.dispose(); toggleQueryResultsPanelActionRef.current = null; } }; }, [languagePreference, toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]); useEffect(() => { const handleRunActiveQuery = (event: Event) => { if (!isActive) { return; } if ((event as CustomEvent<{ requireSelection?: boolean }>).detail?.requireSelection) { void handleRunSelectedShortcut(); return; } void handleRun(); }; window.addEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); }; }, [isActive, handleRun, handleRunSelectedShortcut]); useEffect(() => { const handleFindActiveQuery = () => { if (!isActive) { return; } handleOpenEditorFind(); }; window.addEventListener('gonavi:find-active-query', handleFindActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:find-active-query', handleFindActiveQuery as EventListener); }; }, [handleOpenEditorFind, isActive]); useEffect(() => { const binding = selectCurrentStatementShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleSelectCurrentStatementShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); void handleSelectCurrentStatement(); }; window.addEventListener('keydown', handleSelectCurrentStatementShortcut, true); return () => { window.removeEventListener('keydown', handleSelectCurrentStatementShortcut, true); }; }, [handleSelectCurrentStatement, isActive, selectCurrentStatementShortcutBinding]); useEffect(() => { const binding = selectCurrentStatementShortcutBinding; if ( activeShortcutPlatform !== 'mac' || !binding?.enabled || normalizeShortcutCombo(binding.combo) !== 'Meta+E' ) { return; } try { return EventsOn(QUERY_EDITOR_NATIVE_SELECT_CURRENT_LINE_EVENT, () => { if (!isActive) { return; } void handleSelectCurrentStatement(); }); } catch { return; } }, [activeShortcutPlatform, handleSelectCurrentStatement, isActive, selectCurrentStatementShortcutBinding]); useEffect(() => { const binding = duplicateCurrentLineShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleDuplicateCurrentLineShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); handleDuplicateCurrentLine(); }; window.addEventListener('keydown', handleDuplicateCurrentLineShortcut, true); return () => { window.removeEventListener('keydown', handleDuplicateCurrentLineShortcut, true); }; }, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, isActive]); // 监听由 TabManager 分发的专用注入事件 useEffect(() => { const handleInsertSql = (e: any) => { if (e.detail?.tabId !== tab.id || !e.detail?.sql) return; const { sql: sqlText, connectionId, dbName } = e.detail; // 同步更新 ref,防止异步 fetchDbs 竞态覆盖正确的 dbName if (connectionId && connectionId !== currentConnectionId) { if (dbName) { currentDbRef.current = dbName; setCurrentDb(dbName); } setCurrentConnectionId(connectionId); } else if (dbName && dbName !== currentDb) { currentDbRef.current = dbName; setCurrentDb(dbName); } const editor = editorRef.current; const monaco = monacoRef.current; if (editor && monaco) { const model = editor.getModel(); const existingContent = editor.getValue?.() || ''; // runImmediately 模式下,如果编辑器内容已是待注入的 SQL(TabManager 创建时已传入), // 跳过追加,直接选中全部内容并执行 if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) { if (model) { const lineCount = model.getLineCount(); const maxCol = model.getLineMaxColumn(lineCount); editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol)); editor.focus(); setTimeout(() => handleRun(), 500); } } else { let position = editor.getPosition(); if (!position && model) { const lineCount = model.getLineCount(); const maxCol = model.getLineMaxColumn(lineCount); position = new monaco.Position(lineCount, maxCol); } if (position) { const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n'); const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); editor.executeEdits('ai-insert', [{ range: startRange, text: (position.column > 1 ? '\n' : '') + mText, forceMoveMarkers: true }]); const nextValue = editor.getValue?.(); if (typeof nextValue === 'string') { applyQueryState(nextValue); } // 定位并滚动到可见区域 const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0); editor.revealLineInCenterIfOutsideViewport(targetLine); editor.setPosition({ lineNumber: targetLine + mText.split('\n').length - 1, column: 1 }); editor.focus(); if (!e.detail.runImmediately) { message.success(translate('query_editor.message.insert_success')); } if (e.detail.runImmediately) { const endPosition = editor.getPosition(); editor.setSelection(new monaco.Range( targetLine, 1, endPosition.lineNumber, endPosition.column )); // 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行 setTimeout(() => handleRun(), 500); } } } } else { applyQueryState(getCurrentQuery() ? `${getCurrentQuery()}\n${sqlText}` : sqlText); message.success(translate('query_editor.message.append_success')); } }; window.addEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); return () => window.removeEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); }, [tab.id, handleRun]); const resolveDefaultQueryName = () => { const rawTitle = String(tab.title || '').trim(); if (isLocalizedUntitledQueryTitle(rawTitle)) { return translate('query_editor.save_modal.unnamed'); } return rawTitle; }; const persistQuery = async (payload: { id: string; name: string; createdAt?: number }) => { const sql = getCurrentQuery(); const saved = { id: payload.id, name: payload.name, sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', createdAt: payload.createdAt ?? Date.now(), }; const persisted = await saveQuery(saved); addTab({ ...tab, title: persisted.name, query: sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', savedQueryId: persisted.id, }); clearQueryTabDraft(tab.id); return persisted; }; const openSaveQueryModal = (mode: 'save' | 'rename') => { setSaveModalMode(mode); saveForm.setFieldsValue({ name: currentSavedQuery?.name || resolveDefaultQueryName() }); setIsSaveModalOpen(true); }; const handleQuickSave = async () => { const filePath = String(tab.filePath || '').trim(); if (filePath) { const sql = getCurrentQuery(); try { const res = await WriteSQLFile(filePath, sql); if (!res.success) { message.error(translate('query_editor.message.save_sql_file_failed', { error: res.message || translate('common.unknown'), })); return; } addTab({ ...tab, query: sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', filePath, savedQueryId: undefined, }); clearQueryTabDraft(tab.id); message.success(translate('query_editor.message.sql_file_saved')); } catch (error) { message.error(translate('query_editor.message.save_sql_file_failed', { error: error instanceof Error ? error.message : String(error), })); } return; } const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); const saveId = existed?.id || fallbackSavedId || ''; if (!saveId) { openSaveQueryModal('save'); return; } const saveName = existed?.name || resolveDefaultQueryName(); await persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt }); message.success(translate('query_editor.message.saved')); }; const handleRenameQuery = () => { const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); if (!existed && !fallbackSavedId) { message.warning(translate('query_editor.message.save_first_before_rename')); openSaveQueryModal('save'); return; } openSaveQueryModal('rename'); }; const handleExportSQLFile = async () => { try { const res = await ExportSQLFile(currentSavedQuery?.name || resolveDefaultQueryName(), getCurrentQuery()); if (!res.success) { if ((res.message || '') !== '已取消') { message.error(translate('query_editor.message.export_sql_file_failed', { error: res.message || translate('common.unknown'), })); } return; } message.success(translate('query_editor.message.export_sql_file_success')); } catch (error) { const errorDetail = error instanceof Error ? error.message || translate('common.unknown') : (typeof (error as any)?.message === 'string' && (error as any).message) || (typeof error === 'string' && error) || translate('common.unknown'); message.error(translate('query_editor.message.export_sql_file_failed', { error: errorDetail, })); } }; const saveMoreMenuItems: MenuProps['items'] = [ { key: 'rename-query', label: translate('query_editor.action.rename_query'), disabled: !!tab.filePath, onClick: handleRenameQuery, }, { key: 'export-sql-file', label: translate('query_editor.action.export_sql_file'), onClick: () => void handleExportSQLFile(), }, { type: 'divider' }, { key: 'diagnose-query', label: ( {translate('app.shortcuts.action.diagnoseQuery.label' as any)} {diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && ( {getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)} )} ), onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()), }, { key: 'show-slow-queries', label: ( {translate('app.shortcuts.action.showSlowQueries.label' as any)} {showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && ( {getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)} )} ), onClick: () => openSqlAnalysisWorkbench('slow-query'), }, ]; useEffect(() => { const handleFindShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, findInEditorShortcutCombo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (isEditableElement(event.target) && !inEditorPane) { return; } if (!editorHasFocus && !inEditorPane && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); handleOpenEditorFind(); }; window.addEventListener('keydown', handleFindShortcut, true); return () => { window.removeEventListener('keydown', handleFindShortcut, true); }; }, [findInEditorShortcutCombo, handleOpenEditorFind, isActive]); useEffect(() => { const binding = saveQueryShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleSaveShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); void handleQuickSave(); }; window.addEventListener('keydown', handleSaveShortcut, true); return () => { window.removeEventListener('keydown', handleSaveShortcut, true); }; }, [isActive, saveQueryShortcutBinding, handleQuickSave]); useEffect(() => { const binding = formatSqlShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleFormatShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); handleFormatRef.current(); }; window.addEventListener('keydown', handleFormatShortcut, true); return () => { window.removeEventListener('keydown', handleFormatShortcut, true); }; }, [isActive, formatSqlShortcutBinding]); useEffect(() => { const updateAltState = (event: KeyboardEvent) => { const key = String(event.key || '').trim().toLowerCase(); const code = String(event.code || '').trim().toLowerCase(); const isAltKey = key === 'alt' || code === 'altleft' || code === 'altright'; if (isAltKey) { if (event.type === 'keydown') { triggerSqlAiCompletionAltGestureAtRef.current = Date.now(); } triggerSqlAiCompletionAltPressedRef.current = event.type !== 'keyup'; } else if (event.type === 'keyup' && !event.altKey) { triggerSqlAiCompletionAltPressedRef.current = false; } }; const clearAltState = () => { triggerSqlAiCompletionAltPressedRef.current = false; triggerSqlAiCompletionAltGestureAtRef.current = 0; triggerSqlAiCompletionFallbackRef.current = null; }; window.addEventListener('keydown', updateAltState, true); window.addEventListener('keyup', updateAltState, true); window.addEventListener('blur', clearAltState); return () => { window.removeEventListener('keydown', updateAltState, true); window.removeEventListener('keyup', updateAltState, true); window.removeEventListener('blur', clearAltState); }; }, []); useEffect(() => { const binding = triggerSqlAiCompletionShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleTriggerSqlAiCompletionShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } if (!isTriggerSqlAiCompletionShortcutEvent(event)) { if (isPossibleTriggerSqlAiCompletionFallbackEvent(event)) { triggerSqlAiCompletionFallbackRef.current = { observedAt: Date.now() }; } return; } triggerSqlAiCompletionFallbackRef.current = null; event.preventDefault(); event.stopPropagation(); triggerAiInlineCompletionRef.current?.(); }; window.addEventListener('keydown', handleTriggerSqlAiCompletionShortcut, true); return () => { window.removeEventListener('keydown', handleTriggerSqlAiCompletionShortcut, true); }; }, [isActive, isPossibleTriggerSqlAiCompletionFallbackEvent, isTriggerSqlAiCompletionShortcutEvent, triggerSqlAiCompletionShortcutBinding]); useEffect(() => { const binding = toggleQueryResultsPanelShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } const handleToggleResultsShortcut = (event: KeyboardEvent) => { if (!isActive) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editor = editorRef.current; const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } event.preventDefault(); event.stopPropagation(); toggleResultPanelVisibility(); }; window.addEventListener('keydown', handleToggleResultsShortcut, true); return () => { window.removeEventListener('keydown', handleToggleResultsShortcut, true); }; }, [isActive, toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]); useEffect(() => { const handleSaveActiveQuery = () => { if (!isActive) { return; } void handleQuickSave(); }; window.addEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener); }; }, [isActive, handleQuickSave]); useEffect(() => { const handleOpenSqlExecutionLog = (event: Event) => { const mode = event instanceof CustomEvent && event.detail?.mode === 'open' ? 'open' : 'toggle'; handleShowSqlExecutionLog(mode); }; window.addEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener); return () => { window.removeEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener); }; }, [handleShowSqlExecutionLog]); const handleSave = async () => { try { const values = await saveForm.validateFields(); const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`; await persistQuery({ id: nextSavedId, name: String(values.name || '').trim() || translate('query_editor.save_modal.unnamed'), createdAt: existed?.createdAt, }); message.success(translate( saveModalMode === 'rename' ? 'query_editor.message.renamed' : 'query_editor.message.saved' )); setIsSaveModalOpen(false); } catch (e) { if (e instanceof Error) { message.error(translate('query_editor.message.save_query_failed', { error: e.message, })); } } }; const handleCloseResult = (key: string) => { setResultSets(prev => { const idx = prev.findIndex(r => r.key === key); if (idx < 0) return prev; const next = prev.filter(r => r.key !== key); setActiveResultKey(prevActive => { if (prevActive && prevActive !== key) return prevActive; return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; }); return next; }); }; const replaceResultSetsAfterMenuClose = (next: ResultSet[], preferredKey?: string) => { setResultSets(next); setActiveResultKey(prevActive => { if (preferredKey && next.some(result => result.key === preferredKey)) return preferredKey; if (prevActive && next.some(result => result.key === prevActive)) return prevActive; return next[0]?.key || ''; }); }; const closeOtherResultTabs = (key: string) => { const target = resultSets.find(result => result.key === key); replaceResultSetsAfterMenuClose(target ? [target] : resultSets, key); }; const closeResultTabsToLeft = (key: string) => { const index = resultSets.findIndex(result => result.key === key); if (index <= 0) return; replaceResultSetsAfterMenuClose(resultSets.slice(index), key); }; const closeResultTabsToRight = (key: string) => { const index = resultSets.findIndex(result => result.key === key); if (index < 0 || index >= resultSets.length - 1) return; replaceResultSetsAfterMenuClose(resultSets.slice(0, index + 1), key); }; const closeAllResultTabs = () => { setResultSets([]); setActiveResultKey(''); }; const openResultInWindow = ( key: string, preferred?: { x?: number; y?: number; width?: number; height?: number }, ) => { const target = resultSets.find((result) => result.key === key); if (!target) return; const index = resultSets.findIndex((result) => result.key === key); const title = target.resultType === 'message' ? translate('query_editor.results_panel.tab.message', { index: index + 1 }) : translate('query_editor.results_panel.detached.title', { index: index + 1 }); const windowId = `query-result:${tab.id}:${target.key}`; useStore.getState().detachQueryResultWindow({ id: windowId, sourceQueryTabId: tab.id, connectionId: currentConnectionId || tab.connectionId || '', // 独立窗也要带上结果表元数据所属库,否则列类型/注释会丢 dbName: target.metadataDbName || currentDb || tab.dbName || '', title, ...(preferred?.x !== undefined ? { x: preferred.x } : {}), ...(preferred?.y !== undefined ? { y: preferred.y } : {}), ...(preferred?.width !== undefined ? { width: preferred.width } : {}), ...(preferred?.height !== undefined ? { height: preferred.height } : {}), result: { key: target.key, sql: target.sql, exportSql: target.exportSql, sourceStatementIndex: target.sourceStatementIndex, statementResultIndex: target.statementResultIndex, rows: target.rows, columns: target.columns, messages: target.messages, resultType: target.resultType, tableName: target.metadataTableName || target.tableName, metadataDbName: target.metadataDbName, metadataTableName: target.metadataTableName, pkColumns: target.pkColumns || [], editLocator: target.editLocator as any, readOnly: target.readOnly !== false, showRowNumberColumn: target.showRowNumberColumn, truncated: target.truncated, }, }); handleCloseResult(key); }; React.useEffect(() => { const handleRestoreQueryResult = (event: Event) => { const detail = (event as CustomEvent).detail || {}; const sourceQueryTabId = String(detail.sourceQueryTabId || '').trim(); if (sourceQueryTabId !== tab.id) return; const restored = detail.result; if (!restored || typeof restored !== 'object') return; const restoredKey = String(restored.key || '').trim(); if (!restoredKey) return; setResultSets((prev) => { if (prev.some((item) => item.key === restoredKey)) { return prev; } return [ ...prev, { key: restoredKey, sql: String(restored.sql || ''), exportSql: restored.exportSql, sourceStatementIndex: restored.sourceStatementIndex, statementResultIndex: restored.statementResultIndex, rows: Array.isArray(restored.rows) ? restored.rows : [], columns: Array.isArray(restored.columns) ? restored.columns : [], messages: Array.isArray(restored.messages) ? restored.messages : undefined, resultType: restored.resultType === 'message' ? 'message' : 'grid', tableName: restored.tableName, metadataDbName: restored.metadataDbName, metadataTableName: restored.metadataTableName, pkColumns: Array.isArray(restored.pkColumns) ? restored.pkColumns : [], editLocator: restored.editLocator, readOnly: restored.readOnly !== false, showRowNumberColumn: restored.showRowNumberColumn, truncated: restored.truncated, } as ResultSet, ]; }); setActiveResultKey(restoredKey); updateResultPanelVisibility(true); }; window.addEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener); return () => { window.removeEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener); }; }, [tab.id, updateResultPanelVisibility]); const toggleQueryResultsPanelShortcutLabel = toggleQueryResultsPanelShortcutBinding.enabled && toggleQueryResultsPanelShortcutBinding.combo ? getShortcutDisplayLabel(toggleQueryResultsPanelShortcutBinding.combo, activeShortcutPlatform) : ''; const handleDiagnoseExecutionError = () => { const errSql = getCurrentQuery(); const prompt = translate('query_editor.ai_prompt.diagnose', { sql: errSql, error: executionError, }); const store = useStore.getState(); const wasClosed = !store.aiPanelVisible; if (wasClosed) store.setAIPanelVisible(true); setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); }, wasClosed ? 350 : 0); }; const sqlEditorTransactionToolbar = ( void finishPendingSqlTransaction(action, 'manual')} /> ); const queryEditorStageStyle: React.CSSProperties = isResultPanelVisible ? { height: editorHeight, minHeight: '100px', } : { flex: '1 1 auto', minHeight: 0, }; const resolvedQueryEditorStageStyle: React.CSSProperties = isV2Ui ? { ...queryEditorStageStyle, } as React.CSSProperties : queryEditorStageStyle; return (
{ setCurrentConnectionId(val); setCurrentDb(''); }} onDatabaseChange={setCurrentDb} onMaxRowsChange={(maxRows) => setQueryOptions({ maxRows })} onCommitModeChange={(mode) => setSqlEditorTransactionOptions( mode === 'auto' ? { commitMode: mode, autoCommitDelayMs: 0 } : { commitMode: mode }, )} onAutoCommitDelayMsChange={(delayMs) => setSqlEditorTransactionOptions({ autoCommitDelayMs: delayMs })} onCaptureEditorCursorPosition={captureEditorCursorPosition} onRun={handleRun} onCancel={handleCancel} onQuickSave={handleQuickSave} onFindInEditor={handleOpenEditorFind} onFormat={handleFormat} onTriggerSqlAiCompletion={() => triggerAiInlineCompletionRef.current?.()} onToggleResultPanelVisibility={toggleResultPanelVisibility} onAIAction={handleAIAction} showViewDataVerify={ isObjectEditQueryTab && ( Boolean(String(tab.viewName || '').trim()) || tab.objectType === 'view' || tab.objectType === 'materialized-view' || isViewEditSql(query) ) } onViewDataVerify={() => { const viewName = resolveViewNameForVerify({ sql: query, tabViewName: tab.viewName, tabTitle: tab.title, }); if (!viewName) { message.warning(translate('result_diff.view_verify.error.no_view_name')); return; } setViewDataVerifyOpen(true); }} />
{ const nextValue = val || ''; syncQueryDraft(nextValue); }} onMount={handleEditorDidMount} options={queryEditorMonacoOptions} />
{isResultPanelVisible && (
)}
{isResultPanelVisible && ( updateResultPanelVisibility(false)} onCloseResult={handleCloseResult} onCloseOtherResultTabs={closeOtherResultTabs} onCloseResultTabsToLeft={closeResultTabsToLeft} onCloseResultTabsToRight={closeResultTabsToRight} onCloseAllResultTabs={closeAllResultTabs} onOpenResultInWindow={openResultInWindow} onReloadResult={handleReloadResult} onResultPageChange={handleResultPageChange} onDiagnoseExecutionError={handleDiagnoseExecutionError} onCompareResult={(resultKey) => { setResultDiffAnchorKey(resultKey); setResultDiffWizardOpen(true); }} /> )} ({ rs, idx })) .filter(({ rs }) => rs.resultType !== 'message' && Array.isArray(rs.columns) && rs.columns.length > 0) .map(({ rs, idx }): ResultDiffComparableResult => ({ key: rs.key, label: translate('query_editor.results_panel.tab.result', { index: idx + 1 }) + ` (${rs.rows?.length ?? 0})`, sql: String(rs.sql || rs.exportSql || ''), columns: rs.columns || [], rows: (rs.rows || []) as Record[], pkColumns: rs.pkColumns || [], truncated: Boolean(rs.truncated), metadataDbName: rs.metadataDbName || currentDb, metadataTableName: rs.metadataTableName || rs.tableName, }))} initialRightKey={resultDiffAnchorKey} connectionConfig={(() => { const conn = connections.find((c) => c.id === currentConnectionId); return conn ? buildRpcConnectionConfig(conn) : {}; })()} database={currentDb} onCancel={() => setResultDiffWizardOpen(false)} onCompleted={(payload) => { setResultDiffWizardOpen(false); setResultDiffSession(payload); }} /> {resultDiffSession && ( setResultDiffSession(null)} /> )} { const conn = connections.find((c) => c.id === currentConnectionId); return conn ? buildRpcConnectionConfig(conn) : {}; })()} database={currentDb} dbType={String(connections.find((c) => c.id === currentConnectionId)?.config?.type || '')} viewName={resolveViewNameForVerify({ sql: query, tabViewName: tab.viewName, tabTitle: tab.title, })} ddlSql={query} onCancel={() => setViewDataVerifyOpen(false)} onCompleted={(payload) => { setViewDataVerifyOpen(false); setResultDiffSession(payload); }} /> { if (!textToSqlGenerating) { setIsTextToSqlModalOpen(false); } }} footer={[ , , ]} styles={{ content: { borderRadius: 16, border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(15,23,42,0.12)', background: darkMode ? 'rgba(18,18,20,0.98)' : 'rgba(255,255,255,0.98)', boxShadow: darkMode ? '0 24px 60px rgba(0,0,0,0.45)' : '0 24px 60px rgba(15,23,42,0.16)', backdropFilter: 'blur(12px)', }, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8, }, body: { paddingTop: 8, paddingBottom: 16, }, }} >
{translate('query_editor.text_to_sql.description')}
setTextToSqlInstruction(event.target.value)} placeholder={translate('query_editor.text_to_sql.placeholder')} autoSize={{ minRows: 5, maxRows: 10 }} disabled={textToSqlGenerating} /> setTextToSqlApplyMode(value as QueryEditorAiApplyMode)} disabled={textToSqlGenerating} options={[ { label: translate('query_editor.text_to_sql.mode.insert'), value: 'insert' }, { label: translate('query_editor.text_to_sql.mode.replace_selection'), value: 'replaceSelection' }, { label: translate('query_editor.text_to_sql.mode.replace_all'), value: 'replaceAll' }, ]} />
{translate('query_editor.snippet_picker.description')}
setSqlSnippetPickerKeyword(event.target.value)} onPressEnter={() => { if (filteredSqlSnippets[0]) { handleInsertSqlSnippet(filteredSqlSnippets[0]); } }} placeholder={translate('query_editor.snippet_picker.search_placeholder')} />
{filteredSqlSnippets.map((snippet) => { const preview = String(snippet.description || snippet.syntaxHelp || snippet.body || '') .replace(/\s+/g, ' ') .trim(); return ( ); })} {!filteredSqlSnippets.length ? (
{sqlSnippetPickerEmptyLabel}
) : null}
setIsSaveModalOpen(false)} okText={translate(saveModalMode === 'rename' ? 'query_editor.save_modal.rename_ok' : 'common.save')} cancelText={translate('common.cancel')} >
); }; const setQueryEditorMouseCursor = ( editor: any, cursor: '' | 'pointer', ) => { const domNode = editor?.getDomNode?.(); if (domNode?.style) { domNode.style.cursor = cursor; } }; export default React.memo(QueryEditor);