diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index c82779656a..d717cd2d5e 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1011,6 +1011,9 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction) case "open-source": void (row.type === "EVENT" ? openEventEditor(row) : openSource(row)); break; + case "open-source-tab": + openSourceTab(row); + break; } } @@ -1038,7 +1041,7 @@ function onRowClick(row: ObjectBrowserRow, event: MouseEvent) { const activation = settingsStore.editorSettings.sidebarActivation; const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value); // Double click: cancel any pending single-click and fire immediately. When - // the row's single/double actions are identical (e.g. VIEW → open-source), + // the row's single/double actions are identical (e.g. SEQUENCE → open-source), // this gesture's first click already ran it — re-executing would toggle the // just-opened side panel back off (or emit open-table twice for MongoDB). if (isDouble) { @@ -1559,6 +1562,24 @@ function openTableStructureEditor() { queryStore.openTableStructure(props.connection.id, props.database, row.schema || selectedSchema.value, row.name, tableInfoTab.value, undefined, props.catalog); } +/** + * Double-clicking a routine opens its source as an editable query tab + * (issue #10202), the same container the sidebar and editor navigation use. + * The store owns connection setup, source loading and tab de-duplication, so + * this path deliberately does not fall back to the side panel. + */ +function openSourceTab(row: ObjectBrowserRow) { + queryStore.openObjectSourceTabPending({ + connectionId: props.connection.id, + database: props.database, + title: `Source - ${row.displayName || row.name}`, + schema: row.schema || selectedSchema.value || props.database, + catalog: props.catalog, + initialEditing: true, + request: { name: row.name, objectType: row.type as ObjectSourceKind, signature: row.signature ?? undefined }, + }); +} + async function openSource(row: ObjectBrowserRow) { // Toggle off if clicking the same source row if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "source") { diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.routineSourceTab.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.routineSourceTab.component.spec.ts new file mode 100644 index 0000000000..339cf7d682 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.routineSourceTab.component.spec.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, ref, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ObjectBrowser from "@/components/objects/ObjectBrowser.vue"; +import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; +import type { ConnectionConfig } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + listObjects: vi.fn(), + listSchemas: vi.fn(), + getObjectSource: vi.fn(), + buildEditableObjectSource: vi.fn(), + ensureConnected: vi.fn(), + openObjectSourceTabPending: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + listObjects: (...args: unknown[]) => mocks.listObjects(...args), + listSchemas: (...args: unknown[]) => mocks.listSchemas(...args), + listObjectStatistics: vi.fn().mockResolvedValue([]), + getObjectSource: (...args: unknown[]) => mocks.getObjectSource(...args), + buildEditableObjectSource: (...args: unknown[]) => mocks.buildEditableObjectSource(...args), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + treeClipboard: null, + ensureConnected: mocks.ensureConnected, + getConfig: () => connection, + orderByPinnedTreeNodes: (rows: unknown[]) => rows, + }), +})); +vi.mock("@/stores/queryStore", () => ({ + useQueryStore: () => ({ openObjectSourceTabPending: mocks.openObjectSourceTabPending }), +})); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { refreshData: "F5" }, + objectBrowserViewMode: "list", + objectBrowserShowCheckbox: false, + sidebarActivation: "single", + }, + }), +})); +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) })); +vi.mock("@/i18n", () => ({ default: { install: () => undefined } })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("vue-virtual-scroller", () => ({ + RecycleScroller: defineComponent({ + props: { items: { type: Array, default: () => [] } }, + setup(props, { slots }) { + return () => + h( + "div", + props.items.map((item) => slots.default?.({ item })), + ); + }, + }), +})); +vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } })); +vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/ui/CustomContextMenu.vue", () => ({ + default: defineComponent({ + setup(_, { slots }) { + return () => slots.default?.({ onContextMenu: () => undefined, isOpen: false }); + }, + }), +})); +vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } })); + +const connection = { + id: "mysql-routines", + name: "MySQL", + db_type: "mysql", + database: "app", + driver_profile: null, + url_params: null, + transport_layers: [], +} as unknown as ConnectionConfig; + +// ObjectBrowser.vue's SINGLE_CLICK_DELAY; the deferral is what lets a second +// click cancel the side panel before it loads. +const SINGLE_CLICK_DELAY = 250; +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listObjects.mockResolvedValue([ + { name: "sp_daily_report", schema: "app", object_type: "PROCEDURE", signature: "IN day DATE" }, + { name: "fn_total", schema: "app", object_type: "FUNCTION", signature: "" }, + { name: "seq_invoice", schema: "app", object_type: "SEQUENCE" }, + ]); + mocks.listSchemas.mockResolvedValue([]); + mocks.getObjectSource.mockResolvedValue({ source: "CREATE PROCEDURE sp_daily_report() BEGIN END", editable: true }); + mocks.buildEditableObjectSource.mockImplementation(async (options: { source: string }) => options.source); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.openObjectSourceTabPending.mockReturnValue("tab-1"); + invalidateObjectBrowserRowsCache({}); +}); + +afterEach(() => { + vi.useRealTimers(); + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + invalidateObjectBrowserRowsCache({}); +}); + +async function mountBrowser() { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "app" }) }); + mountedApps.push({ app, host }); + app.mount(host); + await vi.waitFor(() => expect(host.textContent).toContain("sp_daily_report")); + return host; +} + +function rowFor(host: HTMLElement, name: string): HTMLElement { + const row = [...host.querySelectorAll(".grid.h-\\[34px\\]")].find((element) => element.textContent?.includes(name)); + expect(row, name).toBeDefined(); + return row!; +} + +function click(row: HTMLElement, detail: number) { + row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail })); +} + +describe("ObjectBrowser routine double click", () => { + it("opens an editable source tab for a double-clicked PROCEDURE", async () => { + const host = await mountBrowser(); + vi.useFakeTimers(); + const row = rowFor(host, "sp_daily_report"); + + click(row, 1); + click(row, 2); + + expect(mocks.openObjectSourceTabPending).toHaveBeenCalledTimes(1); + expect(mocks.openObjectSourceTabPending).toHaveBeenCalledWith({ + connectionId: "mysql-routines", + database: "app", + title: "Source - sp_daily_report(IN day DATE)", + schema: "app", + catalog: undefined, + initialEditing: true, + request: { name: "sp_daily_report", objectType: "PROCEDURE", signature: "IN day DATE" }, + }); + // The second click cancelled the pending single click, so the side panel + // never fetched the source. + await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY * 2); + expect(mocks.getObjectSource).not.toHaveBeenCalled(); + }); + + it("opens an editable source tab for a double-clicked FUNCTION", async () => { + const host = await mountBrowser(); + vi.useFakeTimers(); + const row = rowFor(host, "fn_total"); + + click(row, 1); + click(row, 2); + + expect(mocks.openObjectSourceTabPending).toHaveBeenCalledWith(expect.objectContaining({ initialEditing: true, request: { name: "fn_total", objectType: "FUNCTION", signature: "" } })); + await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY * 2); + expect(mocks.getObjectSource).not.toHaveBeenCalled(); + }); + + it("keeps a single click on the source side panel", async () => { + const host = await mountBrowser(); + vi.useFakeTimers(); + + click(rowFor(host, "sp_daily_report"), 1); + + // Deferred: nothing happens until the double-click window closes. + expect(mocks.getObjectSource).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY); + expect(mocks.getObjectSource).toHaveBeenCalledWith("mysql-routines", "app", "app", "sp_daily_report", "PROCEDURE", "IN day DATE"); + expect(mocks.openObjectSourceTabPending).not.toHaveBeenCalled(); + }); + + it("leaves non-routine source rows on the side panel when double clicked", async () => { + const host = await mountBrowser(); + vi.useFakeTimers(); + const row = rowFor(host, "seq_invoice"); + + click(row, 1); + click(row, 2); + await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY * 2); + + expect(mocks.openObjectSourceTabPending).not.toHaveBeenCalled(); + expect(mocks.getObjectSource).toHaveBeenCalledWith("mysql-routines", "app", "app", "seq_invoice", "SEQUENCE", undefined); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts index bd88ba3fcd..306bee4ee9 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts @@ -80,8 +80,27 @@ describe("doubleClickRowAction", () => { expect(doubleClickRowAction(row(type, "v_orders"))).toBe("open-table"); }); - it("returns open-source for PROCEDURE", () => { - expect(doubleClickRowAction(row("PROCEDURE", "sp_run"))).toBe("open-source"); + it.each(["PROCEDURE", "FUNCTION"] as const)("returns open-source-tab for %s (editable source tab, not the side panel)", (type) => { + expect(doubleClickRowAction(row(type, "sp_run"))).toBe("open-source-tab"); + }); + + it("keeps routines on the source tab across database types", () => { + for (const dbType of ["mysql", "postgres", "oracle", "xugu"] as const) { + expect(doubleClickRowAction(row("PROCEDURE", "sp_run"), dbType), String(dbType)).toBe("open-source-tab"); + expect(doubleClickRowAction(row("FUNCTION", "fn_run"), dbType), String(dbType)).toBe("open-source-tab"); + } + }); + + it("leaves the other source-backed types on the side panel", () => { + expect(doubleClickRowAction(row("EVENT", "ev_nightly"))).toBe("open-source"); + expect(doubleClickRowAction(row("SEQUENCE", "seq_test"))).toBe("open-source"); + expect(doubleClickRowAction(row("PACKAGE", "pkg_test"))).toBe("open-source"); + expect(doubleClickRowAction(row("PACKAGE_BODY", "pkg_body_test"))).toBe("open-source"); + expect(doubleClickRowAction(row("TRIGGER", "trg_test"), "xugu")).toBe("open-source"); + expect(doubleClickRowAction(row("TYPE", "app_status"), "xugu")).toBe("open-source"); + expect(doubleClickRowAction(row("TYPE_BODY", "app_status"), "xugu")).toBe("open-source"); + expect(doubleClickRowAction(row("VIEW", "v_users"))).toBe("open-table"); + expect(doubleClickRowAction(row("MATERIALIZED_VIEW", "mv_users"))).toBe("open-table"); }); it("returns none for null/undefined", () => { @@ -128,6 +147,12 @@ describe("resolveRowClickAction", () => { expect(result.action).toBe("open-table"); expect(result.isDouble).toBe(true); }); + + it.each(["PROCEDURE", "FUNCTION"] as const)("single click on %s returns open-source, double click returns open-source-tab", (type) => { + const routineRow = row(type, "sp_run"); + expect(resolveRowClickAction(routineRow, 1, "single")).toEqual({ action: "open-source", isDouble: false }); + expect(resolveRowClickAction(routineRow, 2, "single")).toEqual({ action: "open-source-tab", isDouble: true }); + }); }); describe("double-click activation mode", () => { @@ -154,6 +179,12 @@ describe("resolveRowClickAction", () => { expect(result.action).toBe("open-table"); expect(result.isDouble).toBe(true); }); + + it.each(["PROCEDURE", "FUNCTION"] as const)("single click on %s returns open-source, double click returns open-source-tab", (type) => { + const routineRow = row(type, "sp_run"); + expect(resolveRowClickAction(routineRow, 1, "double")).toEqual({ action: "open-source", isDouble: false }); + expect(resolveRowClickAction(routineRow, 2, "double")).toEqual({ action: "open-source-tab", isDouble: true }); + }); }); }); @@ -169,8 +200,15 @@ describe("shouldDeferSingleClick", () => { expect(shouldDeferSingleClick(viewRow, "open-source")).toBe(true); }); - it("does not defer PROCEDURE open-source (same single/double action)", () => { - expect(shouldDeferSingleClick(row("PROCEDURE", "sp_run"), "open-source")).toBe(false); + it.each(["PROCEDURE", "FUNCTION"] as const)("defers %s open-source so a second click can cancel the side panel", (type) => { + // single → open-source, double → open-source-tab: the 250ms deferral is what + // keeps a fast double click from also opening the side panel (issue #10202). + expect(shouldDeferSingleClick(row(type, "sp_run"), "open-source")).toBe(true); + expect(shouldDeferSingleClick(row(type, "sp_run"), "open-source-tab")).toBe(false); + }); + + it("does not defer SEQUENCE open-source (same single/double action)", () => { + expect(shouldDeferSingleClick(row("SEQUENCE", "seq_run"), "open-source")).toBe(false); }); it("does not defer none action", () => { diff --git a/apps/desktop/src/lib/table/objectBrowserRowAction.ts b/apps/desktop/src/lib/table/objectBrowserRowAction.ts index fc5468f799..4a2561dada 100644 --- a/apps/desktop/src/lib/table/objectBrowserRowAction.ts +++ b/apps/desktop/src/lib/table/objectBrowserRowAction.ts @@ -2,7 +2,7 @@ import type { DatabaseType } from "@/types/database"; import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; -export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" | "open-source" | "none"; +export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" | "open-source" | "open-source-tab" | "none"; /** * Determine the action for a single click on an object browser row. @@ -28,7 +28,8 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d * Determine the action for a double click on an object browser row. * - TABLE/VIEW/MATERIALIZED_VIEW → open-table (open data tab, matching the * sidebar's data-node double-click behavior) - * - PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source + * - PROCEDURE/FUNCTION → open-source-tab (editable source tab; single click keeps the side panel) + * - TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source * - otherwise → none */ export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction { @@ -37,6 +38,10 @@ export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, d if (row.type === "TABLE" || row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return "open-table"; if (row.type === "EVENT") return "open-source"; if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info"; + // Routines are the only source-backed rows with a dedicated double-click + // gesture (issue #10202): single click keeps the side panel, double click + // hands the object to the source tab. canOpenSource always accepts them. + if (row.type === "PROCEDURE" || row.type === "FUNCTION") return "open-source-tab"; if (canOpenSource(row, dbType)) return "open-source"; return "none"; } @@ -47,7 +52,8 @@ export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, d * * In both single-click and double-click activation modes, a single click * triggers the side-panel action (table-info / open-source). When a distinct - * double-click action exists (e.g. TABLE single→table-info, double→open-table), + * double-click action exists (e.g. TABLE single→table-info, double→open-table; + * PROCEDURE single→open-source, double→open-source-tab), * the caller defers the single-click via shouldDeferSingleClick so the second * click can cancel it. */ @@ -65,8 +71,9 @@ export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, * Whether a single-click action should be deferred to distinguish it from a * possible upcoming double-click. Applies when the row's single-click and * double-click actions differ (e.g. TABLE: single → table-info, double → - * open-table). For rows whose single and double actions are identical - * (e.g. VIEW → open-source both), no deferral is needed. + * open-table; PROCEDURE/FUNCTION: single → open-source, double → + * open-source-tab). For rows whose single and double actions are identical + * (e.g. SEQUENCE → open-source both), no deferral is needed. */ export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction, dbType?: DatabaseType): boolean { if (action === "none") return false;