From 7472f160668d82abd764d71915f0a49777697b8d Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 13 Sep 2026 01:43:12 +0800 Subject: [PATCH] fix(editor): preserve SQL tab viewport state --- .../src/components/editor/QueryEditor.vue | 76 ++++++++++++++++--- .../src/lib/tabs/contentSurfaceEvents.ts | 1 + .../editorTabSwitchViewportRestore.test.ts | 40 +++++++++- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 35309d4d01..a6c0ed6aa7 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -282,6 +282,7 @@ const viewportEmitTask = createDeferredEditorTask(() => { let viewportRestoreFrame: number | null = null; let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport; let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport; +let tabSwitchStateCaptured = false; const executionViewportOwnership = createQueryEditorExecutionViewportOwnership(); let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection; let contextMenuDoc: Text | null = null; @@ -680,6 +681,7 @@ const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18; const EDITOR_SELECTION_DRAG_THRESHOLD_PX = 6; const tableNavigationHoverClass = "query-editor--table-navigation-hover"; const DBX_VIM_SAVE_EVENT = "dbx-vim-save"; +const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch"; function editorThemeAppearance() { return editorThemeAppearanceFor(isDark.value ? "dark" : "light", themePalette.value, themePalette.value === "custom" ? activeCustomUiColors.value : undefined); @@ -6790,6 +6792,7 @@ onMounted(async () => { contextMenuPointerCleanup = null; }; + restoreEditorSelection(props.initialSelection, !props.initialViewport); restoreEditorViewport(); syncContextMenuState(view.value); emit("previewChangesAvailable", !!previewContextSql.value); @@ -6874,8 +6877,10 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde const currentView = view.value; if (!currentView) return; // Flush the outgoing document before props and restored scroll positions - // become the new tab's state. The event carries its original owner. - flushEditorViewport(); + // become the new tab's state. The event carries its original owner. A + // before-tab-switch capture already flushed this editor while it was still + // visible, so avoid reading the reset scroll position during the transition. + if (!tabSwitchStateCaptured) flushEditorViewport(); viewportOwnerTabId = tabId; latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 }; lastEmittedViewport = undefined; @@ -6896,7 +6901,7 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde // document keeps whatever scroll offset the dispatch left behind (#8374). // A brand-new tab has no saved state, so reset it instead of falling back // to the previous tab's latest position (#8378). - restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 }); + restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 }, !props.initialViewport); restoreEditorViewport(props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 }); clearScheduledPreviewContextRefresh(); syncContextMenuState(currentView); @@ -6921,7 +6926,7 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde } searchPanelRef.value?.scheduleDocumentSearchUpdate(); invalidateSemanticDiagnosticsForDocumentChange(); - restoreEditorSelection(); + restoreEditorSelection(undefined, !props.initialViewport); restoreEditorViewport(); clearScheduledPreviewContextRefresh(); syncContextMenuState(currentView); @@ -6945,6 +6950,31 @@ watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) = } }); +watch( + () => props.initialViewport, + (viewport, previousViewport) => { + if (!view.value || !viewport || previousViewport) return; + // Saved SQL content can hydrate after the editor has already mounted. In + // that case the initial prop was undefined and the mount-time restore had + // nothing to apply. + latestViewport = { ...viewport }; + lastEmittedViewport = { ...viewport }; + restoreEditorViewport(viewport); + }, + { deep: true }, +); + +watch( + () => props.initialSelection, + (selection, previousSelection) => { + if (!view.value || !selection || previousSelection) return; + // Keep the cursor and viewport in sync when a saved SQL tab hydrates after + // the editor component has already been mounted. + restoreEditorSelection(selection, !props.initialViewport); + }, + { deep: true }, +); + watch( () => props.formatRequestId, (val) => { @@ -7181,9 +7211,16 @@ watch( function pauseQueryEditorBackgroundWork() { finishBatchColumnSelectionDrag(false); cancelBatchColumnSelectionRefresh(); - flushEditorViewport(); - flushEditorSelection(); - emit("editorStateFlushed"); + const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured; + tabSwitchStateCaptured = false; + // A KeepAlive-evicted editor can be unmounted after it was already + // deactivated. Its DOM scroll position has been reset by then, so flushing + // that inactive view would overwrite the saved viewport with zero. + if (editorIsActive && !stateWasCapturedBeforeTabSwitch) { + flushEditorViewport(); + flushEditorSelection(); + emit("editorStateFlushed"); + } clearTableNavigationHover(); clearPendingCompletionEnter(); clearPendingCompletionTab(); @@ -7196,12 +7233,23 @@ function pauseQueryEditorBackgroundWork() { unregisterTableReferenceDropListener(); } +function captureEditorStateBeforeTabSwitch(event: Event) { + const fromTabId = (event as CustomEvent<{ fromTabId?: string }>).detail?.fromTabId; + if (!view.value || !fromTabId || fromTabId !== props.tabId) return; + // Capture while the outgoing editor is still visible. Once KeepAlive starts + // deactivating the surface, WebKit can report a reset scrollTop of zero. + flushEditorViewport(); + flushEditorSelection(); + emit("editorStateFlushed"); + tabSwitchStateCaptured = true; +} + function resumeQueryEditorBackgroundWork() { editorIsActive = true; registerTableReferenceDropListener(); scheduleSemanticDiagnostics(); if (view.value) schedulePreviewContextRefresh(view.value); - restoreEditorSelection(); + restoreEditorSelection(undefined, !props.initialViewport); restoreEditorFocus(); restoreEditorViewport(); } @@ -7210,6 +7258,11 @@ onActivated(resumeQueryEditorBackgroundWork); onDeactivated(pauseQueryEditorBackgroundWork); +onMounted(() => { + if (typeof window === "undefined") return; + window.addEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch); +}); + onBeforeUnmount(() => { pauseQueryEditorBackgroundWork(); viewportEmitTask.cancel(); @@ -7222,6 +7275,7 @@ onBeforeUnmount(() => { view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit); window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease); window.removeEventListener("blur", clearTableNavigationHover); + window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch); contextMenuPointerCleanup?.(); postCompositionKeyGuardCleanup?.(); postCompositionKeyGuardCleanup = null; @@ -7265,10 +7319,10 @@ function flushEditorSelection() { if (latestSelection) emitEditorSelection(latestSelection); } -function restoreEditorSelection(selection = props.initialSelection ?? latestSelection) { +function restoreEditorSelection(selection = props.initialSelection ?? latestSelection, scrollIntoView = false) { const normalizedSelection = normalizedEditorSelection(selection, props.modelValue.length); if (!view.value || !normalizedSelection) return; - view.value.dispatch({ selection: normalizedSelection }); + view.value.dispatch({ selection: normalizedSelection, scrollIntoView }); } function restoreEditorFocus() { @@ -7321,7 +7375,7 @@ function restoreEditorViewport(viewport = props.initialViewport ?? latestViewpor const restoreNextFrame = () => { restoreScroll(); attempts += 1; - if (attempts >= 8) { + if (attempts >= 32) { viewportRestoreFrame = null; return; } diff --git a/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts b/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts index e88e7fb156..dd6cb6bbff 100644 --- a/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts +++ b/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts @@ -21,6 +21,7 @@ export const contentSurfaceEventNames = [ "editorCursorChange", "editorViewportChange", "editorSelectionStateChange", + "editorStateFlushed", "formatError", "reload", "paginate", diff --git a/packages/app-tests/editorTabSwitchViewportRestore.test.ts b/packages/app-tests/editorTabSwitchViewportRestore.test.ts index ecdfcd03d9..02cfab51d4 100644 --- a/packages/app-tests/editorTabSwitchViewportRestore.test.ts +++ b/packages/app-tests/editorTabSwitchViewportRestore.test.ts @@ -28,17 +28,53 @@ test("uncached tab activation restores the saved cursor and scroll position", () test("uncached tabs without saved state do not inherit the previous tab position", () => { const source = activateTabDocumentSource(); - assert.match(source, /restoreEditorSelection\(props\.initialSelection \?\? \{ anchor: 0, head: 0 \}\);/); + assert.match(source, /restoreEditorSelection\(props\.initialSelection \?\? \{ anchor: 0, head: 0 \}, !props\.initialViewport\);/); assert.match(source, /restoreEditorViewport\(props\.initialViewport \?\? \{ scrollTop: 0, scrollLeft: 0 \}\);/); }); test("cached tab activation keeps restoring selection and viewport", () => { const source = activateTabDocumentSource(); assert.match(source, /currentView\.setState\(cached\);/); - assert.match(source, /currentView\.setState\(cached\);[\s\S]*?restoreEditorSelection\(\);\s*restoreEditorViewport\(\);/); + assert.match(source, /currentView\.setState\(cached\);[\s\S]*?restoreEditorSelection\(undefined, !props\.initialViewport\);\s*restoreEditorViewport\(\);/); }); test("viewport restore prefers the per-tab saved viewport", () => { const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); assert.match(source, /function restoreEditorViewport\(viewport = props\.initialViewport \?\? latestViewport\) \{/); }); + +test("captures the outgoing editor viewport before KeepAlive deactivation", () => { + const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); + assert.match(source, /function captureEditorStateBeforeTabSwitch\(event: Event\) \{/); + assert.match(source, /fromTabId !== props\.tabId/); + assert.match(source, /captureEditorStateBeforeTabSwitch[\s\S]*?flushEditorViewport\(\);[\s\S]*?flushEditorSelection\(\);[\s\S]*?emit\("editorStateFlushed"\);/); + assert.match(source, /window\.addEventListener\(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch\)/); + assert.match(source, /window\.removeEventListener\(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch\)/); +}); + +test("does not flush a reset viewport after a tab-switch capture", () => { + const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); + assert.match(source, /let tabSwitchStateCaptured = false;/); + assert.match(source, /tabSwitchStateCaptured = true;/); + assert.match(source, /const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;[\s\S]*?tabSwitchStateCaptured = false;[\s\S]*?if \(editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/); + assert.match(source, /if \(!tabSwitchStateCaptured\) flushEditorViewport\(\);/); +}); + +test("does not flush an editor again when KeepAlive evicts it after deactivation", () => { + const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); + assert.match(source, /if \(editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/); +}); + +test("restores a saved cursor into view when no viewport was persisted", () => { + const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); + assert.match(source, /restoreEditorSelection\(props\.initialSelection, !props\.initialViewport\);/); + assert.match(source, /function restoreEditorSelection\(selection = props\.initialSelection \?\? latestSelection, scrollIntoView = false\)/); + assert.match(source, /view\.value\.dispatch\(\{ selection: normalizedSelection, scrollIntoView \}\);/); +}); + +test("re-applies the viewport when saved SQL content hydrates after mount", () => { + const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); + assert.match(source, /watch\(\s*\(\) => props\.initialViewport,[\s\S]*?previousViewport[\s\S]*?latestViewport = \{ \.\.\.viewport \};[\s\S]*?restoreEditorViewport\(viewport\);/); + assert.match(source, /watch\(\s*\(\) => props\.initialSelection,[\s\S]*?previousSelection[\s\S]*?restoreEditorSelection\(selection, !props\.initialViewport\);/); + assert.match(source, /if \(attempts >= 32\)/); +});