From 3232195e1fdbcca05ddd91f1853f5c8e9ad61d80 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 24 Aug 2026 19:25:05 +0000 Subject: [PATCH] fix(editor): preserve visible cursor after query execution Closes #7037 --- .../src/components/editor/QueryEditor.vue | 10 +++-- .../editor/queryEditorExecution.spec.ts | 45 ++++++++++++++++++- .../editor/queryEditorExecutionViewport.ts | 10 +++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 413fb33c17..9c24c81bc0 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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" }), }); } diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts index 3a1363a9e6..f57df3bb31 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts @@ -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'); diff --git a/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts b/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts index b0afc73194..ba75c01798 100644 --- a/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts +++ b/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts @@ -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;