fix(editor): preserve user-adjusted SQL editor position during execution
This commit is contained in:
4 files changed
+86
-17
No files matched your search
@@ -226,7 +226,7 @@ let viewportEmitFrame: number | null = null;
|
||||
let viewportRestoreFrame: number | null = null;
|
||||
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
const gutterExecutionViewport = createQueryEditorExecutionViewportOwnership();
|
||||
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
|
||||
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -830,7 +830,7 @@ interface RequestExecuteOptions {
|
||||
|
||||
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
|
||||
if (typeof source === "string" || source.editorViewportRequestId === undefined) {
|
||||
gutterExecutionViewport.cancelPendingRequest();
|
||||
executionViewportOwnership.cancelPendingRequest();
|
||||
}
|
||||
if (openInNewResultTab) {
|
||||
emit("executeInNewResultTab", source);
|
||||
@@ -840,7 +840,7 @@ function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab =
|
||||
}
|
||||
|
||||
function requestExecute(options: RequestExecuteOptions = {}) {
|
||||
gutterExecutionViewport.cancelPendingRequest();
|
||||
executionViewportOwnership.cancelPendingRequest();
|
||||
const currentView = view.value;
|
||||
if (!currentView) return false;
|
||||
currentView.focus();
|
||||
@@ -1689,7 +1689,7 @@ function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from
|
||||
event.stopPropagation();
|
||||
// Gutter play is always scoped to the statement/command for that line, even
|
||||
// when the main editor execute action would run the full document.
|
||||
const editorViewportRequestId = gutterExecutionViewport.beginRequest();
|
||||
const editorViewportRequestId = executionViewportOwnership.beginRequest();
|
||||
emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId });
|
||||
// 不主动聚焦编辑器,否则 CodeMirror 会把屏幕滚回之前的光标位置。
|
||||
// currentView.focus();
|
||||
@@ -5847,7 +5847,7 @@ function pauseQueryEditorBackgroundWork() {
|
||||
flushEditorSelection();
|
||||
clearTableNavigationHover();
|
||||
clearPendingCompletionTab();
|
||||
gutterExecutionViewport.reset();
|
||||
executionViewportOwnership.reset();
|
||||
editorIsActive = false;
|
||||
clearScheduledSemanticDiagnostics();
|
||||
completionEpoch++;
|
||||
@@ -6006,7 +6006,7 @@ function openReplace(): boolean {
|
||||
}
|
||||
|
||||
function scrollCursorIntoView() {
|
||||
const preserveViewport = gutterExecutionViewport.consumeAcceptedRequest();
|
||||
const preserveViewport = executionViewportOwnership.consumeCompletionPreservation();
|
||||
if (!view.value || !editorViewModule || !editorIsActive || preserveViewport) return;
|
||||
const pos = view.value.state.selection.main.head;
|
||||
// Use "center" rather than "nearest": by the time this runs, the results pane has already
|
||||
@@ -6020,19 +6020,28 @@ function scrollCursorIntoView() {
|
||||
});
|
||||
}
|
||||
|
||||
function beginExecutionViewportTracking() {
|
||||
executionViewportOwnership.beginExecution();
|
||||
}
|
||||
|
||||
function recordExecutionViewportInteraction() {
|
||||
executionViewportOwnership.recordUserInteraction();
|
||||
}
|
||||
|
||||
function dismissHoverTooltip() {
|
||||
if (!view.value || !hoverCloseEffect) return;
|
||||
view.value.dispatch({ effects: hoverCloseEffect });
|
||||
}
|
||||
|
||||
function acceptGutterExecutionViewport(requestId: number) {
|
||||
return gutterExecutionViewport.acceptRequest(requestId);
|
||||
return executionViewportOwnership.acceptRequest(requestId);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openSearch,
|
||||
openReplace,
|
||||
scrollCursorIntoView,
|
||||
beginExecutionViewportTracking,
|
||||
acceptGutterExecutionViewport,
|
||||
requestExecute,
|
||||
requestExecuteInNewResultTab,
|
||||
@@ -6044,7 +6053,7 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full overflow-hidden relative" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
||||
<div class="h-full w-full overflow-hidden relative" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
||||
<CustomContextMenu :items="currentContextMenuItems" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
ref="editorRef"
|
||||
|
||||
@@ -751,6 +751,9 @@ watch(
|
||||
watch(
|
||||
() => props.activeTab.isExecuting,
|
||||
(isExecuting, wasExecuting) => {
|
||||
if (isExecuting && !wasExecuting) {
|
||||
queryEditorRef.value?.beginExecutionViewportTracking();
|
||||
}
|
||||
if (!isExecuting && wasExecuting) {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("QueryEditor execution routing", () => {
|
||||
});
|
||||
|
||||
it("preserves the source range when executing from the statement gutter", () => {
|
||||
expect(queryEditorSource).toContain("const editorViewportRequestId = gutterExecutionViewport.beginRequest()");
|
||||
expect(queryEditorSource).toContain("const editorViewportRequestId = executionViewportOwnership.beginRequest()");
|
||||
expect(queryEditorSource).toContain("emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId })");
|
||||
expect(queryEditorSource).not.toContain('emit("execute", statementRange.sql)');
|
||||
});
|
||||
@@ -62,6 +62,13 @@ describe("QueryEditor execution routing", () => {
|
||||
expect(queryStoreSource.indexOf("tab.isExecuting = true")).toBeLessThan(queryStoreSource.indexOf("options?.onExecutionStarted?.()"));
|
||||
});
|
||||
|
||||
it("tracks editor interaction while a query is executing", () => {
|
||||
expect(contentAreaSource).toContain("queryEditorRef.value?.beginExecutionViewportTracking()");
|
||||
expect(queryEditorSource).toContain('@wheel="recordExecutionViewportInteraction"');
|
||||
expect(queryEditorSource).toContain('@pointerdown="recordExecutionViewportInteraction"');
|
||||
expect(queryEditorSource).toContain("executionViewportOwnership.recordUserInteraction()");
|
||||
});
|
||||
|
||||
it("lets the shortcut skip the picker without affecting other execution entry points", () => {
|
||||
// The picker guard must also honor the shortcut's bypass flag, otherwise Ctrl+Enter would keep popping the dialog.
|
||||
expect(queryEditorSource).toContain("if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker");
|
||||
@@ -75,7 +82,33 @@ describe("QueryEditor execution routing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryEditor gutter execution viewport ownership", () => {
|
||||
describe("QueryEditor execution viewport ownership", () => {
|
||||
it("keeps automatic cursor centering when the user does not interact during execution", () => {
|
||||
const ownership = createQueryEditorExecutionViewportOwnership();
|
||||
|
||||
ownership.beginExecution();
|
||||
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the viewport once after user interaction during execution", () => {
|
||||
const ownership = createQueryEditorExecutionViewportOwnership();
|
||||
|
||||
ownership.beginExecution();
|
||||
ownership.recordUserInteraction();
|
||||
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(true);
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores editor interaction outside an active execution", () => {
|
||||
const ownership = createQueryEditorExecutionViewportOwnership();
|
||||
|
||||
ownership.recordUserInteraction();
|
||||
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a cancelled or early-returned gutter request affect the next ordinary execution", () => {
|
||||
const ownership = createQueryEditorExecutionViewportOwnership();
|
||||
const cancelledRequestId = ownership.beginRequest();
|
||||
@@ -83,7 +116,7 @@ describe("QueryEditor gutter execution viewport ownership", () => {
|
||||
ownership.cancelPendingRequest();
|
||||
|
||||
expect(ownership.acceptRequest(cancelledRequestId)).toBe(false);
|
||||
expect(ownership.consumeAcceptedRequest()).toBe(false);
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the viewport once for the matching accepted execution", () => {
|
||||
@@ -91,8 +124,9 @@ describe("QueryEditor gutter execution viewport ownership", () => {
|
||||
const requestId = ownership.beginRequest();
|
||||
|
||||
expect(ownership.acceptRequest(requestId)).toBe(true);
|
||||
expect(ownership.consumeAcceptedRequest()).toBe(true);
|
||||
expect(ownership.consumeAcceptedRequest()).toBe(false);
|
||||
ownership.beginExecution();
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(true);
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears pending and accepted ownership when the editor becomes inactive", () => {
|
||||
@@ -106,7 +140,17 @@ describe("QueryEditor gutter execution viewport ownership", () => {
|
||||
expect(ownership.acceptRequest(acceptedRequestId)).toBe(true);
|
||||
ownership.reset();
|
||||
|
||||
expect(ownership.consumeAcceptedRequest()).toBe(false);
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears execution interaction when the editor becomes inactive", () => {
|
||||
const ownership = createQueryEditorExecutionViewportOwnership();
|
||||
ownership.beginExecution();
|
||||
ownership.recordUserInteraction();
|
||||
|
||||
ownership.reset();
|
||||
|
||||
expect(ownership.consumeCompletionPreservation()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ export function createQueryEditorExecutionViewportOwnership() {
|
||||
let nextRequestId = 0;
|
||||
let pendingRequestId: number | undefined;
|
||||
let acceptedRequestId: number | undefined;
|
||||
let executionActive = false;
|
||||
let userInteractedDuringExecution = false;
|
||||
|
||||
return {
|
||||
beginRequest(): number {
|
||||
@@ -18,14 +20,25 @@ export function createQueryEditorExecutionViewportOwnership() {
|
||||
acceptedRequestId = requestId;
|
||||
return true;
|
||||
},
|
||||
consumeAcceptedRequest(): boolean {
|
||||
if (acceptedRequestId === undefined) return false;
|
||||
beginExecution() {
|
||||
executionActive = true;
|
||||
userInteractedDuringExecution = false;
|
||||
},
|
||||
recordUserInteraction() {
|
||||
if (executionActive) userInteractedDuringExecution = true;
|
||||
},
|
||||
consumeCompletionPreservation(): boolean {
|
||||
const preserveViewport = acceptedRequestId !== undefined || userInteractedDuringExecution;
|
||||
acceptedRequestId = undefined;
|
||||
return true;
|
||||
executionActive = false;
|
||||
userInteractedDuringExecution = false;
|
||||
return preserveViewport;
|
||||
},
|
||||
reset() {
|
||||
pendingRequestId = undefined;
|
||||
acceptedRequestId = undefined;
|
||||
executionActive = false;
|
||||
userInteractedDuringExecution = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user