425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import type { ReactNode } from 'react'
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
GitConflictOperation,
|
|
GlobalSettings,
|
|
Repo,
|
|
Worktree,
|
|
WorktreeCardProperty
|
|
} from '../../../../shared/types'
|
|
import type WorktreeCardComponent from './WorktreeCard'
|
|
import type * as WorkspaceDeleteQuickAction from './workspace-delete-quick-action'
|
|
|
|
const fetchHostedReviewForBranch = vi.fn()
|
|
const fetchIssue = vi.fn()
|
|
const openModal = vi.fn()
|
|
const updateWorktreeMeta = vi.fn()
|
|
|
|
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'unread']
|
|
let tabsByWorktree: Record<string, { id: string }[]> = {}
|
|
let ptyIdsByTabId: Record<string, string[]> = {}
|
|
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
|
|
let settings: Partial<GlobalSettings> | null = null
|
|
let workspaceDeleteModifierPressed = false
|
|
let gitConflictOperationByWorktree: Record<string, GitConflictOperation> = {}
|
|
let WorktreeCard: typeof WorktreeCardComponent
|
|
|
|
vi.mock('@/store', () => ({
|
|
useAppStore: (selector: (state: unknown) => unknown) =>
|
|
selector({
|
|
deleteStateByWorktreeId: {},
|
|
fetchHostedReviewForBranch,
|
|
fetchIssue,
|
|
gitConflictOperationByWorktree,
|
|
hostedReviewCache: {},
|
|
issueCache: {},
|
|
openModal,
|
|
remoteBranchConflictByWorktreeId: {},
|
|
settings,
|
|
sshConnectionStates: new Map(),
|
|
sshTargetLabels: new Map(),
|
|
browserTabsByWorktree,
|
|
ptyIdsByTabId,
|
|
tabsByWorktree,
|
|
updateWorktreeMeta,
|
|
worktreeCardProperties
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/lib/worktree-activation', () => ({
|
|
activateAndRevealWorktree: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/components/ui/tooltip', () => ({
|
|
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
|
}))
|
|
|
|
vi.mock('./use-worktree-activity-status', () => ({
|
|
useWorktreeActivityStatus: () => 'idle'
|
|
}))
|
|
|
|
vi.mock('./CacheTimer', () => ({
|
|
default: () => null,
|
|
usePromptCacheCountdownStartedAt: () => null
|
|
}))
|
|
|
|
vi.mock('./WorktreeCardAgents', () => ({
|
|
default: () => null
|
|
}))
|
|
|
|
vi.mock('./SshDisconnectedDialog', () => ({
|
|
SshDisconnectedDialog: () => null
|
|
}))
|
|
|
|
vi.mock('./WorktreeContextMenu', () => ({
|
|
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
|
|
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
|
|
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
|
|
}))
|
|
|
|
vi.mock('./workspace-delete-quick-action', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof WorkspaceDeleteQuickAction>()
|
|
return {
|
|
...actual,
|
|
useWorkspaceDeleteModifierPressed: () => workspaceDeleteModifierPressed
|
|
}
|
|
})
|
|
|
|
function makeRepo(): Repo {
|
|
return {
|
|
id: 'repo-1',
|
|
path: '/repo',
|
|
displayName: 'orca',
|
|
badgeColor: '#999999',
|
|
addedAt: 1
|
|
}
|
|
}
|
|
|
|
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|
return {
|
|
id: 'repo-1::/repo/worktrees/quick-action',
|
|
repoId: 'repo-1',
|
|
path: '/repo/worktrees/quick-action',
|
|
displayName: 'Quick action',
|
|
branch: 'quick-action',
|
|
head: 'abc123',
|
|
isBare: false,
|
|
isMainWorktree: false,
|
|
comment: '',
|
|
linkedIssue: null,
|
|
linkedPR: null,
|
|
linkedLinearIssue: null,
|
|
isArchived: false,
|
|
isUnread: true,
|
|
isPinned: false,
|
|
sortOrder: 0,
|
|
lastActivityAt: 1,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('WorktreeCard quick actions', () => {
|
|
beforeAll(async () => {
|
|
WorktreeCard = (await import('./WorktreeCard')).default
|
|
}, 20_000)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
worktreeCardProperties = ['status', 'unread']
|
|
tabsByWorktree = {}
|
|
ptyIdsByTabId = {}
|
|
browserTabsByWorktree = {}
|
|
settings = null
|
|
workspaceDeleteModifierPressed = false
|
|
gitConflictOperationByWorktree = {}
|
|
})
|
|
|
|
it('marks the unread toggle as a workspace-board-preserving action', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Mark as read"')
|
|
expect(markup).toContain('data-workspace-board-preserve-open=""')
|
|
})
|
|
|
|
it('renders repo identity in the detailed metadata row', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Project orca"')
|
|
expect(markup).toContain('>orca</span>')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
|
|
it('renders folder kind in the detailed metadata row', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
|
|
repo={{ ...makeRepo(), kind: 'folder' }}
|
|
isActive={false}
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('Docs folder')
|
|
expect(markup).toContain('>Folder</span>')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
|
|
it('renders the pending first-agent rename title button', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ pendingFirstAgentMessageRename: true })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Will be renamed from first agent message"')
|
|
expect(markup).toContain('rename pending')
|
|
expect(markup).toContain('Will be renamed from first agent message')
|
|
})
|
|
|
|
it('renders the repeated branch metadata row in detailed cards', () => {
|
|
worktreeCardProperties = []
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ displayName: 'quick-action', branch: 'quick-action' })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('quick-action')
|
|
expect(markup).toContain('text-[11px] text-muted-foreground truncate leading-none')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
expect(markup).toContain('tabindex="0"')
|
|
})
|
|
|
|
it('renders detached HEAD identity in detailed card metadata', () => {
|
|
worktreeCardProperties = []
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ displayName: 'orca', branch: '' })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('orca')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
expect(markup).toContain('Detached HEAD @ abc123')
|
|
expect(markup).toContain('Detached HEAD at abc123. You are viewing a commit, not a branch.')
|
|
expect(markup).toContain('tabindex="0"')
|
|
})
|
|
|
|
it('omits the repeated branch metadata row when compact cards are enabled', () => {
|
|
worktreeCardProperties = []
|
|
settings = { experimentalCompactWorktreeCards: true }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ displayName: 'quick-action', branch: 'quick-action' })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).not.toContain('data-worktree-card-meta-row=""')
|
|
expect(markup).toContain('tabindex="0"')
|
|
})
|
|
|
|
it('omits the branch metadata row when the workspace has a custom title', () => {
|
|
worktreeCardProperties = []
|
|
settings = { experimentalCompactWorktreeCards: true }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ displayName: 'Custom workspace', branch: 'quick-action' })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('Custom workspace')
|
|
expect(markup).not.toContain('>quick-action<')
|
|
expect(markup).not.toContain('data-worktree-card-meta-row=""')
|
|
expect(markup).not.toContain('text-[11px] text-muted-foreground truncate leading-none')
|
|
})
|
|
|
|
it('uses the pre-compact unread lane and primary badge when compact cards are disabled', () => {
|
|
worktreeCardProperties = ['status', 'unread']
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({
|
|
displayName: 'main',
|
|
branch: 'main',
|
|
isMainWorktree: true
|
|
})}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('primary')
|
|
expect(markup).not.toContain('aria-label="Primary worktree"')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
|
|
it('moves unread and primary into the title row when compact cards are enabled', () => {
|
|
worktreeCardProperties = ['status', 'unread']
|
|
settings = { experimentalCompactWorktreeCards: true }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({
|
|
displayName: 'main',
|
|
branch: 'main',
|
|
isMainWorktree: true
|
|
})}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
hideRepoBadge
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Primary worktree"')
|
|
expect(markup).not.toContain('>primary<')
|
|
expect(markup).not.toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
|
|
it('hides delete by default for an inactive workspace', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('shows delete as the top-right quick action while Option/Alt is held', () => {
|
|
workspaceDeleteModifierPressed = true
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('shows delete as the quick action for folder workspace instances while Option/Alt is held', () => {
|
|
workspaceDeleteModifierPressed = true
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({
|
|
id: 'repo-1::/repo::workspace:123e4567-e89b-12d3-a456-426614174000',
|
|
path: '/repo',
|
|
isMainWorktree: false
|
|
})}
|
|
repo={{ ...makeRepo(), kind: 'folder' }}
|
|
isActive={false}
|
|
/>
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('shows delete for a current workspace while Option/Alt is held', () => {
|
|
workspaceDeleteModifierPressed = true
|
|
const worktree = makeWorktree()
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive isCurrentWorktree />
|
|
)
|
|
|
|
expect(markup).toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('does not show delete for the main worktree while Option/Alt is held', () => {
|
|
workspaceDeleteModifierPressed = true
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard
|
|
worktree={makeWorktree({ isMainWorktree: true })}
|
|
repo={makeRepo()}
|
|
isActive={false}
|
|
/>
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('does not replace sleep with delete for a workspace with live activity', () => {
|
|
const worktree = makeWorktree()
|
|
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
|
|
ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Sleep workspace"')
|
|
expect(markup).not.toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('does not show sleep as the top-right quick action for an active workspace', () => {
|
|
const worktree = makeWorktree()
|
|
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
|
|
ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive />
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Sleep workspace"')
|
|
expect(markup).not.toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('does not show delete when the workspace is current but not selected in the sidebar', () => {
|
|
const worktree = makeWorktree()
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} isCurrentWorktree />
|
|
)
|
|
|
|
expect(markup).not.toContain('aria-label="Delete workspace"')
|
|
})
|
|
|
|
it('does not show the rebase operation chip on the card', () => {
|
|
const worktree = makeWorktree()
|
|
gitConflictOperationByWorktree = { [worktree.id]: 'rebase' }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).not.toContain('Rebasing')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
|
|
it('keeps non-rebase operation chips on the card', () => {
|
|
const worktree = makeWorktree()
|
|
gitConflictOperationByWorktree = { [worktree.id]: 'merge' }
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
|
|
)
|
|
|
|
expect(markup).toContain('Merging')
|
|
expect(markup).toContain('data-worktree-card-meta-row=""')
|
|
})
|
|
})
|