diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 1d9592543a..de4388b8f0 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -3573,6 +3573,7 @@ onUnmounted(() => { " @editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)" @editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)" + @editor-state-flushed="(tabId: string) => void queryStore.flushEditorState(tabId)" @format-error="toast(t('toolbar.formatSqlFailed'))" @save-sql="(tabId: string) => void openSaveSqlDialog(tabId)" @reload="(tabId: string, sql: any, searchText: any, whereInput: any, orderBy: any, limit: any, offset: any, intent: any) => onReloadData(tabId, sql, searchText, whereInput, orderBy, limit, offset, intent)" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index d8099499c9..b6fa5d40f5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -244,6 +244,7 @@ const emit = defineEmits<{ closeColumnPanel: []; viewportChange: [viewport: { scrollTop: number; scrollLeft: number }]; selectionStateChange: [selection: { anchor: number; head: number }]; + editorStateFlushed: []; sendSelectionToAi: [sql: string]; }>(); @@ -6983,6 +6984,7 @@ function pauseQueryEditorBackgroundWork() { cancelBatchColumnSelectionRefresh(); flushEditorViewport(); flushEditorSelection(); + emit("editorStateFlushed"); clearTableNavigationHover(); clearPendingCompletionEnter(); clearPendingCompletionTab(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 69be1e865d..bd12425b16 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1305,6 +1305,7 @@ defineExpose({ @preview-changes-available="emit('previewChangesAvailable', activeTab.id, $event)" @viewport-change="emit('editorViewportChange', activeTab.id, $event)" @selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)" + @editor-state-flushed="emit('editorStateFlushed', activeTab.id)" @format-error="emit('formatError', activeTab.id)" @execute="emit('execute', activeTab.id, $event)" @execute-in-new-result-tab="emit('executeInNewResultTab', activeTab.id, $event)" diff --git a/apps/desktop/src/components/layout/querySurfaces.ts b/apps/desktop/src/components/layout/querySurfaces.ts index a1ce13ea2f..acd60810f3 100644 --- a/apps/desktop/src/components/layout/querySurfaces.ts +++ b/apps/desktop/src/components/layout/querySurfaces.ts @@ -75,6 +75,7 @@ export interface ContentAreaSurfaceEmits { editorCursorChange: [tabId: string, pos: number]; editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }]; editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }]; + editorStateFlushed: [tabId: string]; formatError: [tabId: string]; reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent]; paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string]; diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 710941f1da..9bdcde930e 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -25,6 +25,8 @@ export interface SavedOpenTab { catalog?: string; schema?: string; sql: string; + editorViewport?: QueryTab["editorViewport"]; + editorSelection?: QueryTab["editorSelection"]; originalSql?: string; savedSqlId?: string; externalSqlPath?: string; @@ -108,6 +110,24 @@ function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: str return ""; } +function restoredEditorSelection(tab: SavedOpenTab, docLength: number): QueryTab["editorSelection"] { + const selection = tab.editorSelection; + if (!selection || !Number.isFinite(selection.anchor) || !Number.isFinite(selection.head)) return undefined; + return { + anchor: Math.min(Math.max(0, Math.trunc(selection.anchor)), docLength), + head: Math.min(Math.max(0, Math.trunc(selection.head)), docLength), + }; +} + +function restoredEditorViewport(tab: SavedOpenTab): QueryTab["editorViewport"] { + const viewport = tab.editorViewport; + if (!viewport || !Number.isFinite(viewport.scrollTop) || !Number.isFinite(viewport.scrollLeft)) return undefined; + return { + scrollTop: Math.max(0, viewport.scrollTop), + scrollLeft: Math.max(0, viewport.scrollLeft), + }; +} + export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { return tabs.map((tab) => ({ id: tab.id, @@ -119,6 +139,8 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { ...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}), schema: tab.schema, sql: shouldPersistTabSql(tab) ? tab.sql : "", + ...(tab.editorViewport ? { editorViewport: tab.editorViewport } : {}), + ...(tab.editorSelection ? { editorSelection: tab.editorSelection } : {}), // Plain query tabs always round-trip originalSql. External-file tabs only persist it // while dirty so their disk baseline survives restart without duplicating clean SQL. ...(tab.originalSql !== undefined && !tab.savedSqlId && (!tab.externalSqlPath || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}), @@ -212,8 +234,8 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op isCancelling: false, queryExecutionStartedAt: undefined, executingResultRunId: undefined, - editorViewport: undefined, - editorSelection: undefined, + editorViewport: restoredEditorViewport(tab), + editorSelection: restoredEditorSelection(tab, typeof tab.sql === "string" ? tab.sql.length : 0), isExplaining: false, originalSql: restoredOriginalSql(tab, mode, typeof tab.sql === "string" ? tab.sql : ""), resultEvicted: mode === "data" ? undefined : tab.resultEvicted, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index aa975a80fd..79f1e94e9e 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2228,6 +2228,8 @@ export const useQueryStore = defineStore("query", () => { database: t.database, schema: t.schema, sql: t.sql, + editorViewport: t.editorViewport, + editorSelection: t.editorSelection, savedSqlId: t.savedSqlId, externalSqlPath: t.externalSqlPath, externalSqlFileVersion: t.externalSqlFileVersion, @@ -3237,6 +3239,13 @@ export const useQueryStore = defineStore("query", () => { ); } + function flushEditorState(id: string): Promise { + const tab = tabs.value.find((item) => item.id === id); + if (!tab) return Promise.resolve(); + persistSavedSqlEditorPosition(tab); + return flushPendingPersist(); + } + function queueSavedSqlEditorPositionPersist(tab: QueryTab | undefined) { if (!tab?.savedSqlId || tab.mode !== "query") return; const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId); @@ -7780,6 +7789,7 @@ export const useQueryStore = defineStore("query", () => { updateDataGridHiddenColumnKeys, updateEditorViewport, updateEditorSelection, + flushEditorState, updateObjectBrowserViewport, updateObjectBrowserSearch, updateNacosConfigEditorViewport, diff --git a/packages/app-tests/openTabsPersistence.test.ts b/packages/app-tests/openTabsPersistence.test.ts index dd0e18d47b..00b19261b2 100644 --- a/packages/app-tests/openTabsPersistence.test.ts +++ b/packages/app-tests/openTabsPersistence.test.ts @@ -50,6 +50,19 @@ test("round-trips query transaction mode", () => { assert.equal(restored.tabs[0]?.autoCommit, false); }); +test("round-trips editor selection and viewport", () => { + const saved = serializeOpenTabs([ + queryTab({ + editorSelection: { anchor: 8, head: 8 }, + editorViewport: { scrollTop: 120, scrollLeft: 16 }, + }), + ]); + const restored = restoreOpenTabsState(JSON.stringify(saved), "tab-1"); + + assert.deepEqual(restored.tabs[0]?.editorSelection, { anchor: 8, head: 8 }); + assert.deepEqual(restored.tabs[0]?.editorViewport, { scrollTop: 120, scrollLeft: 16 }); +}); + test("serializes object source query tabs with save context", () => { const saved = serializeOpenTabs([ queryTab({