Move worktree PR/MR unlink action behind a dropdown menu (#4998)

Keep the worktree details hover card visible while the review actions
dropdown is open. This change prevents the hover card from unmounting
when interacting with the portaled dropdown items, and adds support
for unlinking GitLab MRs with appropriate terminology.
This commit is contained in:
Jinjing 2026-06-08 23:21:21 -07:00 committed by GitHub
parent 7d353523d1
commit 27889edc01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 585 additions and 134 deletions

View File

@ -45,6 +45,7 @@ import {
import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts'
import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
@ -648,13 +649,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
},
[metaReview, openTaskPage, repo]
)
const handleUnlinkReview = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
void updateWorktreeMeta(worktree.id, { linkedPR: null })
},
[updateWorktreeMeta, worktree.id]
)
const detailsHoverControl = useWorktreeCardDetailsHoverControl()
const hasExplicitLinkedReview =
(metaReview?.provider === 'github' && worktree.linkedPR !== null) ||
(metaReview?.provider === 'gitlab' && linkedGitLabMR !== null)
const handleUnlinkReview = useCallback(() => {
if (metaReview?.provider === 'gitlab') {
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null })
return
}
void updateWorktreeMeta(worktree.id, { linkedPR: null })
}, [metaReview?.provider, updateWorktreeMeta, worktree.id])
const handleOpenLinearIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@ -737,6 +742,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
workspaceTitle={worktree.displayName}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
openDelay={100}
hoverControl={detailsHoverControl}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
@ -752,11 +758,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
// Why: compact mode hides the metadata badge row, so title hover
// carries the same explicit-link affordance without adding chrome.
onUnlinkReview={
metaReview?.provider === 'github' && worktree.linkedPR !== null
? handleUnlinkReview
: undefined
}
onUnlinkReview={hasExplicitLinkedReview ? handleUnlinkReview : undefined}
>
{title}
</WorktreeCardDetailsHover>
@ -771,6 +773,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
review={metaReview}
comment={metaComment}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
hoverControl={detailsHoverControl}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
@ -780,13 +783,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
// Why: branch lookup can show a PR without persisted metadata. Only
// expose unlink when this workspace has an explicit GitHub linkedPR.
onUnlinkReview={
metaReview?.provider === 'github' && worktree.linkedPR !== null
? handleUnlinkReview
: undefined
}
// Why: branch lookup can show a review without persisted metadata. Only
// expose unlink when this workspace has an explicit linked PR/MR.
onUnlinkReview={hasExplicitLinkedReview ? handleUnlinkReview : undefined}
>
<div className="flex shrink-0 items-center gap-1">
{hasPorts && <WorktreeCardPortsTrigger ports={workspacePorts} />}

View File

