fix(editor): keep scroll after canceling dangerous sql

This commit is contained in:
jearhe authored and GitHub committed 2026-09-01 12:16:59 +08:00
1 parent a751b5690b
commit 694611aade
9 files changed
+215 -11

No files matched your search

@@ -1,4 +1,4 @@
import { computed, ref } from "vue";
import { computed, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isDangerousSql, requiresDatabaseSelection, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
@@ -7,6 +7,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { createQueryEditorExecutionViewportOwnership } from "@/lib/editor/queryEditorExecutionViewport";
import * as objectMetadataCache from "@/lib/metadata/objectMetadataCache";
import type { ConnectionConfig, QueryTab } from "@/types/database";
@@ -1114,6 +1115,97 @@ SELECT @value AS Message;`;
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: true, error: undefined }));
});
it("cancels the editor viewport request when dangerous SQL is dismissed", async () => {
const sql = "DELETE FROM users WHERE id = 7";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql");
const onExecutionCancelled = vi.fn();
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
onExecutionCancelled,
});
await execution.tryExecute({
fullSql: sql,
selectedSql: sql,
cursorPos: 0,
selectionFrom: 0,
selectionTo: sql.length,
editorViewportRequestId: 17,
});
expect(execution.showDangerDialog.value).toBe(true);
execution.showDangerDialog.value = false;
await nextTick();
expect(onExecutionCancelled).toHaveBeenCalledTimes(1);
expect(onExecutionCancelled).toHaveBeenCalledWith(17);
expect(executeCurrentSql).not.toHaveBeenCalled();
});
it("does not reuse a cancelled viewport request on the next dangerous execution", async () => {
const sql = "DELETE FROM users WHERE id = 7";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const ownership = createQueryEditorExecutionViewportOwnership();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async (_sql, options) => {
options?.onExecutionStarted?.();
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const onExecutionStarted = vi.fn((requestId: number) => ownership.acceptRequest(requestId));
const onExecutionCancelled = vi.fn((requestId: number) => ownership.cancelPendingRequest(requestId));
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
onExecutionStarted,
onExecutionCancelled,
});
const snapshotFor = (editorViewportRequestId: number) => ({
fullSql: sql,
selectedSql: sql,
cursorPos: 0,
selectionFrom: 0,
selectionTo: sql.length,
editorViewportRequestId,
});
const firstRequestId = ownership.beginRequest();
await execution.tryExecute(snapshotFor(firstRequestId));
expect(execution.showDangerDialog.value).toBe(true);
execution.showDangerDialog.value = false;
await nextTick();
expect(onExecutionCancelled).toHaveBeenCalledTimes(1);
expect(onExecutionCancelled).toHaveBeenCalledWith(firstRequestId);
expect(ownership.acceptRequest(firstRequestId)).toBe(false);
const secondRequestId = ownership.beginRequest();
await execution.tryExecute(snapshotFor(secondRequestId));
expect(execution.showDangerDialog.value).toBe(true);
await execution.onDangerConfirm();
expect(onExecutionStarted).toHaveBeenCalledTimes(1);
expect(onExecutionStarted).toHaveBeenCalledWith(secondRequestId);
expect(ownership.consumeCompletionPreservation()).toBe(true);
expect(ownership.consumeCompletionPreservation()).toBe(false);
expect(executeCurrentSql).toHaveBeenCalledTimes(1);
});
it("keeps the full dangerous script and new-result-tab intent through confirmation", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
@@ -117,6 +117,7 @@ export function useSqlExecution(deps: {
onMissingDatabase?: () => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
onExecutionStarted?: (editorViewportRequestId: number) => void;
onExecutionCancelled?: (editorViewportRequestId: number) => void;
}) {
const { t } = useI18n();
const queryStore = useQueryStore();
@@ -144,6 +145,10 @@ export function useSqlExecution(deps: {
const pendingDangerEditorViewportRequestId = ref<number | undefined>();
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | void) | undefined;
function cancelEditorViewportRequest(editorViewportRequestId?: number) {
if (editorViewportRequestId !== undefined) deps.onExecutionCancelled?.(editorViewportRequestId);
}
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 databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type;
@@ -164,9 +169,13 @@ export function useSqlExecution(deps: {
const tab = deps.activeTab.value;
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride);
const executionOptions = { ...options, editorViewportRequestId };
if (!tab || !sql.trim()) return;
if (!tab || !sql.trim()) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
deps.onMissingDatabase?.();
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) return;
@@ -178,7 +187,10 @@ export function useSqlExecution(deps: {
}
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
@@ -198,6 +210,7 @@ export function useSqlExecution(deps: {
}
if (highestSafety === "blocked") {
toast(t("redis.blockedCommand", { command: "Redis" }), 5000);
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
if (highestSafety === "confirm") {
@@ -223,6 +236,7 @@ export function useSqlExecution(deps: {
source: t("production.sourceSqlEditor"),
});
if (confirmed) await doExecute(sql, sourceOffset, options);
else cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
@@ -284,13 +298,19 @@ export function useSqlExecution(deps: {
}
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (sql === undefined) ({ sql, sourceOffset } = await resolvedExecutableSql());
if (sql === undefined) {
({ sql, sourceOffset } = await resolvedExecutableSql());
}
const tab = deps.activeTab.value;
if (!tab || !sql.trim()) return;
if (!tab || !sql.trim()) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
const executionDatabaseType = executionConnection?.db_type;
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
deps.onMissingDatabase?.();
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
@@ -304,7 +324,10 @@ export function useSqlExecution(deps: {
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}),
});
if (producedResult === false) return;
if (producedResult === false) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const executionTabStillActive = deps.activeTab.value?.id === tab.id;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) {
@@ -554,7 +577,10 @@ export function useSqlExecution(deps: {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
}
@@ -578,6 +604,7 @@ export function useSqlExecution(deps: {
watch(showSqlParameterDialog, (open) => {
if (open) return;
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
sqlParameterDatabaseType.value = undefined;
@@ -586,16 +613,19 @@ export function useSqlExecution(deps: {
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
pendingSqlParameterContinuation = undefined;
cancelEditorViewportRequest(editorViewportRequestId);
});
watch(showDangerDialog, (open) => {
if (open) return;
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
suppressDangerConfirm.value = false;
cancelEditorViewportRequest(editorViewportRequestId);
});
return {