diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 60718a3..b92e93c 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SavedQuery, TabData } from '../types'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; -import QueryEditor from './QueryEditor'; +import QueryEditor, { resolveQueryEditorNavigationTarget } from './QueryEditor'; const storeState = vi.hoisted(() => ({ connections: [ @@ -23,6 +23,7 @@ const storeState = vi.hoisted(() => ({ ], addSqlLog: vi.fn(), addTab: vi.fn(), + setActiveContext: vi.fn(), updateQueryTabDraft: vi.fn(), savedQueries: [] as SavedQuery[], saveQuery: vi.fn(), @@ -72,6 +73,10 @@ const dataGridState = vi.hoisted(() => ({ latestProps: null as any, })); +const autoFetchState = vi.hoisted(() => ({ + visible: false, +})); + const editorState = vi.hoisted(() => { const state = { value: '', @@ -80,7 +85,11 @@ const editorState = vi.hoisted(() => { selection: null as any, providers: [] as any[], cursorPositionListeners: [] as Array<(event: any) => void>, + mouseMoveListeners: [] as Array<(event: any) => void>, + mouseDownListeners: [] as Array<(event: any) => void>, + mouseLeaveListeners: [] as Array<() => void>, hasTextFocus: true, + decorationIds: [] as string[], }; const offsetAt = (position: { lineNumber: number; column: number }) => { const text = state.value; @@ -147,6 +156,24 @@ const editorState = vi.hoisted(() => { state.cursorPositionListeners.push(listener); return { dispose: vi.fn() }; }), + onMouseMove: vi.fn((listener: (event: any) => void) => { + state.mouseMoveListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseDown: vi.fn((listener: (event: any) => void) => { + state.mouseDownListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseLeave: vi.fn((listener: () => void) => { + state.mouseLeaveListeners.push(listener); + return { dispose: vi.fn() }; + }), + deltaDecorations: vi.fn((oldDecorations: string[], newDecorations: any[]) => { + state.decorationIds = newDecorations.map((_: any, index: number) => `decoration-${index + 1}`); + return state.decorationIds; + }), + updateOptions: vi.fn(), + onDidDispose: vi.fn(), hasTextFocus: vi.fn(() => state.hasTextFocus), revealLineInCenterIfOutsideViewport: vi.fn(), revealRangeInCenterIfOutsideViewport: vi.fn(), @@ -167,7 +194,7 @@ vi.mock('../store', () => { vi.mock('../../wailsjs/go/app/App', () => backendApp); vi.mock('../utils/autoFetchVisibility', () => ({ - useAutoFetchVisibility: () => false, + useAutoFetchVisibility: () => autoFetchState.visible, })); vi.mock('@monaco-editor/react', () => ({ @@ -196,6 +223,12 @@ vi.mock('@monaco-editor/react', () => ({ this.endColumn = endColumn; } }, + MarkdownString: class { + value: string; + constructor(value: string) { + this.value = value; + } + }, Position: class { lineNumber: number; column: number; @@ -291,6 +324,7 @@ describe('QueryEditor external SQL save', () => { dispatchEvent: vi.fn(), }); storeState.addTab.mockReset(); + storeState.setActiveContext.mockReset(); storeState.saveQuery.mockReset(); storeState.savedQueries = []; storeState.activeTabId = 'tab-1'; @@ -302,20 +336,30 @@ describe('QueryEditor external SQL save', () => { backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] }); + backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] }); + backendApp.DBGetDatabases.mockResolvedValue({ success: true, data: [] }); + backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] }); backendApp.GenerateQueryID.mockResolvedValue('query-1'); storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; storeState.appearance.uiVersion = 'legacy'; + autoFetchState.visible = false; dataGridState.latestProps = null; editorState.value = ''; editorState.position = { lineNumber: 1, column: 1 }; editorState.selection = null; editorState.providers = []; editorState.cursorPositionListeners = []; + editorState.mouseMoveListeners = []; + editorState.mouseDownListeners = []; + editorState.mouseLeaveListeners = []; editorState.hasTextFocus = true; + editorState.decorationIds = []; editorState.editor.getValue.mockClear(); editorState.editor.setValue.mockClear(); editorState.editor.executeEdits.mockClear(); + editorState.editor.deltaDecorations.mockClear(); + editorState.editor.updateOptions.mockClear(); storeState.updateQueryTabDraft.mockReset(); }); @@ -332,6 +376,321 @@ describe('QueryEditor external SQL save', () => { expect(editorState.value).toBe('SELECT * FROM '); }); + it('resolves database and table targets for ctrl/cmd navigation', () => { + const tables = [ + { dbName: 'main', tableName: 'users' }, + { dbName: 'main', tableName: 'dbo.orders' }, + { dbName: 'analytics', tableName: 'events' }, + ]; + const views = [ + { dbName: 'main', viewName: 'reporting.active_users', schemaName: 'reporting' }, + ]; + const materializedViews = [ + { dbName: 'analytics', viewName: 'mv_daily_stats', schemaName: undefined }, + ]; + const triggers = [ + { dbName: 'main', triggerName: 'audit.users_bi', tableName: 'audit.users', schemaName: 'audit' }, + ]; + const routines = [ + { dbName: 'main', routineName: 'reporting.refresh_stats', routineType: 'PROCEDURE', schemaName: 'reporting' }, + ]; + + expect(resolveQueryEditorNavigationTarget('select * from analytics.events', 31, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'table', + dbName: 'analytics', + tableName: 'events', + schemaName: undefined, + }); + expect(resolveQueryEditorNavigationTarget('select * from dbo.orders', 21, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'table', + dbName: 'main', + tableName: 'dbo.orders', + schemaName: 'dbo', + }); + expect(resolveQueryEditorNavigationTarget('use analytics', 6, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'database', + dbName: 'analytics', + }); + expect(resolveQueryEditorNavigationTarget('select * from users', 18, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'table', + dbName: 'main', + tableName: 'users', + schemaName: undefined, + }); + expect(resolveQueryEditorNavigationTarget('select * from reporting.active_users', 31, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'view', + dbName: 'main', + viewName: 'reporting.active_users', + schemaName: 'reporting', + }); + expect(resolveQueryEditorNavigationTarget('select * from analytics.mv_daily_stats', 37, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'materialized-view', + dbName: 'analytics', + viewName: 'mv_daily_stats', + schemaName: undefined, + }); + expect(resolveQueryEditorNavigationTarget('call audit.users_bi()', 18, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'trigger', + dbName: 'main', + triggerName: 'audit.users_bi', + tableName: 'audit.users', + schemaName: 'audit', + }); + expect(resolveQueryEditorNavigationTarget('call reporting.refresh_stats()', 21, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({ + type: 'routine', + dbName: 'main', + routineName: 'reporting.refresh_stats', + routineType: 'PROCEDURE', + schemaName: 'reporting', + }); + }); + + it('opens a table tab on ctrl left click inside the editor', async () => { + editorState.value = 'select * from analytics.events where id = 1'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] }); + backendApp.DBGetTables + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] }); + backendApp.DBGetAllColumns + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 27 } }, + event: { + leftButton: true, + ctrlKey: true, + metaKey: false, + preventDefault, + stopPropagation, + }, + }); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'analytics' }); + expect(storeState.addTab).toHaveBeenCalledWith({ + id: 'conn-1-analytics-table-events', + title: 'events', + type: 'table', + connectionId: 'conn-1', + dbName: 'analytics', + tableName: 'events', + }); + expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'gonavi:locate-sidebar-object', + })); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('shows link-style hover feedback when ctrl/cmd is pressed over a navigable identifier', async () => { + editorState.value = 'select * from analytics.events where id = 1'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] }); + backendApp.DBGetTables + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] }); + backendApp.DBGetAllColumns + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + editorState.mouseMoveListeners[0]?.({ + target: { position: { lineNumber: 1, column: 27 } }, + event: { + ctrlKey: true, + metaKey: false, + }, + }); + }); + + expect(editorState.editor.deltaDecorations).toHaveBeenCalled(); + expect(editorState.editor.updateOptions).toHaveBeenCalledWith({ mouseStyle: 'pointer' }); + const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); + expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); + + await act(async () => { + editorState.mouseLeaveListeners[0]?.(); + }); + expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' }); + }); + + it('opens a view tab on ctrl left click inside the editor', async () => { + editorState.value = 'select * from reporting.active_users'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + if (sql.includes('information_schema.views') || sql.includes('pg_catalog.pg_views') || sql.includes('USER_VIEWS') || sql.includes('ALL_VIEWS')) { + return { success: true, data: [{ view_name: 'active_users', schema_name: 'reporting' }] }; + } + return { success: true, data: [] }; + }); + + await act(async () => { + create(); + }); + await act(async () => { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 31 } }, + event: { + leftButton: true, + ctrlKey: true, + metaKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }, + }); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); + expect(storeState.addTab).toHaveBeenCalledWith({ + id: 'view-def-conn-1-main-active_users', + title: '视图: active_users', + type: 'view-def', + connectionId: 'conn-1', + dbName: 'main', + viewName: 'active_users', + viewKind: 'view', + }); + }); + + it('opens trigger and routine tabs on ctrl left click inside the editor', async () => { + editorState.value = 'call audit.users_bi(); call reporting.refresh_stats();'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + if (sql.includes('information_schema.triggers') || sql.includes('SHOW TRIGGERS') || sql.includes('USER_TRIGGERS') || sql.includes('ALL_TRIGGERS')) { + return { success: true, data: [{ trigger_name: 'users_bi', table_name: 'users', schema_name: 'audit' }] }; + } + if (sql.includes('information_schema.routines') || sql.includes('SHOW FUNCTION STATUS') || sql.includes('SHOW PROCEDURE STATUS') || sql.includes('USER_OBJECTS') || sql.includes('ALL_OBJECTS')) { + return { success: true, data: [{ routine_name: 'refresh_stats', routine_type: 'PROCEDURE', schema_name: 'reporting' }] }; + } + return { success: true, data: [] }; + }); + + await act(async () => { + create(); + }); + await act(async () => { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 12 } }, + event: { + leftButton: true, + ctrlKey: true, + metaKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }, + }); + }); + + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 39 } }, + event: { + leftButton: true, + ctrlKey: true, + metaKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }, + }); + }); + + expect(storeState.addTab).toHaveBeenCalledWith({ + id: 'trigger-conn-1-main-audit.users_bi', + title: '触发器: audit.users_bi', + type: 'trigger', + connectionId: 'conn-1', + dbName: 'main', + triggerName: 'audit.users_bi', + }); + expect(storeState.addTab).toHaveBeenCalledWith({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + title: '存储过程: reporting.refresh_stats', + type: 'routine-def', + connectionId: 'conn-1', + dbName: 'main', + routineName: 'reporting.refresh_stats', + routineType: 'PROCEDURE', + }); + }); + + it('switches current database on cmd left click for database identifiers', async () => { + editorState.value = 'use analytics'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] }); + backendApp.DBGetTables + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] }); + backendApp.DBGetAllColumns + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 6 } }, + event: { + leftButton: true, + ctrlKey: false, + metaKey: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }, + }); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'analytics' }); + expect(storeState.addTab).not.toHaveBeenCalled(); + expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', expect.objectContaining({ + dbName: 'analytics', + })); + }); + it('keeps the editor empty when a tab draft is externally synced to an empty query', async () => { let renderer!: ReactTestRenderer; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 530e4a6..d1ede81 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -19,6 +19,8 @@ import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/q import { quoteIdentPart } from '../utils/sql'; import { resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; +import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; +import { normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; @@ -195,6 +197,9 @@ let sharedCurrentConnectionId = ''; let sharedConnections: any[] = []; type CompletionTableMeta = {dbName: string, tableName: string, comment?: string}; type CompletionColumnMeta = {dbName: string, tableName: string, name: string, type: string, comment?: string}; +type CompletionViewMeta = {dbName: string, viewName: string, schemaName?: string}; +type CompletionTriggerMeta = {dbName: string, triggerName: string, tableName: string, schemaName?: string}; +type CompletionRoutineMeta = {dbName: string, routineName: string, routineType: string, schemaName?: string}; let sharedTablesData: CompletionTableMeta[] = []; let sharedAllColumnsData: CompletionColumnMeta[] = []; let sharedVisibleDbs: string[] = []; @@ -475,6 +480,16 @@ const escapeMetadataSqlLiteral = (raw: string): string => String(raw || '').repl const quoteSqlServerDbIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; +type MetadataQuerySpec = { + sql: string; + inferredType?: 'FUNCTION' | 'PROCEDURE'; +}; + +type MetadataQueryResult = { + rows: Record[]; + inferredType?: 'FUNCTION' | 'PROCEDURE'; +}; + const normalizeMetadataDialect = (conn: any): string => { const type = String(conn?.config?.type || '').trim().toLowerCase(); const driver = String(conn?.config?.driver || '').trim(); @@ -641,6 +656,581 @@ const getNormalizedOffsetAtPosition = ( return Math.max(0, Math.min(text.length, offset + Math.max(0, position.column - 1))); }; +const getFirstRowValue = (row: Record): string => { + for (const value of Object.values(row || {})) { + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; +}; + +const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => { + const seen = new Set(); + const normalized: MetadataQuerySpec[] = []; + specs.forEach((spec) => { + const sql = String(spec.sql || '').trim(); + if (!sql) return; + const key = `${spec.inferredType || ''}@@${sql}`; + if (seen.has(key)) return; + seen.add(key); + normalized.push({ sql, inferredType: spec.inferredType }); + }); + return normalized; +}; + +const buildQualifiedCompletionName = (schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const object = String(objectName || '').trim(); + if (!object) return ''; + if (!schema || object.includes('.')) return object; + return `${schema}.${object}`; +}; + +const buildCompletionViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeMetadataSqlLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TABLE_NAME AS view_name, TABLE_SCHEMA AS schema_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME` + : '', + }, + { sql: dbIdent ? `SHOW FULL TABLES FROM \`${dbIdent}\`` : '' }, + { sql: 'SHOW FULL TABLES' }, + ]); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerDbIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }]; + } + case 'oracle': { + return normalizeMetadataQuerySpecs([ + { sql: 'SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME' }, + { sql: 'SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME' }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` + : '', + }, + ]); + } + case 'sqlite': + return [{ sql: 'SELECT name AS view_name FROM sqlite_master WHERE type = \'view\' ORDER BY name' }]; + case 'duckdb': + return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; + default: + return []; + } +}; + +const buildCompletionMaterializedViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + if (dialect !== 'starrocks') { + return []; + } + const safeDbName = escapeMetadataSqlLiteral(dbName); + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME` + : '', + }, + { sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' }, + { sql: 'SHOW MATERIALIZED VIEWS' }, + ]); +}; + +const buildCompletionTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeMetadataSqlLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME` + : '', + }, + { sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' }, + { sql: 'SHOW TRIGGERS' }, + ]); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerDbIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }]; + } + case 'oracle': + if (!safeDbName) { + return [{ sql: 'SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME' }]; + } + return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; + case 'sqlite': + return [{ sql: 'SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = \'trigger\' ORDER BY tbl_name, name' }]; + default: + return []; + } +}; + +const buildCompletionFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeMetadataSqlLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME` + : '', + }, + { + sql: safeDbName ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` : 'SHOW FUNCTION STATUS', + inferredType: 'FUNCTION', + }, + { + sql: safeDbName ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` : 'SHOW PROCEDURE STATUS', + inferredType: 'PROCEDURE', + }, + ]); + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + return normalizeMetadataQuerySpecs([ + { + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`, + }, + { + sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`, + }, + { + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`, + }, + ]); + case 'sqlserver': { + const safeDb = quoteSqlServerDbIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }]; + } + case 'oracle': + return normalizeMetadataQuerySpecs([ + { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` + : '', + }, + ]); + case 'duckdb': + return [{ + sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, + inferredType: 'FUNCTION', + }]; + default: + return []; + } +}; + +const queryCompletionMetadataRowsBySpecs = async ( + config: Record, + dbName: string, + specs: MetadataQuerySpec[], +): Promise => { + const normalizedSpecs = normalizeMetadataQuerySpecs(specs); + if (normalizedSpecs.length === 0) { + return []; + } + const results: MetadataQueryResult[] = []; + for (const spec of normalizedSpecs) { + try { + const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql); + if (!result.success || !Array.isArray(result.data)) { + continue; + } + results.push({ + rows: result.data as Record[], + inferredType: spec.inferredType, + }); + } catch { + // 忽略单条元数据查询失败,继续走兼容查询。 + } + } + return results; +}; + +type QueryEditorNavigationTarget = + | { type: 'database'; dbName: string } + | { type: 'table'; dbName: string; tableName: string; schemaName?: string } + | { type: 'view'; dbName: string; viewName: string; schemaName?: string } + | { type: 'materialized-view'; dbName: string; viewName: string; schemaName?: string } + | { type: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string } + | { type: 'routine'; dbName: string; routineName: string; routineType: string; schemaName?: string }; + +const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/; + +const findIdentifierWindowAtOffset = ( + lineContent: string, + rawOffset: number, +): { start: number; end: number } | null => { + const text = String(lineContent || ''); + if (!text) return null; + const maxIndex = text.length - 1; + if (maxIndex < 0) return null; + let offset = Math.max(0, Math.min(maxIndex, Number.isFinite(rawOffset) ? rawOffset : 0)); + + if (!QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset] || '')) { + if (offset > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset - 1] || '')) { + offset -= 1; + } else if (offset < maxIndex && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset + 1] || '')) { + offset += 1; + } else { + return null; + } + } + + let start = offset; + while (start > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[start - 1] || '')) { + start -= 1; + } + + let end = offset + 1; + while (end < text.length && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[end] || '')) { + end += 1; + } + + return start < end ? { start, end } : null; +}; + +const normalizeNavigationIdentifierParts = (text: string): string[] => ( + String(text || '') + .split('.') + .map((part) => stripCompletionIdentifierQuotes(part)) + .map((part) => part.trim()) + .filter(Boolean) +); + +export const resolveQueryEditorNavigationTarget = ( + lineContent: string, + column: number, + currentDb: string, + visibleDbs: string[], + tables: CompletionTableMeta[], + views: CompletionViewMeta[] = [], + materializedViews: CompletionViewMeta[] = [], + triggers: CompletionTriggerMeta[] = [], + routines: CompletionRoutineMeta[] = [], +): QueryEditorNavigationTarget | null => { + const text = String(lineContent || ''); + if (!text) return null; + + const offset = Math.max(0, Number(column || 1) - 2); + const windowRange = findIdentifierWindowAtOffset(text, offset); + if (!windowRange) return null; + + const rawIdentifier = text.slice(windowRange.start, windowRange.end).trim(); + if (!rawIdentifier) return null; + + const parts = normalizeNavigationIdentifierParts(rawIdentifier); + if (parts.length === 0 || parts.length > 3) return null; + + const currentDbName = String(currentDb || '').trim(); + const visibleDbSet = new Set(visibleDbs.map((db) => String(db || '').trim().toLowerCase()).filter(Boolean)); + const tableMetas = tables.map((table) => { + const dbName = String(table.dbName || '').trim(); + const rawTableName = String(table.tableName || '').trim(); + const parsed = splitSidebarQualifiedName(rawTableName); + return { + dbName, + rawTableName, + normalizedDbName: dbName.toLowerCase(), + normalizedRawTableName: rawTableName.toLowerCase(), + normalizedObjectName: String(parsed.objectName || rawTableName).trim().toLowerCase(), + schemaName: String(parsed.schemaName || '').trim(), + normalizedSchemaName: String(parsed.schemaName || '').trim().toLowerCase(), + }; + }); + + const buildObjectNameMeta = ( + dbName: string, + rawObjectName: string, + explicitSchemaName = '', + ) => { + const parsed = splitSidebarQualifiedName(rawObjectName); + const schemaName = String(explicitSchemaName || parsed.schemaName || '').trim(); + const objectName = String(parsed.objectName || rawObjectName).trim(); + return { + dbName: String(dbName || '').trim(), + rawObjectName: String(rawObjectName || '').trim(), + objectName, + schemaName, + normalizedDbName: String(dbName || '').trim().toLowerCase(), + normalizedRawObjectName: String(rawObjectName || '').trim().toLowerCase(), + normalizedObjectName: objectName.toLowerCase(), + normalizedSchemaName: schemaName.toLowerCase(), + }; + }; + + const viewMetas = views.map((view) => buildObjectNameMeta(view.dbName, view.viewName, view.schemaName)); + const materializedViewMetas = materializedViews.map((view) => buildObjectNameMeta(view.dbName, view.viewName, view.schemaName)); + const triggerMetas = triggers.map((trigger) => ({ + ...buildObjectNameMeta(trigger.dbName, trigger.triggerName, trigger.schemaName), + tableName: String(trigger.tableName || '').trim(), + })); + const routineMetas = routines.map((routine) => ({ + ...buildObjectNameMeta(routine.dbName, routine.routineName, routine.schemaName), + routineType: String(routine.routineType || 'FUNCTION').trim().toUpperCase() || 'FUNCTION', + })); + + const findTable = (candidateDbName: string, candidateTableName: string, schemaName = ''): QueryEditorNavigationTarget | null => { + const normalizedDbName = String(candidateDbName || '').trim().toLowerCase(); + const normalizedTableName = String(candidateTableName || '').trim().toLowerCase(); + const normalizedSchemaName = String(schemaName || '').trim().toLowerCase(); + if (!normalizedDbName || !normalizedTableName) return null; + + const exactQualifiedName = normalizedSchemaName ? `${normalizedSchemaName}.${normalizedTableName}` : normalizedTableName; + const exact = tableMetas.find((meta) => + meta.normalizedDbName === normalizedDbName + && meta.normalizedRawTableName === exactQualifiedName + ); + if (exact) { + return { + type: 'table', + dbName: exact.dbName, + tableName: exact.rawTableName, + schemaName: exact.schemaName || undefined, + }; + } + + const matched = tableMetas.find((meta) => + meta.normalizedDbName === normalizedDbName + && meta.normalizedObjectName === normalizedTableName + && (!normalizedSchemaName || meta.normalizedSchemaName === normalizedSchemaName) + ); + if (!matched) return null; + return { + type: 'table', + dbName: matched.dbName, + tableName: matched.rawTableName, + schemaName: matched.schemaName || undefined, + }; + }; + + const findNamedObject = ( + metas: TMeta[], + candidateDbName: string, + candidateObjectName: string, + schemaName = '', + ): TMeta | null => { + const normalizedDbName = String(candidateDbName || '').trim().toLowerCase(); + const normalizedObjectName = String(candidateObjectName || '').trim().toLowerCase(); + const normalizedSchemaName = String(schemaName || '').trim().toLowerCase(); + if (!normalizedDbName || !normalizedObjectName) return null; + + const exactQualifiedName = normalizedSchemaName ? `${normalizedSchemaName}.${normalizedObjectName}` : normalizedObjectName; + const exact = metas.find((meta) => + meta.normalizedDbName === normalizedDbName + && meta.normalizedRawObjectName === exactQualifiedName + ); + if (exact) { + return exact; + } + + return metas.find((meta) => + meta.normalizedDbName === normalizedDbName + && meta.normalizedObjectName === normalizedObjectName + && (!normalizedSchemaName || meta.normalizedSchemaName === normalizedSchemaName) + ) || null; + }; + + const findView = (candidateDbName: string, candidateViewName: string, schemaName = ''): QueryEditorNavigationTarget | null => { + const matched = findNamedObject(viewMetas, candidateDbName, candidateViewName, schemaName); + if (!matched) return null; + return { + type: 'view', + dbName: matched.dbName, + viewName: matched.rawObjectName, + schemaName: matched.schemaName || undefined, + }; + }; + + const findMaterializedView = (candidateDbName: string, candidateViewName: string, schemaName = ''): QueryEditorNavigationTarget | null => { + const matched = findNamedObject(materializedViewMetas, candidateDbName, candidateViewName, schemaName); + if (!matched) return null; + return { + type: 'materialized-view', + dbName: matched.dbName, + viewName: matched.rawObjectName, + schemaName: matched.schemaName || undefined, + }; + }; + + const findTrigger = (candidateDbName: string, candidateTriggerName: string, schemaName = ''): QueryEditorNavigationTarget | null => { + const matched = findNamedObject(triggerMetas, candidateDbName, candidateTriggerName, schemaName); + if (!matched) return null; + return { + type: 'trigger', + dbName: matched.dbName, + triggerName: matched.rawObjectName, + tableName: matched.tableName, + schemaName: matched.schemaName || undefined, + }; + }; + + const findRoutine = (candidateDbName: string, candidateRoutineName: string, schemaName = ''): QueryEditorNavigationTarget | null => { + const matched = findNamedObject(routineMetas, candidateDbName, candidateRoutineName, schemaName); + if (!matched) return null; + return { + type: 'routine', + dbName: matched.dbName, + routineName: matched.rawObjectName, + routineType: matched.routineType, + schemaName: matched.schemaName || undefined, + }; + }; + + const findObjectInPriorityOrder = (candidateDbName: string, candidateObjectName: string, schemaName = ''): QueryEditorNavigationTarget | null => ( + findTable(candidateDbName, candidateObjectName, schemaName) + || findView(candidateDbName, candidateObjectName, schemaName) + || findMaterializedView(candidateDbName, candidateObjectName, schemaName) + || findTrigger(candidateDbName, candidateObjectName, schemaName) + || findRoutine(candidateDbName, candidateObjectName, schemaName) + ); + + if (parts.length === 1) { + const [singlePart] = parts; + const normalizedSingle = singlePart.toLowerCase(); + if (visibleDbSet.has(normalizedSingle)) { + return { type: 'database', dbName: singlePart }; + } + return findObjectInPriorityOrder(currentDbName, singlePart); + } + + if (parts.length === 2) { + const [firstPart, secondPart] = parts; + if (visibleDbSet.has(firstPart.toLowerCase())) { + return findObjectInPriorityOrder(firstPart, secondPart); + } + return findObjectInPriorityOrder(currentDbName, secondPart, firstPart); + } + + const [dbName, schemaName, tableName] = parts; + if (!visibleDbSet.has(dbName.toLowerCase())) { + return null; + } + return findObjectInPriorityOrder(dbName, tableName, schemaName); +}; + +export const resolveQueryEditorNavigationDecorations = ( + lineContent: string, + column: number, + currentDb: string, + visibleDbs: string[], + tables: CompletionTableMeta[], + views: CompletionViewMeta[] = [], + materializedViews: CompletionViewMeta[] = [], + triggers: CompletionTriggerMeta[] = [], + routines: CompletionRoutineMeta[] = [], +): Array<{ startColumn: number; endColumn: number; hoverMessage: string }> => { + const text = String(lineContent || ''); + if (!text) return []; + const offset = Math.max(0, Number(column || 1) - 2); + const windowRange = findIdentifierWindowAtOffset(text, offset); + if (!windowRange) return []; + + const navigationTarget = resolveQueryEditorNavigationTarget( + lineContent, + column, + currentDb, + visibleDbs, + tables, + views, + materializedViews, + triggers, + routines, + ); + if (!navigationTarget) return []; + + const hoverMessage = (() => { + if (navigationTarget.type === 'database') { + return 'Ctrl/Cmd + 点击切换到该数据库'; + } + if (navigationTarget.type === 'table') { + return 'Ctrl/Cmd + 点击打开该表'; + } + if (navigationTarget.type === 'view') { + return 'Ctrl/Cmd + 点击打开该视图'; + } + if (navigationTarget.type === 'materialized-view') { + return 'Ctrl/Cmd + 点击打开该物化视图'; + } + if (navigationTarget.type === 'trigger') { + return 'Ctrl/Cmd + 点击打开该触发器'; + } + return navigationTarget.routineType === 'PROCEDURE' + ? 'Ctrl/Cmd + 点击打开该存储过程' + : 'Ctrl/Cmd + 点击打开该函数'; + })(); + + return [{ + startColumn: windowRange.start + 1, + endColumn: windowRange.end + 1, + hoverMessage, + }]; +}; + +const dispatchQueryEditorSidebarLocate = (detail: Record) => { + if (typeof window === 'undefined') { + return; + } + const connectionId = String(detail.connectionId || '').trim(); + const dbName = String(detail.dbName || '').trim(); + const objectName = String(detail.tableName || detail.viewName || detail.triggerName || detail.routineName || detail.objectName || '').trim(); + if (!connectionId || !dbName || !objectName) { + return; + } + window.dispatchEvent(new CustomEvent('gonavi:locate-sidebar-object', { + detail, + })); +}; + +const clearQueryEditorLinkDecorations = ( + editor: any, + decorationIdsRef: React.MutableRefObject, +) => { + if (!editor?.deltaDecorations) { + decorationIdsRef.current = []; + return; + } + decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []); +}; + const resolveQueryLocatorPlan = async ({ statement, dbType, @@ -823,11 +1413,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const lastExternalQueryRef = useRef(getTabQueryValue(tab)); const lastEditorCursorPositionRef = useRef(null); const lastExecutedEditorQueryRef = useRef(''); + const linkDecorationIdsRef = useRef([]); + const ctrlMetaPressedRef = useRef(false); const dragRef = useRef<{ startY: number, startHeight: number } | null>(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 visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const connections = useStore(state => state.connections); @@ -837,6 +1433,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ); const addSqlLog = useStore(state => state.addSqlLog); const addTab = useStore(state => state.addTab); + const setActiveContext = useStore(state => state.setActiveContext); const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft); const savedQueries = useStore(state => state.savedQueries); const currentConnectionIdRef = useRef(currentConnectionId); @@ -1055,6 +1652,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // 加载所有可见数据库的表 const allTables: CompletionTableMeta[] = []; const allColumns: CompletionColumnMeta[] = []; + const allViews: CompletionViewMeta[] = []; + const allMaterializedViews: CompletionViewMeta[] = []; + const allTriggers: CompletionTriggerMeta[] = []; + const allRoutines: CompletionRoutineMeta[] = []; const metadataDialect = normalizeMetadataDialect(conn); for (const dbName of visibleDbs) { @@ -1104,10 +1705,122 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); }); } + + const viewResults = await queryCompletionMetadataRowsBySpecs( + config, + dbName, + buildCompletionViewsMetadataQuerySpecs(metadataDialect, dbName), + ); + const seenViews = new Set(); + viewResults.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const tableType = String(getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']) || '').trim().toUpperCase(); + if (tableType && tableType !== 'VIEW') 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() || 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, + }); + }); + }); + + const materializedViewResults = await queryCompletionMetadataRowsBySpecs( + config, + dbName, + buildCompletionMaterializedViewsMetadataQuerySpecs(metadataDialect, dbName), + ); + 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, + }); + }); + }); + + const triggerResults = await queryCompletionMetadataRowsBySpecs( + config, + dbName, + buildCompletionTriggersMetadataQuerySpecs(metadataDialect, dbName), + ); + 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, + }); + }); + }); + + const routineResults = await queryCompletionMetadataRowsBySpecs( + config, + dbName, + buildCompletionFunctionsMetadataQuerySpecs(metadataDialect, dbName), + ); + 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, + }); + }); + }); } tablesRef.current = allTables; allColumnsRef.current = allColumns; + viewsRef.current = allViews; + materializedViewsRef.current = allMaterializedViews; + triggersRef.current = allTriggers; + routinesRef.current = allRoutines; // 如果当前 Tab 是活跃 Tab,同步更新共享变量 if (isActive) { sharedTablesData = allTables; @@ -1155,6 +1868,68 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc monacoRef.current = monaco; lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.()); + const applyNavigationHoverState = (event: any) => { + if (!ctrlMetaPressedRef.current) { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + return; + } + const targetPosition = normalizeEditorPosition(event?.target?.position); + if (!targetPosition) { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + 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, + ); + if (decorations.length === 0) { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + 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', + hoverMessage: { value: item.hoverMessage }, + }, + })), + ); + editor.updateOptions?.({ mouseStyle: 'pointer' }); + }; + + const syncModifierState = (keyboardEvent?: KeyboardEvent | MouseEvent | null) => { + ctrlMetaPressedRef.current = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey); + if (!ctrlMetaPressedRef.current) { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + } + }; + const handleWindowBlur = () => { + ctrlMetaPressedRef.current = false; + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + }; + // 应用透明主题(主题由 MonacoEditor 包装组件按需注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); @@ -1165,6 +1940,171 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }); + editor.onMouseMove?.((event: any) => { + syncModifierState(event?.event || null); + applyNavigationHoverState(event); + }); + editor.onMouseLeave?.(() => { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + editor.updateOptions?.({ mouseStyle: 'text' }); + }); + + window.addEventListener('keydown', syncModifierState); + window.addEventListener('keyup', syncModifierState); + window.addEventListener('blur', handleWindowBlur); + + editor.onMouseDown?.((event: any) => { + const browserEvent = event?.event; + syncModifierState(browserEvent || null); + const targetPosition = normalizeEditorPosition(event?.target?.position); + if (!browserEvent || !targetPosition) { + return; + } + if (browserEvent.leftButton !== true) { + return; + } + if (!browserEvent.ctrlKey && !browserEvent.metaKey) { + 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, + ); + 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; + } + + setCurrentDb(targetDbName); + currentDbRef.current = targetDbName; + setActiveContext({ connectionId, dbName: targetDbName }); + 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, + }); + dispatchQueryEditorSidebarLocate({ + connectionId, + dbName: targetDbName, + tableName: targetTableName, + schemaName: navigationTarget.schemaName, + objectGroup: 'tables', + }); + return; + } + + if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') { + const targetViewName = String(navigationTarget.viewName || '').trim(); + if (!targetViewName) return; + addTab({ + id: `view-def-${connectionId}-${targetDbName}-${targetViewName}`, + title: `${navigationTarget.type === 'materialized-view' ? '物化视图' : '视图'}: ${targetViewName}`, + type: 'view-def', + connectionId, + dbName: targetDbName, + viewName: targetViewName, + viewKind: navigationTarget.type === 'materialized-view' ? 'materialized' : 'view', + }); + dispatchQueryEditorSidebarLocate({ + connectionId, + dbName: targetDbName, + viewName: targetViewName, + tableName: targetViewName, + schemaName: navigationTarget.schemaName, + objectGroup: navigationTarget.type === 'materialized-view' ? 'materializedViews' : 'views', + }); + return; + } + + if (navigationTarget.type === 'trigger') { + const targetTriggerName = String(navigationTarget.triggerName || '').trim(); + if (!targetTriggerName) return; + addTab({ + id: `trigger-${connectionId}-${targetDbName}-${targetTriggerName}`, + title: `触发器: ${targetTriggerName}`, + type: 'trigger', + connectionId, + dbName: targetDbName, + triggerName: targetTriggerName, + }); + dispatchQueryEditorSidebarLocate({ + connectionId, + dbName: targetDbName, + triggerName: targetTriggerName, + tableName: targetTriggerName, + schemaName: navigationTarget.schemaName, + objectGroup: 'triggers', + }); + return; + } + + const targetRoutineName = String(navigationTarget.routineName || '').trim(); + if (!targetRoutineName) return; + addTab({ + id: `routine-def-${connectionId}-${targetDbName}-${targetRoutineName}`, + title: `${navigationTarget.routineType === 'PROCEDURE' ? '存储过程' : '函数'}: ${targetRoutineName}`, + type: 'routine-def', + connectionId, + dbName: targetDbName, + routineName: targetRoutineName, + routineType: navigationTarget.routineType, + }); + dispatchQueryEditorSidebarLocate({ + connectionId, + dbName: targetDbName, + routineName: targetRoutineName, + tableName: targetRoutineName, + schemaName: navigationTarget.schemaName, + objectGroup: 'routines', + }); + }); + + editor.onDidDispose?.(() => { + clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + window.removeEventListener('keydown', syncModifierState); + window.removeEventListener('keyup', syncModifierState); + window.removeEventListener('blur', handleWindowBlur); + }); + // 注册 AI 右键菜单操作 const aiActions = [ { id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' }, diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index 5555c19..8b73688 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -96,6 +96,32 @@ describe('sidebarLocate', () => { })).toBeNull(); }); + it('builds locate requests from trigger and routine tabs', () => { + expect(normalizeSidebarLocateObjectRequestFromTab({ + id: 'trigger-conn-1-main-audit.users_bi', + type: 'trigger', + connectionId: 'conn-1', + dbName: 'main', + triggerName: 'audit.users_bi', + })).toMatchObject({ + tableName: 'audit.users_bi', + schemaName: 'audit', + objectGroup: 'triggers', + }); + + expect(normalizeSidebarLocateObjectRequestFromTab({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + type: 'routine-def', + connectionId: 'conn-1', + dbName: 'main', + routineName: 'reporting.refresh_stats', + })).toMatchObject({ + tableName: 'reporting.refresh_stats', + schemaName: 'reporting', + objectGroup: 'routines', + }); + }); + it('keeps StarRocks materialized view tabs on the materialized views branch', () => { const request = normalizeSidebarLocateObjectRequestFromTab({ id: 'view-def-conn-1-main-sales.mv_daily', @@ -182,4 +208,81 @@ describe('sidebarLocate', () => { 'conn-1-main-public.users', ]); }); + + it('finds trigger and routine paths from loaded tree data', () => { + const triggerTarget = resolveSidebarLocateTarget({ + connectionId: 'conn-1', + dbName: 'main', + tableName: 'audit.users_bi', + schemaName: 'audit', + objectGroup: 'triggers', + }, { groupBySchema: true }); + + const routineTarget = resolveSidebarLocateTarget({ + connectionId: 'conn-1', + dbName: 'main', + tableName: 'reporting.refresh_stats', + schemaName: 'reporting', + objectGroup: 'routines', + }, { groupBySchema: true }); + + const tree = [ + { + key: 'conn-1', + children: [ + { + key: 'conn-1-main', + dataRef: { id: 'conn-1', dbName: 'main' }, + children: [ + { + key: 'conn-1-main-schema-audit', + children: [ + { + key: 'conn-1-main-schema-audit-triggers', + children: [ + { + key: 'conn-1-main-trigger-audit.users_bi-audit.users', + type: 'db-trigger', + dataRef: { id: 'conn-1', dbName: 'main', triggerName: 'audit.users_bi', schemaName: 'audit' }, + }, + ], + }, + ], + }, + { + key: 'conn-1-main-schema-reporting', + children: [ + { + key: 'conn-1-main-schema-reporting-routines', + children: [ + { + key: 'conn-1-main-routine-reporting.refresh_stats', + type: 'routine', + dataRef: { id: 'conn-1', dbName: 'main', routineName: 'reporting.refresh_stats', schemaName: 'reporting' }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + expect(findSidebarNodePathForLocate(tree, triggerTarget)).toEqual([ + 'conn-1', + 'conn-1-main', + 'conn-1-main-schema-audit', + 'conn-1-main-schema-audit-triggers', + 'conn-1-main-trigger-audit.users_bi-audit.users', + ]); + expect(findSidebarNodePathForLocate(tree, routineTarget)).toEqual([ + 'conn-1', + 'conn-1-main', + 'conn-1-main-schema-reporting', + 'conn-1-main-schema-reporting-routines', + 'conn-1-main-routine-reporting.refresh_stats', + ]); + }); }); diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index d633ae7..88f8b89 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -1,4 +1,4 @@ -export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews'; +export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines'; export interface SidebarLocateObjectRequest { tabId?: string; @@ -38,6 +38,8 @@ export interface SidebarLocateTabLike { tableName?: string; viewName?: string; viewKind?: string; + triggerName?: string; + routineName?: string; } const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); @@ -57,15 +59,21 @@ const inferObjectGroup = (detail: Record, connectionId: string, const explicitGroup = toTrimmedString(detail.objectGroup); if (explicitGroup === 'views' || explicitGroup === 'view') return 'views'; if (explicitGroup === 'materializedViews' || explicitGroup === 'materialized-view') return 'materializedViews'; + if (explicitGroup === 'triggers' || explicitGroup === 'trigger') return 'triggers'; + if (explicitGroup === 'routines' || explicitGroup === 'routine') return 'routines'; const explicitType = toTrimmedString(detail.objectType); if (explicitType === 'view' || explicitType === 'views') return 'views'; if (explicitType === 'materialized' || explicitType === 'materialized-view') return 'materializedViews'; + if (explicitType === 'trigger' || explicitType === 'triggers') return 'triggers'; + if (explicitType === 'routine' || explicitType === 'routines') return 'routines'; const tabId = toTrimmedString(detail.tabId); const dbNodeKey = `${connectionId}-${dbName}`; if (tabId.startsWith(`${dbNodeKey}-materialized-view-`)) return 'materializedViews'; if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views'; + if (tabId.startsWith(`${dbNodeKey}-trigger-`)) return 'triggers'; + if (tabId.startsWith(`${dbNodeKey}-routine-`) || tabId.startsWith(`routine-def-${connectionId}-${dbName}-`)) return 'routines'; return 'tables'; }; @@ -74,7 +82,7 @@ export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLoc const raw = (detail || {}) as Record; const connectionId = toTrimmedString(raw.connectionId); const dbName = toTrimmedString(raw.dbName); - const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName); + const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName || raw.triggerName || raw.routineName); if (!connectionId || !dbName || !tableName) { return null; @@ -97,8 +105,12 @@ export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTab if (!tab) return null; const objectName = tab.type === 'view-def' ? toTrimmedString(tab.viewName || tab.tableName) - : toTrimmedString(tab.tableName || tab.viewName); - if (tab.type !== 'table' && tab.type !== 'view-def') { + : tab.type === 'trigger' + ? toTrimmedString(tab.triggerName || tab.tableName) + : tab.type === 'routine-def' + ? toTrimmedString(tab.routineName || tab.tableName) + : toTrimmedString(tab.tableName || tab.viewName); + if (tab.type !== 'table' && tab.type !== 'view-def' && tab.type !== 'trigger' && tab.type !== 'routine-def') { return null; } @@ -109,7 +121,7 @@ export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTab tableName: objectName, objectGroup: tab.type === 'view-def' ? (tab.viewKind === 'materialized' ? 'materializedViews' : 'views') - : undefined, + : (tab.type === 'trigger' ? 'triggers' : (tab.type === 'routine-def' ? 'routines' : undefined)), }); }; @@ -121,7 +133,13 @@ export const resolveSidebarLocateTarget = ( const databaseKey = `${request.connectionId}-${request.dbName}`; const fallbackTargetKey = request.objectGroup === 'materializedViews' ? `${databaseKey}-materialized-view-${request.tableName}` - : (request.objectGroup === 'views' ? `${databaseKey}-view-${request.tableName}` : `${databaseKey}-${request.tableName}`); + : request.objectGroup === 'views' + ? `${databaseKey}-view-${request.tableName}` + : request.objectGroup === 'triggers' + ? `${databaseKey}-trigger-${request.tableName}` + : request.objectGroup === 'routines' + ? `${databaseKey}-routine-${request.tableName}` + : `${databaseKey}-${request.tableName}`; const targetKey = request.tabId || fallbackTargetKey; const schemaSegment = request.schemaName || 'default'; const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined; @@ -204,6 +222,16 @@ const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: Sideba return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); } + if (target.objectGroup === 'triggers') { + if (node.type !== 'db-trigger') return false; + return matchesLocateObjectName(target, toTrimmedString(dataRef.triggerName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + } + + if (target.objectGroup === 'routines') { + if (node.type !== 'routine') return false; + return matchesLocateObjectName(target, toTrimmedString(dataRef.routineName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + } + if (node.type !== 'table') return false; return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName)); };