mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(objects): open routines in source tab on double click
This commit is contained in:
@@ -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") {
|
||||
|
||||
Vendored
+200
@@ -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<HTMLElement>(".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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user