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,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
import { Button } from "@/components/ui/button";
@@ -21,6 +21,10 @@ const suppressFuturePrompts = defineModel<boolean>("suppressFuturePrompts", { de
const wrap = ref(true);
const copied = ref(false);
// The CodeMirror editor (if any) that was focused when this dialog opened, so
// its internal selection can be restored without letting the browser move the caret.
let editorRootToRestoreFocus: HTMLElement | null = null;
const props = withDefaults(
defineProps<{
sql?: string;
@@ -73,6 +77,34 @@ const dialogOpen = computed({
},
});
watch(
() => open.value,
(isOpen) => {
if (!isOpen) return;
const active = document.activeElement;
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
},
{ immediate: true },
);
/**
* Restores focus through CodeMirror's own `EditorView.focus()` instead of the browser default.
*
* Reka UI's default close-auto-focus calls plain DOM `.focus()` on the element that was active
* before the dialog opened. In WebKit, refocusing a CodeMirror contenteditable this way can reset
* its caret to the start of the document, which makes the editor scroll to the top (#7692).
* `EditorView.focus()` restores CodeMirror's internal selection without creating that false change.
*/
function onDangerDialogCloseAutoFocus(event: Event) {
const target = editorRootToRestoreFocus;
editorRootToRestoreFocus = null;
if (!target || !target.isConnected) return;
event.preventDefault();
void import("@codemirror/view").then(({ EditorView }) => {
EditorView.findFromDOM(target)?.focus();
});
}
function onConfirm() {
// Guard here as well as on the button: a disabled button still fires on some
// synthetic/keyboard paths, and this one gates a destructive operation.
@@ -92,7 +124,7 @@ async function copyFullCode() {
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[480px]">
<DialogContent class="sm:max-w-[480px]" @close-auto-focus="onDangerDialogCloseAutoFocus">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
@@ -6584,6 +6584,10 @@ function acceptGutterExecutionViewport(requestId: number) {
return executionViewportOwnership.acceptRequest(requestId);
}
function cancelGutterExecutionViewport(requestId: number) {
return executionViewportOwnership.cancelPendingRequest(requestId);
}
function shouldBlockExecutionShortcut(event?: KeyboardEvent, currentView: EditorViewType | null = view.value): boolean {
return (currentView ? isEditorComposing(currentView) : false) || (event ? postCompositionKeyGuard.blocks(event) : false);
}
@@ -6594,6 +6598,7 @@ defineExpose({
scrollCursorIntoView,
beginExecutionViewportTracking,
acceptGutterExecutionViewport,
cancelGutterExecutionViewport,
shouldBlockExecutionShortcut,
requestExecute,
requestExecuteInNewResultTab,
@@ -9,6 +9,17 @@ import { copyToClipboard } from "@/lib/common/clipboard";
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
const DANGER_PREVIEW_MAX_LINES = 200;
const highlight = vi.fn((sql: string) => `<span>${sql}</span>`);
const focusMocks = vi.hoisted(() => {
const focus = vi.fn();
return {
focus,
findFromDOM: vi.fn(() => ({ focus })),
};
});
vi.mock("@codemirror/view", () => ({
EditorView: { findFromDOM: focusMocks.findFromDOM },
}));
vi.mock("@/composables/useSqlHighlighter", () => ({
useSqlHighlighter: () => ({ highlight }),
@@ -50,6 +61,8 @@ afterEach(() => {
document.body.innerHTML = "";
highlight.mockClear();
vi.mocked(copyToClipboard).mockClear();
focusMocks.findFromDOM.mockClear();
focusMocks.focus.mockClear();
});
describe("DangerConfirmDialog SQL preview", () => {
@@ -107,6 +120,26 @@ describe("DangerConfirmDialog SQL preview", () => {
expect(copyToClipboard).toHaveBeenCalledWith(sql);
});
it("restores a previously focused CodeMirror editor through EditorView on close", async () => {
const editorRoot = document.createElement("div");
editorRoot.className = "cm-editor";
const editorContent = document.createElement("div");
editorContent.className = "cm-content";
editorContent.tabIndex = 0;
editorRoot.append(editorContent);
document.body.append(editorRoot);
editorContent.focus();
await mountDialog("DROP TABLE users;");
const cancelButton = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Cancel");
cancelButton?.click();
await vi.waitFor(() => {
expect(focusMocks.findFromDOM).toHaveBeenCalledWith(editorRoot);
});
expect(focusMocks.focus).toHaveBeenCalledOnce();
});
});
describe("DangerConfirmDialog running/cancel footer state", () => {