From 1d5ad2240793d4721d61ace9f4e6fa568768bf88 Mon Sep 17 00:00:00 2001 From: Thanadon Kongkanun <71448810+thanadon-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:20:16 +0700 Subject: [PATCH] fix(objects): copy selected table names to the system clipboard --- .../src/components/objects/ObjectBrowser.vue | 34 +++- ...owser.tableCopyClipboard.component.spec.ts | 157 ++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 619d9cfd5a..c82779656a 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -121,6 +121,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { treeNodePinIdentity, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems"; import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { useSettingsStore } from "@/stores/settingsStore"; +import { formatSidebarTableNamesForCopy, type SidebarTableCopyTarget } from "@/lib/sidebar/sidebarTableNameCopy"; import { useQueryStore } from "@/stores/queryStore"; import QueryEditor from "@/components/editor/QueryEditor.vue"; import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue"; @@ -2472,7 +2473,29 @@ async function confirmDuplicateStructure() { } } -function copySelectedTablesToClipboard() { +function objectBrowserTableNamesCopyText(rows: readonly ObjectBrowserRow[]): string { + const targets = rows.map( + (row) => + ({ + id: row.name, + label: row.name, + type: "table", + connectionId: props.connection.id, + database: props.database, + catalog: props.catalog, + schema: row.schema || selectedSchema.value || undefined, + }) as SidebarTableCopyTarget, + ); + return formatSidebarTableNamesForCopy(targets, { + separator: settingsStore.editorSettings.sidebarCopyTableNameSeparator, + includeSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema, + databaseType: effectiveDatabaseType.value, + driverProfile: props.connection.driver_profile, + identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id), + }); +} + +async function copySelectedTablesToClipboard() { const selectedRows = selectedTableRows.value; if (selectedRows.length === 0) return; connectionStore.treeClipboard = { @@ -2485,7 +2508,12 @@ function copySelectedTablesToClipboard() { tableComment: row.comment, })), }; - toast(t("contextMenu.pasteTableClipboardUpdated"), 2000); + try { + await copyToClipboard(objectBrowserTableNamesCopyText(selectedRows)); + toast(t("contextMenu.pasteTableClipboardUpdated"), 2000); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } } function canPasteTableClipboard(): boolean { @@ -2586,7 +2614,7 @@ function onObjectBrowserKeydown(event: KeyboardEvent) { if (selectedTableCount.value === 0) return; event.preventDefault(); event.stopPropagation(); - copySelectedTablesToClipboard(); + void copySelectedTablesToClipboard(); return; } if (eventTargetAllowsAppClipboardShortcut(event, "v")) { diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts new file mode 100644 index 0000000000..ee4e98f521 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, 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(), + ensureConnected: vi.fn(), + copyToClipboard: vi.fn(), + toast: vi.fn(), + store: { treeClipboard: null as unknown }, +})); + +vi.mock("@/lib/backend/api", () => ({ + listObjects: (...args: unknown[]) => mocks.listObjects(...args), + listSchemas: (...args: unknown[]) => mocks.listSchemas(...args), + listObjectStatistics: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + get treeClipboard() { + return mocks.store.treeClipboard; + }, + set treeClipboard(value: unknown) { + mocks.store.treeClipboard = value; + }, + ensureConnected: mocks.ensureConnected, + getConfig: () => connection, + orderByPinnedTreeNodes: (rows: unknown[]) => rows, + }), +})); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { refreshData: "F5" }, + objectBrowserViewMode: "list", + objectBrowserShowCheckbox: false, + sidebarCopyTableNameSeparator: "newline", + sidebarCopyTableNameIncludeSchema: false, + }, + }), +})); +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("@/lib/common/clipboard", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args), +})); +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-copy", + name: "MySQL", + db_type: "mysql", + database: "app", + driver_profile: null, + url_params: null, + transport_layers: [], +} as unknown as ConnectionConfig; +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.store.treeClipboard = null; + mocks.listObjects.mockResolvedValue([ + { name: "orders", object_type: "TABLE" }, + { name: "users", object_type: "TABLE" }, + { name: "audit", object_type: "TABLE" }, + ]); + mocks.listSchemas.mockResolvedValue([]); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.copyToClipboard.mockResolvedValue(undefined); + invalidateObjectBrowserRowsCache({}); +}); + +afterEach(() => { + 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("users")); + 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!; +} + +describe("ObjectBrowser table copy", () => { + it("writes the selected table names to the system clipboard on Ctrl+C", async () => { + const host = await mountBrowser(); + rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); + rowFor(host, "users").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); + await nextTick(); + + host.querySelector("[data-object-browser-root]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true })); + + await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("orders\nusers")); + expect(mocks.store.treeClipboard).toMatchObject({ kind: "table-copy", tables: [{ tableName: "orders" }, { tableName: "users" }] }); + expect(mocks.toast).toHaveBeenCalledWith("contextMenu.pasteTableClipboardUpdated", 2000); + }); + + it("reports a failed system clipboard write instead of claiming success", async () => { + mocks.copyToClipboard.mockRejectedValue(new Error("denied")); + const host = await mountBrowser(); + rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); + await nextTick(); + + host.querySelector("[data-object-browser-root]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true })); + + await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed", 5000)); + expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.pasteTableClipboardUpdated", 2000); + }); +});