From c155443d4bef2d51aeeffb0574c11d42d8f3afdc Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 18 Jun 2026 14:27:53 +0800 Subject: [PATCH] feat(grid): add pinned query result history --- .../src/components/layout/ContentArea.vue | 70 ++++++++--- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/lib/openTabsPersistence.ts | 3 + apps/desktop/src/stores/queryStore.ts | 114 ++++++++++++++++-- apps/desktop/src/types/database.ts | 1 + 7 files changed, 167 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index e1489bfe1..d660609c9 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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") }} + + + - - - - {{ t("tabs.runN", { n: run.sequence }) }} + + + + {{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }} + - - - - - + + + + + + {{ 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 345c91fa4..8fb0c9490 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 09fc13dfe..4ce0e2c9f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -452,6 +452,9 @@ export default { runN: "実行 {n}", resultRuns: "実行履歴", removeRun: "実行 {n} を削除", + autoKeepResults: "クエリ結果を自動保持", + autoKeepResultsEnabled: "結果の自動保持をオンにしました", + autoKeepResultsDisabled: "結果の自動保持をオフにしました", missingResultRun: "この結果は利用できなくなりました", exportResultArchive: "結果をエクスポート", importResultArchive: "結果をインポート", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6e385afc4..d87b4c771 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -454,6 +454,9 @@ export default { runN: "执行 {n}", resultRuns: "执行结果", removeRun: "删除执行 {n}", + autoKeepResults: "自动保留查询结果", + autoKeepResultsEnabled: "已开启自动保留结果", + autoKeepResultsDisabled: "已关闭自动保留结果", missingResultRun: "此结果已不可用", exportResultArchive: "导出结果", importResultArchive: "导入结果", diff --git a/apps/desktop/src/lib/openTabsPersistence.ts b/apps/desktop/src/lib/openTabsPersistence.ts index 000b78320..3b1ba2cf2 100644 --- a/apps/desktop/src/lib/openTabsPersistence.ts +++ b/apps/desktop/src/lib/openTabsPersistence.ts @@ -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; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index aac569ccc..ac9cc1775 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -44,6 +44,10 @@ const STORAGE_KEY = "dbx-open-tabs"; const ACTIVE_TAB_KEY = "dbx-active-tab"; const ORACLE_LIKE_METADATA_TYPES = new Set(["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[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[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[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, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 207a45d07..9692fe722 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -519,6 +519,7 @@ export interface QueryTab { activeResultIndex?: number; resultRuns?: QueryResultRun[]; activeResultRunId?: string; + resultAutoSave?: boolean; explainPlan?: import("@/lib/explainPlan").ParsedExplainPlan; explainError?: string; explainSql?: string;