fix(tabs): prompt before closing dirty tabs

This commit is contained in:
t8y2 2026-07-01 15:49:33 +08:00
parent ee883ce2b8
commit d6a0da7169
2 changed files with 119 additions and 27 deletions

View File

@ -5,7 +5,6 @@ import { useI18n } from "vue-i18n";
import type { DatabaseType, QueryResult, QueryTab, TableInfoTab } from "@/types/database";
import { orderPinnedFirst } from "@/lib/pinnedItems";
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sqlAnalysis";
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
@ -191,6 +190,8 @@ export const useQueryStore = defineStore("query", () => {
const activeTabId = ref<string | null>(restored.activeTabId);
const showCloseConfirm = ref(false);
const pendingCloseTabId = ref<string | null>(null);
const pendingBatchCloseTabIds = ref<string[] | null>(null);
const pendingBatchCloseFinalActiveTabId = ref<string | null | undefined>(undefined);
for (const tab of restored.tabs) {
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
}
@ -797,6 +798,53 @@ export const useQueryStore = defineStore("query", () => {
if (tab) tab.originalSql = tab.sql;
}
function finishPendingBatchClose() {
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
if (finalActiveTabId !== undefined) {
activeTabId.value = finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId) ? finalActiveTabId : null;
}
}
function continuePendingBatchClose() {
const pendingIds = pendingBatchCloseTabIds.value;
if (!pendingIds) return;
const remainingIds = pendingIds.filter((id) => tabs.value.some((tab) => tab.id === id));
pendingBatchCloseTabIds.value = remainingIds;
if (remainingIds.length === 0) {
finishPendingBatchClose();
return;
}
const dirtyTab = remainingIds.map((id) => tabs.value.find((tab) => tab.id === id)).find((tab): tab is QueryTab => !!tab && isTabDirty(tab));
if (dirtyTab) {
// Batch close must pause before dropping dirty query tabs so the existing save/discard dialog can protect unsaved SQL.
pendingCloseTabId.value = dirtyTab.id;
showCloseConfirm.value = true;
return;
}
finishPendingBatchClose();
for (const id of remainingIds) closeTab(id, { force: true });
}
function beginBatchClose(ids: string[], finalActiveTabId?: string | null) {
const uniqueIds = [...new Set(ids)].filter((id) => tabs.value.some((tab) => tab.id === id));
if (uniqueIds.length === 0) return;
pendingBatchCloseTabIds.value = uniqueIds;
pendingBatchCloseFinalActiveTabId.value = finalActiveTabId;
continuePendingBatchClose();
}
function resumePendingBatchCloseAfter(id: string) {
const pendingIds = pendingBatchCloseTabIds.value;
if (!pendingIds?.includes(id)) return;
pendingBatchCloseTabIds.value = pendingIds.filter((pendingId) => pendingId !== id);
continuePendingBatchClose();
}
function closeTab(id: string, { force = false }: { force?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
@ -818,6 +866,7 @@ export const useQueryStore = defineStore("query", () => {
if (activeTabId.value === id) {
activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null;
}
if (force) resumePendingBatchCloseAfter(id);
}
function forceClosePendingTab() {
@ -830,6 +879,8 @@ export const useQueryStore = defineStore("query", () => {
function cancelClosePendingTab() {
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
}
function saveAndClosePendingTab() {
@ -841,35 +892,18 @@ export const useQueryStore = defineStore("query", () => {
}
function closeOtherTabs(id: string) {
tabs.value
.filter((tab) => tab.id !== id)
.forEach((tab) => {
clearDataGridPendingSnapshotsForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
clearResultPayload(tab);
});
const next = closeOtherTabsState(tabs.value, activeTabId.value, id);
tabs.value = next.tabs;
activeTabId.value = next.activeTabId;
if (!tabs.value.some((tab) => tab.id === id)) return;
beginBatchClose(
tabs.value.filter((tab) => tab.id !== id).map((tab) => tab.id),
id,
);
}
function closeAllTabs() {
tabs.value.forEach((tab) => {
clearDataGridPendingSnapshotsForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
clearResultPayload(tab);
});
const next = closeAllTabsState(tabs.value, activeTabId.value);
tabs.value = next.tabs;
activeTabId.value = next.activeTabId;
beginBatchClose(
tabs.value.map((tab) => tab.id),
null,
);
}
function duplicateTab(id: string) {

View File

@ -236,6 +236,64 @@ test("marked-clean object source tabs close without unsaved confirmation", () =>
);
});
test("close all tabs pauses on unsaved query tabs", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const queryId = store.createTab("conn-1", "db", "draft query");
store.updateSql(queryId, "select 1;");
const dataId = store.createTab("conn-1", "db", "users", "data");
store.closeAllTabs();
assert.equal(store.showCloseConfirm, true);
assert.equal(store.pendingCloseTabId, queryId);
assert.deepEqual(
store.tabs.map((tab) => tab.id),
[queryId, dataId],
);
store.forceClosePendingTab();
assert.equal(store.showCloseConfirm, false);
assert.deepEqual(store.tabs, []);
assert.equal(store.activeTabId, null);
});
test("close other tabs pauses on unsaved query tabs before keeping target tab", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const queryId = store.createTab("conn-1", "db", "draft query");
store.updateSql(queryId, "select 1;");
const dataId = store.createTab("conn-1", "db", "users", "data");
store.closeOtherTabs(dataId);
assert.equal(store.showCloseConfirm, true);
assert.equal(store.pendingCloseTabId, queryId);
assert.deepEqual(
store.tabs.map((tab) => tab.id),
[queryId, dataId],
);
store.cancelClosePendingTab();
assert.equal(store.showCloseConfirm, false);
assert.deepEqual(
store.tabs.map((tab) => tab.id),
[queryId, dataId],
);
store.closeOtherTabs(dataId);
store.forceClosePendingTab();
assert.equal(store.showCloseConfirm, false);
assert.deepEqual(
store.tabs.map((tab) => tab.id),
[dataId],
);
assert.equal(store.activeTabId, dataId);
});
test("editing query sql preserves the displayed result editability state", () => {
setActivePinia(createPinia());
const store = useQueryStore();