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:
parent
7d353523d1
commit
27889edc01
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue