fix(editor): preserve visible cursor after query execution

Closes #7037
This commit is contained in:
t8y2 committed 2026-08-24 19:32:48 +00:00
1 parent 9af98c2db2
commit 3232195e1f
3 files changed
+59 -6

No files matched your search

@@ -24,7 +24,7 @@ import { canFormatSqlForDatabaseType, formatSqlForEditing, compressSqlText, type
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { createQueryEditorExecutionViewportOwnership } from "@/lib/editor/queryEditorExecutionViewport";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible } from "@/lib/editor/queryEditorExecutionViewport";
import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { insertQueryEditorNewline } from "@/lib/editor/queryEditorNewline";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
@@ -6018,15 +6018,17 @@ function openReplace(): boolean {
function scrollCursorIntoView() {
const preserveViewport = executionViewportOwnership.consumeCompletionPreservation();
if (!view.value || !editorViewModule || !editorIsActive || preserveViewport) return;
const pos = view.value.state.selection.main.head;
const currentView = view.value;
if (!currentView || !editorViewModule || !editorIsActive || preserveViewport) return;
const pos = currentView.state.selection.main.head;
if (isQueryEditorPositionVisible(pos, currentView.visibleRanges, currentView.viewport)) return;
// Use "center" rather than "nearest": by the time this runs, the results pane has already
// opened/resized and shrunk the editor viewport, so the cursor's old position is often no
// longer visible. "nearest" then pins it right at the new viewport's edge (Fixes #5281: in a
// long multi-statement file, the just-executed statement lands flush against the results pane
// divider), which is exactly where it's hardest to see and re-click. Centering keeps it
// comfortably visible so the user doesn't have to scroll to find/re-run it.
view.value.dispatch({
currentView.dispatch({
effects: editorViewModule.EditorView.scrollIntoView(pos, { y: "center" }),
});
}
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { createQueryEditorExecutionViewportOwnership } from "@/lib/editor/queryEditorExecutionViewport";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible } from "@/lib/editor/queryEditorExecutionViewport";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
@@ -83,7 +83,7 @@ describe("QueryEditor execution routing", () => {
});
describe("QueryEditor execution viewport ownership", () => {
it("keeps automatic cursor centering when the user does not interact during execution", () => {
it("leaves completion positioning unclaimed when the user does not interact during execution", () => {
const ownership = createQueryEditorExecutionViewportOwnership();
ownership.beginExecution();
@@ -154,6 +154,47 @@ describe("QueryEditor execution viewport ownership", () => {
});
});
describe("QueryEditor completion cursor visibility", () => {
const viewport = { from: 10, to: 20 };
it("treats a position inside a visible range as visible", () => {
expect(isQueryEditorPositionVisible(15, [{ from: 10, to: 20 }], viewport)).toBe(true);
});
it("includes range endpoints but excludes adjacent positions", () => {
expect(isQueryEditorPositionVisible(10, [{ from: 10, to: 20 }], viewport)).toBe(true);
expect(isQueryEditorPositionVisible(20, [{ from: 10, to: 20 }], viewport)).toBe(true);
expect(isQueryEditorPositionVisible(9, [{ from: 10, to: 20 }], viewport)).toBe(false);
expect(isQueryEditorPositionVisible(21, [{ from: 10, to: 20 }], viewport)).toBe(false);
});
it("accepts any visible range without treating a folded gap as visible", () => {
const visibleRanges = [
{ from: 10, to: 14 },
{ from: 17, to: 20 },
];
expect(isQueryEditorPositionVisible(18, visibleRanges, viewport)).toBe(true);
expect(isQueryEditorPositionVisible(15, visibleRanges, viewport)).toBe(false);
});
it("falls back to the viewport when visible ranges are unavailable or empty", () => {
expect(isQueryEditorPositionVisible(15, undefined, viewport)).toBe(true);
expect(isQueryEditorPositionVisible(15, [], viewport)).toBe(true);
expect(isQueryEditorPositionVisible(21, undefined, viewport)).toBe(false);
});
it("checks visibility after completion ownership and before centering", () => {
const ownershipCheck = queryEditorSource.indexOf("executionViewportOwnership.consumeCompletionPreservation()");
const visibilityCheck = queryEditorSource.indexOf("if (isQueryEditorPositionVisible(pos, currentView.visibleRanges, currentView.viewport)) return");
const centerScroll = queryEditorSource.indexOf('EditorView.scrollIntoView(pos, { y: "center" })');
expect(ownershipCheck).toBeGreaterThan(-1);
expect(visibilityCheck).toBeGreaterThan(ownershipCheck);
expect(centerScroll).toBeGreaterThan(visibilityCheck);
});
});
describe("ContentArea execution summary errors", () => {
it("keeps batch errors selectable and copyable without triggering statement navigation", () => {
expect(contentAreaSource).toContain('class="absolute inset-0 z-0 cursor-pointer');
@@ -1,3 +1,13 @@
export interface QueryEditorViewportRange {
from: number;
to: number;
}
export function isQueryEditorPositionVisible(position: number, visibleRanges: readonly QueryEditorViewportRange[] | undefined, viewport: QueryEditorViewportRange): boolean {
const ranges = visibleRanges && visibleRanges.length > 0 ? visibleRanges : [viewport];
return ranges.some((range) => position >= range.from && position <= range.to);
}
export function createQueryEditorExecutionViewportOwnership() {
let nextRequestId = 0;
let pendingRequestId: number | undefined;