fix(grid): preserve viewport during gutter execution

This commit is contained in:
dadong
2026-08-18 10:52:13 +08:00
committed by GitHub
parent 99ded5c8bb
commit a6945a19f2
8 changed files with 131 additions and 14 deletions
+1
View File
@@ -406,6 +406,7 @@ const {
blockDangerousRedisCommands,
onMissingDatabase: promptActiveDatabaseSelection,
requestDangerConfirmation: (request) => sqlExecutionDangerStore.requestConfirmation(request),
onExecutionStarted: (editorViewportRequestId) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(editorViewportRequestId),
});
function requestActiveEditorExecute() {
@@ -22,6 +22,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 { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { insertQueryEditorNewline } from "@/lib/editor/queryEditorNewline";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
@@ -218,6 +219,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();
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@@ -805,6 +807,9 @@ interface RequestExecuteOptions {
}
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
if (typeof source === "string" || source.editorViewportRequestId === undefined) {
gutterExecutionViewport.cancelPendingRequest();
}
if (openInNewResultTab) {
emit("executeInNewResultTab", source);
} else {
@@ -813,6 +818,7 @@ function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab =
}
function requestExecute(options: RequestExecuteOptions = {}) {
gutterExecutionViewport.cancelPendingRequest();
const currentView = view.value;
if (!currentView) return false;
currentView.focus();
@@ -1661,8 +1667,10 @@ 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.
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange));
currentView.focus();
const editorViewportRequestId = gutterExecutionViewport.beginRequest();
emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId });
// 不主动聚焦编辑器,否则 CodeMirror 会把屏幕滚回之前的光标位置。
// currentView.focus();
return true;
}
@@ -5565,6 +5573,7 @@ function pauseQueryEditorBackgroundWork() {
flushEditorSelection();
clearTableNavigationHover();
clearPendingCompletionTab();
gutterExecutionViewport.reset();
editorIsActive = false;
clearScheduledSemanticDiagnostics();
completionEpoch++;
@@ -5723,7 +5732,8 @@ function openReplace(): boolean {
}
function scrollCursorIntoView() {
if (!view.value || !editorViewModule || !editorIsActive) return;
const preserveViewport = gutterExecutionViewport.consumeAcceptedRequest();
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
// opened/resized and shrunk the editor viewport, so the cursor's old position is often no
@@ -5741,10 +5751,15 @@ function dismissHoverTooltip() {
view.value.dispatch({ effects: hoverCloseEffect });
}
function acceptGutterExecutionViewport(requestId: number) {
return gutterExecutionViewport.acceptRequest(requestId);
}
defineExpose({
openSearch,
openReplace,
scrollCursorIntoView,
acceptGutterExecutionViewport,
requestExecute,
requestExecuteInNewResultTab,
pasteClipboardAsSqlInCondition,
@@ -986,6 +986,10 @@ function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
function acceptQueryEditorExecutionViewport(requestId: number) {
return queryEditorRef.value?.acceptGutterExecutionViewport(requestId) ?? false;
}
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }) {
const tab = props.activeTab;
if (!tab || tab.mode !== "query") return;
@@ -1018,7 +1022,7 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, acceptQueryEditorExecutionViewport, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
</script>
<template>
@@ -30,6 +30,7 @@ const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
interface SqlExecutionOptions {
openInNewResultTab?: boolean;
editorViewportRequestId?: number;
}
interface TargetSqlExecutionInput {
@@ -113,6 +114,7 @@ export function useSqlExecution(deps: {
blockDangerousRedisCommands?: Ref<boolean>;
onMissingDatabase?: () => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
onExecutionStarted?: (editorViewportRequestId: number) => void;
}) {
const { t } = useI18n();
const queryStore = useQueryStore();
@@ -136,31 +138,35 @@ export function useSqlExecution(deps: {
const pendingDangerKind = ref<"sql" | "redis">("sql");
const pendingDangerSourceOffset = ref<number | undefined>();
const pendingOpenInNewResultTab = ref(false);
const pendingSqlParameterEditorViewportRequestId = ref<number | undefined>();
const pendingDangerEditorViewportRequestId = ref<number | undefined>();
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | void) | undefined;
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number }> {
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
const expand = (sql: string) => (atSetEnabled ? expandSqlVariables(sql).sql : sql);
if (typeof source === "string") return { sql: expand(source) };
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
const sql = expand(resolved);
if (!isSqlExecutionSnapshot(source) || !source.selectedSql.trim() || sql !== resolved) return { sql };
const editorViewportRequestId = isSqlExecutionSnapshot(source) ? source.editorViewportRequestId : undefined;
if (!isSqlExecutionSnapshot(source) || !source.selectedSql.trim() || sql !== resolved) return { sql, editorViewportRequestId };
const leadingWhitespace = source.selectedSql.length - source.selectedSql.trimStart().length;
return { sql, sourceOffset: source.selectionFrom + leadingWhitespace };
return { sql, sourceOffset: source.selectionFrom + leadingWhitespace, editorViewportRequestId };
}
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
const tab = deps.activeTab.value;
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride);
const executionOptions = { ...options, editorViewportRequestId };
if (!tab || !sql.trim()) return;
if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
deps.onMissingDatabase?.();
return;
}
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, options)) return;
await continueExecute(sql, sourceOffset, options);
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) return;
await continueExecute(sql, sourceOffset, executionOptions);
}
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
@@ -195,6 +201,7 @@ export function useSqlExecution(deps: {
pendingDangerKind.value = "redis";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
return;
@@ -219,6 +226,7 @@ export function useSqlExecution(deps: {
pendingDangerKind.value = "sql";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
} else {
@@ -238,6 +246,7 @@ export function useSqlExecution(deps: {
sqlParameterEnabledSyntaxes.value = enabledSyntaxes;
pendingSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingSqlParameterEditorViewportRequestId.value = options.editorViewportRequestId;
pendingSqlParameterContinuation = continuation;
showSqlParameterDialog.value = true;
return true;
@@ -287,6 +296,7 @@ export function useSqlExecution(deps: {
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}),
});
if (producedResult === false) return;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
@@ -518,19 +528,22 @@ export function useSqlExecution(deps: {
const sourceOffset = pendingDangerSourceOffset.value;
const kind = pendingDangerKind.value;
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
if (suppressDangerConfirm.value && kind === "sql") {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
await doExecute(sql, sourceOffset, { openInNewResultTab });
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
}
async function onSqlParametersConfirm(sql: string) {
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
showSqlParameterDialog.value = false;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
@@ -539,10 +552,11 @@ export function useSqlExecution(deps: {
const sourceOffset = pendingSourceOffset.value;
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
const continuation = pendingSqlParameterContinuation;
pendingSqlParameterContinuation = undefined;
if (continuation) await continuation(sql, sourceOffset);
else await continueExecute(sql, sourceOffset, { openInNewResultTab });
else await continueExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
}
watch(showSqlParameterDialog, (open) => {
@@ -553,6 +567,7 @@ export function useSqlExecution(deps: {
sqlParameterEnabledSyntaxes.value = [];
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
pendingSqlParameterContinuation = undefined;
});
@@ -562,6 +577,7 @@ export function useSqlExecution(deps: {
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
suppressDangerConfirm.value = false;
});
@@ -1,8 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { createQueryEditorExecutionViewportOwnership } 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");
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const sqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8");
describe("QueryEditor execution routing", () => {
it("routes the execution shortcut through the shared execution-mode contract while bypassing the picker", () => {
@@ -46,10 +50,18 @@ describe("QueryEditor execution routing", () => {
});
it("preserves the source range when executing from the statement gutter", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange))");
expect(queryEditorSource).toContain("const editorViewportRequestId = gutterExecutionViewport.beginRequest()");
expect(queryEditorSource).toContain("emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId })");
expect(queryEditorSource).not.toContain('emit("execute", statementRange.sql)');
});
it("claims gutter viewport ownership only after the matching execution starts", () => {
expect(appSource).toContain("acceptQueryEditorExecutionViewport(editorViewportRequestId)");
expect(contentAreaSource).toContain("acceptGutterExecutionViewport(requestId)");
expect(sqlExecutionSource).toContain("onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!)");
expect(queryStoreSource.indexOf("tab.isExecuting = true")).toBeLessThan(queryStoreSource.indexOf("options?.onExecutionStarted?.()"));
});
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");
@@ -63,6 +75,41 @@ describe("QueryEditor execution routing", () => {
});
});
describe("QueryEditor gutter execution viewport ownership", () => {
it("does not let a cancelled or early-returned gutter request affect the next ordinary execution", () => {
const ownership = createQueryEditorExecutionViewportOwnership();
const cancelledRequestId = ownership.beginRequest();
ownership.cancelPendingRequest();
expect(ownership.acceptRequest(cancelledRequestId)).toBe(false);
expect(ownership.consumeAcceptedRequest()).toBe(false);
});
it("preserves the viewport once for the matching accepted execution", () => {
const ownership = createQueryEditorExecutionViewportOwnership();
const requestId = ownership.beginRequest();
expect(ownership.acceptRequest(requestId)).toBe(true);
expect(ownership.consumeAcceptedRequest()).toBe(true);
expect(ownership.consumeAcceptedRequest()).toBe(false);
});
it("clears pending and accepted ownership when the editor becomes inactive", () => {
const ownership = createQueryEditorExecutionViewportOwnership();
const pendingRequestId = ownership.beginRequest();
ownership.reset();
expect(ownership.acceptRequest(pendingRequestId)).toBe(false);
const acceptedRequestId = ownership.beginRequest();
expect(ownership.acceptRequest(acceptedRequestId)).toBe(true);
ownership.reset();
expect(ownership.consumeAcceptedRequest()).toBe(false);
});
});
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');
@@ -0,0 +1,31 @@
export function createQueryEditorExecutionViewportOwnership() {
let nextRequestId = 0;
let pendingRequestId: number | undefined;
let acceptedRequestId: number | undefined;
return {
beginRequest(): number {
nextRequestId += 1;
pendingRequestId = nextRequestId;
return nextRequestId;
},
cancelPendingRequest() {
pendingRequestId = undefined;
},
acceptRequest(requestId: number): boolean {
if (pendingRequestId !== requestId) return false;
pendingRequestId = undefined;
acceptedRequestId = requestId;
return true;
},
consumeAcceptedRequest(): boolean {
if (acceptedRequestId === undefined) return false;
acceptedRequestId = undefined;
return true;
},
reset() {
pendingRequestId = undefined;
acceptedRequestId = undefined;
},
};
}
@@ -10,6 +10,7 @@ export interface SqlExecutionSnapshot {
cursorPos: number;
selectionFrom: number;
selectionTo: number;
editorViewportRequestId?: number;
}
export type SqlExecutionOverride = string | SqlExecutionSnapshot;
+3 -1
View File
@@ -3369,7 +3369,7 @@ export const useQueryStore = defineStore("query", () => {
await executeCurrentSql(tab.sql);
}
async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean }) {
async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean; onExecutionStarted?: () => void }) {
const executionTabId = activeTabId.value;
if (!executionTabId) return;
const tab = tabs.value.find((item) => item.id === executionTabId);
@@ -3976,6 +3976,7 @@ export const useQueryStore = defineStore("query", () => {
openInNewResultTab?: boolean;
targetContext?: SqlExecutionTargetContext;
executionTarget?: MultiDbExecutionTarget;
onExecutionStarted?: () => void;
},
) {
const tab = findExecutionTab(id);
@@ -3993,6 +3994,7 @@ export const useQueryStore = defineStore("query", () => {
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
tab.isExecuting = true;
options?.onExecutionStarted?.();
tab.isCancelling = false;
if (!tab.queryExecutionStartedAt) {
tab.queryExecutionStartedAt = Date.now();