diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b1c078d571..d878f290dc 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -358,6 +358,7 @@ interface DataGridProps { countTotalRows?: () => Promise; loading?: boolean; cacheKey?: string; + pendingStateKey?: string; exportSql?: string; onExecuteSql?: (sql: string) => Promise; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; @@ -3115,7 +3116,7 @@ const editor = useDataGridEditor({ getRowItem, pageSize, currentPage, - cacheKey: computed(() => props.cacheKey), + cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey), onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result), emit, }); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 6278ff6021..d505d71389 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -84,7 +84,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGr import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState"; -import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems, type ExecutionSummaryItem } from "@/lib/tabs/tabPresentation"; +import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultGridInstanceKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems, type ExecutionSummaryItem } from "@/lib/tabs/tabPresentation"; import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive"; import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { isTableDataEditable } from "@/lib/table/tableEditing"; @@ -392,6 +392,7 @@ const allResultExportSheets = computed(() => ); const resultRuns = computed(() => resultRunItems(props.activeTab)); const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab)); +const activeResultGridInstanceKey = computed(() => resultGridInstanceKey(props.activeTab)); const activeResultSql = computed(() => resultSqlForGrid(props.activeTab)); const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab)); const activeStatementExecutionMarkers = computed(() => @@ -1510,8 +1511,9 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand ({ @@ -18,7 +18,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}), })); -function createEditor(sourceColumns?: Array, confirmDangerousRowDeletion = true) { +function createEditor(sourceColumns?: Array, confirmDangerousRowDeletion = true, cacheKey?: string) { let editor: ReturnType; const result = ref<{ columns: string[]; rows: CellValue[][] }>({ columns: ["first", "hidden", "last"], @@ -51,6 +51,7 @@ function createEditor(sourceColumns?: Array, confirmDangerou confirmDangerousRowDeletion: computed(() => confirmDangerousRowDeletion), pageSize: ref(100), currentPage: ref(1), + cacheKey: computed(() => cacheKey), getRowItem: (rowId) => { if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) { return { @@ -83,6 +84,46 @@ function createEditor(sourceColumns?: Array, confirmDangerou return editor; } +describe("useDataGridEditor result snapshots", () => { + it("does not restore scroll or editing state into a replacement result identity", () => { + class TestScroller { + scrollTop = 0; + scrollLeft = 0; + scrollTo({ top, left }: ScrollToOptions) { + if (typeof top === "number") this.scrollTop = top; + if (typeof left === "number") this.scrollLeft = left; + } + } + vi.stubGlobal("HTMLElement", TestScroller); + const oldKey = "tab-current-0-execution-1"; + const previous = createEditor(undefined, true, oldKey); + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + previous.editingCell.value = { rowId: -1, col: 0 }; + previous.editValue.value = "Ada"; + previous.savePendingSnapshot(true, true); + + const replacement = createEditor(undefined, true, "tab-current-0-execution-2"); + const replacementScroller = new TestScroller(); + replacement.scrollerRef.value = replacementScroller as unknown as NonNullable; + replacement.restorePendingSnapshotFocus(); + expect(replacement.editingCell.value).toBeNull(); + expect(replacementScroller.scrollTop).toBe(0); + expect(replacementScroller.scrollLeft).toBe(0); + + clearDataGridPendingSnapshot(oldKey); + const oldIdentity = createEditor(undefined, true, oldKey); + const oldIdentityScroller = new TestScroller(); + oldIdentity.scrollerRef.value = oldIdentityScroller as unknown as NonNullable; + oldIdentity.restorePendingSnapshotFocus(); + expect(oldIdentity.editingCell.value).toBeNull(); + expect(oldIdentityScroller.scrollTop).toBe(0); + expect(oldIdentityScroller.scrollLeft).toBe(0); + }); +}); + describe("useDataGridEditor row deletion confirmation", () => { it("keeps the row pending until confirmation when confirmation is enabled", () => { const editor = createEditor(undefined, true); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 210ae2d6fe..c4161cae57 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -183,6 +183,10 @@ export function clearDataGridPendingSnapshotsForTab(tabId: string) { } } +export function clearDataGridPendingSnapshot(cacheKey: string) { + pendingChangesCache.delete(cacheKey); +} + export function useDataGridEditor(options: UseDataGridEditorOptions) { const connectionStore = useConnectionStore(); const historyStore = useHistoryStore(); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 4f58081a81..87a73d7fac 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; import { useConnectionStore } from "@/stores/connectionStore"; -import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation"; +import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultGridCacheKey, resultGridInstanceKey, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation"; import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint"; import type { ConnectionConfig, QueryTab } from "@/types/database"; @@ -133,6 +133,18 @@ describe("query result labels", () => { }); }); +describe("query result grid identity", () => { + it("separates rerun payloads while retaining run and result-set identity", () => { + const first = queryTab({ activeResultRunId: "run-1", activeResultIndex: 2, resultGridRevision: "execution-1" }); + const rerun = queryTab({ activeResultRunId: "run-1", activeResultIndex: 2, resultGridRevision: "execution-2" }); + + expect(resultGridInstanceKey(first)).toBe("tab-1-run-1-2-execution-1"); + expect(resultGridInstanceKey(rerun)).not.toBe(resultGridInstanceKey(first)); + expect(resultGridInstanceKey({ ...first, activeResultIndex: 1 })).toBe("tab-1-run-1-1-execution-1"); + expect(resultGridCacheKey(rerun)).toBe(resultGridCacheKey(first)); + }); +}); + describe("tab group presentation", () => { it("adds the full, live group path to tab tooltips", () => { const store = useConnectionStore(); diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index ed5f39f113..5d3b808abe 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -349,6 +349,10 @@ export function resultGridCacheKey(tab: Pick): string { + return `${resultGridCacheKey(tab)}-${tab.resultGridRevision ?? "initial"}`; +} + export function nextExecutionSummaryView(currentView: OutputView, canShowResult: boolean): OutputView { if (currentView === "summary" && canShowResult) return "result"; return "summary"; diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index 6029cfa28c..614d14e63a 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ getConnectionConfig: vi.fn(), prepareQueryPaginationExecutionPlan: vi.fn(), saveOpenTabsState: vi.fn(), + clearDataGridPendingSnapshot: vi.fn(), + clearDataGridPendingSnapshotsForTab: vi.fn(), tabResultSnapshots: new Map(), })); @@ -42,6 +44,11 @@ vi.mock("@/stores/settingsStore", () => ({ }), })); +vi.mock("@/composables/useDataGridEditor", () => ({ + clearDataGridPendingSnapshot: mocks.clearDataGridPendingSnapshot, + clearDataGridPendingSnapshotsForTab: mocks.clearDataGridPendingSnapshotsForTab, +})); + vi.mock("@/lib/tabs/tabResultCache", async (importOriginal) => { const actual = await importOriginal(); return { @@ -630,6 +637,12 @@ describe("queryStore multi-statement errors", () => { expect(tab.resultAutoSave).toBeUndefined(); expect(tab.resultRuns).toHaveLength(2); expect(tab.activeResultRunId).toBe(tab.resultRuns?.[1]?.id); + const firstRunRevision = tab.resultRuns?.[0]?.resultGridRevision; + const secondRunRevision = tab.resultRuns?.[1]?.resultGridRevision; + expect(firstRunRevision).toBeTruthy(); + expect(secondRunRevision).toBeTruthy(); + expect(secondRunRevision).not.toBe(firstRunRevision); + expect(mocks.clearDataGridPendingSnapshot).toHaveBeenCalledTimes(1); expect(await store.setActiveResultRun(tabId, tab.resultRuns![0]!.id)).toBe(true); expect(tab.result?.rows[0]?.[0]).toBe(1); @@ -640,6 +653,9 @@ describe("queryStore multi-statement errors", () => { expect(tab.resultRuns).toHaveLength(2); expect(tab.activeResultRunId).toBe(tab.resultRuns?.[1]?.id); + expect(tab.resultGridRevision).toBe(tab.resultRuns?.[1]?.resultGridRevision); + expect(tab.resultGridRevision).not.toBe(secondRunRevision); + expect(mocks.clearDataGridPendingSnapshot).toHaveBeenCalledTimes(2); expect(await store.setActiveResultRun(tabId, tab.resultRuns![0]!.id)).toBe(true); expect(tab.result?.rows[0]?.[0]).toBe(1); expect(await store.setActiveResultRun(tabId, tab.resultRuns![1]!.id)).toBe(true); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 14f7b5412d..58bf84d8c3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; -import { computed, markRaw, onScopeDispose, reactive, ref, watch } from "vue"; +import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; @@ -52,10 +52,10 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { agentProtocolQueryResultMaxRows, capQueryResultTotal, effectiveQueryResultMaxRows, limitQueryPagination, queryResultLimitReached } from "@/lib/dataGrid/queryResultRowLimit"; import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; -import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; +import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize"; -import { queryResultBaseSql, queryResultExecutionSql } from "@/lib/tabs/tabPresentation"; +import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive"; import * as api from "@/lib/backend/api"; @@ -1005,6 +1005,7 @@ export const useQueryStore = defineStore("query", () => { tab.result = run.result ?? run.results?.[activeIndex]; tab.results = run.results; tab.activeResultIndex = run.activeResultIndex; + tab.resultGridRevision = run.resultGridRevision; tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution); tab.resultBaseSql = run.resultBaseSql; tab.resultEditorFingerprint = run.resultEditorFingerprint; @@ -1242,6 +1243,7 @@ export const useQueryStore = defineStore("query", () => { result: tab.result, results: tab.results, activeResultIndex: tab.activeResultIndex, + resultGridRevision: tab.resultGridRevision, batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), resultBaseSql: tab.resultBaseSql, resultEditorFingerprint: tab.resultEditorFingerprint, @@ -1339,6 +1341,7 @@ export const useQueryStore = defineStore("query", () => { result: tab.result, results: tab.results, activeResultIndex: tab.activeResultIndex, + resultGridRevision: tab.resultGridRevision, batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), resultBaseSql: tab.resultBaseSql, resultEditorFingerprint: tab.resultEditorFingerprint, @@ -3266,8 +3269,10 @@ export const useQueryStore = defineStore("query", () => { } async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean }) { - if (!activeTabId.value) return; - const tab = tabs.value.find((item) => item.id === activeTabId.value); + const executionTabId = activeTabId.value; + if (!executionTabId) return; + const tab = tabs.value.find((item) => item.id === executionTabId); + const previousGridKey = tab ? resultGridInstanceKey(tab) : undefined; if (tab?.mode === "query") { tab.resultSortColumn = undefined; tab.resultSortColumnIndex = undefined; @@ -3275,7 +3280,17 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortMode = undefined; tab.resultSortedSql = undefined; } - return await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options }); + const producedResult = await executeTabSql(executionTabId, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options }); + if (producedResult !== false) { + const current = tabs.value.find((item) => item.id === executionTabId); + if (current?.result) { + current.resultGridRevision = uuid(); + if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current); + await nextTick(); + if (previousGridKey && options?.openInNewResultTab !== true) clearDataGridPendingSnapshot(previousGridKey); + } + } + return producedResult; } type QueryMetadataPatch = Pick; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index cad989635d..19ce7bd92b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -767,6 +767,8 @@ export interface QueryResultRun { sequence: number; sql: string; createdAt: number; + /** Distinguishes successive result payloads that reuse the same run slot. */ + resultGridRevision?: string; result?: QueryResult; results?: QueryResult[]; activeResultIndex?: number; @@ -1108,6 +1110,8 @@ export interface QueryTab { result?: QueryResult; results?: QueryResult[]; activeResultIndex?: number; + /** Distinguishes successive result payloads that reuse the current result slot. */ + resultGridRevision?: string; resultRuns?: QueryResultRun[]; activeResultRunId?: string; resultAutoSave?: boolean;