From a9194ba3b57521ffb97925357779a16213c1b10a Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Wed, 12 Aug 2026 21:06:13 +0800 Subject: [PATCH] fix(sidebar): preserve dirty procedure source tabs --- .../sidebar/SidebarTreeRuntimeHost.vue | 19 +++-- apps/desktop/src/stores/queryStore.ts | 38 ++++++++++ packages/app-tests/queryStore.test.ts | 70 +++++++++++++++++-- .../app-tests/sidebarContextMenuHost.test.ts | 7 +- 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 953912e937..1fc1438bd4 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1813,15 +1813,24 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false databaseType, signature: sourceNode.signature, }); - const tabId = queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true }); const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType); if (sourceIsEditable) { - queryStore.setObjectSource(tabId, { + queryStore.openObjectSourceTab({ + connectionId, + database, + title: `Source - ${node.label}`, schema, - name: objectName, - objectType: resolvedType, - signature: node.signature, + catalog: node.catalog, + sql: editableSource, + objectSource: { + schema, + name: objectName, + objectType: resolvedType, + signature: node.signature, + }, }); + } else { + queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true }); } }) .catch((e: any) => { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 765fbc7317..14c9678c01 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -108,6 +108,16 @@ interface OpenSavedSqlOptions { targetMode?: SavedSqlOpenTargetMode; } +interface OpenObjectSourceTabOptions { + connectionId: string; + database: string; + title: string; + schema?: string; + catalog?: string; + sql: string; + objectSource: NonNullable; +} + interface UpdateExecutionTargetOptions { persistSavedSqlTarget?: boolean; } @@ -1625,6 +1635,33 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openObjectSourceTab(options: OpenObjectSourceTabOptions) { + const existing = tabs.value.find( + (tab) => + tab.mode === "query" && + tab.connectionId === options.connectionId && + tab.database === options.database && + (tab.schema || "") === (options.schema || "") && + (tab.catalog || "") === (options.catalog || "") && + tab.objectSource?.name === options.objectSource.name && + tab.objectSource.objectType === options.objectSource.objectType && + (tab.objectSource.schema || "") === (options.objectSource.schema || "") && + (tab.objectSource.signature || "") === (options.objectSource.signature || ""), + ); + if (existing) { + switchTab(existing.id); + if (!isTabDirty(existing)) { + updateSql(existing.id, options.sql); + markTabClean(existing); + } + return existing.id; + } + + const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true }); + setObjectSource(id, options.objectSource); + return id; + } + function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) { const id = createTab(connectionId, database, undefined, "query", undefined, sql); const tab = tabs.value.find((item) => item.id === id); @@ -5819,6 +5856,7 @@ export const useQueryStore = defineStore("query", () => { hasDirtyTabs, isConfirmingAppClose, createTab, + openObjectSourceTab, showExecutedQueryResults, switchTab, closeTab, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 3970029c7b..a97c327d45 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -554,15 +554,73 @@ test("marked-clean object source tabs close without unsaved confirmation", () => ); }); -test("object source tabs can force distinct clean identities for overloaded routines", () => { +test("reopening a dirty object source tab preserves its unsaved SQL", () => { setActivePinia(createPinia()); const store = useQueryStore(); - const firstId = store.createTab("conn-1", "db", "Source - calculate", "query", "public", "CREATE FUNCTION calculate(integer)", undefined, { forceNew: true }); - const secondId = store.createTab("conn-1", "db", "Source - calculate", "query", "public", "CREATE FUNCTION calculate(text)", undefined, { forceNew: true }); + const options = { + connectionId: "conn-1", + database: "db", + title: "Source - refresh_orders", + schema: "public", + sql: "CREATE PROCEDURE refresh_orders() SELECT 1;", + objectSource: { + schema: "public", + name: "refresh_orders", + objectType: "PROCEDURE" as const, + }, + }; + const tabId = store.openObjectSourceTab(options); + store.updateSql(tabId, "CREATE PROCEDURE refresh_orders() SELECT 2;"); - assert.notEqual(firstId, secondId); - assert.equal(store.isTabDirty(store.tabs.find((tab) => tab.id === firstId)!), false); - assert.equal(store.isTabDirty(store.tabs.find((tab) => tab.id === secondId)!), false); + const reopenedId = store.openObjectSourceTab({ ...options, sql: "CREATE PROCEDURE refresh_orders() SELECT 3;" }); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(reopenedId, tabId); + assert.equal(store.activeTabId, tabId); + assert.equal(tab?.sql, "CREATE PROCEDURE refresh_orders() SELECT 2;"); + assert.equal(store.isTabDirty(tab!), true); +}); + +test("clean object source tabs refresh while object type and signature identities stay distinct", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const options = { + connectionId: "conn-1", + database: "db", + title: "Source - order_api", + schema: "public", + sql: "CREATE PACKAGE order_api AS END;", + objectSource: { + schema: "public", + name: "order_api", + objectType: "PACKAGE" as const, + }, + }; + const specId = store.openObjectSourceTab(options); + const refreshedSpecId = store.openObjectSourceTab({ ...options, sql: "CREATE PACKAGE order_api AS PROCEDURE refresh; END;" }); + const bodyId = store.openObjectSourceTab({ + ...options, + sql: "CREATE PACKAGE BODY order_api AS END;", + objectSource: { ...options.objectSource, objectType: "PACKAGE_BODY" }, + }); + const integerOverloadId = store.openObjectSourceTab({ + ...options, + title: "Source - calculate", + sql: "CREATE FUNCTION calculate(integer) RETURNS integer;", + objectSource: { ...options.objectSource, name: "calculate", objectType: "FUNCTION", signature: "integer" }, + }); + const textOverloadId = store.openObjectSourceTab({ + ...options, + title: "Source - calculate", + sql: "CREATE FUNCTION calculate(text) RETURNS text;", + objectSource: { ...options.objectSource, name: "calculate", objectType: "FUNCTION", signature: "text" }, + }); + + assert.equal(refreshedSpecId, specId); + assert.equal(store.tabs.find((tab) => tab.id === specId)?.sql, "CREATE PACKAGE order_api AS PROCEDURE refresh; END;"); + assert.equal(store.isTabDirty(store.tabs.find((tab) => tab.id === specId)!), false); + assert.notEqual(bodyId, specId); + assert.notEqual(integerOverloadId, textOverloadId); }); test("close all tabs pauses on unsaved query tabs", () => { diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index a2223c56df..faaa8f618d 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -60,15 +60,18 @@ test("tree host owns sidebar data-open generations", () => { assert.match(connectionTree, /createSidebarActionTarget\(node\)/); }); -test("query-tab object source opens clean isolated tabs and honors backend editability", () => { +test("query-tab object source uses canonical identity and honors backend editability", () => { const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const openObjectSourceBody = functionBody(runtimeHost, "openObjectSourceDialog"); - assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true \}\)/); + assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTab\(\{/); assert.match(openObjectSourceBody, /raw\.editable !== false/); assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"\]\.includes\(resolvedType\)/); + assert.match(openObjectSourceBody, /objectType: resolvedType/); assert.match(openObjectSourceBody, /signature: node\.signature/); + assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true \}\)/); assert.doesNotMatch(openObjectSourceBody, /queryStore\.updateSql/); + assert.doesNotMatch(openObjectSourceBody, /queryStore\.markTabClean/); }); test("table copy menu uses the shared single and multi-selection clipboard path", () => {