fix(editor): preserve SQL tab viewport state

This commit is contained in:
zipg
2026-09-13 01:43:12 +08:00
committed by GitHub
parent 80d314b711
commit 7472f16066
3 changed files with 104 additions and 13 deletions
@@ -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;
}
@@ -21,6 +21,7 @@ export const contentSurfaceEventNames = [
"editorCursorChange",
"editorViewportChange",
"editorSelectionStateChange",
"editorStateFlushed",
"formatError",
"reload",
"paginate",
+38 -2
View File
@@ -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\)/);
});