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": "正在删除...",