diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index dfe5a9cef..ce9689ee6 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -602,6 +602,7 @@ async function confirmDrop() { await api.executeQuery(props.connection.id, props.database, sql); const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess"; toast(t(successKey, { name: row.name })); + closeDroppedTableObjectTabsForRow(row); await reload(); await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value); } catch (e: any) { @@ -668,6 +669,25 @@ function openStructureEditor(row: ObjectBrowserRow) { queryStore.openTableStructure(props.connection.id, props.database, row.schema || selectedSchema.value, row.name); } +function droppedTableObjectTypeForRow(row: ObjectBrowserRow): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | null { + if (row.type === "TABLE") return "TABLE"; + if (row.type === "VIEW") return "VIEW"; + if (row.type === "MATERIALIZED_VIEW") return "MATERIALIZED_VIEW"; + return null; +} + +function closeDroppedTableObjectTabsForRow(row: ObjectBrowserRow) { + const objectType = droppedTableObjectTypeForRow(row); + if (!objectType) return; + queryStore.closeDroppedTableObjectTabs({ + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + name: row.name, + objectType, + }); +} + function openDiagram(row: ObjectBrowserRow) { connectionStore.diagramSource = { connectionId: props.connection.id, @@ -796,6 +816,7 @@ async function confirmBatchDropTables() { name: row.name, }); await api.executeQuery(props.connection.id, props.database, sql); + closeDroppedTableObjectTabsForRow(row); } toast(t("objects.batchDropSuccess", { count: targets.length })); clearTableSelection(); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 856ecf696..c2b575c5b 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -1963,6 +1963,28 @@ function canDropTreeNode(node: TreeNode): boolean { return canDropTableChildObjectNode(node); } +function droppedTableObjectTypeForNode(node: TreeNode): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | null { + if (node.type === "table") return "TABLE"; + if (node.type === "view") return "VIEW"; + if (node.type === "materialized_view") return "MATERIALIZED_VIEW"; + return null; +} + +function closeDroppedTableObjectTabsForNode(node: TreeNode) { + const objectType = droppedTableObjectTypeForNode(node); + if (!objectType || !node.connectionId || !node.database) return; + const config = connectionStore.getConfig(node.connectionId); + const dataTabSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema); + queryStore.closeDroppedTableObjectTabs({ + connectionId: node.connectionId, + database: node.database, + schema: dataTabSchema, + schemaCandidates: [node.schema, dataTabSchema], + name: node.label, + objectType, + }); +} + function selectedBatchDropTargets(): TreeNode[] { const selected = selectedTreeNodesInVisibleOrder(); if (selected.length <= 1 || !selected.some((node) => node.id === props.node.id)) return []; @@ -2185,6 +2207,7 @@ async function confirmDropObject() { await api.executeQuery(node.connectionId, node.database, sql, node.schema); const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess"; toast(t(msgKey, { name: node.label }), 3000); + closeDroppedTableObjectTabsForNode(node); if (node.type === "view" || node.type === "materialized_view") { connectionStore.removeTreeNode(node.id); } else { @@ -2247,6 +2270,7 @@ async function confirmBatchDrop() { const sql = await dropSqlForTreeNode(target); if (!sql) continue; await api.executeQuery(target.connectionId, target.database, sql, target.schema); + closeDroppedTableObjectTabsForNode(target); connectionStore.removeTreeNode(target.id); } toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000); @@ -2390,6 +2414,7 @@ async function confirmDropTable() { const sql = dropTablePreviewSql.value || (await buildDropTableSql(tableAdminSqlOptions())); await api.executeQuery(node.connectionId, node.database, sql, node.schema); toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000); + closeDroppedTableObjectTabsForNode(node); connectionStore.removeTreeNode(node.id); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts index 38cfa3460..76f9cbede 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts @@ -40,6 +40,107 @@ describe("queryStore database open state", () => { expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false); }); + it("closes data and structure tabs for a dropped table object", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const queryId = store.createTab("pg-1", "app", "Query", "query", "public"); + const dataId = store.createTab("pg-1", "app", "users", "data", "public"); + store.setTableMeta(dataId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [], + primaryKeys: [], + }); + const otherSchemaDataId = store.createTab("pg-1", "app", "users", "data", "archive"); + store.setTableMeta(otherSchemaDataId, { + schema: "archive", + tableName: "users", + tableType: "TABLE", + columns: [], + primaryKeys: [], + }); + const otherConnectionDataId = store.createTab("pg-2", "app", "users", "data", "public"); + store.setTableMeta(otherConnectionDataId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [], + primaryKeys: [], + }); + const structureId = store.openTableStructure("pg-1", "app", "public", "users"); + + store.activeTabId = dataId; + store.closeDroppedTableObjectTabs({ + connectionId: "pg-1", + database: "app", + schema: "public", + name: "users", + objectType: "TABLE", + }); + + expect(store.tabs.some((tab) => tab.id === dataId)).toBe(false); + expect(store.tabs.some((tab) => tab.id === structureId)).toBe(false); + expect(store.tabs.some((tab) => tab.id === otherSchemaDataId)).toBe(true); + expect(store.tabs.some((tab) => tab.id === otherConnectionDataId)).toBe(true); + expect(store.tabs.some((tab) => tab.id === queryId)).toBe(true); + expect(store.activeTabId).not.toBe(dataId); + }); + + it("closes data tabs but keeps structure tabs for dropped views", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const dataId = store.createTab("pg-1", "app", "report_view", "data", "public"); + store.setTableMeta(dataId, { + schema: "public", + tableName: "report_view", + tableType: "VIEW", + columns: [], + primaryKeys: [], + }); + const structureId = store.openTableStructure("pg-1", "app", "public", "report_view"); + + store.closeDroppedTableObjectTabs({ + connectionId: "pg-1", + database: "app", + schema: "public", + name: "report_view", + objectType: "VIEW", + }); + + expect(store.tabs.some((tab) => tab.id === dataId)).toBe(false); + expect(store.tabs.some((tab) => tab.id === structureId)).toBe(true); + }); + + it("matches dropped table schema candidates", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const dataId = store.createTab("pg-1", "app", "orders", "data", "app"); + store.setTableMeta(dataId, { + schema: "app", + tableName: "orders", + tableType: "TABLE", + columns: [], + primaryKeys: [], + }); + const structureId = store.openTableStructure("pg-1", "app", undefined, "orders"); + + store.closeDroppedTableObjectTabs({ + connectionId: "pg-1", + database: "app", + schema: "app", + schemaCandidates: [undefined, "app"], + name: "orders", + objectType: "TABLE", + }); + + expect(store.tabs.some((tab) => tab.id === dataId)).toBe(false); + expect(store.tabs.some((tab) => tab.id === structureId)).toBe(false); + }); + it("does not restore open tabs when launch restore mode is none", async () => { const persistedTabs = JSON.stringify([ { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 0b356fa42..1e11587d0 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -62,6 +62,17 @@ interface BuildQueryResultExportRequestOptions { format: "csv" | "xlsx"; } +type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW"; + +interface DroppedTableObjectTarget { + connectionId: string; + database: string; + schema?: string; + schemaCandidates?: Array; + name: string; + objectType?: DroppedTableObjectType; +} + function tabClientSessionId(tab: Pick, suffix?: (typeof BACKGROUND_CLIENT_SESSION_SUFFIXES)[number]): string { return suffix ? `${tab.id}:${suffix}` : tab.id; } @@ -70,6 +81,15 @@ function resultRunCacheKey(tabId: string, runId: string): string { return `tab:${tabId}:run:${runId}`; } +function normalizeOptionalSchema(schema: string | null | undefined): string { + return schema?.trim() ?? ""; +} + +function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): Set { + const schemas = target.schemaCandidates?.length ? target.schemaCandidates : [target.schema]; + return new Set(schemas.map(normalizeOptionalSchema)); +} + function markQueryResultRowsRaw(result: QueryResult): QueryResult { markRaw(result.rows); return result; @@ -1219,6 +1239,30 @@ export const useQueryStore = defineStore("query", () => { closeTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database); } + function tabMatchesDroppedTableObject(tab: QueryTab, target: DroppedTableObjectTarget): boolean { + if (tab.connectionId !== target.connectionId || tab.database !== target.database) return false; + const targetSchemas = droppedTableObjectSchemaCandidates(target); + + if (tab.mode === "data") { + const tableMeta = tableMetaForDataTab(tab); + if (!tableMeta || tableMeta.tableName !== target.name) return false; + return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema)); + } + + if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "structure") { + if ((tab.structureTableName || "") !== target.name) return false; + return targetSchemas.has(normalizeOptionalSchema(tab.schema)); + } + + return false; + } + + function closeDroppedTableObjectTabs(target: DroppedTableObjectTarget) { + // A dropped table-like object makes existing data/structure tabs stale; close + // them immediately instead of letting the next refresh fail against a missing object. + closeTabsWhere((tab) => tabMatchesDroppedTableObject(tab, target)); + } + function releaseTabsWhere(predicate: (tab: QueryTab) => boolean) { closeTabsWhere((tab) => predicate(tab) && tab.mode !== "query"); tabs.value @@ -2857,6 +2901,7 @@ export const useQueryStore = defineStore("query", () => { duplicateTab, closeConnectionTabs, closeDatabaseTabs, + closeDroppedTableObjectTabs, releaseConnectionTabs, releaseDatabaseTabs, isDatabaseOpen,