fix(query): protect unsaved tabs on connection close

Closes #4641
This commit is contained in:
t8y2 committed 2026-08-24 18:44:23 +00:00
1 parent 492c9727ae
commit 332daba302
6 files changed
+272 -9

No files matched your search

@@ -33,6 +33,46 @@ describe("connectionStore disconnect data-tab metadata freshness", () => {
setActivePinia(createPinia());
});
it("disconnect keeps an unsaved query pending until the user resolves scoped close", async () => {
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
const disconnectDb = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth,
disconnectDb,
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.disconnectTabHandlingMode = "close-tabs";
store.connections = [pgConnection("conn-a"), pgConnection("conn-b")];
store.connectedIds.add("conn-a");
const queryStore = useQueryStore();
const queryId = queryStore.createTab("conn-a", "app", "draft query");
queryStore.updateSql(queryId, "select 1;");
const outsideId = queryStore.createTab("conn-b", "app", "outside");
await store.disconnect("conn-a");
expect(disconnectDb).toHaveBeenCalledWith("conn-a", undefined);
expect(store.connectedIds.has("conn-a")).toBe(false);
expect(queryStore.showCloseConfirm).toBe(true);
expect(queryStore.pendingCloseTabId).toBe(queryId);
expect(queryStore.tabs.map((tab) => tab.id)).toEqual([queryId, outsideId]);
queryStore.forceCloseAllPendingTabs();
expect(queryStore.tabs.map((tab) => tab.id)).toEqual([outsideId]);
}, 10_000);
it("clears data-tab metadata freshness for the disconnected connection only", async () => {
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
const disconnectDb = vi.fn().mockResolvedValue(undefined);
@@ -132,12 +132,14 @@ describe("connectionStore one_time runtime cleanup", () => {
const store = useConnectionStore();
const queryStore = useQueryStore();
store.connections = [previewConnection()];
queryStore.createTab("preview-1", "", "sales.parquet", "query");
const queryId = queryStore.createTab("preview-1", "", "sales.parquet", "query");
queryStore.updateSql(queryId, "select 1;");
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(true);
await store.removeConnection("preview-1");
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
expect(queryStore.showCloseConfirm).toBe(false);
});
it("deleteConnectionGroups closes the tabs of removed one_time connections", async () => {
@@ -77,6 +77,28 @@ describe("queryStore structure editor close protection", () => {
expect(store.closeConfirmContext).toBe("app");
});
it("protects scoped dirty structure tabs when SQL confirmation is disabled", async () => {
const { useSettingsStore } = await import("@/stores/settingsStore");
const { useQueryStore } = await import("@/stores/queryStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.confirmUnsavedSqlClose = false;
const store = useQueryStore();
const structureId = store.openTableStructure("mysql-1", "app", undefined, "users");
const structureTab = store.tabs.find((item) => item.id === structureId)!;
structureTab.structureDraft = { dirty: true } as typeof structureTab.structureDraft;
const queryId = store.createTab("mysql-1", "app", "Query", "query");
store.updateSql(queryId, "select 1");
const outsideId = store.createTab("mysql-2", "app", "Outside", "query");
store.closeConnectionTabs("mysql-1");
expect(store.showCloseConfirm).toBe(true);
expect(store.pendingCloseTabId).toBe(structureId);
expect(store.closeConfirmDirtyTabIds).toEqual([structureId]);
store.cancelClosePendingTab();
expect(store.tabs.map((tab) => tab.id)).toEqual([structureId, queryId, outsideId]);
});
it("discards a structure draft before force closing", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+1 -1
View File
@@ -824,7 +824,7 @@ export const useConnectionStore = defineStore("connection", () => {
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
for (const connectionId of connectionIds) {
queryStore.closeConnectionTabs(connectionId);
queryStore.closeConnectionTabs(connectionId, { force: true });
}
}
+49 -7
View File
@@ -2429,7 +2429,7 @@ export const useQueryStore = defineStore("query", () => {
if (pendingBatchCloseTabIds.value) {
return pendingBatchCloseTabIds.value
.map((id) => tabs.value.find((tab) => tab.id === id))
.filter((tab): tab is QueryTab => !!tab && isTabDirty(tab))
.filter((tab): tab is QueryTab => !!tab && shouldConfirmTabClose(tab))
.map((tab) => tab.id);
}
const pendingTab = pendingCloseTabId.value ? tabs.value.find((tab) => tab.id === pendingCloseTabId.value) : undefined;
@@ -2579,9 +2579,31 @@ export const useQueryStore = defineStore("query", () => {
onComplete?.();
return;
}
pendingBatchCloseTabIds.value = uniqueIds;
pendingBatchCloseFinalActiveTabId.value = finalActiveTabId;
pendingBatchCloseComplete = onComplete ?? null;
const existingIds = pendingBatchCloseTabIds.value;
if (existingIds) {
// Sidebar bulk disconnects settle independently, so later scopes must join the open dialog instead of replacing it.
const combinedIds = [...new Set([...existingIds, ...uniqueIds])];
const preferredFinalActiveTabId = finalActiveTabId !== undefined ? finalActiveTabId : pendingBatchCloseFinalActiveTabId.value;
pendingBatchCloseTabIds.value = combinedIds;
pendingBatchCloseFinalActiveTabId.value = preferredFinalActiveTabId && combinedIds.includes(preferredFinalActiveTabId) ? activeTabAfterClosing(combinedIds, preferredFinalActiveTabId) : preferredFinalActiveTabId;
if (onComplete) {
const previousComplete = pendingBatchCloseComplete;
pendingBatchCloseComplete = previousComplete
? () => {
try {
previousComplete();
} finally {
onComplete();
}
}
: onComplete;
}
} else {
pendingBatchCloseTabIds.value = uniqueIds;
pendingBatchCloseFinalActiveTabId.value = finalActiveTabId;
pendingBatchCloseComplete = onComplete ?? null;
}
continuePendingBatchClose();
}
@@ -2757,6 +2779,17 @@ export const useQueryStore = defineStore("query", () => {
return tabs.value.find((tab) => !closingIds.has(tab.id))?.id ?? null;
}
function activeTabAfterClosing(ids: string[], preferredActiveTabId = activeTabId.value) {
const closingIds = new Set(ids);
if (preferredActiveTabId && !closingIds.has(preferredActiveTabId) && tabs.value.some((tab) => tab.id === preferredActiveTabId)) {
return preferredActiveTabId;
}
const preferredIndex = preferredActiveTabId ? tabs.value.findIndex((tab) => tab.id === preferredActiveTabId) : -1;
const remainingTabs = tabs.value.filter((tab) => !closingIds.has(tab.id));
if (preferredIndex < 0) return remainingTabs[0]?.id ?? null;
return remainingTabs[Math.min(preferredIndex, remainingTabs.length - 1)]?.id ?? null;
}
function closeOtherRegularTabs(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.pinned) return;
@@ -2905,12 +2938,21 @@ export const useQueryStore = defineStore("query", () => {
}
}
function closeConnectionTabs(connectionId: string) {
closeTabsWhere((tab) => tab.connectionId === connectionId);
function closeScopedTabsWhere(predicate: (tab: QueryTab) => boolean, options: { force?: boolean } = {}) {
const ids = tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id);
if (options.force) {
closeTabsWhere(predicate);
return;
}
beginBatchClose(ids, activeTabAfterClosing(ids));
}
function closeConnectionTabs(connectionId: string, options?: { force?: boolean }) {
closeScopedTabsWhere((tab) => tab.connectionId === connectionId, options);
}
function closeDatabaseTabs(connectionId: string, database: string) {
closeTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
closeScopedTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
}
function tabMatchesDroppedTableObject(tab: QueryTab, target: DroppedTableObjectTarget): boolean {