feat(result-tabs): support pinned tabs and batch closing

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-23 02:53:07 +08:00
1 parent 9815e5ac81
commit 93dc455c72
17 files changed
+883 -57

No files matched your search

@@ -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<HTMLElement>('[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<HTMLElement>('[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({
</div>
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
<div role="tablist" :aria-label="t('tabs.resultRuns')" class="flex h-full shrink-0 items-center gap-1">
<div
v-for="(run, runIndex) in resultRuns"
:key="run.id"
role="presentation"
class="group/result-run inline-flex h-7 shrink-0 items-center overflow-hidden rounded-md border transition-colors"
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
>
<button
type="button"
role="tab"
data-result-run-tab
:tabindex="run.active ? 0 : -1"
:aria-selected="run.active"
:data-active-result-run="run.active ? 'true' : undefined"
class="h-full whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
<CustomContextMenu v-for="(run, runIndex) in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
<div
role="presentation"
class="group/result-run inline-flex h-7 shrink-0 select-none items-center overflow-hidden rounded-md border transition-colors"
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
@contextmenu="onContextMenu"
>
{{ run.title || t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
class="mr-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</div>
<button
type="button"
role="tab"
data-result-run-tab
:tabindex="run.active ? 0 : -1"
:aria-selected="run.active"
:data-active-result-run="run.active ? 'true' : undefined"
class="flex h-full select-none items-center gap-1 whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
>
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
{{ run.title || t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
class="mr-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</CustomContextMenu>
</div>
</div>
</div>
@@ -1205,20 +1294,23 @@ defineExpose({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ run.title || t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
<CustomContextMenu v-for="run in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)" @contextmenu="onContextMenu">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
<span class="min-w-0 flex-1 truncate">{{ run.title || t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
</CustomContextMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -1389,6 +1389,12 @@ export default withEnglishFallback({
resultRuns: "実行履歴",
resultSets: "結果セット",
removeRun: "実行 {n} を削除",
pinResultRun: "結果タブを固定",
unpinResultRun: "結果タブの固定を解除",
unpinAllResultRuns: "すべての結果タブの固定を解除",
closeOtherResultRuns: "他の結果タブを閉じる",
closeResultRunsToLeft: "左側の結果タブを閉じる",
closeResultRunsToRight: "右側の結果タブを閉じる",
closeResult: "結果を閉じる",
autoKeepResults: "クエリ結果を自動保持",
autoKeepResultsEnabled: "結果の自動保持をオンにしました",
+6
View File
@@ -1276,6 +1276,12 @@ export default withEnglishFallback({
resultRuns: "결과 실행",
resultSets: "결과 집합",
removeRun: "실행 {n} 제거",
pinResultRun: "결과 탭 고정",
unpinResultRun: "결과 탭 고정 해제",
unpinAllResultRuns: "모든 결과 탭 고정 해제",
closeOtherResultRuns: "다른 결과 탭 닫기",
closeResultRunsToLeft: "왼쪽 결과 탭 닫기",
closeResultRunsToRight: "오른쪽 결과 탭 닫기",
closeResult: "결과 닫기",
autoKeepResults: "쿼리 결과 자동 보관",
autoKeepResultsEnabled: "결과 자동 보관 활성화됨",
+6
View File
@@ -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",
+6
View File
@@ -1319,6 +1319,12 @@ export default withEnglishFallback({
resultRuns: "执行结果",
resultSets: "语句结果",
removeRun: "删除执行 {n}",
pinResultRun: "固定结果标签",
unpinResultRun: "取消固定结果标签",
unpinAllResultRuns: "取消固定所有结果标签",
closeOtherResultRuns: "关闭其他结果标签",
closeResultRunsToLeft: "关闭左侧结果标签",
closeResultRunsToRight: "关闭右侧结果标签",
closeResult: "关闭结果",
autoKeepResults: "自动保留查询结果",
autoKeepResultsEnabled: "已开启自动保留结果",
+6
View File
@@ -1371,6 +1371,12 @@ export default withEnglishFallback({
resultRuns: "執行結果",
resultSets: "語句結果",
removeRun: "移除執行 {n}",
pinResultRun: "釘選結果分頁",
unpinResultRun: "取消釘選結果分頁",
unpinAllResultRuns: "取消釘選所有結果分頁",
closeOtherResultRuns: "關閉其他結果分頁",
closeResultRunsToLeft: "關閉左側結果分頁",
closeResultRunsToRight: "關閉右側結果分頁",
closeResult: "關閉結果",
autoKeepResults: "自動保留查詢結果",
autoKeepResultsEnabled: "自動保留結果已啟用",
@@ -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 } : {}),
+2 -1
View File
@@ -344,12 +344,13 @@ export function activeResultRun(tab: Pick<QueryTab, "resultRuns" | "activeResult
return tab.resultRuns?.find((run) => run.id === tab.activeResultRunId);
}
export function resultRunItems(tab: Pick<QueryTab, "resultRuns" | "activeResultRunId">): { id: string; title: string; sequence: number; active: boolean }[] {
export function resultRunItems(tab: Pick<QueryTab, "resultRuns" | "activeResultRunId">): { 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,
}));
}
+122 -5
View File
@@ -1117,9 +1117,18 @@ export const useQueryStore = defineStore("query", () => {
{
...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,
resultEvicted: undefined,
// 快照编解码会重建负载(如省略 session_id),落盘前的估算值不再对应
// 恢复后的对象,置空以便 projectResultRun 按当前负载重算
resultEstimatedBytes: undefined,
@@ -1129,14 +1138,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<boolean> {
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<boolean> {
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<boolean> {
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;
}
@@ -1230,6 +1304,9 @@ export const useQueryStore = defineStore("query", () => {
}
function persistResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]): Promise<boolean> {
// 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";
@@ -1616,6 +1693,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,
})),
);
@@ -4238,7 +4329,26 @@ export const useQueryStore = defineStore("query", () => {
if (!tab || !sql.trim()) return;
const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true;
const 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) {
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;
@@ -4278,7 +4388,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;
@@ -5299,7 +5409,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);
@@ -6469,6 +6581,11 @@ export const useQueryStore = defineStore("query", () => {
invalidateResultEstimateForPayload,
toggleResultAutoSave,
setActiveResultRun,
toggleResultRunPinned,
unpinAllResultRuns,
closeOtherResultRuns,
closeResultRunsToLeft,
closeResultRunsToRight,
removeResultRun,
closeQueryResult,
clearQueryResults,
+2
View File
@@ -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;