@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorktreeCardDetailsHover } from './WorktreeCardMeta'
const interactionMocks = vi.hoisted(() => ({
hoverOpen: false,
onHoverOpenChange: undefined as ((open: boolean) => void) | undefined,
reviewMenuOpen: false,
onReviewMenuOpenChange: undefined as ((open: boolean) => void) | undefined,
onUnlinkSelect: undefined as (() => void) | undefined
}))
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({
children,
open,
onOpenChange
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
interactionMocks.hoverOpen = open ?? false
interactionMocks.onHoverOpenChange = onOpenChange
return <div data-hover-open={open ? 'true' : 'false'}>{children}</div>
},
HoverCardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children, open }: { children: ReactNode; open?: boolean }) => (
<div data-tooltip-open={open === false ? 'false' : 'default'}>{children}</div>
),
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({
children,
open,
onOpenChange
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
interactionMocks.reviewMenuOpen = open ?? false
interactionMocks.onReviewMenuOpenChange = onOpenChange
return <div data-review-menu-open={open ? 'true' : 'false'}>{children}</div>
},
DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={() => onSelect?.()}>
{children}
</button>
)
}))
const reviewFixture = {
provider: 'github' as const,
number: 456,
title: 'Fix stale GH PR',
state: 'open' as const,
url: 'https://github.com/acme/orca/pull/456',
status: 'success' as const,
updatedAt: '2026-05-17T00:00:00.000Z',
mergeable: 'MERGEABLE' as const
}
describe('WorktreeCardDetailsHover interactions', () => {
let container: HTMLDivElement
let root: Root
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
interactionMocks.hoverOpen = false
interactionMocks.reviewMenuOpen = false
interactionMocks.onHoverOpenChange = undefined
interactionMocks.onReviewMenuOpenChange = undefined
interactionMocks.onUnlinkSelect = undefined
})
function renderHover(onUnlinkReview = vi.fn()): ReturnType<typeof vi.fn> {
container = document.createElement('div')
root = createRoot(container)
act(() => {
root.render(
<WorktreeCardDetailsHover
issue={null}
linearIssue={null}
review={reviewFixture}
comment={null}
onEditIssue={vi.fn()}
onEditComment={vi.fn()}
onOpenReviewInOrca={vi.fn()}
onUnlinkReview={onUnlinkReview}
>
<span>Linked PR</span>
</WorktreeCardDetailsHover>
)
})
return onUnlinkReview
}
it('defers hover close while the review menu is open', () => {
renderHover()
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
interactionMocks.onHoverOpenChange?.(false)
})
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'true'
)
})
it('closes the hover after the review menu dismisses a deferred close', () => {
renderHover()
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
interactionMocks.onHoverOpenChange?.(false)
interactionMocks.onReviewMenuOpenChange?.(false)
})
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
})
it('suppresses the tooltip while the review menu is open', () => {
renderHover()
act(() => {
interactionMocks.onReviewMenuOpenChange?.(true)
})
expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe(
'false'
)
})
it('invokes unlink and closes the hover from the menu item', () => {
const onUnlinkReview = renderHover()
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
})
const unlinkButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Unlink PR')
)
act(() => {
unlinkButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onUnlinkReview).toHaveBeenCalledTimes(1)
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
expect(
container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open')
).toBe('false')
})
})

View File

