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 <me@seonghobae.me>
This commit is contained in:
Jinjing
2026-09-23 11:25:49 -07:00
committed by GitHub
co-authored by Seongho Bae
parent 641a7f36d9
commit a77f87ea16
10 changed files with 187 additions and 30 deletions
@@ -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> = {}): 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(
<TooltipProvider>
<WorktreeContextMenu worktree={worktree}>
<div>Card</div>
</WorktreeContextMenu>
</TooltipProvider>
)
})
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<HTMLElement>('[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)
})
})
@@ -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
<Copy className="size-3.5" />
{translate('auto.components.sidebar.WorktreeContextMenu.3350101edb', 'Copy Path')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCopyName} disabled={isDeleting}>
<Copy className="size-3.5" />
{translate(
'auto.components.sidebar.WorktreeContextMenu.copyWorktreeName',
'Copy Worktree Name'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleTogglePin} disabled={isDeleting}>
{worktree.isPinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
@@ -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,
@@ -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,
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -4710,6 +4710,7 @@
"76865d827f": "グループに移動",
"503ec0f8e6": "プロジェクトからの新規グループ",
"3350101edb": "パスのコピー",
"copyWorktreeName": "ワークツリー名をコピー",
"f4475537d8": "削除",
"f5ac91531d": "Orca からプロジェクトを削除",
"b42391d8bf": "削除中…",
+1
View File
@@ -4715,6 +4715,7 @@
"76865d827f": "그룹으로 이동",
"503ec0f8e6": "프로젝트의 새 그룹",
"3350101edb": "경로 복사",
"copyWorktreeName": "워크트리 이름 복사",
"f4475537d8": "삭제",
"f5ac91531d": "Orca에서 프로젝트 제거",
"b42391d8bf": "삭제 중…",
+1
View File
@@ -4758,6 +4758,7 @@
"76865d827f": "移至群组",
"503ec0f8e6": "来自项目的新组",
"3350101edb": "复制路径",
"copyWorktreeName": "复制工作树名称",
"f4475537d8": "删除",
"f5ac91531d": "从 Orca 中删除项目",
"b42391d8bf": "正在删除...",