fix(editor): execute manually selected sql from the toolbar

This commit is contained in:
jearhe authored and GitHub committed 2026-09-13 13:27:12 +08:00
1 parent 28206f2963
commit 1137cab7f4
9 files changed
+310 -33

No files matched your search

@@ -192,8 +192,8 @@ const groupExecutableSql = computed(() => {
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@execute-pointer-down="toolbar.captureExecutionSnapshot()"
@toolbar-execute="toolbar.toolbarExecute($event)"
@execute-pointer-down="toolbar.captureExecutionSnapshot(activeTab.id)"
@toolbar-execute="toolbar.toolbarExecute($event, activeTab.id)"
@multi-execute="toolbar.multiExecute()"
@preview-changes="activeTab && toolbar.previewChanges(activeTab.id)"
@cancel="activeTab && toolbar.cancelExecution(activeTab.id)"
@@ -92,9 +92,9 @@ defineExpose({
const group = groupForElement(target) ?? activeEditorGroup();
return group?.handleModRTarget(target) ?? false;
},
requestQueryEditorExecute: () => activeEditorGroup()?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: () => activeEditorGroup()?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: () => activeEditorGroup()?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorExecute: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: (tabId?: string) => editorGroupForTab(tabId)?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorPreviewChanges: (stackSql?: string) => activeEditorGroup()?.requestQueryEditorPreviewChanges(stackSql) ?? false,
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => activeEditorGroup()?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
cancelQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.cancelQueryEditorExecutionViewport(requestId) ?? false,
@@ -214,6 +214,11 @@ function activeEditorGroup() {
const group = queryStore.groups.find((item) => item.id === queryStore.focusedGroupId) ?? queryStore.groups[0];
return group ? (groupRefs.get(group.id) ?? null) : null;
}
function editorGroupForTab(tabId?: string): InstanceType<typeof EditorGroup> | null {
if (!tabId) return activeEditorGroup();
const group = queryStore.groups.find((item) => item.activeTabId === tabId);
return group ? (groupRefs.get(group.id) ?? null) : null;
}
function groupForElement(element: Element | null): InstanceType<typeof EditorGroup> | null {
const groupElement = element?.closest<HTMLElement>("[data-group-id]");
const groupId = groupElement?.dataset.groupId;
@@ -198,6 +198,8 @@ describe("EditorGroup mount contract", () => {
const actions: EditorToolbarActions = {
...createNoopEditorToolbarActions(),
captureExecutionSnapshot: vi.fn(),
toolbarExecute: vi.fn(),
cancelExecution: vi.fn(),
explain: vi.fn(),
saveSql: vi.fn(),
@@ -229,10 +231,14 @@ describe("EditorGroup mount contract", () => {
expect(toolbar?.textContent).toBe(queryId);
// Toolbar events flow to the injected actions with the group's tab id.
(toolbar as any).__vueParentComponent.emit("executePointerDown");
(toolbar as any).__vueParentComponent.emit("toolbarExecute", "pointer");
(toolbar as any).__vueParentComponent.emit("cancel");
(toolbar as any).__vueParentComponent.emit("explain");
(toolbar as any).__vueParentComponent.emit("saveSql", queryId);
await nextTick();
expect(actions.captureExecutionSnapshot).toHaveBeenCalledWith(queryId);
expect(actions.toolbarExecute).toHaveBeenCalledWith("pointer", queryId);
expect(actions.cancelExecution).toHaveBeenCalledWith(queryId);
expect(actions.explain).toHaveBeenCalledWith(queryId);
expect(actions.saveSql).toHaveBeenCalledWith(queryId);
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createApp, h, nextTick, reactive, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -63,6 +63,9 @@ vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
import EditorToolbar from "../EditorToolbar.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { EditorView } from "@codemirror/view";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
@@ -141,6 +144,169 @@ describe("EditorToolbar mount contract", () => {
host.remove();
});
it("keeps the manually selected SQL from pointer-down through toolbar execution", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "conn-1",
name: "conn",
db_type: "postgres",
color: "",
} as never,
];
const fullSql = "SELECT first_value;\nSELECT selected_value;";
const selectedSql = "SELECT selected_value;";
const snapshot = {
fullSql,
selectedSql,
cursorPos: fullSql.length,
selectionFrom: fullSql.indexOf(selectedSql),
selectionTo: fullSql.length,
};
let pendingSnapshot: typeof snapshot | undefined;
let executedSql = "";
const onExecutePointerDown = vi.fn(() => {
pendingSnapshot = snapshot;
});
const onToolbarExecute = vi.fn((source: "pointer" | "keyboard") => {
executedSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
});
const host = createHost();
const app = createApp(EditorToolbar, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: fullSql,
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
},
activeConnection: connectionStore.getConfig("conn-1"),
executableSql: selectedSql,
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
oracleTxnPossiblyDirty: false,
isOracleManualTransaction: false,
onExecutePointerDown,
onToolbarExecute,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const runButton = host.querySelectorAll("button")[0];
runButton?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }));
runButton?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
await nextTick();
expect(onExecutePointerDown).toHaveBeenCalledTimes(1);
expect(onToolbarExecute).toHaveBeenCalledWith("pointer");
expect(onExecutePointerDown.mock.invocationCallOrder[0]).toBeLessThan(onToolbarExecute.mock.invocationCallOrder[0]!);
expect(executedSql).toBe(selectedSql);
app.unmount();
host.remove();
});
it("executes the exact CodeMirror selection after the toolbar click moves focus", async () => {
const connectionStore = useConnectionStore();
const connection = {
id: "conn-1",
name: "conn",
db_type: "postgres",
color: "",
} as never;
connectionStore.connections = [connection];
const fullSql = "SELECT first_value;\nSELECT selected_value;";
const selectedSql = "SELECT selected_value;";
const selectionFrom = fullSql.indexOf(selectedSql);
const editorRef = ref<{ captureExecutionSnapshot?: () => SqlExecutionSnapshot | undefined } | null>(null);
const state = reactive({ sql: fullSql });
let pendingSnapshot: SqlExecutionSnapshot | undefined;
let executedSql = "";
const host = createHost();
const app = createApp({
setup() {
return () =>
h("div", [
h(QueryEditor, {
ref: editorRef,
modelValue: state.sql,
tabId: "tab-1",
connectionId: "conn-1",
database: "db",
databaseType: "postgres",
dialect: "postgres",
autoFocus: false,
"onUpdate:modelValue": (value: string) => (state.sql = value),
}),
h(EditorToolbar, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: fullSql,
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
},
activeConnection: connection,
executableSql: fullSql,
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
oracleTxnPossiblyDirty: false,
isOracleManualTransaction: false,
onExecutePointerDown: () => {
pendingSnapshot = editorRef.value?.captureExecutionSnapshot?.();
},
onToolbarExecute: (source: "pointer" | "keyboard") => {
executedSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
},
}),
]);
},
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
view.dispatch({ selection: { anchor: selectionFrom, head: fullSql.length } });
expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe(selectedSql);
const runButton = host.querySelector<HTMLElement>(".app-editor-toolbar button");
runButton?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }));
runButton?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
await nextTick();
expect(pendingSnapshot?.selectedSql).toBe(selectedSql);
expect(executedSql).toBe(selectedSql);
app.unmount();
host.remove();
});
it("does not expose SQL formatting for Redis connections", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
@@ -21,6 +21,7 @@ const groupHandleModRCalls = vi.hoisted(() => [] as Element[]);
const groupFocusSearchCalls = vi.hoisted(() => [] as Array<Element | null>);
const resultHandleModRCalls = vi.hoisted(() => [] as Element[]);
const resultFocusSearchCalls = vi.hoisted(() => [] as boolean[]);
const groupExecutionCalls = vi.hoisted(() => ({ capture: [] as string[], request: [] as string[] }));
vi.mock("@/components/layout/EditorGroup.vue", () => ({
default: {
@@ -44,6 +45,14 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({
groupFocusSearchCalls.push(target ?? null);
return true;
},
captureQueryEditorExecutionSnapshot(this: { groupId: string }) {
groupExecutionCalls.capture.push(this.groupId);
return { fullSql: `SELECT ${this.groupId}`, selectedSql: `SELECT ${this.groupId}`, cursorPos: 0, selectionFrom: 0, selectionTo: 13 };
},
requestQueryEditorExecute(this: { groupId: string }) {
groupExecutionCalls.request.push(this.groupId);
return true;
},
},
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
},
@@ -149,6 +158,8 @@ describe("SqlEditorWorkspace mount contract", () => {
groupFocusSearchCalls.length = 0;
resultHandleModRCalls.length = 0;
resultFocusSearchCalls.length = 0;
groupExecutionCalls.capture.length = 0;
groupExecutionCalls.request.length = 0;
pinia = createPinia();
setActivePinia(pinia);
i18n = createI18n({
@@ -158,6 +169,47 @@ describe("SqlEditorWorkspace mount contract", () => {
});
});
it("routes toolbar execution to the requested tab's editor group", async () => {
const store = useQueryStore();
const tabs = [tab("tab-a"), tab("tab-b")];
store.tabs = tabs;
store.activeTabId = "tab-a";
store.groups = [
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
];
store.focusedGroupId = "g1";
store.sizes = [50, 50];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tabs[0]!,
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
const vm = app.mount(host) as unknown as {
captureQueryEditorExecutionSnapshot: (tabId?: string) => { selectedSql: string } | undefined;
requestQueryEditorExecute: (tabId?: string) => boolean;
};
await nextTick();
const snapshot = vm.captureQueryEditorExecutionSnapshot("tab-b");
expect(snapshot?.selectedSql).toBe("SELECT g2");
expect(groupExecutionCalls.capture).toEqual(["g2"]);
expect(vm.requestQueryEditorExecute("tab-b")).toBe(true);
expect(groupExecutionCalls.request).toEqual(["g2"]);
app.unmount();
host.remove();
});
it("gives an idle query the full editor pane without a result surface or re-show button", async () => {
const { host, vm, cleanup } = mountOutputWorkspace([{ ...tab("idle"), result: undefined }]);
await nextTick();
@@ -26,8 +26,8 @@ export interface EditorToolbarActions {
/** Highlights the database selector of the tab that needs a database choice. */
databaseRequiredSignalFor(tabId: string): number;
/** Captures the acting editor's execution snapshot before a toolbar click. */
captureExecutionSnapshot(): void;
toolbarExecute(source: "pointer" | "keyboard"): void;
captureExecutionSnapshot(tabId: string): void;
toolbarExecute(source: "pointer" | "keyboard", tabId: string): void;
cancelExecution(tabId: string): void;
explain(tabId: string): void;
formatSql(tabId: string): void;