From a77f87ea162bc581cb6d77a47d60c050eaf3bf4b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:25:49 -0700 Subject: [PATCH] feat(sidebar): copy workspace name from context menu (#22338) * feat(sidebar): copy workspace name from context menu Add Copy Name directly below Copy Path in the workspace context menu. It copies the name through resolveWorktreeDisplayName, the renderer mirror of main's mergeWorktree fallback (custom name, then branch, then folder), so the copied text is what `name:` worktree selectors resolve against and a cleared custom name no longer copies `undefined`. The context-menu model now spreads the command handlers instead of listing each one twice, which keeps it under the file-length limit. Fixes #21980 Linear: STA-8068 * refactor(sidebar): rename Copy Name to Copy Worktree Name - Clarify that this copies the worktree display name, not the path - Update all locale strings and i18n keys - Rename test file to match * test(sidebar): cover folder workspace copy worktree name --------- Co-authored-by: Seongho Bae --- ...reeContextMenu.copy-worktree-name.test.tsx | 166 ++++++++++++++++++ .../sidebar/WorktreeContextMenuView.tsx | 8 + .../use-worktree-context-menu-commands.ts | 5 + .../use-worktree-context-menu-model.tsx | 32 +--- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 10 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx new file mode 100644 index 00000000000..11549f24167 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx @@ -0,0 +1,166 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/worktree/types' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { TooltipProvider } from '@/components/ui/tooltip' +import WorktreeContextMenu from './WorktreeContextMenu' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const state = { + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + workspaceStatuses: [], + openModal: vi.fn(), + projectGroups: [], + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + updateWorktreeLineage: vi.fn(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + keybindings: {}, + settings: { activeRuntimeEnvironmentId: null, openInApplications: [] }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (s: typeof state) => unknown) => selector(state), { + getState: () => state + }) +})) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: (repoId?: string) => + repoId ? { id: repoId, name: repoId, displayName: repoId, projectGroupId: null } : undefined, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en', on: () => {}, off: () => {} } +})) + +vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null })) +vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null })) + +const writeClipboardText = vi.fn() +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +beforeEach(() => { + writeClipboardText.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { writeClipboardText } } + }) +}) + +afterEach(() => { + for (const { root, container } of mounted) { + act(() => root.unmount()) + container.remove() + } + mounted.length = 0 +}) + +function worktreeFixture(overrides: Partial = {}): Worktree { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the menu reads only the fields set here. + return { + id: 'repo::wt-1', + repoId: 'repo', + displayName: 'Fix authentication race', + branch: 'refs/heads/feature/auth-race', + path: '/path/to/wt-1', + isMainWorktree: false, + ...overrides + } as Worktree +} + +function openMenu(worktree: Worktree): void { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mounted.push({ container, root }) + act(() => { + root.render( + + +
Card
+
+
+ ) + }) + const scope = container.querySelector('[data-worktree-context-menu-scope]') + act(() => { + scope?.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) +} + +function clickMenuItem(label: string): void { + const item = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (element) => element.textContent === label + ) + expect(item, `menu item "${label}"`).toBeTruthy() + // Why: the menu swallows clicks until a primary pointerdown proves they aren't the opening right-click. + act(() => { + item?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + item?.click() + }) +} + +describe('WorktreeContextMenu Copy Worktree Name', () => { + it('copies the workspace display name', () => { + openMenu(worktreeFixture()) + + clickMenuItem('Copy Worktree Name') + + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('Fix authentication race') + }) + + it('falls back like every other name read when the custom name is blank', () => { + openMenu(worktreeFixture({ displayName: '' })) + + clickMenuItem('Copy Worktree Name') + + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('feature/auth-race') + }) + + it.each([ + ['its name', 'Refund fix', 'Refund fix'], + ['the folder name when its name is blank', '', 'platform'] + ])('copies a non-git folder workspace by %s', (_case, name, expected) => { + openMenu( + folderWorkspaceToWorktree({ + id: 'folder-1', + projectGroupId: 'group-1', + name, + folderPath: '/workspace/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + }) + ) + + clickMenuItem('Copy Worktree Name') + + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith(expected) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx index 6162f0fec1f..4e4605f70f6 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx @@ -64,6 +64,7 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont handleAssignWorkspaceStatus, handleCloseAutoFocus, handleCloseTerminals, + handleCopyName, handleCopyPath, handleCreateGroupFromRepo, handleDelete, @@ -181,6 +182,13 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont {translate('auto.components.sidebar.WorktreeContextMenu.3350101edb', 'Copy Path')} + + + {translate( + 'auto.components.sidebar.WorktreeContextMenu.copyWorktreeName', + 'Copy Worktree Name' + )} + {worktree.isPinned ? : } diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 7fb0790267b..ebef4544b23 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -8,6 +8,7 @@ import { } from './worktree-context-menu-delete-intent' import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { planWorkspaceStatusAssignment, @@ -41,6 +42,9 @@ export function useWorktreeContextMenuCommands(args: { const handleCopyPath = useCallback(() => { window.api.ui.writeClipboardText(args.worktree.path) }, [args]) + const handleCopyName = useCallback(() => { + window.api.ui.writeClipboardText(resolveWorktreeDisplayName(args.worktree)) + }, [args]) const handleToggleRead = useCallback(() => { args.updateWorktreeMeta( args.worktree.id, @@ -166,6 +170,7 @@ export function useWorktreeContextMenuCommands(args: { return { handleAssignWorkspaceStatus, handleCloseTerminals, + handleCopyName, handleCopyPath, handleCreateGroupDialogOpenChange, handleCreateGroupFromRepo, diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 3a54b29f607..772675d035f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -282,22 +282,7 @@ export function useWorktreeContextMenuModel({ [] ) - const { - handleAssignWorkspaceStatus, - handleCloseTerminals, - handleCopyPath, - handleCreateGroupDialogOpenChange, - handleCreateGroupFromRepo, - handleDelete, - handleMoveProjectToGroup, - handleOpenParent, - handleRemoveProjectFromGroup, - handleRename, - handleSleepSubtree, - handleSubmitNewProjectGroup, - handleTogglePin, - handleToggleRead - } = useWorktreeContextMenuCommands({ + const commands = useWorktreeContextMenuCommands({ activeContextWorktrees, batchDeleteWorktrees, createGroupDialogActiveRef, @@ -357,6 +342,7 @@ export function useWorktreeContextMenuModel({ ) return { + ...commands, activeContextWorktrees, allWorktrees, batchDeleteWorktrees, @@ -375,24 +361,10 @@ export function useWorktreeContextMenuModel({ eligibleParentCount, effectiveSelectedWorktrees, folderWorkspaceId, - handleAssignWorkspaceStatus, handleCloseAutoFocus, - handleCloseTerminals, - handleCopyPath, - handleCreateGroupDialogOpenChange, - handleCreateGroupFromRepo, - handleDelete, - handleMoveProjectToGroup, - handleOpenParent, handleOpenParentPicker, handleParentPickerOpenChange, handleRemoveParentLink, - handleRemoveProjectFromGroup, - handleRename, - handleSleepSubtree, - handleSubmitNewProjectGroup, - handleTogglePin, - handleToggleRead, hasAnyContextLineage, hasParentLink, isDeleting, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 79730cba488..25985d6a725 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5674,6 +5674,7 @@ "76865d827f": "Move to group", "503ec0f8e6": "New group from project", "3350101edb": "Copy Path", + "copyWorktreeName": "Copy Worktree Name", "f4475537d8": "Delete", "f5ac91531d": "Remove Project from Orca", "b42391d8bf": "Deleting…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7c0322c563e..43ff6bc0fd2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4729,6 +4729,7 @@ "76865d827f": "Mover al grupo", "503ec0f8e6": "Nuevo grupo del proyecto", "3350101edb": "Copiar ruta", + "copyWorktreeName": "Copiar nombre del worktree", "f4475537d8": "Eliminar", "f5ac91531d": "Quitar proyecto de Orca", "b42391d8bf": "Eliminando…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d685d2a2718..9f01e9e8c58 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5345,6 +5345,7 @@ "76865d827f": "Déplacer vers un groupe", "503ec0f8e6": "Nouveau groupe à partir du projet", "3350101edb": "Copier le chemin", + "copyWorktreeName": "Copier le nom du worktree", "f4475537d8": "Supprimer", "f5ac91531d": "Retirer le projet d'Orca", "b42391d8bf": "Suppression…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6a399420852..6dfc431c4c4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4710,6 +4710,7 @@ "76865d827f": "グループに移動", "503ec0f8e6": "プロジェクトからの新規グループ", "3350101edb": "パスのコピー", + "copyWorktreeName": "ワークツリー名をコピー", "f4475537d8": "削除", "f5ac91531d": "Orca からプロジェクトを削除", "b42391d8bf": "削除中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 636e1c699f2..d781667a80b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4715,6 +4715,7 @@ "76865d827f": "그룹으로 이동", "503ec0f8e6": "프로젝트의 새 그룹", "3350101edb": "경로 복사", + "copyWorktreeName": "워크트리 이름 복사", "f4475537d8": "삭제", "f5ac91531d": "Orca에서 프로젝트 제거", "b42391d8bf": "삭제 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 975f2c34b25..a1bfed704aa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4758,6 +4758,7 @@ "76865d827f": "移至群组", "503ec0f8e6": "来自项目的新组", "3350101edb": "复制路径", + "copyWorktreeName": "复制工作树名称", "f4475537d8": "删除", "f5ac91531d": "从 Orca 中删除项目", "b42391d8bf": "正在删除...",