diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 581d8d70c..de38c70e1 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -2837,23 +2837,6 @@ async function confirmPasteTable() { } } -function copyTableToClipboard() { - const node = activeNode.value; - if (node.type !== "table" || !node.connectionId || !node.database) return; - connectionStore.treeClipboard = { - kind: "table-copy", - tables: [ - { - connectionId: node.connectionId, - database: node.database, - schema: node.schema, - tableName: node.label, - }, - ], - }; - toast(t("contextMenu.pasteTableClipboardUpdated"), 2000); -} - function openPasteTableDialog() { const clipboard = connectionStore.treeClipboard; if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) { @@ -3939,7 +3922,8 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (isTableNotView.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus }); - items.push({ label: t("contextMenu.copyTable"), action: copyTableToClipboard, icon: Copy }); + // Keep menu copy aligned with keyboard copy so frozen multi-selection and single-row fallback stay compatible. + items.push({ label: t("contextMenu.copyTable"), action: copySelectedNames, icon: Copy }); if (supportsTruncate.value) { destructiveActions.push({ label: truncateMenuLabel(t("contextMenu.truncateTable")), diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index 74d41209f..2941bfb10 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -2,6 +2,26 @@ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import { test } from "vitest"; +function functionBody(source: string, name: string): string { + const signature = `function ${name}(`; + const asyncSignature = `async ${signature}`; + const signatureIndex = source.indexOf(asyncSignature) >= 0 ? source.indexOf(asyncSignature) : source.indexOf(signature); + assert.notEqual(signatureIndex, -1, `Could not find function ${name}`); + const bodyStart = source.indexOf("{", signatureIndex); + assert.notEqual(bodyStart, -1, `Could not find body for ${name}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return source.slice(bodyStart + 1, index); + } + } + throw new Error(`Could not parse body for ${name}`); +} + test("tree-level context menu opens with the current row items atomically", () => { const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); const contextMenu = readFileSync("apps/desktop/src/components/ui/CustomContextMenu.vue", "utf8"); @@ -39,3 +59,15 @@ test("tree host owns sidebar data-open generations", () => { assert.match(connectionTree, /runSidebarDataOpenImmediately/); assert.match(connectionTree, /createSidebarActionTarget\(node\)/); }); + +test("table copy menu uses the shared single and multi-selection clipboard path", () => { + const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); + const copySelectedNamesBody = functionBody(runtimeHost, "copySelectedNames"); + + assert.match(runtimeHost, /label: t\("contextMenu\.copyTable"\), action: copySelectedNames, icon: Copy/); + assert.doesNotMatch(runtimeHost, /function copyTableToClipboard\(/); + assert.match(copySelectedNamesBody, /const selectedNodes = selectedTreeNodesInVisibleOrder\(\)/); + assert.match(copySelectedNamesBody, /selectedNodes\.length > 1 && selectedNodes\.some\(\(node\) => node\.id === activeNode\.value\.id\) \? selectedNodes : \[activeNode\.value\]/); + assert.match(copySelectedNamesBody, /updateTreeClipboardForNodes\(nodes\)/); + assert.match(copySelectedNamesBody, /copyToClipboard\(nodes\.map\(copyNameForTreeNode\)\.join\("\\n"\)\)/); +});