feat(grid): add pinned query result history

This commit is contained in:
t8y2 2026-06-18 14:27:53 +08:00
parent fe39a38dd4
commit c155443d4b
7 changed files with 167 additions and 30 deletions

View File

@ -2,7 +2,7 @@
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Loader2, Search, Bot, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, ListChecks, Database, FileUp, Download, X } from "@lucide/vue";
import { Check, Columns3, Loader2, Search, Bot, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, ListChecks, Database, FileUp, Download, X, Pin } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -230,9 +230,11 @@ const activeQueryError = computed(() => {
const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const resultRuns = computed(() => resultRunItems(props.activeTab));
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
watch(
() => tabularResults.value.map((item) => item.index).join(","),
() => {
@ -482,6 +484,19 @@ function removeResultRun(runId: string) {
if (removed && removedActiveRun) emit("update:activeOutputView", "result");
}
async function selectResultRun(runId: string) {
if (!(await queryStore.setActiveResultRun(props.activeTab.id, runId))) {
toast(t("tabs.missingResultRun"), 4000);
return;
}
emit("update:activeOutputView", "result");
}
function toggleResultAutoSave() {
const enabled = queryStore.toggleResultAutoSave(props.activeTab.id);
toast(t(enabled ? "tabs.autoKeepResultsEnabled" : "tabs.autoKeepResultsDisabled"), 2500);
}
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
@ -545,26 +560,45 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
{{ t("tabs.tableData") }}
</Button>
</div>
<Button
v-if="activeTab.mode === 'query' && activeTab.result"
variant="ghost"
size="icon"
class="h-6 w-7 shrink-0 text-muted-foreground hover:text-foreground"
:class="{ 'text-primary': resultAutoSave }"
:title="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
:aria-label="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
:aria-pressed="resultAutoSave"
@click="toggleResultAutoSave"
>
<Pin class="h-3.5 w-3.5" :class="{ 'fill-current': resultAutoSave }" />
</Button>
<template v-if="resultRuns.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="flex min-w-0 max-w-[35%] items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :aria-label="t('tabs.resultRuns')">
<div v-for="run in resultRuns" :key="run.id" class="inline-flex shrink-0 items-center">
<Button
size="sm"
:variant="run.active ? 'default' : 'ghost'"
class="h-6 rounded-r-none px-2 text-xs"
@click="
queryStore.setActiveResultRun(activeTab.id, run.id);
emit('update:activeOutputView', 'result');
"
>
{{ t("tabs.runN", { n: run.sequence }) }}
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs">
{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
<Button size="icon" :variant="run.active ? 'default' : 'ghost'" class="h-6 w-6 rounded-l-none border-l border-border/50 px-0" :title="t('tabs.removeRun', { n: run.sequence })" :aria-label="t('tabs.removeRun', { n: run.sequence })" @click.stop="removeResultRun(run.id)">
<X class="h-3 w-3" />
</Button>
</div>
</div>
</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">{{ 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>
</DropdownMenuContent>
</DropdownMenu>
</template>
<template v-if="tabularResults.length > 1">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />

View File

@ -453,6 +453,9 @@ export default {
runN: "Run {n}",
resultRuns: "Result runs",
removeRun: "Remove run {n}",
autoKeepResults: "Auto-keep query results",
autoKeepResultsEnabled: "Auto-keep results enabled",
autoKeepResultsDisabled: "Auto-keep results disabled",
missingResultRun: "This result is no longer available",
exportResultArchive: "Export Results",
importResultArchive: "Import Results",

View File

@ -452,6 +452,9 @@ export default {
runN: "実行 {n}",
resultRuns: "実行履歴",
removeRun: "実行 {n} を削除",
autoKeepResults: "クエリ結果を自動保持",
autoKeepResultsEnabled: "結果の自動保持をオンにしました",
autoKeepResultsDisabled: "結果の自動保持をオフにしました",
missingResultRun: "この結果は利用できなくなりました",
exportResultArchive: "結果をエクスポート",
importResultArchive: "結果をインポート",

View File

@ -454,6 +454,9 @@ export default {
runN: "执行 {n}",
resultRuns: "执行结果",
removeRun: "删除执行 {n}",
autoKeepResults: "自动保留查询结果",
autoKeepResultsEnabled: "已开启自动保留结果",
autoKeepResultsDisabled: "已关闭自动保留结果",
missingResultRun: "此结果已不可用",
exportResultArchive: "导出结果",
importResultArchive: "导入结果",

View File

@ -41,6 +41,7 @@ export interface SavedOpenTab {
resultCacheKey?: string;
resultRuns?: SavedQueryResultRun[];
activeResultRunId?: string;
resultAutoSave?: boolean;
}
export interface RestoredOpenTabs {
@ -92,6 +93,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
}
: {}),
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
...(tab.mode === "query" && tab.resultAutoSave ? { resultAutoSave: true } : {}),
}));
}
@ -135,6 +137,7 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str
resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined,
resultRuns,
activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id,
resultAutoSave: mode === "query" && tab.resultAutoSave ? true : undefined,
};
});
const activeTabId = rawActiveTabId || null;

