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

This commit is contained in:
jearhe
2026-09-13 13:27:12 +08:00
committed by GitHub
parent 28206f2963
commit 1137cab7f4
9 changed files with 310 additions and 33 deletions
+21 -20
View File
@@ -776,15 +776,17 @@ const executableSql = computed(() => {
: "";
});
async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot) {
const tab = activeTab.value;
return tab
? await resolveExecutableSqlWithBackend(snapshot?.fullSql ?? tab.sql, snapshot?.selectedSql ?? selectedSql.value, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: snapshot?.cursorPos ?? cursorPos.value,
databaseType: activeConnection.value?.db_type,
})
: "";
async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot, executionTab?: QueryTab) {
const tab = executionTab ?? activeTab.value;
if (!tab) return "";
const connection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value;
const selection = tab.editorSelection;
const fallbackSelectedSql = tab.id === activeTab.value?.id ? selectedSql.value : selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
return await resolveExecutableSqlWithBackend(snapshot?.fullSql ?? tab.sql, snapshot?.selectedSql ?? fallbackSelectedSql, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: snapshot?.cursorPos ?? (tab.id === activeTab.value?.id ? cursorPos.value : (selection?.head ?? 0)),
databaseType: connection?.db_type,
});
}
const blockDangerousRedisCommands = ref(true);
@@ -835,22 +837,21 @@ const {
onExecutionCancelled: (editorViewportRequestId) => contentAreaRef.value?.cancelQueryEditorExecutionViewport(editorViewportRequestId),
});
function captureActiveEditorExecutionSnapshot() {
const snapshot = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();
pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;
function captureActiveEditorExecutionSnapshot(tabId: string) {
const snapshot = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.(tabId);
pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId } : undefined;
}
function requestActiveEditorExecute(source?: "pointer" | "keyboard") {
function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: string) {
const snapshot = pendingToolbarExecutionSnapshot.value;
pendingToolbarExecutionSnapshot.value = undefined;
if (source === "pointer") {
if (snapshot) {
void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });
return;
}
const targetTabId = tabId ?? (source === "pointer" ? snapshot?.tabId : undefined);
if (source === "pointer" && snapshot && snapshot.tabId === targetTabId) {
void tryExecute(snapshot, { tabId: targetTabId });
return;
}
if (contentAreaRef.value?.requestQueryEditorExecute?.()) return;
void tryExecute();
if (contentAreaRef.value?.requestQueryEditorExecute?.(targetTabId)) return;
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
function requestActiveEditorExecuteInNewResultTab() {
@@ -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;
@@ -10,6 +10,7 @@ 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";
import type { SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
vi.mock("vue-i18n", () => ({
createI18n: () => ({ global: { locale: { value: "en" }, setLocaleMessage: vi.fn() } }),
@@ -248,6 +249,49 @@ describe("useSqlExecution", () => {
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { tabId: "tab-1", sourceOffset: selectionFrom });
});
it("keeps a toolbar snapshot bound to its owning tab when focus changes", async () => {
const fullSql = "SELECT active_value;\nSELECT selected_value;";
const selectedSql = "SELECT selected_value;";
const selectionFrom = fullSql.indexOf(selectedSql);
const activeTabValue = { ...queryTab("app"), id: "tab-a", sql: "SELECT active_value;" };
const executionTabValue = { ...queryTab("app"), id: "tab-b", sql: fullSql };
const activeTab = ref<QueryTab | undefined>(activeTabValue);
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
connectionStore.connections = [connection("mysql")];
queryStore.tabs = [activeTabValue, executionTabValue];
queryStore.activeTabId = activeTabValue.id;
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => undefined);
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const resolveExecutableSql = vi.fn(async (snapshot: SqlExecutionSnapshot | undefined, tab?: QueryTab) => {
expect(tab?.id).toBe("tab-b");
return snapshot?.selectedSql ?? "";
});
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => activeTab.value?.sql ?? ""),
resolveExecutableSql,
activeOutputView,
});
await execution.tryExecute(
{
fullSql,
selectedSql,
cursorPos: selectionFrom,
selectionFrom,
selectionTo: fullSql.length,
},
{ tabId: executionTabValue.id },
);
expect(resolveExecutableSql).toHaveBeenCalledWith(expect.objectContaining({ selectedSql }), executionTabValue);
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { tabId: executionTabValue.id, sourceOffset: selectionFrom });
});
it("expands preceding @set values in a selected shell-style statement", async () => {
const selectedSql = "SELECT * FROM patrol WHERE post_id = ${postid};";
const fullSql = ["-- saved query", "@set postid='224';", selectedSql, "@set postid = 'future';"].join("\n");
@@ -58,17 +58,20 @@ describe("QueryEditor execution routing", () => {
expect(contentAreaSource).toContain("function captureQueryEditorExecutionSnapshot()");
expect(contentAreaSource).toContain("queryEditorRef.value?.captureExecutionSnapshot();");
expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot & { tabId?: string }>();");
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;");
expect(appSource).toContain('if (source === "pointer")');
expect(appSource).toContain("contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.(tabId)");
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId } : undefined;");
expect(appSource).toContain('if (source === "pointer" && snapshot && snapshot.tabId === targetTabId)');
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = undefined;");
expect(appSource).toContain("void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });");
expect(appSource).toContain("void tryExecute(snapshot, { tabId: targetTabId });");
expect(appSource).toContain('@execute="(tabId: string, override?: SqlExecutionOverride) => tryExecute(override, { tabId })"');
expect(appSource).toContain("async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot, executionTab?: QueryTab)");
expect(appSource).toContain("const connection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value;");
// Per-group toolbars call back into App-owned orchestration via injection.
expect(appSource).toContain("provide(EDITOR_TOOLBAR_ACTIONS, {");
expect(appSource).toContain("captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,");
expect(appSource).toContain("toolbarExecute: requestActiveEditorExecute,");
expect(editorGroupSource).toContain('@execute-pointer-down="toolbar.captureExecutionSnapshot()"');
expect(editorGroupSource).toContain('@toolbar-execute="toolbar.toolbarExecute($event)"');
expect(editorGroupSource).toContain('@execute-pointer-down="toolbar.captureExecutionSnapshot(activeTab.id)"');
expect(editorGroupSource).toContain('@toolbar-execute="toolbar.toolbarExecute($event, activeTab.id)"');
expect(editorToolbarSource).toContain("function onExecutePointerDown(event: MouseEvent)");
expect(editorToolbarSource).toContain('emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard")');
expect(editorToolbarSource).not.toContain('emit("execute", event.detail > 0 ? "pointer" : "keyboard")');