fix(tabs): prompt before closing dirty tabs

This commit is contained in:
t8y2 committed 2026-07-01 15:50:08 +08:00
1 parent ee883ce2b8
commit d6a0da7169
2 files changed
+119 -27

No files matched your search

+61 -27
View File
@@ -5,7 +5,6 @@ import { useI18n } from "vue-i18n";
import type { DatabaseType, QueryResult, QueryTab, TableInfoTab } from "@/types/database"; import type { DatabaseType, QueryResult, QueryTab, TableInfoTab } from "@/types/database";
import { orderPinnedFirst } from "@/lib/pinnedItems"; import { orderPinnedFirst } from "@/lib/pinnedItems";
import { canCancelQueryExecution } from "@/lib/queryExecutionState"; import { canCancelQueryExecution } from "@/lib/queryExecutionState";
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/explainPlan"; import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sqlAnalysis"; import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sqlAnalysis";
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence"; import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
@@ -191,6 +190,8 @@ export const useQueryStore = defineStore("query", () => {
const activeTabId = ref<string | null>(restored.activeTabId); const activeTabId = ref<string | null>(restored.activeTabId);
const showCloseConfirm = ref(false); const showCloseConfirm = ref(false);
const pendingCloseTabId = ref<string | null>(null); 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) { for (const tab of restored.tabs) {
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
} }
@@ -797,6 +798,53 @@ export const useQueryStore = defineStore("query", () => {
if (tab) tab.originalSql = tab.sql; 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 } = {}) { function closeTab(id: string, { force = false }: { force?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id); const tab = tabs.value.find((t) => t.id === id);
if (!tab) return; if (!tab) return;
@@ -818,6 +866,7 @@ export const useQueryStore = defineStore("query", () => {
if (activeTabId.value === id) { if (activeTabId.value === id) {
activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null; activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null;
} }
if (force) resumePendingBatchCloseAfter(id);
} }
function forceClosePendingTab() { function forceClosePendingTab() {
@@ -830,6 +879,8 @@ export const useQueryStore = defineStore("query", () => {
function cancelClosePendingTab() { function cancelClosePendingTab() {
pendingCloseTabId.value = null; pendingCloseTabId.value = null;
showCloseConfirm.value = false; showCloseConfirm.value = false;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
} }
function saveAndClosePendingTab() { function saveAndClosePendingTab() {
@@ -841,35 +892,18 @@ export const useQueryStore = defineStore("query", () => {
} }
function closeOtherTabs(id: string) { function closeOtherTabs(id: string) {
tabs.value if (!tabs.value.some((tab) => tab.id === id)) return;
.filter((tab) => tab.id !== id) beginBatchClose(
.forEach((tab) => { tabs.value.filter((tab) => tab.id !== id).map((tab) => tab.id),
clearDataGridPendingSnapshotsForTab(tab.id); 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;
} }
function closeAllTabs() { function closeAllTabs() {
tabs.value.forEach((tab) => { beginBatchClose(
clearDataGridPendingSnapshotsForTab(tab.id); tabs.value.map((tab) => tab.id),
if (tab.isExecuting) void cancelTabExecution(tab.id); null,
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;
} }
function duplicateTab(id: string) { function duplicateTab(id: string) {
+58
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", () => { test("editing query sql preserves the displayed result editability state", () => {
setActivePinia(createPinia()); setActivePinia(createPinia());
const store = useQueryStore(); const store = useQueryStore();