View File

@ -44,6 +44,10 @@ const STORAGE_KEY = "dbx-open-tabs";
const ACTIVE_TAB_KEY = "dbx-active-tab";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
function resultRunCacheKey(tabId: string, runId: string): string {
return `tab:${tabId}:run:${runId}`;
}
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
markRaw(result.rows);
return result;
@ -197,6 +201,12 @@ export const useQueryStore = defineStore("query", () => {
}
}
function clearResultRunSnapshots(tab: QueryTab) {
for (const run of tab.resultRuns ?? []) {
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
}
}
function projectResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
const activeIndex = run.activeResultIndex ?? 0;
tab.activeResultRunId = run.id;
@ -227,10 +237,33 @@ export const useQueryStore = defineStore("query", () => {
touchResult(tab);
}
function setActiveResultRun(id: string, runId: string) {
async function restoreResultRunPayload(tab: QueryTab, runId: string) {
const run = tab.resultRuns?.find((item) => item.id === runId);
if (!run || run.result || run.results?.length) return run;
const cacheKey = run.resultCacheKey ?? tab.resultCacheKey;
if (!cacheKey) return run;
const snapshot = await readTabResultSnapshot(cacheKey);
const snapshotRun = snapshot?.resultRuns?.find((item) => item.id === runId);
if (!snapshotRun) return run;
const restoredRun = {
...run,
...snapshotRun,
result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined,
results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined,
resultCacheState: "memory" as const,
};
tab.resultRuns = tab.resultRuns?.map((item) => (item.id === runId ? restoredRun : item));
return restoredRun;
}
async function setActiveResultRun(id: string, runId: string) {
const tab = tabs.value.find((t) => t.id === id);
const run = tab?.resultRuns?.find((item) => item.id === runId);
if (!tab || !run) return false;
if (!tab) return false;
const run = await restoreResultRunPayload(tab, runId);
if (!run?.result && !run?.results?.length) return false;
projectResultRun(tab, run);
return true;
}
@ -240,6 +273,8 @@ export const useQueryStore = defineStore("query", () => {
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab || !tab.resultRuns || runIndex < 0) return false;
const removedRun = tab.resultRuns[runIndex];
if (removedRun?.resultCacheKey) void deleteTabResultSnapshot(removedRun.resultCacheKey);
const wasActive = tab.activeResultRunId === runId;
const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId);
tab.resultRuns = remainingRuns;
@ -261,6 +296,29 @@ export const useQueryStore = defineStore("query", () => {
return (tab.resultRuns?.reduce((max, run) => Math.max(max, run.sequence), 0) ?? 0) + 1;
}
function persistResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
const key = run.resultCacheKey ?? resultRunCacheKey(tab.id, run.id);
run.resultCacheKey = key;
run.resultCacheState = "memory";
void writeTabResultSnapshot(key, {
result: run.result,
results: run.results,
activeResultIndex: run.activeResultIndex,
resultRuns: [run],
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
querySourceColumns: run.querySourceColumns,
queryEditabilityReason: run.queryEditabilityReason,
tableMeta: run.tableMeta,
resultPageSql: run.resultPageSql,
resultPageLimit: run.resultPageLimit,
resultPageOffset: run.resultPageOffset,
resultCountSql: run.resultCountSql,
resultTotalRowCount: run.resultTotalRowCount,
cachedAt: Date.now(),
});
}
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now()) {
if (tab.mode !== "query" || !tab.result) return;
const sequence = nextResultRunSequence(tab);
@ -295,15 +353,26 @@ export const useQueryStore = defineStore("query", () => {
queryEditabilityReason: tab.queryEditabilityReason,
tableMeta: tab.tableMeta,
};
persistResultRun(tab, run);
tab.resultRuns = [...(tab.resultRuns ?? []), run];
tab.activeResultRunId = run.id;
}
function toggleResultAutoSave(id: string): boolean {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "query") return false;
tab.resultAutoSave = tab.resultAutoSave ? undefined : true;
if (tab.resultAutoSave && tab.result && !tab.activeResultRunId) {
captureDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
}
return tab.resultAutoSave === true;
}
function syncActiveResultRunFromDisplayed(tab: QueryTab) {
if (!tab.activeResultRunId || !tab.resultRuns?.length) return;
const index = tab.resultRuns.findIndex((run) => run.id === tab.activeResultRunId);
if (index < 0) return;
tab.resultRuns[index] = {
const run = {
...tab.resultRuns[index],
result: tab.result,
results: tab.results,
@ -330,6 +399,17 @@ export const useQueryStore = defineStore("query", () => {
queryEditabilityReason: tab.queryEditabilityReason,
tableMeta: tab.tableMeta,
};
persistResultRun(tab, run);
tab.resultRuns[index] = run;
}
function syncDisplayedResultRun(tab: QueryTab, sql: string) {
if (tab.mode !== "query" || !tab.result) return;
if (tab.activeResultRunId) {
syncActiveResultRunFromDisplayed(tab);
} else if (tab.resultAutoSave) {
captureDisplayedResultRun(tab, sql);
}
}
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
@ -370,6 +450,7 @@ export const useQueryStore = defineStore("query", () => {
whereInput: t.whereInput,
pinned: t.pinned,
mode: t.mode,
resultAutoSave: t.resultAutoSave,
structureTableName: t.structureTableName,
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
@ -564,6 +645,7 @@ export const useQueryStore = defineStore("query", () => {
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
void closeResultSession(tabs.value[idx]);
void closeClientConnectionSession(tabs.value[idx]);
clearResultRunSnapshots(tabs.value[idx]);
clearResultPayload(tabs.value[idx]);
tabs.value.splice(idx, 1);
if (activeTabId.value === id) {
@ -600,6 +682,7 @@ export const useQueryStore = defineStore("query", () => {
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
clearResultPayload(tab);
});
const next = closeOtherTabsState(tabs.value, activeTabId.value, id);
@ -614,6 +697,7 @@ export const useQueryStore = defineStore("query", () => {
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
clearResultPayload(tab);
});
const next = closeAllTabsState(tabs.value, activeTabId.value);
@ -697,6 +781,7 @@ export const useQueryStore = defineStore("query", () => {
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
clearResultPayload(tab);
});
@ -1150,6 +1235,10 @@ export const useQueryStore = defineStore("query", () => {
}
tab.executionId = executionId;
tab.lastExecutedSql = sql;
const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true;
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
}
if (!options?.preserveTotalRowCountDuringExecution) {
tab.resultTotalRowCount = undefined;
}
@ -1236,7 +1325,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
// Reflect db switches from SELECT N in the tab so the toolbar dropdown, tab title and
// sidebar stay in sync with the command's effective db.
if (current.database !== String(currentDb)) {
@ -1294,7 +1383,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1320,7 +1409,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1352,7 +1441,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1379,7 +1468,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1420,7 +1509,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1485,7 +1574,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCountLoading = false;
}
touchResult(current);
captureDisplayedResultRun(current, queryBaseSql);
syncDisplayedResultRun(current, queryBaseSql);
if (current.mode === "query" && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
@ -1539,7 +1628,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCount = undefined;
current.resultTotalRowCountLoading = false;
touchResult(current);
captureDisplayedResultRun(current, queryBaseSql);
syncDisplayedResultRun(current, queryBaseSql);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
@ -2006,6 +2095,7 @@ export const useQueryStore = defineStore("query", () => {
setExecuting,
setExecutingWithId,
setErrorResult,
toggleResultAutoSave,
setActiveResultRun,
removeResultRun,
setActiveResultIndex,

View File

@ -519,6 +519,7 @@ export interface QueryTab {
activeResultIndex?: number;
resultRuns?: QueryResultRun[];
activeResultRunId?: string;
resultAutoSave?: boolean;
explainPlan?: import("@/lib/explainPlan").ParsedExplainPlan;
explainError?: string;
explainSql?: string;