@ -15,6 +15,17 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode; onSelect?: () => void }) => (
<div>{children}</div>
)
}))
describe('WorktreeCardDetailsHover', () => {
it('includes branch identity before metadata details', () => {
const markup = renderToStaticMarkup(
@ -46,7 +57,7 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup).toContain('Fix stale GH PR')
})
it('shows an unlink action for linked PR details when provided', () => {
it('puts unlink behind the first PR actions menu and keeps GitHub last', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
issue={null}
@ -64,14 +75,50 @@ describe('WorktreeCardDetailsHover', () => {
comment={null}
onEditIssue={vi.fn()}
onEditComment={vi.fn()}
onOpenReviewInOrca={vi.fn()}
onUnlinkReview={vi.fn()}
>
<span>Linked PR</span>
</WorktreeCardDetailsHover>
)
expect(markup).toContain('aria-label="Unlink PR"')
const moreActionsIndex = markup.indexOf('aria-label="More PR actions"')
const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"')
const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"')
expect(moreActionsIndex).toBeGreaterThan(-1)
expect(markup).toContain('More PR actions')
expect(markup).toContain('Unlink PR')
expect(moreActionsIndex).toBeLessThan(openInOrcaIndex)
expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
expect(markup).not.toContain('aria-label="Unlink PR"')
})
it('labels GitLab unlink actions with MR terminology', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
issue={null}
linearIssue={null}
review={{
provider: 'gitlab',
number: 77,
title: 'Fix GitLab MR display',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/77',
status: 'success'
}}
comment={null}
onEditIssue={vi.fn()}
onEditComment={vi.fn()}
onUnlinkReview={vi.fn()}
>
<span>Linked MR</span>
</WorktreeCardDetailsHover>
)
expect(markup).toContain('aria-label="More MR actions"')
expect(markup).toContain('Unlink MR')
expect(markup).toContain('View on GitLab')
})
it('displays Linear issue details with link', () => {

View File

@ -3,18 +3,25 @@ import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import {
CircleDot,
Ellipsis,
ExternalLink,
GitMerge,
MonitorUp,
Pencil,
StickyNote,
Unlink
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
import CommentMarkdown from './CommentMarkdown'
import { PullRequestIcon } from './WorktreeCardHelpers'
import { WORKTREE_NATIVE_CONTEXT_MENU_ATTR } from './WorktreeContextMenu'
import {
WorktreeCardDetailSection,
@ -27,50 +34,22 @@ import {
ReviewChecksBadge,
ReviewStateBadge
} from './WorktreeCardMetadataStatusBadges'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { IssueInfo } from '../../../../shared/types'
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
import { getReviewLabel, getProviderName, ReviewIcon } from './worktree-review-helpers'
import type {
WorktreeCardIssueDisplay,
WorktreeCardLinearIssueDisplay,
WorktreeCardMetaBadgesProps,
WorktreeCardMetaBadgesRootProps,
WorktreeCardDetailsHoverProps
} from './worktree-card-meta-types'
export type WorktreeCardIssueDisplay =
| IssueInfo
| {
number: number
title: string
state?: IssueInfo['state']
url?: string
labels?: string[]
}
export type WorktreeCardLinearIssueDisplay = {
identifier: string
title: string
url?: string
stateName?: string
labels?: string[]
}
type WorktreeCardMetaBadgesProps = {
issue: WorktreeCardIssueDisplay | null
linearIssue: WorktreeCardLinearIssueDisplay | null
review: WorktreeCardPrDisplay | null
comment: string | null
}
type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps &
React.HTMLAttributes<HTMLDivElement>
type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
children: React.ReactElement
branchName?: string
workspaceTitle?: string
detailsAfter?: React.ReactNode
openDelay?: number
closeDelay?: number
onEditIssue: (event: React.MouseEvent) => void
onEditComment: (event: React.MouseEvent) => void
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
onOpenReviewInOrca?: (event: React.MouseEvent) => void
onUnlinkReview?: (event: React.MouseEvent) => void
export type {
WorktreeCardIssueDisplay,
WorktreeCardLinearIssueDisplay,
WorktreeCardMetaBadgesProps,
WorktreeCardMetaBadgesRootProps,
WorktreeCardDetailsHoverProps
}
function hasComment(comment: string | null): boolean {
@ -86,61 +65,6 @@ export function hasWorktreeCardDetails({
return Boolean(issue || linearIssue || review || hasComment(comment))
}
function getReviewLabel(review: WorktreeCardPrDisplay): 'MR' | 'PR' {
return review.provider === 'gitlab' ? 'MR' : 'PR'
}
function getProviderName(review: WorktreeCardPrDisplay): string {
if (review.provider === 'gitlab') {
return 'GitLab'
}
if (review.provider === 'bitbucket') {
return 'Bitbucket'
}
if (review.provider === 'azure-devops') {
return 'Azure DevOps'
}
if (review.provider === 'gitea') {
return 'Gitea'
}
return 'GitHub'
}
function ReviewIcon({
review,
className
}: {
review: WorktreeCardPrDisplay
className?: string
}): React.JSX.Element {
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
// Why: the standalone CI glyph was removed from the card header, so linked
// PR metadata carries check health unless the review is already merged.
const checkTone =
review.state !== 'merged' && review.status === 'failure'
? 'text-rose-500/85'
: review.state !== 'merged' && review.status === 'pending'
? 'text-amber-500/85'
: review.state === 'open' && review.status === 'success'
? 'text-emerald-500/80'
: null
return (
<Icon
className={cn(
className,
checkTone,
review.state === 'merged' && 'text-purple-600/70 dark:text-purple-400/70',
!checkTone && review.state === 'open' && 'text-emerald-500/80',
!checkTone && review.state === 'closed' && 'text-muted-foreground/60',
!checkTone && review.state === 'draft' && 'text-muted-foreground/50',
!checkTone &&
(!review.state || !['merged', 'open', 'closed', 'draft'].includes(review.state)) &&
'text-muted-foreground opacity-70'
)}
/>
)
}
export const WorktreeCardMetaBadges = React.forwardRef<
HTMLDivElement,
WorktreeCardMetaBadgesRootProps
@ -201,15 +125,23 @@ export function WorktreeCardDetailsHover({
onOpenGitHubIssueInOrca,
onOpenLinearIssueInOrca,
onOpenReviewInOrca,
onUnlinkReview
onUnlinkReview,
hoverControl
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const internalHoverControl = useWorktreeCardDetailsHoverControl()
const {
hoverOpen,
reviewMenuOpen,
handleHoverOpenChange,
handleReviewMenuOpenChange,
closeHover
} = hoverControl ?? internalHoverControl
const dismissAndRun = React.useCallback(
(handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
setOpen(false)
closeHover()
handler?.(event)
},
[]
[closeHover]
)
const showIdentityHeader = Boolean(branchName || workspaceTitle)
@ -227,7 +159,12 @@ export function WorktreeCardDetailsHover({
const issueLabels = issue?.labels ?? []
return (
<HoverCard open={open} onOpenChange={setOpen} openDelay={openDelay} closeDelay={closeDelay}>
<HoverCard
open={hoverOpen}
onOpenChange={handleHoverOpenChange}
openDelay={openDelay}
closeDelay={closeDelay}
>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent
side="right"
@ -351,6 +288,44 @@ export function WorktreeCardDetailsHover({
label={`${reviewLabel} #${review.number}`}
actions={
<>
{onUnlinkReview && (
<DropdownMenu
modal={false}
open={reviewMenuOpen}
onOpenChange={handleReviewMenuOpenChange}
>
<Tooltip open={reviewMenuOpen ? false : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
aria-label={`More ${reviewLabel} actions`}
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className="size-3" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
More {reviewLabel} actions
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => {
closeHover()
onUnlinkReview?.()
}}
>
<Unlink className="size-3.5" />
Unlink {reviewLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{review.url && onOpenReviewInOrca && (
<MetadataActionIcon
label="Open in Orca"
@ -364,14 +339,6 @@ export function WorktreeCardDetailsHover({
<ExternalLink className="size-3" />
</MetadataActionIcon>
)}
{onUnlinkReview && (
<MetadataActionIcon
label={`Unlink ${reviewLabel}`}
onClick={dismissAndRun(onUnlinkReview)}
>
<Unlink className="size-3" />
</MetadataActionIcon>
)}
</>
}
/>

View File

@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
type HoverControlSnapshot = ReturnType<typeof useWorktreeCardDetailsHoverControl>
function HoverControlProbe({
onChange
}: {
onChange: (control: HoverControlSnapshot) => void
}): null {
const control = useWorktreeCardDetailsHoverControl()
onChange(control)
return null
}
describe('useWorktreeCardDetailsHoverControl', () => {
let container: HTMLDivElement
let root: Root
let control: HoverControlSnapshot | null = null
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
control = null
})
function mountProbe(): void {
container = document.createElement('div')
root = createRoot(container)
act(() => {
root.render(
<HoverControlProbe
onChange={(next) => {
control = next
}}
/>
)
})
}
it('keeps the hover open while the review menu is open', () => {
mountProbe()
expect(control).not.toBeNull()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleReviewMenuOpenChange(true)
})
expect(control?.hoverOpen).toBe(true)
act(() => {
control?.handleHoverOpenChange(false)
})
expect(control?.hoverOpen).toBe(true)
})
it('closes the hover after the review menu dismisses a deferred close', () => {
mountProbe()
expect(control).not.toBeNull()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleReviewMenuOpenChange(true)
control?.handleHoverOpenChange(false)
})
expect(control?.hoverOpen).toBe(true)
act(() => {
control?.handleReviewMenuOpenChange(false)
})
expect(control?.hoverOpen).toBe(false)
})
it('clears a deferred close when the pointer returns before the menu closes', () => {
mountProbe()
expect(control).not.toBeNull()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleReviewMenuOpenChange(true)
control?.handleHoverOpenChange(false)
control?.handleHoverOpenChange(true)
control?.handleReviewMenuOpenChange(false)
})
expect(control?.hoverOpen).toBe(true)
})
it('closes both layers from closeHover', () => {
mountProbe()
expect(control).not.toBeNull()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleReviewMenuOpenChange(true)
control?.closeHover()
})
expect(control?.hoverOpen).toBe(false)
expect(control?.reviewMenuOpen).toBe(false)
})
})

View File

@ -0,0 +1,45 @@
import { useCallback, useRef, useState } from 'react'
export function useWorktreeCardDetailsHoverControl() {
const [open, setOpen] = useState(false)
const [reviewMenuOpen, setReviewMenuOpen] = useState(false)
const pendingHoverCloseRef = useRef(false)
const closeHover = useCallback(() => {
pendingHoverCloseRef.current = false
setReviewMenuOpen(false)
setOpen(false)
}, [])
const handleHoverOpenChange = useCallback(
(next: boolean) => {
// Why: the portaled PR menu sits outside HoverCardContent — keep the card
// mounted until the menu closes so the unlink item stays clickable.
if (reviewMenuOpen) {
pendingHoverCloseRef.current = !next
return
}
pendingHoverCloseRef.current = false
setOpen(next)
},
[reviewMenuOpen]
)
const handleReviewMenuOpenChange = useCallback((next: boolean) => {
setReviewMenuOpen(next)
if (!next && pendingHoverCloseRef.current) {
pendingHoverCloseRef.current = false
setOpen(false)
}
}, [])
return {
hoverOpen: open || reviewMenuOpen,
reviewMenuOpen,
handleHoverOpenChange,
handleReviewMenuOpenChange,
closeHover
}
}
export type WorktreeCardDetailsHoverControl = ReturnType<typeof useWorktreeCardDetailsHoverControl>

View File

@ -0,0 +1,47 @@
import type { IssueInfo } from '../../../../shared/types'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
export type WorktreeCardIssueDisplay =
| IssueInfo
| {
number: number
title: string
state?: IssueInfo['state']
url?: string
labels?: string[]
}
export type WorktreeCardLinearIssueDisplay = {
identifier: string
title: string
url?: string
stateName?: string
labels?: string[]
}
export type WorktreeCardMetaBadgesProps = {
issue: WorktreeCardIssueDisplay | null
linearIssue: WorktreeCardLinearIssueDisplay | null
review: WorktreeCardPrDisplay | null
comment: string | null
}
export type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps &
React.HTMLAttributes<HTMLDivElement>
export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
children: React.ReactElement
branchName?: string
workspaceTitle?: string
detailsAfter?: React.ReactNode
openDelay?: number
closeDelay?: number
onEditIssue: (event: React.MouseEvent) => void
onEditComment: (event: React.MouseEvent) => void
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
onOpenReviewInOrca?: (event: React.MouseEvent) => void
onUnlinkReview?: () => void
hoverControl?: WorktreeCardDetailsHoverControl
}

View File

@ -0,0 +1,57 @@
import { GitMerge } from 'lucide-react'
import { cn } from '@/lib/utils'
import { PullRequestIcon } from './WorktreeCardHelpers'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
export function getReviewLabel(review: WorktreeCardPrDisplay): 'MR' | 'PR' {
return review.provider === 'gitlab' ? 'MR' : 'PR'
}
export function getProviderName(review: WorktreeCardPrDisplay): string {
if (review.provider === 'gitlab') {
return 'GitLab'
}
if (review.provider === 'bitbucket') {
return 'Bitbucket'
}
if (review.provider === 'azure-devops') {
return 'Azure DevOps'
}
if (review.provider === 'gitea') {
return 'Gitea'
}
return 'GitHub'
}
export function ReviewIcon({
review,
className
}: {
review: WorktreeCardPrDisplay
className?: string
}): React.JSX.Element {
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
const checkTone =
review.state !== 'merged' && review.status === 'failure'
? 'text-rose-500/85'
: review.state !== 'merged' && review.status === 'pending'
? 'text-amber-500/85'
: review.state === 'open' && review.status === 'success'
? 'text-emerald-500/80'
: null
return (
<Icon
className={cn(
className,
checkTone,
review.state === 'merged' && 'text-purple-600/70 dark:text-purple-400/70',
!checkTone && review.state === 'open' && 'text-emerald-500/80',
!checkTone && review.state === 'closed' && 'text-muted-foreground/60',
!checkTone && review.state === 'draft' && 'text-muted-foreground/50',
!checkTone &&
(!review.state || !['merged', 'open', 'closed', 'draft'].includes(review.state)) &&
'text-muted-foreground opacity-70'
)}
/>
)
}