feat(result-tabs): support pinned tabs and batch closing
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1389,6 +1389,12 @@ export default withEnglishFallback({
|
||||
resultRuns: "実行履歴",
|
||||
resultSets: "結果セット",
|
||||
removeRun: "実行 {n} を削除",
|
||||
pinResultRun: "結果タブを固定",
|
||||
unpinResultRun: "結果タブの固定を解除",
|
||||
unpinAllResultRuns: "すべての結果タブの固定を解除",
|
||||
closeOtherResultRuns: "他の結果タブを閉じる",
|
||||
closeResultRunsToLeft: "左側の結果タブを閉じる",
|
||||
closeResultRunsToRight: "右側の結果タブを閉じる",
|
||||
closeResult: "結果を閉じる",
|
||||
autoKeepResults: "クエリ結果を自動保持",
|
||||
autoKeepResultsEnabled: "結果の自動保持をオンにしました",
|
||||
|
||||
@@ -1276,6 +1276,12 @@ export default withEnglishFallback({
|
||||
resultRuns: "결과 실행",
|
||||
resultSets: "결과 집합",
|
||||
removeRun: "실행 {n} 제거",
|
||||
pinResultRun: "결과 탭 고정",
|
||||
unpinResultRun: "결과 탭 고정 해제",
|
||||
unpinAllResultRuns: "모든 결과 탭 고정 해제",
|
||||
closeOtherResultRuns: "다른 결과 탭 닫기",
|
||||
closeResultRunsToLeft: "왼쪽 결과 탭 닫기",
|
||||
closeResultRunsToRight: "오른쪽 결과 탭 닫기",
|
||||
closeResult: "결과 닫기",
|
||||
autoKeepResults: "쿼리 결과 자동 보관",
|
||||
autoKeepResultsEnabled: "결과 자동 보관 활성화됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1319,6 +1319,12 @@ export default withEnglishFallback({
|
||||
resultRuns: "执行结果",
|
||||
resultSets: "语句结果",
|
||||
removeRun: "删除执行 {n}",
|
||||
pinResultRun: "固定结果标签",
|
||||
unpinResultRun: "取消固定结果标签",
|
||||
unpinAllResultRuns: "取消固定所有结果标签",
|
||||
closeOtherResultRuns: "关闭其他结果标签",
|
||||
closeResultRunsToLeft: "关闭左侧结果标签",
|
||||
closeResultRunsToRight: "关闭右侧结果标签",
|
||||
closeResult: "关闭结果",
|
||||
autoKeepResults: "自动保留查询结果",
|
||||
autoKeepResultsEnabled: "已开启自动保留结果",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user