diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx index 09877f6e..3e314074 100644 --- a/frontend/src/components/DefinitionViewer.object-edit.test.tsx +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -124,6 +124,7 @@ describe('DefinitionViewer object edit entry', () => { expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'view-def-conn-1-main-reporting.active_users', title: 'Edit View: reporting.active_users', type: 'query', connectionId: 'conn-1', @@ -187,6 +188,47 @@ describe('DefinitionViewer object edit entry', () => { }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + title: 'Edit Function/procedure: reporting.refresh_stats', + type: 'query', + queryMode: 'object-edit', + query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), + })); + }); + + it('reuses the displayed routine definition when opening object edit without refetching', async () => { + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ routine_definition: 'CREATE OR REPLACE FUNCTION reporting.refresh_stats() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(renderWithI18n(createTab({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + title: '函数: reporting.refresh_stats', + type: 'routine-def', + routineName: 'reporting.refresh_stats', + routineType: 'FUNCTION', + viewName: undefined, + viewKind: undefined, + }))); + await flushPromises(); + }); + + backendApp.DBQuery.mockClear(); + backendApp.DBQuery.mockImplementation(() => new Promise(() => undefined)); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; + + await act(async () => { + button.props.onClick(); + await Promise.resolve(); + }); + + expect(backendApp.DBQuery).not.toHaveBeenCalled(); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', title: 'Edit Function/procedure: reporting.refresh_stats', type: 'query', queryMode: 'object-edit', @@ -225,6 +267,7 @@ describe('DefinitionViewer object edit entry', () => { const query = storeState.addTab.mock.calls[0][0].query; expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'event-def-conn-1-main-daily_cleanup', title: 'Edit Event: daily_cleanup', type: 'query', queryMode: 'object-edit', @@ -377,6 +420,7 @@ describe('DefinitionViewer object edit entry', () => { }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'sequence-def-conn-1-H2-H2.HWMS_PACK_SEQNO', type: 'query', queryMode: 'object-edit', query: expect.stringContaining('CREATE SEQUENCE H2.HWMS_PACK_SEQNO'), @@ -427,27 +471,6 @@ describe('DefinitionViewer object edit entry', () => { expect(editorText).toContain('PACKAGE BODY pkg_order AS'); expect(editorText).toContain('NULL;'); - backendApp.DBQuery - .mockResolvedValueOnce({ - success: true, - data: [ - { TEXT: 'PACKAGE pkg_order AS\n' }, - { TEXT: ' PROCEDURE sync_order(p_id IN NUMBER);\n' }, - { TEXT: 'END pkg_order;\n' }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { TEXT: 'PACKAGE BODY pkg_order AS\n' }, - { TEXT: ' PROCEDURE sync_order(p_id IN NUMBER) IS\n' }, - { TEXT: ' BEGIN\n' }, - { TEXT: ' NULL;\n' }, - { TEXT: ' END sync_order;\n' }, - { TEXT: 'END pkg_order;\n' }, - ], - }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { await button.props.onClick(); @@ -457,7 +480,7 @@ describe('DefinitionViewer object edit entry', () => { const editQuery = String(storeState.addTab.mock.calls[0][0].query || ''); expect(editQuery).toContain('CREATE OR REPLACE PACKAGE pkg_order AS'); expect(editQuery).toContain('/\nCREATE OR REPLACE PACKAGE BODY pkg_order AS'); - expect(editQuery).toContain('END sync_order;'); + expect(editQuery).toContain('END pkg_order;'); }); it('keeps Oracle routine SQLPlus slash delimiters executable when opening object edit', async () => { @@ -509,7 +532,7 @@ describe('DefinitionViewer object edit entry', () => { expect(editQuery).not.toContain('/;'); }); - it('reloads the latest object definition before opening object edit', async () => { + it('uses the currently displayed object definition before opening object edit', async () => { backendApp.DBQuery .mockResolvedValueOnce({ success: true, @@ -533,16 +556,16 @@ describe('DefinitionViewer object edit entry', () => { await flushPromises(); }); - expect(backendApp.DBQuery).toHaveBeenCalledTimes(2); + expect(backendApp.DBQuery).toHaveBeenCalledTimes(1); const query = storeState.addTab.mock.calls[0][0].query; - expect(query).toContain('SELECT id, name, updated_at FROM users;'); - expect(query).not.toContain('SELECT id FROM users;'); + expect(query).toContain('SELECT id FROM users;'); + expect(query).not.toContain('SELECT id, name, updated_at FROM users;'); const editor = renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0]; - expect(String(editor.children.join(''))).toContain('SELECT id, name, updated_at FROM users'); + expect(String(editor.children.join(''))).toContain('SELECT id FROM users'); }); - it('keeps the current definition visible when refresh for object edit fails', async () => { + it('opens object edit from the current definition even if a later refresh would fail', async () => { backendApp.DBQuery .mockResolvedValueOnce({ success: true, @@ -567,10 +590,15 @@ describe('DefinitionViewer object edit entry', () => { await flushPromises(); }); - expect(storeState.addTab).not.toHaveBeenCalled(); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + id: 'view-def-conn-1-main-reporting.active_users', + type: 'query', + queryMode: 'object-edit', + query: expect.stringContaining('SELECT id, name FROM users;'), + })); expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('SELECT id, name FROM users'); - expect(findButtonText(renderer.root)).toContain('Failed to refresh the latest definition'); - expect(findButtonText(renderer.root)).toContain('network down'); + expect(findButtonText(renderer.root)).not.toContain('Failed to refresh the latest definition'); + expect(findButtonText(renderer.root)).not.toContain('network down'); }); it('does not keep the previous object definition when switching objects and the new load fails', async () => { diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index d7cb6137..0cd5b058 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -898,6 +898,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { }), }; const editorModelPath = `gonavi-definition://${encodeURIComponent(objectIdentityKey)}`; + const currentDefinition = loadedDefinitionKeyRef.current === objectIdentityKey ? String(definition || '') : ''; const refreshEditorLayout = useCallback(() => { const editor = editorRef.current; @@ -936,9 +937,52 @@ const DefinitionViewer: React.FC = ({ tab }) => { editorRef.current = null; }, []); + const openObjectEditTab = useCallback((sourceDefinition: string) => { + const dbName = String(tab.dbName || '').trim(); + const latestDefinition = String(sourceDefinition || ''); + loadedDefinitionKeyRef.current = objectIdentityKey; + setDefinition(latestDefinition); + const query = buildEditableDefinitionSql(tab, latestDefinition, normalizedObjectName, editableDefinitionCopy); + setActiveContext({ connectionId: tab.connectionId, dbName }); + addTab({ + id: tab.id, + title: editTabTitle, + type: 'query', + connectionId: tab.connectionId, + dbName, + query, + queryMode: 'object-edit', + returnToTabId: undefined, + viewName: undefined, + viewKind: undefined, + eventName: undefined, + routineName: undefined, + routineType: undefined, + sequenceName: undefined, + packageName: undefined, + triggerName: undefined, + triggerTableName: undefined, + schemaName: undefined, + objectType: undefined, + tableName: undefined, + sidebarLocateKey: undefined, + }); + }, [ + addTab, + editTabTitle, + editableDefinitionCopy, + normalizedObjectName, + objectIdentityKey, + setActiveContext, + tab, + ]); + const openObjectEditQuery = async () => { if (!normalizedObjectName || openingObjectEdit) return; - const dbName = String(tab.dbName || '').trim(); + if (String(currentDefinition || '').trim()) { + openObjectEditTab(currentDefinition); + return; + } setOpeningObjectEdit(true); setError(null); try { @@ -950,20 +994,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { setError(result.error || t('definition_viewer.error.query_failed')); return; } - const latestDefinition = String(result.definition || ''); - loadedDefinitionKeyRef.current = objectIdentityKey; - setDefinition(latestDefinition); - const query = buildEditableDefinitionSql(tab, latestDefinition, normalizedObjectName, editableDefinitionCopy); - setActiveContext({ connectionId: tab.connectionId, dbName }); - addTab({ - id: `query-edit-object-${tab.connectionId}-${dbName}-${Date.now()}`, - title: editTabTitle, - type: 'query', - connectionId: tab.connectionId, - dbName, - query, - queryMode: 'object-edit', - }); + openObjectEditTab(String(result.definition || '')); } finally { if (isMountedRef.current) { setOpeningObjectEdit(false); diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index db93494f..9baeadcb 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -753,6 +753,23 @@ describe('Sidebar locate toolbar', () => { expect(actionsSource).toContain('SHOW CREATE EVENT ${eventRef}'); }); + it('marks sidebar view and routine edits as object-edit query tabs', () => { + const actionsSource = readSourceFile('./sidebar/useSidebarObjectActions.tsx'); + const viewEditSource = actionsSource.slice( + actionsSource.indexOf('const openEditView = async (node: any) => {'), + actionsSource.indexOf('const openCreateView = (node: any) => {'), + ); + const routineEditSource = actionsSource.slice( + actionsSource.indexOf('const openEditRoutine = async (node: any) => {'), + actionsSource.indexOf('const openCreateRoutine = (node: any, type: \'FUNCTION\' | \'PROCEDURE\') => {'), + ); + + expect(viewEditSource).toContain("id: `query-edit-view-${Date.now()}`"); + expect(viewEditSource).toContain("queryMode: 'object-edit'"); + expect(routineEditSource).toContain("id: `query-edit-routine-${Date.now()}`"); + expect(routineEditSource).toContain("queryMode: 'object-edit'"); + }); + it('wires external SQL directory file actions to dedicated Wails APIs', () => { const source = readSidebarSource(); const loadTablesSource = source.slice( diff --git a/frontend/src/components/sidebar/useSidebarObjectActions.tsx b/frontend/src/components/sidebar/useSidebarObjectActions.tsx index 463f0bd1..6c9de1ef 100644 --- a/frontend/src/components/sidebar/useSidebarObjectActions.tsx +++ b/frontend/src/components/sidebar/useSidebarObjectActions.tsx @@ -759,6 +759,7 @@ export const useSidebarObjectActions = ({ connectionId: id, dbName, query: template, + queryMode: 'object-edit', }); }; @@ -1177,6 +1178,7 @@ export const useSidebarObjectActions = ({ connectionId: id, dbName, query: template, + queryMode: 'object-edit', }); }; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index e5fb1d66..9430991f 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1518,6 +1518,41 @@ describe('store appearance persistence', () => { expect(useStore.getState().activeTabId).toBe('query-other'); }); + it('reuses the current tab when the same id is reopened as an object-edit query', async () => { + const { useStore } = await importStore(); + + useStore.getState().addTab({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + title: '函数: reporting.refresh_stats', + type: 'routine-def', + connectionId: 'conn-1', + dbName: 'main', + routineName: 'reporting.refresh_stats', + routineType: 'FUNCTION', + }); + + useStore.getState().addTab({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + title: '修改函数/存储过程: reporting.refresh_stats', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: 'CREATE OR REPLACE FUNCTION reporting.refresh_stats() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;', + queryMode: 'object-edit', + }); + + const { tabs, activeTabId } = useStore.getState(); + expect(tabs).toHaveLength(1); + expect(activeTabId).toBe('routine-def-conn-1-main-reporting.refresh_stats'); + expect(tabs[0]).toEqual(expect.objectContaining({ + id: 'routine-def-conn-1-main-reporting.refresh_stats', + type: 'query', + queryMode: 'object-edit', + title: '修改函数/存储过程: reporting.refresh_stats', + query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), + })); + }); + it('reuses the same table-export tab for the same connection and table identity', async () => { const { useStore } = await importStore();