mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -4710,6 +4710,7 @@
|
||||
"76865d827f": "グループに移動",
|
||||
"503ec0f8e6": "プロジェクトからの新規グループ",
|
||||
"3350101edb": "パスのコピー",
|
||||
"copyWorktreeName": "ワークツリー名をコピー",
|
||||
"f4475537d8": "削除",
|
||||
"f5ac91531d": "Orca からプロジェクトを削除",
|
||||
"b42391d8bf": "削除中…",
|
||||
|
||||
@@ -4715,6 +4715,7 @@
|
||||
"76865d827f": "그룹으로 이동",
|
||||
"503ec0f8e6": "프로젝트의 새 그룹",
|
||||
"3350101edb": "경로 복사",
|
||||
"copyWorktreeName": "워크트리 이름 복사",
|
||||
"f4475537d8": "삭제",
|
||||
"f5ac91531d": "Orca에서 프로젝트 제거",
|
||||
"b42391d8bf": "삭제 중…",
|
||||
|
||||
@@ -4758,6 +4758,7 @@
|
||||
"76865d827f": "移至群组",
|
||||
"503ec0f8e6": "来自项目的新组",
|
||||
"3350101edb": "复制路径",
|
||||
"copyWorktreeName": "复制工作树名称",
|
||||
"f4475537d8": "删除",
|
||||
"f5ac91531d": "从 Orca 中删除项目",
|
||||
"b42391d8bf": "正在删除...",
|
||||
|
||||
Reference in New Issue
Block a user