mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:43:49 +00:00
fix(editor): preserve SQL tab viewport state
This commit is contained in:
@@ -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
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user