From 7c9cf95698fec57875b3f3b5d83b8baff8661766 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 26 Jun 2026 17:28:09 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AF=B9=E8=B1=A1=E8=B7=B3=E8=BD=AC=E4=B8=8E=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E8=BF=87=E7=A8=8B=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对象超链接支持表跳转到数据页内对象设计,并兼容 Ctrl/Cmd 修饰键状态丢失场景 - MySQL 补全增加 CALL 关键字和存储过程/函数名称提示,支持 CALL routine 跳转编辑 - DataGrid 支持按 tab 请求初始切换到对象设计视图,补充相关回归测试 --- frontend/src/components/DataGrid.ddl.test.tsx | 34 + frontend/src/components/DataGrid.tsx | 6 +- frontend/src/components/DataGridCore.tsx | 2 + frontend/src/components/DataViewer.tsx | 2 + .../QueryEditor.external-sql-save.test.tsx | 571 +++++++++++-- frontend/src/components/QueryEditor.tsx | 778 ++++++++++++++++-- .../queryEditor/QueryEditorHelpers.ts | 10 + frontend/src/components/useDataGridDdlView.ts | 33 +- frontend/src/i18n/catalog.test.ts | 49 +- frontend/src/types.ts | 2 + frontend/src/utils/sqlDialect.test.ts | 2 +- frontend/src/utils/sqlDialect.ts | 1 + shared/i18n/de-DE.json | 6 +- shared/i18n/en-US.json | 6 +- shared/i18n/ja-JP.json | 6 +- shared/i18n/ru-RU.json | 6 +- shared/i18n/zh-CN.json | 6 +- shared/i18n/zh-TW.json | 6 +- 18 files changed, 1374 insertions(+), 152 deletions(-) diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index dce3a549..1b64b7b6 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -1890,6 +1890,40 @@ describe('DataGrid DDL interactions', () => { expect(content).toContain('name'); }); + it('opens the embedded object designer from an initial v2 table view request', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'id', type: 'bigint', key: 'PRI', nullable: 'NO', default: '', comment: '' }, + { name: 'name', type: 'varchar(255)', key: '', nullable: 'YES', default: '', comment: '' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + const content = textContent(renderer!.root); + expect(content).toContain('SCHEMA DESIGNER'); + expect(content).toContain('字段'); + expect(content).toContain('id'); + expect(content).toContain('name'); + }); + it('returns to the legacy table view when v2-only footer views are active during UI switch', async () => { storeState.appearance.uiVersion = 'v2'; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 298c303e..b9a93033 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -286,7 +286,9 @@ const DataGrid: React.FC = ({ resultExportAllSql, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, - scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn = false, isActive = true, enableSqlLogEvent = false + scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn = false, isActive = true, enableSqlLogEvent = false, + initialViewMode, + initialViewModeRequestId }) => { const connections = useStore(state => state.connections); const addTab = useStore(state => state.addTab); @@ -1343,6 +1345,8 @@ const DataGrid: React.FC = ({ }, }, translate: translateDataGrid, + initialViewMode, + initialViewModeRequestId, }); useEffect(() => { diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index fbb999e4..c8741e59 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -1336,6 +1336,8 @@ interface DataGridProps { toolbarExtraActions?: React.ReactNode; isActive?: boolean; enableSqlLogEvent?: boolean; + initialViewMode?: GridViewMode; + initialViewModeRequestId?: string; } type GridFilterCondition = FilterCondition & { diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index dee7fc50..d8cd05bc 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -1231,6 +1231,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ onScrollSnapshotChange={handleTableScrollSnapshotChange} isActive={isActive} enableSqlLogEvent + initialViewMode={tab.initialViewMode} + initialViewModeRequestId={tab.initialViewModeRequestId} /> ); diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index d11c3809..f8d13367 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1302,6 +1302,83 @@ describe('QueryEditor external SQL save', () => { }); }); + it('suggests MySQL CALL keyword and stored routine names in SQL completion', async () => { + let renderer!: ReactTestRenderer; + autoFetchState.visible = true; + storeState.connections[0].config.type = 'mysql'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + const text = String(sql || ''); + if (text.includes('information_schema.routines')) { + return { + success: true, + data: [ + { routine_name: 'codex_tmp_proc_link_test', routine_type: 'PROCEDURE', schema_name: 'main' }, + { routine_name: 'codex_tmp_score_user', routine_type: 'FUNCTION', schema_name: 'main' }, + ], + }; + } + return { success: true, data: [] }; + }); + + await act(async () => { + renderer = create(); + }); + await act(async () => { + for (let i = 0; i < 12; i += 1) { + await Promise.resolve(); + } + }); + + const sqlProvider = findSqlCompletionProvider(); + expect(sqlProvider).toBeTruthy(); + + editorState.value = 'CA'; + editorState.latestOnChange?.(editorState.value); + const keywordItems = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + expect(keywordItems.suggestions.some((item: any) => item.label === 'CALL')).toBe(true); + + editorState.value = 'CALL codex_tmp'; + editorState.latestOnChange?.(editorState.value); + const routineItems = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + const procedureSuggestion = routineItems.suggestions.find((item: any) => item.label === 'codex_tmp_proc_link_test'); + const functionSuggestion = routineItems.suggestions.find((item: any) => item.label === 'codex_tmp_score_user'); + + expect(procedureSuggestion).toMatchObject({ + kind: 2, + insertText: 'codex_tmp_proc_link_test($0)', + detail: '存储过程 (main)', + }); + expect(String(procedureSuggestion?.sortText || '')).toMatch(/^00/); + expect(functionSuggestion).toBeUndefined(); + + editorState.value = 'SELECT codex_tmp'; + editorState.latestOnChange?.(editorState.value); + const expressionRoutineItems = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + const expressionFunctionSuggestion = expressionRoutineItems.suggestions.find((item: any) => item.label === 'codex_tmp_score_user'); + expect(expressionFunctionSuggestion).toMatchObject({ + kind: 2, + insertText: 'codex_tmp_score_user($0)', + detail: '函数 (main)', + }); + + await act(async () => { + renderer.unmount(); + }); + }); + it('quotes uppercase postgres table names in FROM completion insert text', async () => { let renderer!: ReactTestRenderer; autoFetchState.visible = true; @@ -1622,7 +1699,7 @@ describe('QueryEditor external SQL save', () => { }); }); - it('opens a table tab on ctrl left click inside the editor', async () => { + it('opens a table data tab with the embedded object designer 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' }] }); @@ -1664,6 +1741,8 @@ describe('QueryEditor external SQL save', () => { connectionId: 'conn-1', dbName: 'analytics', tableName: 'events', + initialViewMode: 'fields', + initialViewModeRequestId: expect.any(String), objectType: 'table', }); expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({ @@ -1673,7 +1752,7 @@ describe('QueryEditor external SQL save', () => { expect(stopPropagation).toHaveBeenCalled(); }); - it('opens a table tab on macOS cmd click when Monaco omits leftButton', async () => { + it('opens a table data tab with the embedded object designer on macOS cmd click when Monaco omits leftButton', async () => { editorState.value = 'select * from fs_mkefu_regist_record;'; autoFetchState.visible = true; backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); @@ -1709,6 +1788,8 @@ describe('QueryEditor external SQL save', () => { connectionId: 'conn-1', dbName: 'mkefu_location_dev_local', tableName: 'fs_mkefu_regist_record', + initialViewMode: 'fields', + initialViewModeRequestId: expect.any(String), objectType: 'table', })); expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({ @@ -1788,6 +1869,74 @@ describe('QueryEditor external SQL save', () => { expect(stopPropagation).toHaveBeenCalled(); }); + it('opens a MySQL procedure object-edit tab from a CALL routine link', async () => { + storeState.connections[0].config.type = 'mysql'; + storeState.connections[0].config.database = 'main'; + editorState.value = 'CALL codex_tmp_proc_link_test();'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + const text = String(sql || ''); + if (text.includes('information_schema.routines') || text.includes('SHOW FUNCTION STATUS') || text.includes('SHOW PROCEDURE STATUS')) { + return { + success: true, + data: [{ routine_name: 'codex_tmp_proc_link_test', routine_type: 'PROCEDURE', schema_name: 'main' }], + }; + } + if (text.includes('SHOW CREATE PROCEDURE')) { + return { + success: true, + data: [{ + 'Create Procedure': 'CREATE PROCEDURE codex_tmp_proc_link_test() BEGIN SELECT 1 AS codex_tmp_result; END', + }], + }; + } + return { success: true, data: [] }; + }); + + await act(async () => { + create(); + }); + await act(async () => { + for (let i = 0; i < 12; i += 1) { + await Promise.resolve(); + } + }); + + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 12 } }, + event: { + browserEvent: { button: 0, buttons: 1 }, + leftButton: true, + ctrlKey: true, + metaKey: false, + preventDefault, + stopPropagation, + }, + }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + expect(backendApp.DBQuery).toHaveBeenCalledWith(expect.any(Object), 'main', 'SHOW CREATE PROCEDURE `codex_tmp_proc_link_test`'); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: expect.stringContaining('codex_tmp_proc_link_test'), + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + queryMode: 'object-edit', + query: expect.stringContaining('CREATE PROCEDURE codex_tmp_proc_link_test()'), + })); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + it('does not read the full editor model when ctrl/cmd clicking objects in large SQL', async () => { editorState.value = [ ...Array.from({ length: 4000 }, (_, index) => `-- filler ${index + 1}`), @@ -1836,6 +1985,7 @@ describe('QueryEditor external SQL save', () => { connectionId: 'conn-1', dbName: 'analytics', tableName: 'events', + initialViewMode: 'fields', })); expect(preventDefault).toHaveBeenCalled(); expect(stopPropagation).toHaveBeenCalled(); @@ -1891,6 +2041,261 @@ describe('QueryEditor external SQL save', () => { expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' }); }); + it('keeps link-style feedback when modifier state is tracked but mousemove omits ctrl/meta flags', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + editorState.value = 'SELECT * FROM uk_user'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'uk_user' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.editor.deltaDecorations.mockClear(); + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener({ + type: 'keydown', + ctrlKey: true, + metaKey: false, + key: 'Control', + code: 'ControlLeft', + repeat: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target: null, + })); + editorState.mouseMoveListeners[0]?.({ + target: { position: { lineNumber: 1, column: 18 } }, + event: { + ctrlKey: false, + metaKey: false, + }, + }); + }); + + expect(editorState.domNode.style.cursor).toBe('pointer'); + const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); + expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); + }); + + it('opens an object tab when modifier state is tracked but mousedown omits ctrl/meta flags', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + editorState.value = 'SELECT * FROM uk_user'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'uk_user' }] }); + backendApp.DBGetAllColumns.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 () => { + windowListeners.keydown?.forEach((listener) => listener({ + type: 'keydown', + ctrlKey: true, + metaKey: false, + key: 'Control', + code: 'ControlLeft', + repeat: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target: null, + })); + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber: 1, column: 18 } }, + event: { + browserEvent: { button: 0, buttons: 1 }, + ctrlKey: false, + metaKey: false, + preventDefault, + stopPropagation, + }, + }); + }); + + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + type: 'table', + connectionId: 'conn-1', + dbName: 'mkefu_location_dev_local', + tableName: 'uk_user', + initialViewMode: 'fields', + })); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('opens an object tab when mousedown stores ctrl/meta flags on the native browser event', async () => { + editorState.value = 'SELECT * FROM uk_user'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'uk_user' }] }); + backendApp.DBGetAllColumns.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: 18 } }, + event: { + leftButton: true, + ctrlKey: false, + metaKey: false, + browserEvent: { + button: 0, + buttons: 1, + ctrlKey: false, + metaKey: true, + preventDefault, + stopPropagation, + }, + preventDefault, + stopPropagation, + }, + }); + }); + + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + type: 'table', + connectionId: 'conn-1', + dbName: 'mkefu_location_dev_local', + tableName: 'uk_user', + initialViewMode: 'fields', + })); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('shows link-style feedback from the current cursor when ctrl/cmd is pressed without moving the mouse', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + editorState.value = 'SELECT * FROM uk_user'; + editorState.position = { lineNumber: 1, column: 'SELECT * FROM uk_user'.length + 1 }; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'uk_user' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.editor.deltaDecorations.mockClear(); + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener({ + ctrlKey: true, + metaKey: false, + key: 'Control', + code: 'ControlLeft', + repeat: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target: null, + })); + }); + + expect(editorState.editor.deltaDecorations).toHaveBeenCalled(); + expect(editorState.domNode.style.cursor).toBe('pointer'); + const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); + expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); + }); + + it('treats modifier keydown itself as pressed when desktop WebView omits ctrl/meta flags', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + editorState.value = 'SELECT * FROM uk_user'; + editorState.position = { lineNumber: 1, column: 'SELECT * FROM uk_user'.length + 1 }; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'uk_user' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.editor.deltaDecorations.mockClear(); + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener({ + type: 'keydown', + ctrlKey: false, + metaKey: false, + key: 'Meta', + code: 'MetaLeft', + repeat: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target: null, + })); + }); + + expect(editorState.domNode.style.cursor).toBe('pointer'); + const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); + expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); + }); + it('shows hover shortcut hints in English for every navigable object kind', () => { setCurrentLanguage('en-US'); @@ -1920,7 +2325,7 @@ describe('QueryEditor external SQL save', () => { const cases = [ { lineContent: 'use analytics', column: 6, expected: 'Ctrl + click to switch to this database' }, - { lineContent: 'select * from analytics.events', column: 27, expected: 'Ctrl + click to open this table' }, + { lineContent: 'select * from analytics.events', column: 27, expected: 'Ctrl + click to open this table object design' }, { lineContent: 'select * from reporting.active_users', column: 31, expected: 'Ctrl + click to open this view' }, { lineContent: 'select * from analytics.mv_daily_stats', column: 37, expected: 'Ctrl + click to open this materialized view' }, { lineContent: 'call audit.users_bi()', column: 18, expected: 'Ctrl + click to open this trigger' }, @@ -3729,7 +4134,7 @@ describe('QueryEditor external SQL save', () => { expect(css).toMatch(/body\[data-theme='dark'\]\s+\.gonavi-query-editor-link-hint\s*\{[^}]*color:\s*#69b1ff\s*!important;/s); }); - it('opens a view tab on ctrl left click inside the editor', async () => { + it('opens a view object-edit 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' }] }); @@ -3737,7 +4142,7 @@ describe('QueryEditor external SQL save', () => { 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: [{ view_name: 'active_users', schema_name: 'reporting', view_definition: 'select id from users' }] }; } return { success: true, data: [] }; }); @@ -3762,32 +4167,36 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); expect(storeState.setActiveContext).not.toHaveBeenCalled(); - expect(storeState.addTab).toHaveBeenCalledWith({ - id: 'view-def-conn-1-main-active_users', - title: '视图:active_users', - type: 'view-def', + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^query-edit-object-conn-1-main-reporting\.active_users-\d+$/), + title: '修改视图: reporting.active_users', + type: 'query', connectionId: 'conn-1', dbName: 'main', - viewName: 'active_users', - viewKind: 'view', - schemaName: 'reporting', - sidebarLocateKey: 'conn-1-main-view-active_users', - }); + queryMode: 'object-edit', + query: expect.stringContaining('CREATE OR REPLACE VIEW reporting.active_users AS'), + })); expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'gonavi:locate-sidebar-object', })); }); - it('opens trigger and routine tabs on ctrl left click inside the editor', async () => { + it('opens trigger and routine object-edit 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('SHOW CREATE TRIGGER')) { + return { success: true, data: [{ 'SQL Original Statement': 'CREATE TRIGGER audit.users_bi BEFORE INSERT ON audit.users FOR EACH ROW SET @a = 1' }] }; + } 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' }] }; } @@ -3817,6 +4226,9 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); await act(async () => { @@ -3830,19 +4242,20 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); - expect(storeState.addTab).toHaveBeenCalledWith({ - id: 'trigger-conn-1-main-audit.users_bi', - title: '触发器:audit.users_bi', - type: 'trigger', + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^query-edit-trigger-conn-1-main-audit\.users_bi-\d+$/), + title: '修改触发器: audit.users_bi', + type: 'query', connectionId: 'conn-1', dbName: 'main', - triggerName: 'audit.users_bi', - triggerTableName: 'audit.users', - schemaName: 'audit', - sidebarLocateKey: 'conn-1-main-trigger-audit.users_bi-audit.users', - }); + queryMode: 'object-edit', + query: expect.stringContaining('CREATE TRIGGER audit.users_bi'), + })); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ id: expect.stringMatching(/^query-edit-routine-conn-1-main-reporting\.refresh_stats-\d+$/), title: '编辑 存储过程:reporting.refresh_stats', @@ -3857,7 +4270,7 @@ describe('QueryEditor external SQL save', () => { })); }); - it('opens sequence and package tabs on ctrl left click inside the editor', async () => { + it('opens sequence and package object-edit tabs on ctrl left click inside the editor', async () => { editorState.value = 'select billing.order_seq.nextval from dual; begin billing.pkg_order.sync_order(1); end;'; autoFetchState.visible = true; storeState.connections[0].config.type = 'oracle'; @@ -3867,7 +4280,25 @@ describe('QueryEditor external SQL save', () => { backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { if (sql.includes('ALL_SEQUENCES') || sql.includes('USER_SEQUENCES')) { - return { success: true, data: [{ sequence_name: 'order_seq', schema_name: 'billing' }] }; + return { + success: true, + data: [{ + sequence_owner: 'BILLING', + sequence_name: 'ORDER_SEQ', + min_value: 1, + max_value: 999999, + increment_by: 1, + cache_size: 20, + cycle_flag: 'N', + order_flag: 'N', + }], + }; + } + if (sql.includes('ALL_SOURCE') || sql.includes('USER_SOURCE')) { + if (sql.includes("TYPE = 'PACKAGE BODY'")) { + return { success: true, data: [{ TEXT: 'PACKAGE BODY pkg_order AS\nPROCEDURE sync_order(p_id NUMBER) IS BEGIN NULL; END;\nEND pkg_order;\n' }] }; + } + return { success: true, data: [{ TEXT: 'PACKAGE pkg_order AS\nPROCEDURE sync_order(p_id NUMBER);\nEND pkg_order;\n' }] }; } if (sql.includes('ALL_OBJECTS') && sql.includes("OBJECT_TYPE = 'PACKAGE'")) { return { success: true, data: [{ package_name: 'pkg_order', schema_name: 'billing' }] }; @@ -3895,6 +4326,9 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); await act(async () => { @@ -3908,35 +4342,36 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); - expect(storeState.addTab).toHaveBeenCalledWith({ - id: 'sequence-def-conn-1-main-billing.order_seq', - title: '序列:billing.order_seq', - type: 'sequence-def', + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^query-edit-object-conn-1-main-BILLING\.ORDER_SEQ-\d+$/), + title: '修改序列: BILLING.ORDER_SEQ', + type: 'query', connectionId: 'conn-1', dbName: 'main', - sequenceName: 'billing.order_seq', - schemaName: 'billing', - sidebarLocateKey: 'sequence-def-conn-1-main-billing.order_seq', - }); - expect(storeState.addTab).toHaveBeenCalledWith({ - id: 'package-def-conn-1-main-billing.pkg_order', - title: '存储包:billing.pkg_order', - type: 'package-def', + queryMode: 'object-edit', + query: expect.stringContaining('CREATE SEQUENCE BILLING.ORDER_SEQ'), + })); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^query-edit-object-conn-1-main-billing\.pkg_order-\d+$/), + title: '修改存储包: billing.pkg_order', + type: 'query', connectionId: 'conn-1', dbName: 'main', - packageName: 'billing.pkg_order', - schemaName: 'billing', - sidebarLocateKey: 'package-def-conn-1-main-billing.pkg_order', - }); + queryMode: 'object-edit', + query: expect.stringContaining('CREATE OR REPLACE PACKAGE pkg_order'), + })); expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'gonavi:locate-sidebar-object', })); }); describe('object navigation tab title localization', () => { - it('uses the English catalog title for view definition tabs', async () => { + it('uses the English catalog title for view object-edit tabs', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); editorState.value = 'select * from reporting.active_users'; @@ -3946,7 +4381,7 @@ describe('QueryEditor external SQL save', () => { 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: [{ view_name: 'active_users', schema_name: 'reporting', view_definition: 'select id from users' }] }; } return { success: true, data: [] }; }); @@ -3971,16 +4406,20 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - id: 'view-def-conn-1-main-active_users', - title: 'View: active_users', - type: 'view-def', + id: expect.stringMatching(/^query-edit-object-conn-1-main-reporting\.active_users-\d+$/), + title: 'Edit View: reporting.active_users', + type: 'query', + queryMode: 'object-edit', })); }); - it('uses the English catalog titles for trigger and procedure tabs', async () => { + it('uses the English catalog titles for trigger and procedure object-edit tabs', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); editorState.value = 'call audit.users_bi(); call reporting.refresh_stats();'; @@ -3989,6 +4428,9 @@ describe('QueryEditor external SQL save', () => { 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('SHOW CREATE TRIGGER')) { + return { success: true, data: [{ 'SQL Original Statement': 'CREATE TRIGGER audit.users_bi BEFORE INSERT ON audit.users FOR EACH ROW SET @a = 1' }] }; + } 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' }] }; } @@ -4018,6 +4460,9 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); await act(async () => { @@ -4031,12 +4476,16 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - id: 'trigger-conn-1-main-audit.users_bi', - title: 'Trigger: audit.users_bi', - type: 'trigger', + id: expect.stringMatching(/^query-edit-trigger-conn-1-main-audit\.users_bi-\d+$/), + title: 'Edit trigger: audit.users_bi', + type: 'query', + queryMode: 'object-edit', })); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ id: expect.stringMatching(/^query-edit-routine-conn-1-main-reporting\.refresh_stats-\d+$/), @@ -4046,7 +4495,7 @@ describe('QueryEditor external SQL save', () => { })); }); - it('uses the English catalog title for materialized view definition tabs', async () => { + it('uses the English catalog title for materialized view object-edit tabs', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); storeState.connections[0].config.type = 'starrocks'; @@ -4059,6 +4508,9 @@ describe('QueryEditor external SQL save', () => { if (sql.includes("UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%'") || sql.includes('SHOW MATERIALIZED VIEWS')) { return { success: true, data: [{ object_name: 'mv_daily_stats', schema_name: 'analytics' }] }; } + if (sql.includes('SHOW CREATE MATERIALIZED VIEW') || sql.includes('SHOW CREATE TABLE')) { + return { success: true, data: [{ 'Create Table': 'CREATE MATERIALIZED VIEW analytics.mv_daily_stats AS SELECT 1 AS id' }] }; + } return { success: true, data: [] }; }); @@ -4082,13 +4534,16 @@ describe('QueryEditor external SQL save', () => { stopPropagation: vi.fn(), }, }); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - id: 'view-def-conn-1-analytics-analytics.mv_daily_stats', - title: 'Materialized view: analytics.mv_daily_stats', - type: 'view-def', - viewKind: 'materialized', + id: expect.stringMatching(/^query-edit-object-conn-1-analytics-analytics\.mv_daily_stats-\d+$/), + title: 'Edit Materialized view: analytics.mv_daily_stats', + type: 'query', + queryMode: 'object-edit', })); }); }); @@ -7786,6 +8241,8 @@ describe('QueryEditor external SQL save', () => { connectionId: 'conn-1', dbName: 'front_end_sys', tableName: 'fs_mkefu_regist_record', + initialViewMode: 'fields', + initialViewModeRequestId: expect.any(String), objectType: 'table', })); }); @@ -7868,6 +8325,8 @@ describe('QueryEditor external SQL save', () => { connectionId: 'conn-1', dbName: 'front_end_sys', tableName: 'fs_mkefu_regist_record', + initialViewMode: 'fields', + initialViewModeRequestId: expect.any(String), objectType: 'table', })); }); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c8ded0c7..aa793210 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -42,6 +42,7 @@ import { type EditRowLocator, } from '../utils/rowLocator'; import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import { buildEditableTriggerSql } from '../utils/triggerEditSql'; import { getColumnDefinitionComment, getColumnDefinitionKey, @@ -93,6 +94,7 @@ import { getCaseInsensitiveValue, getFirstRowValue, getNormalizedPositionAtOffset, + hasQueryEditorCtrlMetaModifier, getInitialEditorQuery, getMySQLShowTablesName, getNormalizedOffsetAtPosition, @@ -182,6 +184,440 @@ const buildQueryEditorRoutineEditFallbackSql = ( return `CREATE OR REPLACE FUNCTION ${routineName}()\nRETURNS INTEGER\nBEGIN\n -- TODO: edit function body\n RETURN 0;\nEND;`; }; +const normalizeQueryEditorMySQLViewDDL = (rawDefinition: unknown): string => { + const text = String(rawDefinition || '').trim(); + if (!text) return ''; + + const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, ''); + const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i; + if (createViewPrefixPattern.test(normalized)) { + return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`; + } + + if (/^\s*(select|with)\b/i.test(normalized)) { + return normalized; + } + + return `${normalized};`; +}; + +const normalizeQueryEditorSqlPlusSlashTerminator = (sql: string): string => ( + String(sql || '').trim().replace(/(^|\n)([ \t]*\/[ \t]*);+([ \t]*(?:--[^\n]*)?)\s*$/i, '$1$2$3') +); + +const hasQueryEditorSqlPlusSlashTerminator = (sql: string): boolean => ( + /(?:^|\n)[ \t]*\/[ \t]*(?:--[^\n]*)?\s*$/i.test(String(sql || '').trim()) +); + +const ensureQueryEditorObjectEditSqlTerminator = (sql: string): string => { + const normalized = normalizeQueryEditorSqlPlusSlashTerminator(sql); + if (!normalized) return ''; + if (hasQueryEditorSqlPlusSlashTerminator(normalized)) return normalized; + return /;\s*$/.test(normalized) ? normalized : `${normalized};`; +}; + +const isQueryEditorCommentOnlyDefinition = (definition: string): boolean => { + const normalized = String(definition || '').replace(/\r\n/g, '\n').trim(); + if (!normalized) return false; + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return lines.length > 0 && lines.every((line) => line.startsWith('--')); +}; + +const withQueryEditorCreateOrReplacePackageHeaders = (definition: string): string => { + const normalized = String(definition || '').replace(/\r\n/g, '\n').trim(); + if (!normalized) return ''; + return normalized + .split(/(?=^\s*PACKAGE(?:\s+BODY)?\b)/gim) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => (/^\s*CREATE\b/i.test(part) ? part : `CREATE OR REPLACE ${part}`)) + .join('\n/\n'); +}; + +const buildQueryEditorQualifiedObjectName = (objectName: string, schemaName?: string): string => { + const normalizedObjectName = String(objectName || '').trim(); + const normalizedSchemaName = String(schemaName || '').trim(); + if (!normalizedObjectName || !normalizedSchemaName || normalizedObjectName.includes('.')) { + return normalizedObjectName; + } + return `${normalizedSchemaName}.${normalizedObjectName}`; +}; + +const buildQueryEditorEditableDefinitionSql = ( + objectType: 'view-def' | 'sequence-def' | 'package-def', + definition: string, + objectName: string, + objectLabel: string, +): string => { + const normalizedDefinition = String(definition || '').trim(); + const header = [ + `-- ${translate('definition_viewer.edit.comment_title', { object: objectLabel, name: objectName })}`, + `-- ${translate('definition_viewer.edit.comment_compatibility')}`, + ].join('\n') + '\n'; + if (!normalizedDefinition) { + return `${header}-- ${translate('definition_viewer.edit.comment_empty_definition', { name: objectName })}\n`; + } + + if (isQueryEditorCommentOnlyDefinition(normalizedDefinition)) { + return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; + } + + if (objectType === 'view-def' && !/^\s*create\b/i.test(normalizedDefinition)) { + if (/^\s*view\b/i.test(normalizedDefinition)) { + return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition.replace(/^\s*view\b/i, 'CREATE OR REPLACE VIEW'))}`; + } + return `${header}CREATE OR REPLACE VIEW ${objectName} AS\n${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; + } + + if (objectType === 'sequence-def' && !/^\s*create\b/i.test(normalizedDefinition)) { + return `${header}${ensureQueryEditorObjectEditSqlTerminator(`CREATE SEQUENCE ${objectName}\n${normalizedDefinition}`)}`; + } + + if ( + objectType === 'package-def' + && !/^\s*create\b/i.test(normalizedDefinition) + && /^\s*package\b/i.test(normalizedDefinition) + ) { + return `${header}${withQueryEditorCreateOrReplacePackageHeaders(normalizedDefinition)}`; + } + + return `${header}${ensureQueryEditorObjectEditSqlTerminator(normalizedDefinition)}`; +}; + +const buildQueryEditorObjectDefinitionConnectionConfig = (conn: any): Record => ({ + ...conn.config, + port: Number(conn.config?.port), + password: conn.config?.password || '', + database: conn.config?.database || '', + useSSH: conn.config?.useSSH || false, + ssh: conn.config?.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, +}); + +const runQueryEditorObjectDefinitionCandidates = async ( + config: Record, + dbName: string, + queries: string[], + collectAll = false, +): Promise => { + const collectedRows: any[] = []; + for (const query of queries) { + const sql = String(query || '').trim(); + if (!sql || sql.startsWith('--')) continue; + try { + const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql); + if (!result.success || !Array.isArray(result.data) || result.data.length === 0) { + continue; + } + if (!collectAll) { + return result.data; + } + collectedRows.push(...result.data); + } catch { + // 元数据定义读取失败时保留编辑模板。 + } + } + return collectedRows; +}; + +const buildQueryEditorViewDefinitionQueries = ( + dialect: string, + viewName: string, + dbName: string, + schemaName?: string, + viewKind?: 'view' | 'materialized', +): string[] => { + const parsed = splitSidebarQualifiedName(viewName); + const objectName = parsed.objectName || viewName; + const schema = String(schemaName || parsed.schemaName || '').trim(); + const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); + const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); + + switch (dialect) { + case 'mysql': + case 'starrocks': { + const viewRef = schema + ? `\`${schema.replace(/`/g, '``')}\`.\`${objectName.replace(/`/g, '``')}\`` + : `\`${objectName.replace(/`/g, '``')}\``; + if (dialect === 'starrocks' && viewKind === 'materialized') { + return [ + `SHOW CREATE MATERIALIZED VIEW ${viewRef}`, + `SHOW CREATE TABLE ${viewRef}`, + ]; + } + return [ + `SHOW CREATE VIEW ${viewRef}`, + safeDbName + ? `SELECT VIEW_DEFINITION AS view_definition FROM information_schema.views WHERE table_schema = '${safeDbName}' AND table_name = '${safeName}' LIMIT 1` + : '', + `SHOW CREATE TABLE ${viewRef}`, + ].filter(Boolean); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': { + const schemaRef = schema || 'public'; + return [`SELECT pg_get_viewdef('${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`]; + } + case 'sqlserver': + return buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition'); + case 'oracle': { + const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); + if (owner) { + return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${owner}' AND VIEW_NAME = '${safeName.toUpperCase()}'`]; + } + return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`]; + } + case 'sqlite': + return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`]; + case 'duckdb': { + const schemaRef = schema || 'main'; + return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`]; + } + default: + return []; + } +}; + +const extractQueryEditorViewDefinition = (dialect: string, data: any[]): string => { + if (!Array.isArray(data) || data.length === 0) return ''; + const row = data[0] as Record; + if (dialect === 'mysql' || dialect === 'starrocks') { + const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'VIEW_DEFINITION']); + if (direct !== undefined && direct !== null && String(direct).trim()) { + return normalizeQueryEditorMySQLViewDDL(direct); + } + const sqlKey = Object.keys(row).find((key) => { + const lowerKey = key.toLowerCase(); + return lowerKey.includes('create view') || lowerKey === 'create view' || lowerKey.includes('create table'); + }); + if (sqlKey) { + return normalizeQueryEditorMySQLViewDDL(row[sqlKey]); + } + const createValue = Object.values(row).find((value) => { + const text = String(value || '').toUpperCase(); + return text.includes('CREATE') && (text.includes('VIEW') || text.includes('TABLE')); + }); + return createValue ? normalizeQueryEditorMySQLViewDDL(createValue) : ''; + } + if (dialect === 'sqlserver') { + const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'definition']); + if (direct !== undefined && direct !== null && String(direct).trim()) { + return String(direct); + } + return data + .map((item) => getQueryEditorObjectEditRawValue(item, ['Text', 'text'])) + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)) + .join(''); + } + const direct = getQueryEditorObjectEditRawValue(row, ['view_definition', 'definition', 'sql', 'text', 'TEXT', 'SQL']); + return direct !== undefined && direct !== null ? String(direct) : String(Object.values(row)[0] || ''); +}; + +const buildQueryEditorSequenceDefinitionQueries = ( + dialect: string, + sequenceName: string, + dbName: string, + schemaName?: string, +): string[] => { + const parsed = splitSidebarQualifiedName(sequenceName); + const objectName = parsed.objectName || sequenceName; + const schema = String(schemaName || parsed.schemaName || '').trim(); + const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); + const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); + const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); + + switch (dialect) { + case 'oracle': + if (owner) { + return [`SELECT SEQUENCE_OWNER, SEQUENCE_NAME, MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '${owner}' AND SEQUENCE_NAME = '${safeName.toUpperCase()}'`]; + } + return [`SELECT SEQUENCE_NAME, MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER FROM USER_SEQUENCES WHERE SEQUENCE_NAME = '${safeName.toUpperCase()}'`]; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': { + const schemaRef = schema || 'public'; + return [`SELECT sequence_schema, sequence_name, data_type, start_value, minimum_value, maximum_value, increment FROM information_schema.sequences WHERE sequence_schema = '${escapeQueryEditorObjectEditSqlLiteral(schemaRef)}' AND sequence_name = '${safeName}' LIMIT 1`]; + } + default: + return []; + } +}; + +const buildQueryEditorSequenceDefinitionFromRow = ( + row: Record, + fallbackSequenceName: string, + fallbackSchemaName?: string, +): string => { + const sequenceName = String(getQueryEditorObjectEditRawValue(row, ['sequence_name']) || splitSidebarQualifiedName(fallbackSequenceName).objectName || fallbackSequenceName).trim(); + const owner = String(getQueryEditorObjectEditRawValue(row, ['sequence_owner', 'owner', 'sequence_schema']) || fallbackSchemaName || splitSidebarQualifiedName(fallbackSequenceName).schemaName || '').trim(); + const name = buildQueryEditorQualifiedObjectName(sequenceName, owner); + if (!name) return ''; + + const clauses: string[] = []; + const increment = getQueryEditorObjectEditRawValue(row, ['increment_by', 'increment']); + const minValue = getQueryEditorObjectEditRawValue(row, ['min_value', 'minimum_value']); + const maxValue = getQueryEditorObjectEditRawValue(row, ['max_value', 'maximum_value']); + const cacheSize = Number(getQueryEditorObjectEditRawValue(row, ['cache_size'])); + const cycleFlag = String(getQueryEditorObjectEditRawValue(row, ['cycle_flag']) || '').trim().toUpperCase(); + const orderFlag = String(getQueryEditorObjectEditRawValue(row, ['order_flag']) || '').trim().toUpperCase(); + + if (increment !== undefined && increment !== null && String(increment).trim() !== '') { + clauses.push(`INCREMENT BY ${increment}`); + } + if (minValue !== undefined && minValue !== null && String(minValue).trim() !== '') { + clauses.push(`MINVALUE ${minValue}`); + } + if (maxValue !== undefined && maxValue !== null && String(maxValue).trim() !== '') { + clauses.push(`MAXVALUE ${maxValue}`); + } + if (Number.isFinite(cacheSize)) { + clauses.push(cacheSize > 0 ? `CACHE ${cacheSize}` : 'NOCACHE'); + } + if (cycleFlag) clauses.push(cycleFlag === 'Y' ? 'CYCLE' : 'NOCYCLE'); + if (orderFlag) clauses.push(orderFlag === 'Y' ? 'ORDER' : 'NOORDER'); + + return [`CREATE SEQUENCE ${name}`, ...clauses.map((clause) => ` ${clause}`)].join('\n'); +}; + +const extractQueryEditorSequenceDefinition = ( + data: any[], + sequenceName: string, + schemaName?: string, +): string => { + if (!Array.isArray(data) || data.length === 0) return ''; + return buildQueryEditorSequenceDefinitionFromRow(data[0] as Record, sequenceName, schemaName); +}; + +const buildQueryEditorPackageDefinitionQueries = ( + dialect: string, + packageName: string, + dbName: string, + schemaName?: string, +): string[] => { + const parsed = splitSidebarQualifiedName(packageName); + const objectName = parsed.objectName || packageName; + const schema = String(schemaName || parsed.schemaName || '').trim(); + const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); + const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); + + if (dialect !== 'oracle') { + return []; + } + + const owner = schema ? escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); + if (owner) { + return [ + `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE' ORDER BY LINE`, + `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE BODY' ORDER BY LINE`, + ]; + } + return [ + `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE' ORDER BY LINE`, + `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = 'PACKAGE BODY' ORDER BY LINE`, + ]; +}; + +const extractQueryEditorPackageDefinition = (data: any[]): string => { + if (!Array.isArray(data) || data.length === 0) return ''; + return data + .map((row: any) => getQueryEditorObjectEditRawValue(row, ['text', 'TEXT']) ?? Object.values(row || {})[0] ?? '') + .map((value) => String(value)) + .join(''); +}; + +const buildQueryEditorTriggerDefinitionQueries = ( + dialect: string, + triggerName: string, + dbName: string, + schemaName?: string, +): string[] => { + const parsed = splitSidebarQualifiedName(triggerName); + const objectName = parsed.objectName || triggerName; + const schema = String(schemaName || parsed.schemaName || '').trim(); + const safeName = escapeQueryEditorObjectEditSqlLiteral(objectName); + const safeDbName = escapeQueryEditorObjectEditSqlLiteral(dbName); + + switch (dialect) { + case 'mysql': + case 'starrocks': { + const triggerRef = schema + ? `\`${schema.replace(/`/g, '``')}\`.\`${objectName.replace(/`/g, '``')}\`` + : `\`${objectName.replace(/`/g, '``')}\``; + return [ + `SHOW CREATE TRIGGER ${triggerRef}`, + safeDbName + ? `SELECT TRIGGER_NAME, TRIGGER_SCHEMA, EVENT_OBJECT_SCHEMA, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORIENTATION, ACTION_STATEMENT FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeName}' LIMIT 1` + : '', + ].filter(Boolean); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition +FROM pg_trigger t +JOIN pg_class c ON t.tgrelid = c.oid +WHERE t.tgname = '${safeName}' + AND NOT t.tgisinternal +LIMIT 1`]; + case 'sqlserver': + return buildSqlServerObjectDefinitionQueries('trigger', triggerName, dbName, 'trigger_definition'); + case 'oracle': { + if (schema) { + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}', '${escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${escapeQueryEditorObjectEditSqlLiteral(schema).toUpperCase()}' AND TRIGGER_NAME = '${safeName.toUpperCase()}'`, + ]; + } + if (safeDbName) { + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}', '${safeDbName.toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeName.toUpperCase()}'`, + ]; + } + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeName.toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeName.toUpperCase()}'`, + ]; + } + case 'sqlite': + return [`SELECT sql AS trigger_definition FROM sqlite_master WHERE type = 'trigger' AND name = '${safeName}'`]; + default: + return []; + } +}; + +const extractQueryEditorTriggerDefinition = (dialect: string, data: any[]): string => { + if (!Array.isArray(data) || data.length === 0) return ''; + const row = data[0] as Record; + const direct = getQueryEditorObjectEditRawValue(row, ['trigger_definition', 'definition', 'sql', 'SQL']); + if (direct !== undefined && direct !== null && String(direct).trim()) { + return String(direct); + } + if (dialect === 'mysql' || dialect === 'starrocks') { + const statementKey = Object.keys(row).find((key) => { + const lowerKey = key.toLowerCase(); + return lowerKey.includes('statement') || lowerKey.includes('create trigger'); + }); + if (statementKey) return String(row[statementKey] || ''); + const createValue = Object.values(row).find((value) => String(value || '').toUpperCase().includes('CREATE TRIGGER')); + return createValue ? String(createValue) : String(getQueryEditorObjectEditRawValue(row, ['ACTION_STATEMENT', 'action_statement']) || ''); + } + return String(getQueryEditorObjectEditRawValue(row, ['TRIGGER_BODY', 'trigger_body', 'TEXT', 'text']) || Object.values(row)[0] || ''); +}; + const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => { if (!connection) { return ''; @@ -236,6 +672,7 @@ const clearRecord = (record: Record) => { }); }; const resetSharedQueryEditorMetadata = () => { + sharedCurrentDb = ''; sharedTablesData = []; sharedAllColumnsData = []; sharedVisibleDbs = []; @@ -1063,6 +1500,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const metadataDbName = String(currentDbRef.current || currentDb || '').trim(); if (!metadataDbName) return; + if (isActive) { + sharedCurrentDb = metadataDbName; + } const metadataDbNames = collectQueryEditorReferencedDatabaseNames( getCurrentQuery(), metadataDbName, @@ -1097,6 +1537,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sequencesRef.current = [...allSequences]; packagesRef.current = [...allPackages]; if (isActive) { + sharedCurrentDb = metadataDbName; sharedTablesData = tablesRef.current; sharedAllColumnsData = allColumnsRef.current; sharedViewsData = viewsRef.current; @@ -1585,6 +2026,131 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); }, [addTab]); + const openDefinitionObjectEditTab = useCallback(async ( + navigationTarget: Extract, + connectionId: string, + targetDbName: string, + ) => { + const targetSchemaName = String(navigationTarget.schemaName || '').trim(); + const conn = connectionsRef.current.find((item) => item.id === connectionId); + const dialect = conn ? normalizeMetadataDialect(conn) : ''; + let targetObjectName = ''; + let objectEditName = ''; + let objectLabel = ''; + let definitionTabType: 'view-def' | 'sequence-def' | 'package-def' = 'view-def'; + let definitionQueries: string[] = []; + let collectAllDefinitionRows = false; + let latestDefinition = ''; + + if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') { + targetObjectName = String(navigationTarget.viewName || '').trim(); + if (!targetObjectName) return; + definitionTabType = 'view-def'; + objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); + objectLabel = navigationTarget.type === 'materialized-view' + ? translate('definition_viewer.object.materialized_view') + : translate('definition_viewer.object.view'); + definitionQueries = conn + ? buildQueryEditorViewDefinitionQueries( + dialect, + targetObjectName, + targetDbName, + targetSchemaName, + navigationTarget.type === 'materialized-view' ? 'materialized' : 'view', + ) + : []; + } else if (navigationTarget.type === 'sequence') { + targetObjectName = String(navigationTarget.sequenceName || '').trim(); + if (!targetObjectName) return; + definitionTabType = 'sequence-def'; + objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); + objectLabel = translate('definition_viewer.object.sequence'); + definitionQueries = conn + ? buildQueryEditorSequenceDefinitionQueries(dialect, targetObjectName, targetDbName, targetSchemaName) + : []; + } else { + targetObjectName = String(navigationTarget.packageName || '').trim(); + if (!targetObjectName) return; + definitionTabType = 'package-def'; + objectEditName = buildQueryEditorQualifiedObjectName(targetObjectName, targetSchemaName); + objectLabel = translate('definition_viewer.object.package'); + collectAllDefinitionRows = true; + definitionQueries = conn + ? buildQueryEditorPackageDefinitionQueries(dialect, targetObjectName, targetDbName, targetSchemaName) + : []; + } + + if (conn && definitionQueries.length > 0) { + const rows = await runQueryEditorObjectDefinitionCandidates( + buildQueryEditorObjectDefinitionConnectionConfig(conn), + targetDbName, + definitionQueries, + collectAllDefinitionRows, + ); + if (definitionTabType === 'view-def') { + latestDefinition = extractQueryEditorViewDefinition(dialect, rows); + } else if (definitionTabType === 'sequence-def') { + latestDefinition = extractQueryEditorSequenceDefinition(rows, targetObjectName, targetSchemaName); + } else { + latestDefinition = extractQueryEditorPackageDefinition(rows); + } + } + + addTab({ + id: `query-edit-object-${connectionId}-${targetDbName}-${objectEditName}-${Date.now()}`, + title: translate('definition_viewer.edit.tab_title', { + object: objectLabel, + name: objectEditName, + }), + type: 'query', + connectionId, + dbName: targetDbName, + query: buildQueryEditorEditableDefinitionSql( + definitionTabType, + latestDefinition, + objectEditName, + objectLabel, + ), + queryMode: 'object-edit', + }); + }, [addTab]); + + const openTriggerObjectEditTab = useCallback(async ( + navigationTarget: Extract, + connectionId: string, + targetDbName: string, + ) => { + const targetTriggerName = String(navigationTarget.triggerName || '').trim(); + if (!targetTriggerName) return; + + const conn = connectionsRef.current.find((item) => item.id === connectionId); + let latestDefinition = ''; + if (conn) { + const dialect = normalizeMetadataDialect(conn); + const rows = await runQueryEditorObjectDefinitionCandidates( + buildQueryEditorObjectDefinitionConnectionConfig(conn), + targetDbName, + buildQueryEditorTriggerDefinitionQueries( + dialect, + targetTriggerName, + targetDbName, + navigationTarget.schemaName, + ), + ); + latestDefinition = extractQueryEditorTriggerDefinition(dialect, rows); + } + + addTab({ + id: `query-edit-trigger-${connectionId}-${targetDbName}-${targetTriggerName}-${Date.now()}`, + title: translate('trigger_viewer.tab.edit_trigger_title', { name: targetTriggerName }), + type: 'query', + connectionId, + dbName: targetDbName, + query: buildEditableTriggerSql(targetTriggerName, latestDefinition, { translate }), + queryMode: 'object-edit', + }); + }, [addTab]); + // Setup Autocomplete and Editor const handleEditorDidMount: OnMount = (editor, monaco) => { editorRef.current = editor; @@ -1666,7 +2232,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (isKeyboardLikeEvent && isImeComposingKeyEvent(keyboardEvent as KeyboardEvent)) { return; } - const nextPressed = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey); + const keyboardEventType = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).type || '').toLowerCase() : ''; + const keyboardKey = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).key || '').trim().toLowerCase() : ''; + const keyboardCode = isKeyboardLikeEvent ? String((keyboardEvent as KeyboardEvent).code || '').trim().toLowerCase() : ''; + const isModifierKeyDown = isKeyboardLikeEvent + && keyboardEventType !== 'keyup' + && ( + keyboardKey === 'control' + || keyboardKey === 'ctrl' + || keyboardKey === 'meta' + || keyboardKey === 'os' + || keyboardKey === 'command' + || keyboardCode.startsWith('control') + || keyboardCode.startsWith('meta') + || keyboardCode.startsWith('os') + ); + const eventHasModifierFlag = hasQueryEditorCtrlMetaModifier(keyboardEvent); + const nextPressed = isKeyboardLikeEvent + ? !!(eventHasModifierFlag || isModifierKeyDown) + : !!(eventHasModifierFlag || wasPressed); ctrlMetaPressedRef.current = nextPressed; if (!nextPressed && !wasPressed) { return; @@ -1678,7 +2262,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } if (!wasPressed || isKeyboardLikeEvent) { - applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current); + const keyboardFallbackPosition = isKeyboardLikeEvent + ? normalizeEditorPosition(editor.getPosition?.()) || lastEditorCursorPositionRef.current + : null; + applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current || keyboardFallbackPosition); } }; const handleWindowBlur = () => { @@ -1864,7 +2451,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (!isQueryEditorPrimaryMouseButton(browserEvent)) { return; } - if (!browserEvent.ctrlKey && !browserEvent.metaKey) { + if (!hasQueryEditorCtrlMetaModifier(browserEvent) && !ctrlMetaPressedRef.current) { return; } @@ -1921,88 +2508,30 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc connectionId, dbName: targetDbName, tableName: targetTableName, + initialViewMode: 'fields', + initialViewModeRequestId: String(Date.now()), objectType: 'table', }); return; } if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') { - const targetViewName = String(navigationTarget.viewName || '').trim(); - if (!targetViewName) return; - const viewTitle = navigationTarget.type === 'materialized-view' - ? translate('sidebar.tab.materialized_view_definition', { name: targetViewName }) - : translate('sidebar.tab.view_definition', { name: targetViewName }); - const targetSchemaName = String(navigationTarget.schemaName || '').trim(); - const sidebarLocateKey = navigationTarget.type === 'materialized-view' - ? `${connectionId}-${targetDbName}-materialized-view-${targetViewName}` - : `${connectionId}-${targetDbName}-view-${targetViewName}`; - addTab({ - id: `view-def-${connectionId}-${targetDbName}-${targetViewName}`, - title: viewTitle, - type: 'view-def', - connectionId, - dbName: targetDbName, - viewName: targetViewName, - viewKind: navigationTarget.type === 'materialized-view' ? 'materialized' : 'view', - schemaName: targetSchemaName || undefined, - sidebarLocateKey, - }); + void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'trigger') { - const targetTriggerName = String(navigationTarget.triggerName || '').trim(); - if (!targetTriggerName) return; - const targetTriggerTableName = String(navigationTarget.tableName || '').trim(); - const targetSchemaName = String(navigationTarget.schemaName || '').trim(); - const sidebarLocateKey = `${connectionId}-${targetDbName}-trigger-${targetTriggerName}-${targetTriggerTableName}`; - addTab({ - id: `trigger-${connectionId}-${targetDbName}-${targetTriggerName}`, - title: translate('sidebar.tab.trigger', { name: targetTriggerName }), - type: 'trigger', - connectionId, - dbName: targetDbName, - triggerName: targetTriggerName, - triggerTableName: targetTriggerTableName || undefined, - schemaName: targetSchemaName || undefined, - sidebarLocateKey, - }); + void openTriggerObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'sequence') { - const targetSequenceName = String(navigationTarget.sequenceName || '').trim(); - if (!targetSequenceName) return; - const targetSchemaName = String(navigationTarget.schemaName || '').trim(); - const sidebarLocateKey = `sequence-def-${connectionId}-${targetDbName}-${targetSequenceName}`; - addTab({ - id: `sequence-def-${connectionId}-${targetDbName}-${targetSequenceName}`, - title: translate('sidebar.tab.sequence_definition', { name: targetSequenceName }), - type: 'sequence-def', - connectionId, - dbName: targetDbName, - sequenceName: targetSequenceName, - schemaName: targetSchemaName || undefined, - sidebarLocateKey, - }); + void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } if (navigationTarget.type === 'package') { - const targetPackageName = String(navigationTarget.packageName || '').trim(); - if (!targetPackageName) return; - const targetSchemaName = String(navigationTarget.schemaName || '').trim(); - const sidebarLocateKey = `package-def-${connectionId}-${targetDbName}-${targetPackageName}`; - addTab({ - id: `package-def-${connectionId}-${targetDbName}-${targetPackageName}`, - title: translate('sidebar.tab.package_definition', { name: targetPackageName }), - type: 'package-def', - connectionId, - dbName: targetDbName, - packageName: targetPackageName, - schemaName: targetSchemaName || undefined, - sidebarLocateKey, - }); + void openDefinitionObjectEditTab(navigationTarget, connectionId, targetDbName); return; } @@ -2179,6 +2708,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (!raw) return raw; return shouldQuoteCompletionIdentifiers ? quoteQualifiedIdent(activeDialect, raw) : raw; }; + const getActiveCompletionDbName = () => String(sharedCurrentDb || currentDbRef.current || currentDb || tab.dbName || '').trim(); const dialectKeywords = resolveSqlKeywords(activeDialect); const dialectFunctions = resolveSqlFunctions(activeDialect); @@ -2205,6 +2735,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbQualifiedLabel, }; }; + const normalizeRoutineType = (routineType: string) => ( + String(routineType || '').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION' + ); + const getRoutineTypeLabel = (routineType: string) => ( + normalizeRoutineType(routineType) === 'PROCEDURE' + ? translate('sidebar.object.procedure') + : translate('sidebar.object.function') + ); + const buildRoutineSuggestionMeta = (routine: CompletionRoutineMeta) => { + const rawDbName = String(routine.dbName || '').trim(); + const rawRoutineName = String(routine.routineName || '').trim(); + const parsed = splitSchemaAndTable(rawRoutineName); + const schemaName = String(routine.schemaName || parsed.schema || '').trim(); + const objectName = String(parsed.table || rawRoutineName).trim(); + const schemaMatchesDb = !!schemaName + && !!rawDbName + && schemaName.toLowerCase() === rawDbName.toLowerCase(); + const isCurrentDb = rawDbName.toLowerCase() === getActiveCompletionDbName().toLowerCase(); + const displayName = isCurrentDb && schemaMatchesDb + ? objectName + : (parsed.schema ? rawRoutineName : objectName); + const dbQualifiedLabel = rawDbName && !isCurrentDb + ? `${rawDbName}.${displayName}` + : displayName; + const insertName = rawDbName && !isCurrentDb + ? dbQualifiedLabel + : displayName; + return { + displayName, + dbQualifiedLabel, + insertText: `${quoteCompletionPath(insertName)}($0)`, + objectName, + schemaName, + routineType: normalizeRoutineType(routine.routineType), + }; + }; const buildConnConfig = () => { const connId = sharedCurrentConnectionId; @@ -2413,7 +2979,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sortText: '0' + suggestionMeta.displayName }; }); - return { suggestions }; + const routineSuggestions = sharedRoutinesData + .filter((routine) => String(routine.dbName || '').toLowerCase() === qualifierLower) + .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) + .filter(({ routine, meta }) => ( + !prefix + || meta.displayName.toLowerCase().startsWith(prefix) + || meta.objectName.toLowerCase().startsWith(prefix) + || String(routine.routineName || '').toLowerCase().startsWith(prefix) + )) + .map(({ routine, meta }) => ({ + label: meta.displayName, + kind: monaco.languages.CompletionItemKind.Function, + insertText: meta.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName})`, + range, + sortText: '1' + meta.displayName, + })); + return { suggestions: [...suggestions, ...routineSuggestions] }; } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table @@ -2443,7 +3027,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc range, sortText: '0' + t.table })); - return { suggestions }; + const routineSuggestions = sharedRoutinesData + .filter((routine) => { + const meta = buildRoutineSuggestionMeta(routine); + return meta.schemaName.toLowerCase() === qualifierLower; + }) + .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) + .filter(({ meta }) => !prefix || meta.objectName.toLowerCase().startsWith(prefix)) + .map(({ routine, meta }) => ({ + label: meta.objectName, + kind: monaco.languages.CompletionItemKind.Function, + insertText: `${quoteCompletionPart(meta.objectName)}($0)`, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName}${meta.schemaName ? '.' + meta.schemaName : ''})`, + range, + sortText: '1' + meta.objectName, + })); + return { suggestions: [...suggestions, ...routineSuggestions] }; } // 否则检查是否是表别名或表名,提示列 @@ -2488,7 +3088,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc foundTables.add(t.toLowerCase()); } - const currentDatabase = sharedCurrentDb || ''; + const currentDatabase = getActiveCompletionDbName(); const wordPrefix = (word.word || '').toLowerCase(); const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix); const includesWordPrefix = (candidate: string) => !wordPrefix || String(candidate || '').toLowerCase().includes(wordPrefix); @@ -2502,14 +3102,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return '9'; }; const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix); + const expectsRoutineName = /\bCALL\s+[`"]?[\w.]*$/i.test(linePrefix); const shouldBoostKeywords = !expectsTableName + && !expectsRoutineName && wordPrefix.length > 0 && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); const sortGroups = shouldBoostKeywords - ? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } + ? { keyword: '00', func: '05', routineCurrent: '06', routineOther: '07', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } + : expectsRoutineName + ? { keyword: '30', func: '40', routineCurrent: '00', routineOther: '01', columnCurrent: '20', columnOther: '21', tableCurrent: '10', tableOther: '11', db: '35' } : expectsTableName - ? { keyword: '20', func: '25', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' } - : { keyword: '30', func: '25', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' }; + ? { keyword: '20', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' } + : { keyword: '30', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' }; let completionTables = sharedTablesData; if ( expectsTableName @@ -2674,6 +3278,33 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }); + const routineSuggestions = sharedRoutinesData + .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) + .filter(({ routine, meta }) => { + if (expectsRoutineName && meta.routineType !== 'PROCEDURE') return false; + return includesWordPrefix(meta.dbQualifiedLabel) + || includesWordPrefix(meta.displayName) + || includesWordPrefix(meta.objectName) + || includesWordPrefix(routine.routineName || ''); + }) + .map(({ routine, meta }) => { + const isCurrentDb = (routine.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); + const schemaInfo = meta.schemaName && meta.schemaName.toLowerCase() !== String(routine.dbName || '').toLowerCase() + ? `.${meta.schemaName}` + : ''; + return { + label: meta.dbQualifiedLabel, + kind: monaco.languages.CompletionItemKind.Function, + insertText: meta.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName}${schemaInfo})`, + range, + sortText: (isCurrentDb ? sortGroups.routineCurrent : sortGroups.routineOther) + + getPrefixMatchRank(meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName || '') + + meta.dbQualifiedLabel, + }; + }); + // 数据库提示 const dbSuggestions = sharedVisibleDbs .filter((db) => startsWithPrefix(db)) @@ -2714,6 +3345,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...relevantColumns, // FROM 表的列最优先 ...tableSuggestions, // 表次之 ...dbSuggestions, // 数据库 + ...routineSuggestions, // 存储过程/函数 ...funcSuggestions, // 内置函数 ...keywordSuggestions // 关键字最后 ]; diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 9e5f8a44..0c12d703 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -130,6 +130,16 @@ export const isQueryEditorPrimaryMouseButton = (event: any): boolean => { return false; }; +export const hasQueryEditorCtrlMetaModifier = (event: any): boolean => { + const candidates = [ + event, + event?.browserEvent, + event?.nativeEvent, + event?.originalEvent, + ]; + return candidates.some((candidate) => !!(candidate?.ctrlKey || candidate?.metaKey)); +}; + export const readSidebarSqlDropText = ( event: DragEvent, currentConnectionId = '', diff --git a/frontend/src/components/useDataGridDdlView.ts b/frontend/src/components/useDataGridDdlView.ts index 5364ba14..4f1f35e1 100644 --- a/frontend/src/components/useDataGridDdlView.ts +++ b/frontend/src/components/useDataGridDdlView.ts @@ -7,6 +7,8 @@ import { formatDdlForDisplay } from '../utils/ddlFormat'; type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er' | 'sqlLog'; type DdlViewLayoutMode = 'bottom' | 'side'; type TranslateParams = Record; +const GRID_VIEW_MODES: GridViewMode[] = ['table', 'json', 'text', 'fields', 'ddl', 'er', 'sqlLog']; +const V2_ONLY_GRID_VIEW_MODES = new Set(['fields', 'ddl', 'er', 'sqlLog']); const DDL_VIEW_LAYOUT_STORAGE_KEY = 'gonavi.dataGrid.ddlViewLayout'; let sharedDdlViewOpen = false; let sharedDdlViewLayout: DdlViewLayoutMode | null = null; @@ -72,6 +74,17 @@ const buildDdlContextKey = (currentConnConfig: unknown, dbName?: string, tableNa ].join('\u0001'); }; +const resolveInitialGridViewMode = ( + initialViewMode: GridViewMode | undefined, + isV2Ui: boolean, + canViewDdl: boolean, +): GridViewMode | null => { + if (!initialViewMode || !GRID_VIEW_MODES.includes(initialViewMode)) return null; + if (!isV2Ui && V2_ONLY_GRID_VIEW_MODES.has(initialViewMode)) return null; + if (initialViewMode === 'ddl' && !canViewDdl) return null; + return initialViewMode; +}; + interface UseDataGridDdlViewParams { canViewDdl: boolean; currentConnConfig: unknown; @@ -90,6 +103,8 @@ interface UseDataGridDdlViewParams { }; dbType?: string; translate?: (key: string, params?: TranslateParams) => string; + initialViewMode?: GridViewMode; + initialViewModeRequestId?: string; } export interface UseDataGridDdlViewResult { @@ -128,10 +143,15 @@ export const useDataGridDdlView = ({ messageApi, dbType, translate, + initialViewMode, + initialViewModeRequestId, }: UseDataGridDdlViewParams): UseDataGridDdlViewResult => { const canRestoreSharedDdlView = isV2Ui && canViewDdl && !!currentConnConfig && !!tableName && shouldRestoreSharedDdlView(); const shouldStartWithSharedDdlView = isActive && canRestoreSharedDdlView; - const [viewMode, setViewMode] = React.useState(() => (shouldStartWithSharedDdlView ? 'ddl' : 'table')); + const initialResolvedViewMode = resolveInitialGridViewMode(initialViewMode, isV2Ui, canViewDdl); + const [viewMode, setViewMode] = React.useState(() => ( + shouldStartWithSharedDdlView ? 'ddl' : (initialResolvedViewMode || 'table') + )); const [ddlModalOpen, setDdlModalOpen] = React.useState(false); const [ddlLoading, setDdlLoading] = React.useState(shouldStartWithSharedDdlView); const [ddlText, setDdlText] = React.useState(''); @@ -289,6 +309,17 @@ export const useDataGridDdlView = ({ setViewMode(nextMode); }, [cellEditMode, closeCellEditModeRef, closeDdlView, handleOpenTableDdl, isV2Ui, mergedDisplayDataRef, resolvedViewMode, rowKeyStr, selectedRowKeys, setTextRecordIndex]); + const appliedInitialViewModeKeyRef = React.useRef(''); + React.useEffect(() => { + if (!isActive) return; + const nextInitialViewMode = resolveInitialGridViewMode(initialViewMode, isV2Ui, canViewDdl); + if (!nextInitialViewMode) return; + const applyKey = `${ddlContextKey}\u0001${nextInitialViewMode}\u0001${initialViewModeRequestId || ''}`; + if (appliedInitialViewModeKeyRef.current === applyKey) return; + appliedInitialViewModeKeyRef.current = applyKey; + handleViewModeChange(nextInitialViewMode); + }, [canViewDdl, ddlContextKey, handleViewModeChange, initialViewMode, initialViewModeRequestId, isActive, isV2Ui]); + const handleDdlSidebarResizeStart = React.useCallback((event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index fbc1d0cb..ab89bd56 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -189,6 +189,10 @@ describe("i18n catalog", () => { "app.theme.mode.light.description", "app.theme.mode.light.label", "app.theme.mode_title", + "app.theme.data_table.table_double_click_action", + "app.theme.data_table.table_double_click_action.open_data", + "app.theme.data_table.table_double_click_action.open_design", + "app.theme.data_table.table_double_click_action_hint", "app.theme.nav.appearance.description", "app.theme.nav.appearance.title", "app.theme.nav.theme.description", @@ -2216,19 +2220,29 @@ describe("i18n catalog", () => { it("keeps QueryEditor object navigation tab titles in catalogs instead of source literals", () => { const objectTabTitleKeys = [ - "sidebar.tab.view_definition", - "sidebar.tab.materialized_view_definition", - "sidebar.tab.trigger", - "sidebar.tab.routine_definition", + "definition_viewer.edit.tab_title", + "definition_viewer.object.view", + "definition_viewer.object.materialized_view", + "definition_viewer.object.sequence", + "definition_viewer.object.package", + "trigger_viewer.tab.edit_trigger_title", + "sidebar.tab.edit_routine", "sidebar.object.procedure", "sidebar.object.function", ] as const; const source = readQueryEditorSource(); - const objectNavigationSource = sliceBetween( - source, - " if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') {", - " editor.onDidDispose?.(() => {", - ); + const objectNavigationSource = [ + sliceBetween( + source, + "const buildQueryEditorEditableDefinitionSql = (", + "const buildQueryEditorAiContextPrompt = (", + ), + sliceBetween( + source, + " const openRoutineObjectEditTab = useCallback(async (", + " // Setup Autocomplete and Editor", + ), + ].join("\n"); for (const language of SUPPORTED_LANGUAGES) { for (const key of objectTabTitleKeys) { @@ -2236,10 +2250,13 @@ describe("i18n catalog", () => { expect(catalogs[language][key]).toBeTruthy(); } - expect(getPlaceholders(catalogs[language]["sidebar.tab.view_definition"])).toEqual(["name"]); - expect(getPlaceholders(catalogs[language]["sidebar.tab.materialized_view_definition"])).toEqual(["name"]); - expect(getPlaceholders(catalogs[language]["sidebar.tab.trigger"])).toEqual(["name"]); - expect(getPlaceholders(catalogs[language]["sidebar.tab.routine_definition"])).toEqual(["name", "type"]); + expect([...getPlaceholders(catalogs[language]["definition_viewer.edit.tab_title"])].sort()).toEqual(["name", "object"]); + expect(getPlaceholders(catalogs[language]["definition_viewer.object.view"])).toEqual([]); + expect(getPlaceholders(catalogs[language]["definition_viewer.object.materialized_view"])).toEqual([]); + expect(getPlaceholders(catalogs[language]["definition_viewer.object.sequence"])).toEqual([]); + expect(getPlaceholders(catalogs[language]["definition_viewer.object.package"])).toEqual([]); + expect(getPlaceholders(catalogs[language]["trigger_viewer.tab.edit_trigger_title"])).toEqual(["name"]); + expect([...getPlaceholders(catalogs[language]["sidebar.tab.edit_routine"])].sort()).toEqual(["name", "type"]); expect(getPlaceholders(catalogs[language]["sidebar.object.procedure"])).toEqual([]); expect(getPlaceholders(catalogs[language]["sidebar.object.function"])).toEqual([]); } @@ -2248,7 +2265,11 @@ describe("i18n catalog", () => { expect(objectNavigationSource).toContain(key); } - assertSourceDoesNotInlineCatalogValues(objectNavigationSource, objectTabTitleKeys); + assertSourceDoesNotInlineCatalogValues(objectNavigationSource, [ + "definition_viewer.edit.tab_title", + "trigger_viewer.tab.edit_trigger_title", + "sidebar.tab.edit_routine", + ]); }); it("guards QueryEditor V2 empty state against inlining any catalog literal into source", () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 660d5aa3..e7d30957 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -470,6 +470,8 @@ export interface TabData { queryMode?: "standard" | "object-edit"; filePath?: string; initialTab?: string; + initialViewMode?: "table" | "json" | "text" | "fields" | "ddl" | "er" | "sqlLog"; + initialViewModeRequestId?: string; readOnly?: boolean; providerMode?: "jmx" | "endpoint" | "agent"; resourcePath?: string; diff --git a/frontend/src/utils/sqlDialect.test.ts b/frontend/src/utils/sqlDialect.test.ts index ebce9c21..eb9101fa 100644 --- a/frontend/src/utils/sqlDialect.test.ts +++ b/frontend/src/utils/sqlDialect.test.ts @@ -118,7 +118,7 @@ describe('sqlDialect', () => { }); it('resolves mysql-family completion keywords and functions with mysql syntax', () => { - expect(resolveSqlKeywords('mariadb')).toEqual(expect.arrayContaining(['LIMIT', 'CHANGE', 'AUTO_INCREMENT'])); + expect(resolveSqlKeywords('mariadb')).toEqual(expect.arrayContaining(['LIMIT', 'CHANGE', 'AUTO_INCREMENT', 'CALL'])); expect(names(resolveSqlFunctions('diros'))).toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT'])); expect(resolveSqlKeywords('starrocks')).toEqual(expect.arrayContaining(['OLAP', 'DISTRIBUTED BY', 'BUCKETS', 'ADD ROLLUP', 'EXTERNAL CATALOG'])); expect(names(resolveSqlFunctions('starrocks'))).toEqual(expect.arrayContaining(['TO_BITMAP', 'HLL_UNION_AGG'])); diff --git a/frontend/src/utils/sqlDialect.ts b/frontend/src/utils/sqlDialect.ts index 759f9def..1aef8e5c 100644 --- a/frontend/src/utils/sqlDialect.ts +++ b/frontend/src/utils/sqlDialect.ts @@ -581,6 +581,7 @@ const COMMON_KEYWORDS = [ const MYSQL_KEYWORDS = [ 'LIMIT', 'OFFSET', 'MODIFY', 'CHANGE', 'AUTO_INCREMENT', 'SHOW', 'DESCRIBE', 'DESC', 'ENGINE', 'CHARSET', 'COLLATE', 'REPLACE', 'DUPLICATE KEY', 'LOCK', + 'CALL', ]; const PG_KEYWORDS = [ diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 113e1b24..5c8f7173 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "Global folgen", "app.theme.data_table.font_size": "Schriftgröße der Datentabelle", "app.theme.data_table.sidebar_tree_font_size": "Schriftgröße des linken Schemasbaums", + "app.theme.data_table.table_double_click_action": "Doppelklick-Aktion für Tabellen", + "app.theme.data_table.table_double_click_action.open_data": "Daten öffnen", + "app.theme.data_table.table_double_click_action.open_design": "Objektdesign öffnen", + "app.theme.data_table.table_double_click_action_hint": "Steuert die Standardaktion beim Doppelklick auf Tabellenknoten im linken Schemasbaum. Standardmäßig werden Tabellendaten geöffnet; optional kann direkt das Objektdesign geöffnet werden.", "app.theme.data_table.title": "Datentabellenanzeige", "app.theme.data_table.vertical_borders": "Vertikale Trennlinien der Datentabelle anzeigen", "app.theme.data_table.vertical_borders_hint": "Wirkt nur auf DataGrid auf Datentabellenseiten und nicht auf andere Tabellenkomponenten.", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + klicken, um dieses Paket zu öffnen", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + klicken, um diese gespeicherte Prozedur zu öffnen", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + klicken, um diese Sequenz zu öffnen", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + klicken, um diese Tabelle zu öffnen", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + klicken, um das Tabellen-Objektdesign zu öffnen", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + klicken, um diesen Trigger zu öffnen", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + klicken, um diese Ansicht zu öffnen", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + klicken, um zu dieser Datenbank zu wechseln", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index dbe8da78..4a00591c 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "Follow Global", "app.theme.data_table.font_size": "Data Table Font Size", "app.theme.data_table.sidebar_tree_font_size": "Sidebar Schema Tree Font Size", + "app.theme.data_table.table_double_click_action": "Table Double-click Action", + "app.theme.data_table.table_double_click_action.open_data": "Open data", + "app.theme.data_table.table_double_click_action.open_design": "Open object design", + "app.theme.data_table.table_double_click_action_hint": "Controls the default action when double-clicking table nodes in the left schema tree. The default opens table data; switch it to open object design directly.", "app.theme.data_table.title": "Data Table Display", "app.theme.data_table.vertical_borders": "Show Data Table Vertical Separators", "app.theme.data_table.vertical_borders_hint": "Only affects DataGrid on data table pages, not other table components.", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + click to open this package", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + click to open this stored procedure", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + click to open this sequence", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + click to open this table", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + click to open this table object design", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + click to open this trigger", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + click to open this view", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + click to switch to this database", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index e0d93bc9..59d7a26d 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "全体設定に従う", "app.theme.data_table.font_size": "データテーブルのフォントサイズ", "app.theme.data_table.sidebar_tree_font_size": "左側スキーマツリーのフォントサイズ", + "app.theme.data_table.table_double_click_action": "テーブルのダブルクリック動作", + "app.theme.data_table.table_double_click_action.open_data": "データを開く", + "app.theme.data_table.table_double_click_action.open_design": "オブジェクト設計を開く", + "app.theme.data_table.table_double_click_action_hint": "左側スキーマツリーでテーブルノードをダブルクリックしたときの既定動作を制御します。既定ではテーブルデータを開き、直接オブジェクト設計を開くよう切り替えられます。", "app.theme.data_table.title": "データテーブル表示", "app.theme.data_table.vertical_borders": "データテーブルの縦区切り線を表示", "app.theme.data_table.vertical_borders_hint": "データテーブルページの DataGrid のみに作用し、他のテーブルコンポーネントには影響しません。", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + クリックでこのパッケージを開く", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + クリックでこのストアドプロシージャを開く", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + クリックでこのシーケンスを開く", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + クリックでこのテーブルを開く", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + クリックでこのテーブルのオブジェクト設計を開く", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + クリックでこのトリガーを開く", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + クリックでこのビューを開く", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + クリックでこのデータベースに切り替え", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 7c608194..c2244ab7 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "Следовать глобально", "app.theme.data_table.font_size": "Размер шрифта таблицы данных", "app.theme.data_table.sidebar_tree_font_size": "Размер шрифта левого дерева схем", + "app.theme.data_table.table_double_click_action": "Действие двойного щелчка по таблице", + "app.theme.data_table.table_double_click_action.open_data": "Открыть данные", + "app.theme.data_table.table_double_click_action.open_design": "Открыть дизайн объекта", + "app.theme.data_table.table_double_click_action_hint": "Управляет действием по умолчанию при двойном щелчке по таблице в левом дереве схем. По умолчанию открываются данные таблицы; можно переключить на прямое открытие дизайна объекта.", "app.theme.data_table.title": "Отображение таблицы данных", "app.theme.data_table.vertical_borders": "Показывать вертикальные разделители таблицы данных", "app.theme.data_table.vertical_borders_hint": "Влияет только на DataGrid на страницах таблиц данных и не затрагивает другие табличные компоненты.", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть этот пакет", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть эту хранимую процедуру", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть эту последовательность", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть эту таблицу", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть проектирование объекта таблицы", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть этот триггер", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + щелчок, чтобы открыть это представление", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + щелчок, чтобы переключиться на эту базу данных", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 48f1aef6..92f25d10 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "跟随全局", "app.theme.data_table.font_size": "数据表字体大小", "app.theme.data_table.sidebar_tree_font_size": "左侧库表字体大小", + "app.theme.data_table.table_double_click_action": "双击表名行为", + "app.theme.data_table.table_double_click_action.open_data": "打开表数据", + "app.theme.data_table.table_double_click_action.open_design": "打开对象设计", + "app.theme.data_table.table_double_click_action_hint": "控制左侧库表树中双击表节点的默认动作。默认打开表数据,可切换为直接打开表对象设计。", "app.theme.data_table.title": "数据表显示", "app.theme.data_table.vertical_borders": "显示数据表竖向分隔线", "app.theme.data_table.vertical_borders_hint": "仅作用于数据表页面 DataGrid,不影响其他表格组件。", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + 点击打开该存储包", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + 点击打开该存储过程", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + 点击打开该序列", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + 点击打开该表", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + 点击打开该表对象设计", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + 点击打开该触发器", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + 点击打开该视图", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + 点击切换到该数据库", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index a6d2de10..cd6ad139 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2677,6 +2677,10 @@ "app.theme.data_table.follow_global": "跟隨全域", "app.theme.data_table.font_size": "資料表字型大小", "app.theme.data_table.sidebar_tree_font_size": "左側庫表字型大小", + "app.theme.data_table.table_double_click_action": "雙擊表名行為", + "app.theme.data_table.table_double_click_action.open_data": "開啟表資料", + "app.theme.data_table.table_double_click_action.open_design": "開啟物件設計", + "app.theme.data_table.table_double_click_action_hint": "控制左側庫表樹中雙擊表節點的預設動作。預設開啟表資料,可切換為直接開啟表物件設計。", "app.theme.data_table.title": "資料表显示", "app.theme.data_table.vertical_borders": "显示資料表竖向分隔线", "app.theme.data_table.vertical_borders_hint": "仅作用于資料表页面 DataGrid,不影响其他表格组件。", @@ -6112,7 +6116,7 @@ "query_editor.hover.open_package_with_shortcut": "{{shortcut}} + 點擊開啟該套件", "query_editor.hover.open_procedure_with_shortcut": "{{shortcut}} + 點擊開啟該預存程序", "query_editor.hover.open_sequence_with_shortcut": "{{shortcut}} + 點擊開啟該序列", - "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + 點擊開啟該資料表", + "query_editor.hover.open_table_with_shortcut": "{{shortcut}} + 點擊開啟該資料表物件設計", "query_editor.hover.open_trigger_with_shortcut": "{{shortcut}} + 點擊開啟該觸發器", "query_editor.hover.open_view_with_shortcut": "{{shortcut}} + 點擊開啟該檢視", "query_editor.hover.switch_database_with_shortcut": "{{shortcut}} + 點擊切換到該資料庫",