diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 9bb1c60c08..164d1bbee6 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1271,6 +1271,7 @@ export default {
resultRuns: "Result runs",
resultSets: "Result sets",
removeRun: "Remove run {n}",
+ closeResult: "Close result",
autoKeepResults: "Auto-keep query results",
autoKeepResultsEnabled: "Auto-keep results enabled",
autoKeepResultsDisabled: "Auto-keep results disabled",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index ca55d29d53..5373e1fb68 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1248,6 +1248,7 @@ export default withEnglishFallback({
resultRuns: "Ejecuciones de resultado",
resultSets: "Conjuntos de resultados",
removeRun: "Eliminar ejecución {n}",
+ closeResult: "Cerrar resultado",
autoKeepResults: "Conservar resultados automáticamente",
autoKeepResultsEnabled: "Conservación automática activada",
autoKeepResultsDisabled: "Conservación automática desactivada",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 7087148523..e538e99a50 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1246,6 +1246,7 @@ export default withEnglishFallback({
resultRuns: "Esecuzioni risultati",
resultSets: "Set di risultati",
removeRun: "Rimuovi esecuzione {n}",
+ closeResult: "Chiudi risultato",
autoKeepResults: "Mantieni automaticamente i risultati",
autoKeepResultsEnabled: "Mantieni risultati abilitato",
autoKeepResultsDisabled: "Mantieni risultati disabilitato",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 15cab3b1f2..ac2d0ef8a9 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1265,6 +1265,7 @@ export default withEnglishFallback({
resultRuns: "実行履歴",
resultSets: "結果セット",
removeRun: "実行 {n} を削除",
+ closeResult: "結果を閉じる",
autoKeepResults: "クエリ結果を自動保持",
autoKeepResultsEnabled: "結果の自動保持をオンにしました",
autoKeepResultsDisabled: "結果の自動保持をオフにしました",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index fadee6cfc7..648d9b59bd 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1160,6 +1160,7 @@ export default withEnglishFallback({
resultRuns: "결과 실행",
resultSets: "결과 집합",
removeRun: "실행 {n} 제거",
+ closeResult: "결과 닫기",
autoKeepResults: "쿼리 결과 자동 보관",
autoKeepResultsEnabled: "결과 자동 보관 활성화됨",
autoKeepResultsDisabled: "결과 자동 보관 비활성화됨",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 62890071c5..102812ae5b 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1248,6 +1248,7 @@ export default withEnglishFallback({
resultRuns: "Execuções de resultado",
resultSets: "Conjuntos de resultados",
removeRun: "Remover execução {n}",
+ closeResult: "Fechar resultado",
autoKeepResults: "Manter resultados automaticamente",
autoKeepResultsEnabled: "Manutenção automática de resultados ativada",
autoKeepResultsDisabled: "Manutenção automática de resultados desativada",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 2f29c77d4e..145011de52 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1196,6 +1196,7 @@ export default withEnglishFallback({
resultRuns: "执行结果",
resultSets: "语句结果",
removeRun: "删除执行 {n}",
+ closeResult: "关闭结果",
autoKeepResults: "自动保留查询结果",
autoKeepResultsEnabled: "已开启自动保留结果",
autoKeepResultsDisabled: "已关闭自动保留结果",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index d03a270930..4bb6e1c0ba 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1247,6 +1247,7 @@ export default withEnglishFallback({
resultRuns: "執行結果",
resultSets: "語句結果",
removeRun: "移除執行 {n}",
+ closeResult: "關閉結果",
autoKeepResults: "自動保留查詢結果",
autoKeepResultsEnabled: "自動保留結果已啟用",
autoKeepResultsDisabled: "自動保留結果已停用",
diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
index 47744448d5..c95dcff7c1 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
@@ -810,4 +810,74 @@ describe("queryStore multi-statement errors", () => {
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
+
+ it("does not clear a newer result while closing the previous result session", async () => {
+ const pendingClose = deferred();
+ mocks.closeQuerySession.mockReturnValue(pendingClose.promise);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ tab.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, session_id: "result-session-1" };
+ tab.resultSessionId = "result-session-1";
+ tab.resultClientSessionId = "result-client-1";
+
+ const closing = store.closeQueryResult(tabId);
+ expect(tab.result).toBeUndefined();
+
+ tab.result = { columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, session_id: "result-session-2" };
+ tab.resultSessionId = "result-session-2";
+ tab.resultClientSessionId = "result-client-2";
+ pendingClose.resolve();
+
+ await expect(closing).resolves.toBe(true);
+ expect(tab.result?.rows).toEqual([[2]]);
+ expect(tab.resultSessionId).toBe("result-session-2");
+ expect(tab.resultClientSessionId).toBe("result-client-2");
+ });
+
+ it("clears every retained result run while preserving the query tab", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ tab.sql = "select draft";
+ tab.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, session_id: "result-session-1" };
+ tab.resultSessionId = "result-session-1";
+ tab.resultClientSessionId = "result-client-1";
+ tab.resultAutoSave = true;
+ tab.resultRuns = [
+ {
+ id: "run-1",
+ title: "Run 1",
+ sequence: 1,
+ sql: "select 1",
+ createdAt: 1,
+ result: tab.result,
+ resultSessionId: "result-session-1",
+ resultClientSessionId: "result-client-1",
+ },
+ {
+ id: "run-2",
+ title: "Run 2",
+ sequence: 2,
+ sql: "select 2",
+ createdAt: 2,
+ result: { columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, session_id: "result-session-2" },
+ resultSessionId: "result-session-2",
+ resultClientSessionId: "result-client-2",
+ },
+ ];
+ tab.activeResultRunId = "run-1";
+
+ await expect(store.clearQueryResults(tabId)).resolves.toBe(true);
+
+ expect(mocks.closeQuerySession).toHaveBeenCalledTimes(2);
+ expect(tab.sql).toBe("select draft");
+ expect(tab.resultAutoSave).toBe(true);
+ expect(tab.result).toBeUndefined();
+ expect(tab.results).toBeUndefined();
+ expect(tab.resultRuns).toBeUndefined();
+ expect(tab.activeResultRunId).toBeUndefined();
+ });
});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 4e8e552c70..0ae77ceb31 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -1095,6 +1095,45 @@ export const useQueryStore = defineStore("query", () => {
return true;
}
+ async function closeQueryResult(id: string) {
+ const tab = tabs.value.find((item) => item.id === id);
+ if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
+
+ if (tab.activeResultRunId) return removeResultRun(id, tab.activeResultRunId);
+ if (!tab.result && !tab.results?.length && !tab.resultEvicted) return false;
+
+ const closeSession = closeResultSession(tab);
+ clearResultPayload(tab);
+ await closeSession;
+ return true;
+ }
+
+ async function clearQueryResults(id: string) {
+ const tab = tabs.value.find((item) => item.id === id);
+ if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
+
+ const resultRuns = tab.resultRuns ?? [];
+ if (!tab.result && !tab.results?.length && !tab.resultEvicted && resultRuns.length === 0) return false;
+
+ const closedSessionIds = new Set();
+ const currentSessionId = tab.resultSessionId ?? tab.result?.session_id;
+ if (currentSessionId) closedSessionIds.add(currentSessionId);
+ const closeOperations = [closeResultSession(tab)];
+
+ for (const run of resultRuns) {
+ if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
+ if (!run.resultSessionId || closedSessionIds.has(run.resultSessionId)) continue;
+ closedSessionIds.add(run.resultSessionId);
+ closeOperations.push(closeResultRunSession(tab, run));
+ }
+
+ tab.resultRuns = undefined;
+ tab.activeResultRunId = undefined;
+ clearResultPayload(tab);
+ await Promise.all(closeOperations);
+ return true;
+ }
+
function nextResultRunSequence(tab: QueryTab): number {
return (tab.resultRuns?.reduce((max, run) => Math.max(max, run.sequence), 0) ?? 0) + 1;
}
@@ -5697,6 +5736,8 @@ export const useQueryStore = defineStore("query", () => {
toggleResultAutoSave,
setActiveResultRun,
removeResultRun,
+ closeQueryResult,
+ clearQueryResults,
setActiveResultIndex,
executeCurrentTab,
executeCurrentSql,
diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts
index 3f94db10f4..f5aac7ed9d 100644
--- a/packages/app-tests/queryResultToolbar.test.ts
+++ b/packages/app-tests/queryResultToolbar.test.ts
@@ -4,6 +4,7 @@ import { test } from "vitest";
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
+const appPath = "apps/desktop/src/App.vue";
const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue";
const viewSwitcherPath = "apps/desktop/src/components/layout/QueryResultViewSwitcher.vue";
const toolbarActionsPath = "apps/desktop/src/components/layout/QueryResultToolbarActions.vue";
@@ -55,6 +56,10 @@ test("ContentArea exposes retained result runs as switchable tabs or a compact l
assert.match(contentArea, /data-result-run-tab/);
assert.match(contentArea, /@keydown="onResultRunTabKeydown\(\$event, runIndex\)"/);
assert.match(contentArea, /@click\.stop\.prevent="removeResultRun\(run\.id\)"/);
+ assert.match(contentArea, /const canCloseQueryResult = computed\([\s\S]*!props\.activeTab\.activeResultRunId/);
+ assert.match(contentArea, /v-if="canCloseQueryResult"[\s\S]*@click="closeCurrentQueryResult"/);
+ assert.match(contentArea, /queryStore\.closeQueryResult\(props\.activeTab\.id\)/);
+ assert.match(contentArea, /t\('tabs\.closeResult'\)/);
assert.match(contentArea, /[\s\S]*v-for="run in resultRuns"/);
assert.match(contentArea, /setResultRunDisplayMode\('list'\)/);
assert.match(contentArea, /setResultRunDisplayMode\('tabs'\)/);
@@ -68,6 +73,16 @@ test("ContentArea exposes retained result runs as switchable tabs or a compact l
assert.equal((contentArea.match(/ {
+ const app = source(appPath);
+ const closeShortcutStart = app.indexOf("if (isCloseTabShortcut(e, shortcuts))");
+ const closeShortcutEnd = app.indexOf("if (isSaveShortcut", closeShortcutStart);
+ const closeShortcut = app.slice(closeShortcutStart, closeShortcutEnd);
+
+ assert.ok(closeShortcutStart >= 0);
+ assert.ok(closeShortcut.indexOf("await queryStore.clearQueryResults(queryStore.activeTabId)") < closeShortcut.indexOf("queryStore.closeTab(queryStore.activeTabId)"));
+});
+
test("ContentArea keeps MySQL standard explain results available in the shared toolbar", () => {
const contentArea = source(contentAreaPath);
diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts
index dd4cdd97ba..df39c7ba77 100644
--- a/packages/app-tests/queryStore.test.ts
+++ b/packages/app-tests/queryStore.test.ts
@@ -1304,6 +1304,27 @@ test("removing the active result run selects an adjacent run", async () => {
assert.deepEqual(tab.result?.columns, ["one"]);
});
+test("closing an ordinary query result preserves the query tab", async () => {
+ setActivePinia(createPinia());
+ const store = useQueryStore();
+ const tabId = store.createTab("conn-1", "db");
+ const tab = store.tabs.find((item) => item.id === tabId);
+ assert.ok(tab);
+
+ tab.sql = "select draft";
+ tab.result = { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
+ tab.lastExecutedSql = "select 1";
+
+ assert.equal(await store.closeQueryResult(tabId), true);
+
+ assert.equal(tab.sql, "select draft");
+ assert.equal(tab.connectionId, "conn-1");
+ assert.equal(tab.database, "db");
+ assert.equal(tab.result, undefined);
+ assert.equal(tab.results, undefined);
+ assert.equal(tab.activeResultRunId, undefined);
+});
+
test("removing the active result run clears output when remaining caches are unavailable", async () => {
setActivePinia(createPinia());
const store = useQueryStore();