From 27889edc0199f8cbb645bf07bc7d5edb529b88ca Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:21:21 -0700 Subject: [PATCH] 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. --- .../src/components/sidebar/WorktreeCard.tsx | 37 ++-- .../WorktreeCardMeta.interaction.test.tsx | 181 ++++++++++++++++ .../sidebar/WorktreeCardMeta.test.tsx | 51 ++++- .../components/sidebar/WorktreeCardMeta.tsx | 193 ++++++++---------- ...worktree-card-details-hover-state.test.tsx | 108 ++++++++++ .../worktree-card-details-hover-state.ts | 45 ++++ .../sidebar/worktree-card-meta-types.ts | 47 +++++ .../sidebar/worktree-review-helpers.tsx | 57 ++++++ 8 files changed, 585 insertions(+), 134 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-meta-types.ts create mode 100644 src/renderer/src/components/sidebar/worktree-review-helpers.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index de284fa3e..2d92a3e0b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 ? : 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} @@ -771,6 +773,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ review={metaReview} comment={metaComment} detailsAfter={hasPorts ? : 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} >
{hasPorts && } diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx new file mode 100644 index 000000000..07c73ec56 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -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
{children}
+ }, + HoverCardContent: ({ children }: { children: ReactNode }) =>
{children}
, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children, open }: { children: ReactNode; open?: boolean }) => ( +
{children}
+ ), + 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
{children}
+ }, + DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => ( + <>{children} + ), + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ) +})) + +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 { + container = document.createElement('div') + root = createRoot(container) + act(() => { + root.render( + + Linked PR + + ) + }) + 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') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 49f66e86b..02851b42c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -15,6 +15,17 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => ( + <>{children} + ), + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: ReactNode; onSelect?: () => void }) => ( +
{children}
+ ) +})) + 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( { comment={null} onEditIssue={vi.fn()} onEditComment={vi.fn()} + onOpenReviewInOrca={vi.fn()} onUnlinkReview={vi.fn()} > Linked PR ) - 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( + + Linked MR + + ) + + 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', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 37b8e5fba..6ceb28f24 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -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 - -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 ( - - ) -} - 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 ( - + {children} + {onUnlinkReview && ( + + + + + + + + + More {reviewLabel} actions + + + + { + closeHover() + onUnlinkReview?.() + }} + > + + Unlink {reviewLabel} + + + + )} {review.url && onOpenReviewInOrca && ( )} - {onUnlinkReview && ( - - - - )} } /> diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx new file mode 100644 index 000000000..d985c2d78 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx @@ -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 + +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( + { + 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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts new file mode 100644 index 000000000..b90b20d55 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts new file mode 100644 index 000000000..2e5975f21 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -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 + +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 +} diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx new file mode 100644 index 000000000..7251281f4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx @@ -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 ( + + ) +}