From 952a1642a01bd1785b38e82b1fcb55e5980d07a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 22 Aug 2026 18:54:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(result-tabs):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=9B=BA=E5=AE=9A=E7=BB=93=E6=9E=9C=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=8F=8A=E6=89=B9=E9=87=8F=E5=85=B3=E9=97=AD=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为结果标签和下拉选择器增加右键菜单,可固定、取消固定、关闭其他及关闭左右侧标签 - 固定结果在普通查询执行时不会被覆盖,必要时自动复用未固定标签或创建新标签 - 持久化固定状态,并完善结果快照恢复与淘汰场景处理 - 优化新增结果标签时的滚动位置,避免打断用户横向浏览 - 补充多语言文案与持久化测试 --- .../src/components/layout/ContentArea.vue | 190 +++++++--- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../src/lib/app/openTabsPersistence.ts | 2 + apps/desktop/src/lib/tabs/tabPresentation.ts | 3 +- apps/desktop/src/stores/queryStore.ts | 115 +++++- apps/desktop/src/types/database.ts | 2 + .../app-tests/openTabsPersistence.test.ts | 4 + packages/app-tests/queryResultToolbar.test.ts | 16 + packages/app-tests/queryStore.test.ts | 344 ++++++++++++++++++ packages/app-tests/tabPresentation.test.ts | 5 +- 17 files changed, 672 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index f4bf2ec526..19ea6c9ec8 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -49,6 +49,7 @@ import "splitpanes/dist/splitpanes.css"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal } from "@/components/ui/dropdown-menu"; +import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { Switch } from "@/components/ui/switch"; import LightTooltip from "@/components/ui/LightTooltip.vue"; import QueryEditor from "@/components/editor/QueryEditor.vue"; @@ -255,6 +256,7 @@ onMounted(() => { window.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.visualViewport?.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.addEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions); + revealActiveResultRunAfterRender(); }); watch( @@ -474,13 +476,43 @@ const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.ac const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs"); const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list"); const canCloseQueryResult = computed(() => props.activeTab.mode === "query" && !props.activeTab.isExecuting && !props.activeTab.activeResultRunId && (!!props.activeTab.result || !!props.activeTab.results?.length || props.activeTab.resultEvicted === true)); + +function updateResultTabsAfterRender() { + nextTick(() => updateResultTabsScrollbar()); +} + +function revealActiveResultRunAfterRender() { + nextTick(() => { + if (!showResultRunTabs.value) return; + updateResultTabsScrollbar(); + resultTabsScrollerRef.value?.querySelector('[data-active-result-run="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }); +} + +function resultRunIdsWereAppended(previous: string[], current: string[]) { + return current.length > previous.length && previous.every((id, index) => current[index] === id); +} + watch( - () => `${resultRunDisplayMode.value}:${resultRuns.value.map((run) => run.id).join(",")}:${props.activeTab.activeResultRunId ?? ""}`, - () => { - nextTick(() => { - updateResultTabsScrollbar(); - resultTabsScrollerRef.value?.querySelector('[data-active-result-run="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }); + () => ({ + tabId: props.activeTab.id, + displayMode: resultRunDisplayMode.value, + runIds: resultRuns.value.map((run) => run.id), + activeRunId: props.activeTab.activeResultRunId, + }), + (current, previous) => { + const switchedTab = current.tabId !== previous.tabId; + const switchedDisplayMode = current.displayMode !== previous.displayMode; + const activeRunChanged = current.activeRunId !== previous.activeRunId; + + // Appending a fresh result must keep the user's horizontal position stable. + // Reused result slots, tab/display-mode changes, and keyboard/close flows + // still reveal the active run when it may be outside the visible strip. + if (switchedTab || switchedDisplayMode || (activeRunChanged && !resultRunIdsWereAppended(previous.runIds, current.runIds))) { + revealActiveResultRunAfterRender(); + return; + } + updateResultTabsAfterRender(); }, ); const summaryItems = computed(() => executionSummaryItems(props.activeTab)); @@ -911,6 +943,61 @@ async function removeResultRun(runId: string) { activeRunTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); } +function toggleResultRunPinned(runId: string) { + queryStore.toggleResultRunPinned(props.activeTab.id, runId); +} + +async function closeOtherResultRuns(runId: string) { + if (!(await queryStore.closeOtherResultRuns(props.activeTab.id, runId))) return; + await selectResultRun(runId); +} + +async function closeResultRunsToLeft(runId: string) { + if (!(await queryStore.closeResultRunsToLeft(props.activeTab.id, runId))) return; + await selectResultRun(runId); +} + +async function closeResultRunsToRight(runId: string) { + if (!(await queryStore.closeResultRunsToRight(props.activeTab.id, runId))) return; + await selectResultRun(runId); +} + +function resultRunContextMenuItems(run: (typeof resultRuns.value)[number]): ContextMenuItem[] { + return [ + { + label: t(run.pinned ? "tabs.unpinResultRun" : "tabs.pinResultRun"), + action: () => toggleResultRunPinned(run.id), + icon: Pin, + iconClass: run.pinned ? "fill-current" : "", + }, + { + label: t("tabs.unpinAllResultRuns"), + action: () => queryStore.unpinAllResultRuns(props.activeTab.id), + disabled: !resultRuns.value.some((item) => item.pinned), + icon: Pin, + }, + { label: "", separator: true }, + { + label: t("tabs.closeOtherResultRuns"), + action: () => void closeOtherResultRuns(run.id), + disabled: resultRuns.value.length <= 1, + icon: X, + }, + { + label: t("tabs.closeResultRunsToLeft"), + action: () => void closeResultRunsToLeft(run.id), + disabled: resultRuns.value.findIndex((item) => item.id === run.id) <= 0, + icon: X, + }, + { + label: t("tabs.closeResultRunsToRight"), + action: () => void closeResultRunsToRight(run.id), + disabled: resultRuns.value.findIndex((item) => item.id === run.id) >= resultRuns.value.length - 1, + icon: X, + }, + ]; +} + async function closeCurrentQueryResult() { if (!(await queryStore.closeQueryResult(props.activeTab.id))) return; emit("update:activeOutputView", "result"); @@ -1163,36 +1250,38 @@ defineExpose({
- + + +
+
@@ -1205,20 +1294,23 @@ defineExpose({ - - - - {{ run.title || t("tabs.runN", { n: run.sequence }) }} - - + + + + + + {{ run.title || t("tabs.runN", { n: run.sequence }) }} + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f897a5e161..478f7af472 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1395,6 +1395,12 @@ export default { resultRuns: "Result runs", resultSets: "Result sets", removeRun: "Remove run {n}", + pinResultRun: "Pin result tab", + unpinResultRun: "Unpin result tab", + unpinAllResultRuns: "Unpin all result tabs", + closeOtherResultRuns: "Close other result tabs", + closeResultRunsToLeft: "Close result tabs to the left", + closeResultRunsToRight: "Close result tabs to the right", closeResult: "Close result", autoKeepResults: "Auto-keep query results", autoKeepResultsEnabled: "Auto-keep results enabled", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 08bbef154a..e4be8bd5f3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1372,6 +1372,12 @@ export default withEnglishFallback({ resultRuns: "Ejecuciones de resultado", resultSets: "Conjuntos de resultados", removeRun: "Eliminar ejecución {n}", + pinResultRun: "Fijar pestaña de resultado", + unpinResultRun: "Desfijar pestaña de resultado", + unpinAllResultRuns: "Desfijar todas las pestañas de resultado", + closeOtherResultRuns: "Cerrar otras pestañas de resultado", + closeResultRunsToLeft: "Cerrar pestañas de resultado a la izquierda", + closeResultRunsToRight: "Cerrar pestañas de resultado a la derecha", closeResult: "Cerrar resultado", autoKeepResults: "Conservar resultados automáticamente", autoKeepResultsEnabled: "Conservación automática activada", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b15f8604c5..1ef954baeb 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1370,6 +1370,12 @@ export default withEnglishFallback({ resultRuns: "Esecuzioni risultati", resultSets: "Set di risultati", removeRun: "Rimuovi esecuzione {n}", + pinResultRun: "Fissa scheda risultato", + unpinResultRun: "Sblocca scheda risultato", + unpinAllResultRuns: "Sblocca tutte le schede risultato", + closeOtherResultRuns: "Chiudi le altre schede risultato", + closeResultRunsToLeft: "Chiudi le schede risultato a sinistra", + closeResultRunsToRight: "Chiudi le schede risultato a destra", closeResult: "Chiudi risultato", autoKeepResults: "Mantieni automaticamente i risultati", autoKeepResultsEnabled: "Mantieni risultati abilitato", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index dd9436bd16..137c847c5b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1389,6 +1389,12 @@ export default withEnglishFallback({ resultRuns: "実行履歴", resultSets: "結果セット", removeRun: "実行 {n} を削除", + pinResultRun: "結果タブを固定", + unpinResultRun: "結果タブの固定を解除", + unpinAllResultRuns: "すべての結果タブの固定を解除", + closeOtherResultRuns: "他の結果タブを閉じる", + closeResultRunsToLeft: "左側の結果タブを閉じる", + closeResultRunsToRight: "右側の結果タブを閉じる", closeResult: "結果を閉じる", autoKeepResults: "クエリ結果を自動保持", autoKeepResultsEnabled: "結果の自動保持をオンにしました", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 19c4be5af4..08d6d0153c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1276,6 +1276,12 @@ export default withEnglishFallback({ resultRuns: "결과 실행", resultSets: "결과 집합", removeRun: "실행 {n} 제거", + pinResultRun: "결과 탭 고정", + unpinResultRun: "결과 탭 고정 해제", + unpinAllResultRuns: "모든 결과 탭 고정 해제", + closeOtherResultRuns: "다른 결과 탭 닫기", + closeResultRunsToLeft: "왼쪽 결과 탭 닫기", + closeResultRunsToRight: "오른쪽 결과 탭 닫기", closeResult: "결과 닫기", autoKeepResults: "쿼리 결과 자동 보관", autoKeepResultsEnabled: "결과 자동 보관 활성화됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0f1c39f0d4..3ff3f92627 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1372,6 +1372,12 @@ export default withEnglishFallback({ resultRuns: "Execuções de resultado", resultSets: "Conjuntos de resultados", removeRun: "Remover execução {n}", + pinResultRun: "Fixar aba de resultado", + unpinResultRun: "Desafixar aba de resultado", + unpinAllResultRuns: "Desafixar todas as abas de resultado", + closeOtherResultRuns: "Fechar outras abas de resultado", + closeResultRunsToLeft: "Fechar abas de resultado à esquerda", + closeResultRunsToRight: "Fechar abas de resultado à direita", closeResult: "Fechar resultado", autoKeepResults: "Manter resultados automaticamente", autoKeepResultsEnabled: "Manutenção automática de resultados ativada", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 33c96e7b2f..8416cc074f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1319,6 +1319,12 @@ export default withEnglishFallback({ resultRuns: "执行结果", resultSets: "语句结果", removeRun: "删除执行 {n}", + pinResultRun: "固定结果标签", + unpinResultRun: "取消固定结果标签", + unpinAllResultRuns: "取消固定所有结果标签", + closeOtherResultRuns: "关闭其他结果标签", + closeResultRunsToLeft: "关闭左侧结果标签", + closeResultRunsToRight: "关闭右侧结果标签", closeResult: "关闭结果", autoKeepResults: "自动保留查询结果", autoKeepResultsEnabled: "已开启自动保留结果", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7222579e11..f239a5a63d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1371,6 +1371,12 @@ export default withEnglishFallback({ resultRuns: "執行結果", resultSets: "語句結果", removeRun: "移除執行 {n}", + pinResultRun: "釘選結果分頁", + unpinResultRun: "取消釘選結果分頁", + unpinAllResultRuns: "取消釘選所有結果分頁", + closeOtherResultRuns: "關閉其他結果分頁", + closeResultRunsToLeft: "關閉左側結果分頁", + closeResultRunsToRight: "關閉右側結果分頁", closeResult: "關閉結果", autoKeepResults: "自動保留查詢結果", autoKeepResultsEnabled: "自動保留結果已啟用", diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 4a32024dd0..c93bd6827f 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -9,6 +9,7 @@ export interface SavedQueryResultRun { sequence: number; sql: string; createdAt: number; + pinned?: boolean; activeResultIndex?: number; resultCacheKey?: string; resultEvicted?: boolean; @@ -138,6 +139,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { sequence: run.sequence, sql: run.sql, createdAt: run.createdAt, + ...(run.pinned ? { pinned: true } : {}), activeResultIndex: run.activeResultIndex, ...(run.resultCacheKey !== undefined ? { resultCacheKey: run.resultCacheKey } : {}), ...(run.resultEvicted ? { resultEvicted: true } : {}), diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 52191bcdab..e5397c70e1 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -344,12 +344,13 @@ export function activeResultRun(tab: Pick run.id === tab.activeResultRunId); } -export function resultRunItems(tab: Pick): { id: string; title: string; sequence: number; active: boolean }[] { +export function resultRunItems(tab: Pick): { id: string; title: string; sequence: number; active: boolean; pinned: boolean }[] { return (tab.resultRuns ?? []).map((run) => ({ id: run.id, title: run.title, sequence: run.sequence, active: run.id === tab.activeResultRunId, + pinned: run.pinned === true, })); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5d500cd3c3..9c4bc99ffe 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1119,11 +1119,14 @@ export const useQueryStore = defineStore("query", () => { const restoredRun = markQueryResultRunsRowsRaw([ { - ...run, ...snapshotRun, + // The open-tab metadata may have changed after this payload was evicted + // (for example, pinning or unpinning a result). Keep it authoritative. + ...run, result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined, results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined, resultCacheState: "memory" as const, + resultEvicted: undefined, // 快照编解码会重建负载(如省略 session_id),落盘前的估算值不再对应 // 恢复后的对象,置空以便 projectResultRun 按当前负载重算 resultEstimatedBytes: undefined, @@ -1133,14 +1136,79 @@ export const useQueryStore = defineStore("query", () => { return restoredRun; } - async function setActiveResultRun(id: string, runId: string) { + async function setActiveResultRun(id: string, runId: string, options: { evictInactive?: boolean } = {}) { const tab = findExecutionTab(id); if (!tab) return false; const existingRun = tab.resultRuns?.find((item) => item.id === runId); const run = existingRun && resultRunHasPayload(existingRun) ? existingRun : await restoreResultRunPayload(tab, runId); if (!run?.result && !run?.results?.length) return false; projectResultRun(tab, run); - evictInactiveResultRunPayloads(tab); + if (options.evictInactive !== false) evictInactiveResultRunPayloads(tab); + return true; + } + + function toggleResultRunPinned(id: string, runId: string): boolean | undefined { + const tab = tabs.value.find((item) => item.id === id); + const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1; + if (!tab?.resultRuns || runIndex < 0) return undefined; + + const run = { ...tab.resultRuns[runIndex]!, pinned: tab.resultRuns[runIndex]!.pinned ? undefined : true }; + tab.resultRuns[runIndex] = run; + void persistResultRun(tab, run); + return run.pinned === true; + } + + function unpinAllResultRuns(id: string): number { + const tab = tabs.value.find((item) => item.id === id); + if (!tab?.resultRuns?.length) return 0; + + let changed = 0; + tab.resultRuns = tab.resultRuns.map((run) => { + if (!run.pinned) return run; + changed += 1; + const updated = { ...run, pinned: undefined }; + void persistResultRun(tab, updated); + return updated; + }); + return changed; + } + + async function closeOtherResultRuns(id: string, keepRunId: string): Promise { + const tab = tabs.value.find((item) => item.id === id); + if (!tab?.resultRuns?.some((run) => run.id === keepRunId)) return false; + + const runIds = tab.resultRuns.filter((run) => run.id !== keepRunId).map((run) => run.id); + if (runIds.length === 0) return false; + // Do not delete otherwise usable runs until the run the user chose to keep + // has been restored successfully. Disk-backed snapshots can be unavailable. + if (!(await setActiveResultRun(id, keepRunId, { evictInactive: false }))) return false; + for (const runId of runIds) { + await removeResultRun(id, runId); + } + return true; + } + + async function closeResultRunsToLeft(id: string, runId: string): Promise { + const tab = tabs.value.find((item) => item.id === id); + const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1; + if (!tab?.resultRuns || runIndex <= 0) return false; + + if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false; + for (const run of tab.resultRuns.slice(0, runIndex)) { + await removeResultRun(id, run.id); + } + return true; + } + + async function closeResultRunsToRight(id: string, runId: string): Promise { + const tab = tabs.value.find((item) => item.id === id); + const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1; + if (!tab?.resultRuns || runIndex < 0 || runIndex >= tab.resultRuns.length - 1) return false; + + if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false; + for (const run of tab.resultRuns.slice(runIndex + 1)) { + await removeResultRun(id, run.id); + } return true; } @@ -1234,6 +1302,9 @@ export const useQueryStore = defineStore("query", () => { } function persistResultRun(tab: QueryTab, run: NonNullable[number]): Promise { + // An evicted run only has metadata in memory. Writing it back here would + // replace its valid disk snapshot with an empty payload. + if (!resultRunHasPayload(run)) return Promise.resolve(false); const key = run.resultCacheKey ?? resultRunCacheKey(tab.id, run.id); run.resultCacheKey = key; run.resultCacheState = "memory"; @@ -1620,6 +1691,20 @@ export const useQueryStore = defineStore("query", () => { mongoEditTarget: t.mongoEditTarget, resultEvicted: t.resultEvicted, resultCacheKey: t.resultCacheKey, + // Keep the watch dependency limited to the metadata that is serialized + // for each result run, without tracking the potentially large payload. + resultRuns: t.resultRuns?.map((run) => ({ + id: run.id, + title: run.title, + sequence: run.sequence, + sql: run.sql, + createdAt: run.createdAt, + pinned: run.pinned, + activeResultIndex: run.activeResultIndex, + resultCacheKey: run.resultCacheKey, + resultEvicted: run.resultEvicted, + })), + activeResultRunId: t.activeResultRunId, })), ); @@ -4242,7 +4327,20 @@ export const useQueryStore = defineStore("query", () => { if (!tab || !sql.trim()) return; const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true; - const captureResultRun = openInNewResultTab; + let captureResultRun = openInNewResultTab; + if (!captureResultRun && tab.mode === "query" && !tab.resultAutoSave && tab.activeResultRunId) { + const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId); + if (activeRun?.pinned) { + const reusableRun = tab.resultRuns?.find((run) => !run.pinned); + if (reusableRun) { + // A stale disk snapshot must not make us fall back to overwriting + // the pinned active run. Capture a fresh run instead. + captureResultRun = !(await setActiveResultRun(id, reusableRun.id)); + } else { + captureResultRun = true; + } + } + } if (captureResultRun && tab.activeResultRunId && !tab.result) { await setActiveResultRun(id, tab.activeResultRunId); if (findExecutionTab(id) !== tab) return false; @@ -5298,7 +5396,9 @@ export const useQueryStore = defineStore("query", () => { current.resultTotalRowCountLoading = false; touchResult(current); producedResult = true; - syncDisplayedResultRun(current, queryBaseSql, openInNewResultTab); + // When a pinned result requires a new run, errors must use that same + // run instead of being replaced by the retained pinned result below. + syncDisplayedResultRun(current, queryBaseSql, captureResultRun); } } finally { if (tableDataNativeSelectionBlockOwner) finishDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner); @@ -6468,6 +6568,11 @@ export const useQueryStore = defineStore("query", () => { invalidateResultEstimateForPayload, toggleResultAutoSave, setActiveResultRun, + toggleResultRunPinned, + unpinAllResultRuns, + closeOtherResultRuns, + closeResultRunsToLeft, + closeResultRunsToRight, removeResultRun, closeQueryResult, clearQueryResults, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index c9d4f52122..f9c5205440 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -793,6 +793,8 @@ export interface QueryResultRun { sequence: number; sql: string; createdAt: number; + /** Keeps this result from being replaced by an ordinary query execution. */ + pinned?: boolean; /** Distinguishes successive result payloads that reuse the same run slot. */ resultGridRevision?: string; result?: QueryResult; diff --git a/packages/app-tests/openTabsPersistence.test.ts b/packages/app-tests/openTabsPersistence.test.ts index 8f51e24304..e71676ba0f 100644 --- a/packages/app-tests/openTabsPersistence.test.ts +++ b/packages/app-tests/openTabsPersistence.test.ts @@ -179,6 +179,7 @@ test("serializes query result run metadata without row payloads", () => { sequence: 1, sql: "select 1", createdAt: 100, + pinned: true, result: { columns: ["id"], rows: [[1]], @@ -200,6 +201,7 @@ test("serializes query result run metadata without row payloads", () => { sequence: 1, sql: "select 1", createdAt: 100, + pinned: true, activeResultIndex: undefined, resultCacheKey: "tab:tab-1:run:run-1", resultEvicted: true, @@ -221,6 +223,7 @@ test("restores query result run metadata as disk-backed runtime state", () => { sequence: 1, sql: "select 1", createdAt: 100, + pinned: true, resultCacheKey: "tab:tab-1:run:run-1", resultEvicted: true, }, @@ -234,6 +237,7 @@ test("restores query result run metadata as disk-backed runtime state", () => { assert.equal(restored.tabs[0]?.resultRuns?.[0]?.id, "run-1"); assert.equal(restored.tabs[0]?.resultRuns?.[0]?.resultCacheState, "disk"); assert.equal(restored.tabs[0]?.resultRuns?.[0]?.result, undefined); + assert.equal(restored.tabs[0]?.resultRuns?.[0]?.pinned, true); }); test("ignores legacy table data result cache handles on restore", () => { diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index 8da9b7f234..16b0df5df4 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -80,6 +80,22 @@ test("ContentArea exposes retained result runs as switchable tabs or a compact l assert.equal((contentArea.match(/ { + const contentArea = source(contentAreaPath); + const resultRunWatcherStart = contentArea.indexOf("function resultRunIdsWereAppended"); + const resultRunWatcherEnd = contentArea.indexOf("const summaryItems", resultRunWatcherStart); + const resultRunWatcher = contentArea.slice(resultRunWatcherStart, resultRunWatcherEnd); + + assert.ok(resultRunWatcherStart >= 0); + assert.match(resultRunWatcher, /current\.length > previous\.length/); + assert.match(resultRunWatcher, /activeRunId: props\.activeTab\.activeResultRunId/); + assert.match(resultRunWatcher, /activeRunChanged && !resultRunIdsWereAppended\(previous\.runIds, current\.runIds\)/); + assert.match(resultRunWatcher, /updateResultTabsAfterRender/); + assert.match(resultRunWatcher, /revealActiveResultRunAfterRender/); + assert.match(contentArea, /function focusResultRunByIndex[\s\S]*scrollIntoView/); + assert.match(contentArea, /async function removeResultRun[\s\S]*scrollIntoView/); +}); + test("the close-tab shortcut clears query results before closing the tab", () => { const app = source(appPath); const closeShortcutStart = app.indexOf("if (isCloseTabShortcut(e, shortcuts))"); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 637959c4a0..af5ed55327 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1418,6 +1418,239 @@ test("removing the active result run selects an adjacent run", async () => { assert.deepEqual(tab.result?.columns, ["one"]); }); +test("pins result runs independently and can unpin all runs", () => { + 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.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1 }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, pinned: true }, + ]; + + assert.equal(store.toggleResultRunPinned(tabId, "run-1"), true); + assert.equal(tab.resultRuns?.[0]?.pinned, true); + assert.equal(store.unpinAllResultRuns(tabId), 2); + assert.deepEqual( + tab.resultRuns?.map((run) => run.pinned), + [undefined, undefined], + ); +}); + +test("changing the pin state preserves an evicted result cache", () => { + 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.resultRuns = [ + { + id: "run-1", + title: "Run 1", + sequence: 1, + sql: "select 1", + createdAt: 1, + resultCacheKey: "tab:tab-1:run:run-1", + resultCacheState: "disk", + resultEvicted: true, + }, + ]; + + assert.equal(store.toggleResultRunPinned(tabId, "run-1"), true); + assert.equal(tab.resultRuns?.[0]?.pinned, true); + assert.equal(tab.resultRuns?.[0]?.resultCacheKey, "tab:tab-1:run:run-1"); + assert.equal(tab.resultRuns?.[0]?.resultCacheState, "disk"); + assert.equal(tab.resultRuns?.[0]?.resultEvicted, true); + assert.equal(store.toggleResultRunPinned(tabId, "run-1"), false); + assert.equal(tab.resultRuns?.[0]?.pinned, undefined); + assert.equal(tab.resultRuns?.[0]?.resultCacheKey, "tab:tab-1:run:run-1"); + assert.equal(tab.resultRuns?.[0]?.resultCacheState, "disk"); + assert.equal(tab.resultRuns?.[0]?.resultEvicted, true); +}); + +test("result run pin and close state persist across a restart", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + let store = useQueryStore(); + const tabId = store.createTab("conn-1", "db"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 } }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + await store.flushPendingPersist(); + + assert.equal(store.toggleResultRunPinned(tabId, "run-1"), true); + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem("dbx-app-state:open_tabs") ?? "null"); + return saved?.tabs?.[0]?.resultRuns?.[0]?.pinned === true; + }); + assert.equal(await store.closeOtherResultRuns(tabId, "run-1"), true); + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem("dbx-app-state:open_tabs") ?? "null"); + return saved?.tabs?.[0]?.resultRuns?.length === 1; + }); + + setActivePinia(createPinia()); + store = useQueryStore(); + await store.initOpenTabs(); + const restored = store.tabs.find((item) => item.id === tabId); + assert.deepEqual(restored?.resultRuns?.map((run) => run.id), ["run-1"]); + assert.equal(restored?.resultRuns?.[0]?.pinned, true); + } finally { + restoreStorage(); + } +}); + +test("closing other result runs preserves the selected run", 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.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, pinned: true, result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-3", title: "Run 3", sequence: 3, sql: "select 3", createdAt: 3, result: { columns: ["three"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 } }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + + assert.equal(await store.closeOtherResultRuns(tabId, "run-2"), true); + assert.deepEqual( + tab.resultRuns?.map((run) => run.id), + ["run-2"], + ); + assert.equal(tab.activeResultRunId, "run-2"); + assert.deepEqual(tab.result?.rows, [[2]]); +}); + +test("bulk result-run close leaves all runs untouched when the selected run is unavailable", 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.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, resultCacheKey: `missing-result-run-${Date.now()}`, resultCacheState: "disk", resultEvicted: true }, + { id: "run-3", title: "Run 3", sequence: 3, sql: "select 3", createdAt: 3, result: { columns: ["three"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 } }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + + assert.equal(await store.closeOtherResultRuns(tabId, "run-2"), false); + assert.equal(await store.closeResultRunsToLeft(tabId, "run-2"), false); + assert.equal(await store.closeResultRunsToRight(tabId, "run-2"), false); + assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1", "run-2", "run-3"]); + assert.equal(tab.activeResultRunId, "run-1"); + assert.deepEqual(tab.result?.rows, [[1]]); +}); + +test("bulk result-run close does not rewrite a deleted session-backed snapshot", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + let releaseSessionClose: (() => void) | undefined; + const sessionCloseGate = new Promise((resolve) => { + releaseSessionClose = resolve; + }); + let sessionCloseRequests = 0; + let cacheWrites = 0; + const deletedCacheKeys: string[] = []; + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/close-session") { + sessionCloseRequests += 1; + await sessionCloseGate; + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/tab-runtime-cache" && init?.method === "POST") { + cacheWrites += 1; + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.startsWith("/api/tab-runtime-cache?")) { + if (init?.method === "DELETE") { + deletedCacheKeys.push(new URL(url, "http://localhost").searchParams.get("key") ?? ""); + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + const removedRun = { + id: "run-1", + title: "Run 1", + sequence: 1, + sql: "select 1", + createdAt: 1, + result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + resultSessionId: "session-1", + resultCacheKey: "tab:tab-1:run:run-1", + }; + tab.resultRuns = [ + removedRun, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 } }, + ]; + tab.activeResultRunId = removedRun.id; + tab.result = removedRun.result; + tab.resultSessionId = removedRun.resultSessionId; + tab.resultCacheKey = removedRun.resultCacheKey; + + assert.equal(await store.closeOtherResultRuns(tabId, "run-2"), true); + await waitFor(() => sessionCloseRequests === 1); + assert.deepEqual(deletedCacheKeys, ["tab:tab-1:run:run-1"]); + + releaseSessionClose?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(cacheWrites, 0); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("closes only result runs to the requested side", 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.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-3", title: "Run 3", sequence: 3, sql: "select 3", createdAt: 3, result: { columns: ["three"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-4", title: "Run 4", sequence: 4, sql: "select 4", createdAt: 4, result: { columns: ["four"], rows: [[4]], affected_rows: 0, execution_time_ms: 1 } }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + + assert.equal(await store.closeResultRunsToLeft(tabId, "run-3"), true); + assert.deepEqual( + tab.resultRuns?.map((run) => run.id), + ["run-3", "run-4"], + ); + assert.equal(await store.closeResultRunsToRight(tabId, "run-3"), true); + assert.deepEqual( + tab.resultRuns?.map((run) => run.id), + ["run-3"], + ); +}); + test("closing an ordinary query result preserves the query tab", async () => { setActivePinia(createPinia()); const store = useQueryStore(); @@ -1661,6 +1894,117 @@ test("completed query executions append result runs and select the latest run", } }); +test("an unavailable reusable result run does not overwrite the pinned active result", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + return new Response(JSON.stringify([{ columns: ["fresh"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/analyze-editability") { + return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.startsWith("/api/tab-runtime-cache?")) { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/tab-runtime-cache" && init?.method === "POST") { + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, pinned: true, result: { columns: ["pinned"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, resultCacheKey: "missing-result-run", resultCacheState: "disk", resultEvicted: true }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + + await store.executeTabSql(tabId, "select 3"); + + assert.equal(tab.resultRuns?.length, 3); + assert.deepEqual(tab.resultRuns?.[0]?.result?.rows, [[1]]); + assert.notEqual(tab.activeResultRunId, "run-1"); + assert.deepEqual(tab.result?.columns, ["fresh"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("a failed execution keeps the pinned result and captures its error separately", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + return new Response("backend exploded", { status: 500 }); + } + if (url.startsWith("/api/tab-runtime-cache?")) { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/tab-runtime-cache" && init?.method === "POST") { + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, pinned: true, result: { columns: ["pinned"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, resultCacheKey: "missing-result-run", resultCacheState: "disk", resultEvicted: true }, + ]; + await store.setActiveResultRun(tabId, "run-1"); + + await store.executeTabSql(tabId, "select broken"); + + assert.equal(tab.resultRuns?.length, 3); + assert.deepEqual(tab.resultRuns?.[0]?.result?.rows, [[1]]); + assert.notEqual(tab.activeResultRunId, "run-1"); + assert.deepEqual(tab.result?.columns, ["Error"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("kept result runs evict inactive payloads without losing switch or archive data", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index 84578858db..c00988b839 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -324,6 +324,7 @@ test("result run items expose ordered labels and active state", () => { sequence: 1, sql: "select 1", createdAt: 10, + pinned: true, result: result(["one"]), }, { @@ -338,8 +339,8 @@ test("result run items expose ordered labels and active state", () => { }); assert.deepEqual(resultRunItems(tab), [ - { id: "run-1", title: "Run 1", sequence: 1, active: false }, - { id: "run-2", title: "Run 2", sequence: 2, active: true }, + { id: "run-1", title: "Run 1", sequence: 1, active: false, pinned: true }, + { id: "run-2", title: "Run 2", sequence: 2, active: true, pinned: false }, ]); assert.equal(activeResultRun(tab)?.id, "run-2"); assert.deepEqual( From 62add817a966d9c71c6de475aa148b0fc8533593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 22 Aug 2026 19:12:07 +0800 Subject: [PATCH 2/3] fix(result-tabs): keep previous result while running --- apps/desktop/src/stores/queryStore.ts | 10 +- packages/app-tests/queryStore.test.ts | 199 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 9c4bc99ffe..3eaba7950b 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4327,7 +4327,13 @@ export const useQueryStore = defineStore("query", () => { if (!tab || !sql.trim()) return; const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true; - let captureResultRun = openInNewResultTab; + // Auto-saved results need two independent decisions: keep the currently + // displayed run visible while the request is pending, then capture the new + // response as another run. Previously `resultAutoSave` only made the latter + // decision after clearing the displayed payload, which caused the result + // toolbar and grid to briefly disappear before the next Run was added. + const captureAutoSavedResultRun = tab.mode === "query" && tab.resultAutoSave === true && !!tab.activeResultRunId && !!tab.result; + let captureResultRun = openInNewResultTab || captureAutoSavedResultRun; if (!captureResultRun && tab.mode === "query" && !tab.resultAutoSave && tab.activeResultRunId) { const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId); if (activeRun?.pinned) { @@ -4380,7 +4386,7 @@ export const useQueryStore = defineStore("query", () => { tab.batchSqlExecution = undefined; liveBatchSqlExecutions.delete(tab); } - const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun); + const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || captureAutoSavedResultRun || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun); const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution; if (!updateActiveResultRun) { tab.activeResultRunId = undefined; diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index af5ed55327..0d3e2e01d6 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1894,6 +1894,205 @@ test("completed query executions append result runs and select the latest run", } }); +test("auto-saved result stays visible until the next run is ready", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + let executeCount = 0; + let resolveSecondExecution: ((response: Response) => void) | undefined; + let secondExecutionStarted: (() => void) | undefined; + const secondExecutionStartedPromise = new Promise((resolve) => { + secondExecutionStarted = resolve; + }); + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + executeCount++; + if (executeCount === 2) { + secondExecutionStarted?.(); + return await new Promise((resolve) => { + resolveSecondExecution = resolve; + }); + } + return new Response(JSON.stringify([{ columns: ["run_1"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/analyze-editability") { + return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + store.toggleResultAutoSave(tabId); + await store.executeTabSql(tabId, "select 1"); + + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab?.resultRuns?.[0]); + const firstRunId = tab.resultRuns[0].id; + const execution = store.executeTabSql(tabId, "select 2"); + await secondExecutionStartedPromise; + + assert.equal(tab.activeResultRunId, firstRunId); + assert.equal(tab.resultRuns?.length, 1); + assert.deepEqual(tab.result?.columns, ["run_1"]); + assert.deepEqual(tab.resultRuns?.[0]?.result?.rows, [[1]]); + + resolveSecondExecution?.(new Response(JSON.stringify([{ columns: ["run_2"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + await execution; + + assert.equal(tab.resultRuns?.length, 2); + assert.deepEqual(tab.resultRuns?.[0]?.result?.columns, ["run_1"]); + assert.deepEqual(tab.result?.columns, ["run_2"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("canceling an auto-saved execution restores the displayed run", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + let executeCount = 0; + let rejectSecondExecution: ((error: Error) => void) | undefined; + let secondExecutionStarted: (() => void) | undefined; + const secondExecutionStartedPromise = new Promise((resolve) => { + secondExecutionStarted = resolve; + }); + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + executeCount++; + if (executeCount === 2) { + secondExecutionStarted?.(); + return await new Promise((_resolve, reject) => { + rejectSecondExecution = reject; + }); + } + return new Response(JSON.stringify([{ columns: ["run_1"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/cancel") { + rejectSecondExecution?.(new Error("Query canceled")); + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/query/analyze-editability") { + return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + store.toggleResultAutoSave(tabId); + await store.executeTabSql(tabId, "select 1"); + + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab?.resultRuns?.[0]); + const firstRunId = tab.resultRuns[0].id; + const execution = store.executeTabSql(tabId, "select 2"); + await secondExecutionStartedPromise; + assert.equal(await store.cancelTabExecution(tabId), true); + await execution; + + assert.equal(tab.activeResultRunId, firstRunId); + assert.equal(tab.resultRuns?.length, 1); + assert.deepEqual(tab.result?.columns, ["run_1"]); + assert.deepEqual(tab.resultRuns?.[0]?.result?.rows, [[1]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("a failed auto-saved execution keeps the prior run and adds an error run", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + let executeCount = 0; + + connectionStore.addEphemeralConnection(conn("conn-1")); + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + executeCount++; + if (executeCount === 2) return new Response("backend exploded", { status: 500 }); + return new Response(JSON.stringify([{ columns: ["run_1"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/analyze-editability") { + return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + store.toggleResultAutoSave(tabId); + await store.executeTabSql(tabId, "select 1"); + await store.executeTabSql(tabId, "select broken"); + + const tab = store.tabs.find((item) => item.id === tabId); + assert.equal(tab?.resultRuns?.length, 2); + assert.deepEqual(tab?.resultRuns?.[0]?.result?.columns, ["run_1"]); + assert.deepEqual(tab?.result?.columns, ["Error"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("an unavailable reusable result run does not overwrite the pinned active result", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); From 616a92c1a2fcacedf832d191ba5af4db74c5e2b3 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 22 Aug 2026 17:45:38 +0000 Subject: [PATCH 3/3] fix(result-tabs): preserve cached run execution state --- apps/desktop/src/stores/queryStore.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 3eaba7950b..39e2839527 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1119,10 +1119,16 @@ export const useQueryStore = defineStore("query", () => { const restoredRun = markQueryResultRunsRowsRaw([ { - ...snapshotRun, - // The open-tab metadata may have changed after this payload was evicted - // (for example, pinning or unpinning a result). Keep it authoritative. ...run, + ...snapshotRun, + id: run.id, + title: run.title, + sequence: run.sequence, + sql: run.sql, + createdAt: run.createdAt, + pinned: run.pinned, + activeResultIndex: run.activeResultIndex, + resultCacheKey: run.resultCacheKey ?? snapshotRun.resultCacheKey, result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined, results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined, resultCacheState: "memory" as const,