From 0b530145baed73131437b8145abf8a816eb0b26a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 14:31:35 +0000 Subject: [PATCH] feat(sidebar): copy MySQL object display paths Closes #5903 --- .../sidebar/SidebarTreeRuntimeHost.vue | 42 +++++++-- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + apps/desktop/src/lib/sidebar/treeNodeClick.ts | 11 +++ .../app-tests/sidebarContextMenuHost.test.ts | 23 +++++ packages/app-tests/treeNodeClick.test.ts | 90 ++++++++++++++++++- 12 files changed, 176 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index f53a013763..e4b8340bdb 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -101,7 +101,7 @@ import { usesTreeSchemaMode, isSingleDatabase, } from "@/lib/database/databaseCapabilities"; -import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; +import { copyDisplayPathForTreeNode, copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation"; import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy"; @@ -1679,6 +1679,35 @@ async function copyName() { } } +async function copyDisplayPath() { + const node = activeNode.value; + const connectionName = node.connectionId ? connectionStore.getConfig(node.connectionId)?.name || "" : ""; + const path = copyDisplayPathForTreeNode(node, connectionName); + if (!path) return; + try { + await copyToClipboard(path); + toast(t("connection.copied"), 2000); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } +} + +function copyNameMenuItem(): ContextMenuItem { + const node = activeNode.value; + const connectionName = node.connectionId ? connectionStore.getConfig(node.connectionId)?.name || "" : ""; + if (currentDatabaseType() === "mysql" && copyDisplayPathForTreeNode(node, connectionName)) { + return { + label: t("contextMenu.copyName"), + icon: Copy, + children: [ + { label: t("contextMenu.name"), action: copyName, icon: Copy }, + { label: t("contextMenu.fullPath"), action: copyDisplayPath, icon: Copy }, + ], + }; + } + return { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }; +} + async function copyCustomTypeDdl() { const node = createSidebarActionTarget(activeNode.value); if (!node.connectionId || !node.database) return; @@ -4490,7 +4519,7 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug }); items.push({ label: "", separator: true }); } - items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push(copyNameMenuItem()); items.push({ label: "", separator: true }); if (canOpenObjectBrowser.value) { items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties }); @@ -4782,7 +4811,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { return true; } const destructiveActions: ContextMenuItem[] = []; - items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push(copyNameMenuItem()); items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties }); items.push({ @@ -4929,7 +4958,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { } if (node.type === "index" || node.type === "fkey" || (node.type === "trigger" && !!node.tableName)) { - items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push(node.type === "trigger" ? copyNameMenuItem() : { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); } @@ -4969,6 +4998,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); } items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + if (currentDatabaseType() === "mysql") { + items.push(copyNameMenuItem()); + } if (!isPackageMember && canRenameObject.value) { items.push({ label: t("contextMenu.renameObject"), @@ -5011,7 +5043,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { icon: Code2, }); } - items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push(node.type === "trigger" ? copyNameMenuItem() : { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); return true; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 296e5ff369..a60cfb4fa6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2630,6 +2630,8 @@ export default { closeAllRegularTabs: "Close All Regular Tabs", closeAllFixedTabs: "Close All Fixed Tabs", copyName: "Copy Name", + name: "Name", + fullPath: "Full Path", copyFinalProxyPort: "Copy Final Proxy Port", finalProxyPortCopied: "Final proxy port {port} copied", setDefaultDatabase: "Set as Default Database", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index be0661d479..319ba745a5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2560,6 +2560,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "Cerrar todas las pestañas normales", closeAllFixedTabs: "Cerrar todas las pestañas fijadas", copyName: "Copiar nombre", + name: "Nombre", + fullPath: "Ruta completa", copyFinalProxyPort: "Copiar puerto proxy final", finalProxyPortCopied: "Puerto proxy final {port} copiado", setDefaultDatabase: "Establecer como base de datos predeterminada", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 91015d72d8..094eb3088c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2558,6 +2558,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "Chiudi Tutte le Schede Normali", closeAllFixedTabs: "Chiudi Tutte le Schede Fisse", copyName: "Copia Nome", + name: "Nome", + fullPath: "Percorso completo", copyFinalProxyPort: "Copia porta proxy finale", finalProxyPortCopied: "Porta proxy finale {port} copiata", setDefaultDatabase: "Imposta come Database Predefinito", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b848bc8f57..36cb87118e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2583,6 +2583,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "すべての通常タブを閉じる", closeAllFixedTabs: "すべての固定タブを閉じる", copyName: "名前をコピー", + name: "名前", + fullPath: "フルパス", copyFinalProxyPort: "最終プロキシポートをコピー", finalProxyPortCopied: "最終プロキシポート {port} をコピーしました", setDefaultDatabase: "デフォルトデータベースに設定", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f4e7cd1c8d..af3f21752d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2483,6 +2483,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "모든 일반 탭 닫기", closeAllFixedTabs: "모든 고정 탭 닫기", copyName: "이름 복사", + name: "이름", + fullPath: "전체 경로", copyFinalProxyPort: "최종 프록시 포트 복사", finalProxyPortCopied: "최종 프록시 포트 {port} 복사됨", setDefaultDatabase: "기본 데이터베이스로 설정", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 421c0a29d2..65f7aececd 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2562,6 +2562,8 @@ export default withEnglishFallback({ copyFinalProxyPort: "Copiar porta final do proxy", finalProxyPortCopied: "Porta final do proxy {port} copiada", copyName: "Copiar Nome", + name: "Nome", + fullPath: "Caminho completo", setDefaultDatabase: "Definir como Banco de Dados Padrão", clearDefaultDatabase: "Limpar Banco de Dados Padrão", setDefaultSchema: "Definir como Schema Padrão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 97a338453a..631a8c6f83 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2554,6 +2554,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "关闭全部普通标签页", closeAllFixedTabs: "关闭全部固定标签页", copyName: "复制名称", + name: "名称", + fullPath: "完整路径", copyFinalProxyPort: "复制最终代理端口", finalProxyPortCopied: "最终代理端口 {port} 已复制", setDefaultDatabase: "设为默认数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 517c24c00f..c338e9c60b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2559,6 +2559,8 @@ export default withEnglishFallback({ closeAllRegularTabs: "關閉全部普通分頁", closeAllFixedTabs: "關閉全部固定分頁", copyName: "複製名稱", + name: "名稱", + fullPath: "完整路徑", copyFinalProxyPort: "複製最終代理連接埠", finalProxyPortCopied: "最終代理連接埠 {port} 已複製", setDefaultDatabase: "設為預設資料庫", diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 13b6b7a9ce..e1a933f027 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -45,6 +45,7 @@ const sourceNodeTypes = new Set(["materialized_view", "procedure", const savedSqlNodeTypes = new Set(["saved-sql-file"]); const tableChildGroupNodeTypes = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-constraints", "group-partitions", "group-table-partitions", "group-table-subpartitions"]); const databaseChildGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]); +const displayPathObjectNodeTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger"]); export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null { if (type === "view") return "VIEW"; @@ -156,3 +157,13 @@ export function copyNameForTreeNode(node: TreeNode): string { } return node.label; } + +export function copyDisplayPathForTreeNode(node: TreeNode, connectionName: string): string | null { + const connection = connectionName.trim(); + const database = node.database?.trim(); + if (!connection || !database) return null; + if (node.type === "database") return `${connection}.${database}`; + if (!displayPathObjectNodeTypes.has(node.type)) return null; + const objectName = (node.objectName || (node.type === "table" ? node.tableName : undefined) || node.label).trim(); + return objectName ? `${connection}.${database}.${objectName}` : null; +} diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index b3c6f2cc0d..da5576a9b8 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -91,6 +91,29 @@ test("table copy menu uses the shared single and multi-selection clipboard path" assert.match(copySelectedNamesBody, /copyToClipboard\(nodes\.map\(copyNameForTreeNode\)\.join\("\\n"\)\)/); }); +test("MySQL object name menus expose leaf and display-path copy choices", () => { + const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); + const copyNameBody = functionBody(runtimeHost, "copyName"); + const copyDisplayPathBody = functionBody(runtimeHost, "copyDisplayPath"); + const copyNameMenuItemBody = functionBody(runtimeHost, "copyNameMenuItem"); + const connectionMenuBody = functionBody(runtimeHost, "buildConnectionSidebarMenu"); + const databaseMenuBody = functionBody(runtimeHost, "buildDatabaseSidebarMenu"); + const objectMenuBody = functionBody(runtimeHost, "buildObjectSidebarMenu"); + + assert.match(copyNameBody, /copyNameForTreeNode\(node\)/); + assert.match(copyDisplayPathBody, /copyDisplayPathForTreeNode\(node, connectionName\)/); + assert.match(copyNameMenuItemBody, /currentDatabaseType\(\) === "mysql"/); + assert.match(copyNameMenuItemBody, /children: \[/); + assert.match(copyNameMenuItemBody, /t\("contextMenu\.name"\)/); + assert.match(copyNameMenuItemBody, /t\("contextMenu\.fullPath"\)/); + assert.match(copyNameMenuItemBody, /return \{ label: t\("contextMenu\.copyName"\), action: copyName, icon: Copy, shortcut: shortcutCopyName\.value \}/); + assert.doesNotMatch(connectionMenuBody, /copyNameMenuItem\(\)/); + assert.match(databaseMenuBody, /items\.push\(copyNameMenuItem\(\)\)/); + assert.match(objectMenuBody, /items\.push\(copyNameMenuItem\(\)\)/); + assert.match(objectMenuBody, /node\.type === "trigger" \? copyNameMenuItem\(\)/); + assert.match(objectMenuBody, /node\.type === "sequence"[\s\S]*action: copyName/); +}); + test("successful tree table paste consumes only the clipboard used to start it", () => { const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable"); diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index 662edf7264..7d12bab23e 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, test, vi } from "vitest"; import assert from "node:assert/strict"; -import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../apps/desktop/src/lib/sidebar/treeNodeClick.ts"; +import { copyDisplayPathForTreeNode, copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../apps/desktop/src/lib/sidebar/treeNodeClick.ts"; beforeAll(() => vi.stubGlobal("navigator", { platform: "Linux x86_64" })); afterAll(() => vi.unstubAllGlobals()); @@ -193,6 +193,94 @@ test("copying database object group rows uses the parent schema or database name ); }); +test("copying a MySQL display path uses connection, database, and object names", () => { + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app", + label: "app", + type: "database", + connectionId: "conn", + database: "app", + }, + "dev-mysql", + ), + "dev-mysql.app", + ); + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app:orders", + label: "orders", + type: "table", + connectionId: "conn", + database: "app", + tableName: "orders", + }, + "dev-mysql", + ), + "dev-mysql.app.orders", + ); + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app:__views:active_orders", + label: "active_orders", + type: "view", + connectionId: "conn", + database: "app", + objectName: "active_orders", + }, + "dev-mysql", + ), + "dev-mysql.app.active_orders", + ); + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app:orders:__triggers:audit_orders", + label: "audit_orders", + type: "trigger", + connectionId: "conn", + database: "app", + tableName: "orders", + objectName: "audit_orders", + }, + "dev-mysql", + ), + "dev-mysql.app.audit_orders", + ); +}); + +test("copying a display path rejects incomplete and synthetic nodes", () => { + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "conn", + database: "app", + }, + "dev-mysql", + ), + null, + ); + assert.equal( + copyDisplayPathForTreeNode( + { + id: "conn:app:orders", + label: "orders", + type: "table", + connectionId: "conn", + database: "app", + }, + "", + ), + null, + ); +}); + test("copying column rows uses the column name without type suffix", () => { assert.equal( copyNameForTreeNode({