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));
};