Add copy link action for linked work items (#6140)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
17dee0861f
commit
edf98f6578
|
|
@ -0,0 +1,147 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
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 { CircleDot, Copy, Ellipsis, ExternalLink, MonitorUp, Pencil } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
WorktreeCardDetailSection,
|
||||
WorktreeCardDetailSectionContent
|
||||
} from './WorktreeCardDetailSection'
|
||||
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
|
||||
import { IssueStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import type { WorktreeCardIssueDisplay } from './worktree-card-meta-types'
|
||||
|
||||
type WorktreeCardIssueDetailSectionProps = {
|
||||
issue: WorktreeCardIssueDisplay | null
|
||||
issueMenuOpen: boolean
|
||||
onIssueMenuOpenChange: (open: boolean) => void
|
||||
onCopyIssueLink?: () => void
|
||||
onEditIssue?: (event: React.MouseEvent) => void
|
||||
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function WorktreeCardIssueDetailSection({
|
||||
issue,
|
||||
issueMenuOpen,
|
||||
onIssueMenuOpenChange,
|
||||
onCopyIssueLink,
|
||||
onEditIssue,
|
||||
onOpenGitHubIssueInOrca
|
||||
}: WorktreeCardIssueDetailSectionProps): React.JSX.Element | null {
|
||||
if (!issue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const issueLabels = issue.labels ?? []
|
||||
const moreActionsLabel = translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.moreIssueActions',
|
||||
'More issue actions'
|
||||
)
|
||||
const moreActionsTrigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={moreActionsLabel}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<CircleDot className="size-3 text-muted-foreground" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.e97d8f2876',
|
||||
'Issue #{{value0}}',
|
||||
{
|
||||
value0: issue.number
|
||||
}
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
{issue.url && onCopyIssueLink && (
|
||||
<DropdownMenu modal={false} open={issueMenuOpen} onOpenChange={onIssueMenuOpenChange}>
|
||||
{issueMenuOpen ? (
|
||||
moreActionsTrigger
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{moreActionsTrigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{moreActionsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onSelect={onCopyIssueLink}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeCardMeta.copyLink', 'Copy link')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{onEditIssue && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.807b13b9ec',
|
||||
'Edit issue'
|
||||
)}
|
||||
onClick={onEditIssue}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{issue.url && onOpenGitHubIssueInOrca && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.2c67730e07',
|
||||
'Open in Orca'
|
||||
)}
|
||||
onClick={onOpenGitHubIssueInOrca}
|
||||
>
|
||||
<MonitorUp className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{issue.url && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.b22f058067',
|
||||
'View on GitHub'
|
||||
)}
|
||||
href={issue.url}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<WorktreeCardDetailSectionContent className="space-y-1.5">
|
||||
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
|
||||
{issue.title}
|
||||
</div>
|
||||
{(issue.state || issueLabels.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.state && <IssueStateBadge state={issue.state} />}
|
||||
{issueLabels.map((label) => (
|
||||
<Badge key={label} variant="outline" className="h-4 px-1.5 text-[9px]">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WorktreeCardDetailSectionContent>
|
||||
</WorktreeCardDetailSection>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,9 +2,14 @@
|
|||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorktreeCardDetailsHover } from './WorktreeCardMeta'
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}))
|
||||
|
||||
const interactionMocks = vi.hoisted(() => ({
|
||||
hoverOpen: false,
|
||||
onHoverOpenChange: undefined as ((open: boolean) => void) | undefined,
|
||||
|
|
@ -13,6 +18,10 @@ const interactionMocks = vi.hoisted(() => ({
|
|||
onUnlinkSelect: undefined as (() => void) | undefined
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: toastMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/hover-card', () => ({
|
||||
HoverCard: ({
|
||||
children,
|
||||
|
|
@ -78,6 +87,7 @@ const reviewFixture = {
|
|||
describe('WorktreeCardDetailsHover interactions', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
const writeClipboardText = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
|
|
@ -89,6 +99,21 @@ describe('WorktreeCardDetailsHover interactions', () => {
|
|||
interactionMocks.onHoverOpenChange = undefined
|
||||
interactionMocks.onReviewMenuOpenChange = undefined
|
||||
interactionMocks.onUnlinkSelect = undefined
|
||||
writeClipboardText.mockReset()
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ui: {
|
||||
writeClipboardText
|
||||
}
|
||||
}
|
||||
})
|
||||
writeClipboardText.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function renderHover(onUnlinkReview = vi.fn()): ReturnType<typeof vi.fn> {
|
||||
|
|
@ -142,16 +167,14 @@ describe('WorktreeCardDetailsHover interactions', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('suppresses the tooltip while the review menu is open', () => {
|
||||
it('omits the review trigger tooltip while the review menu is open', () => {
|
||||
renderHover()
|
||||
|
||||
act(() => {
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
expect(container.textContent).not.toContain('More PR actions')
|
||||
})
|
||||
|
||||
it('invokes unlink and closes the hover from the menu item', () => {
|
||||
|
|
@ -178,4 +201,55 @@ describe('WorktreeCardDetailsHover interactions', () => {
|
|||
container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open')
|
||||
).toBe('false')
|
||||
})
|
||||
|
||||
it('copies the review URL and closes the hover from the menu item', async () => {
|
||||
const onUnlinkReview = renderHover()
|
||||
|
||||
act(() => {
|
||||
interactionMocks.onHoverOpenChange?.(true)
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Copy link')
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
|
||||
expect(onUnlinkReview).not.toHaveBeenCalled()
|
||||
expect(toastMocks.success).toHaveBeenCalledWith('PR link copied')
|
||||
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')
|
||||
})
|
||||
|
||||
it('reports clipboard failures without unlinking the review', async () => {
|
||||
writeClipboardText.mockRejectedValueOnce(new Error('clipboard unavailable'))
|
||||
const onUnlinkReview = renderHover()
|
||||
|
||||
act(() => {
|
||||
interactionMocks.onHoverOpenChange?.(true)
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Copy link')
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
|
||||
expect(onUnlinkReview).not.toHaveBeenCalled()
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('Failed to copy link')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,13 +109,15 @@ describe('WorktreeCardDetailsHover', () => {
|
|||
|
||||
expect(moreActionsIndex).toBeGreaterThan(-1)
|
||||
expect(markup).toContain('More PR actions')
|
||||
expect(markup).toContain('Copy link')
|
||||
expect(markup).toContain('Unlink PR')
|
||||
expect(moreActionsIndex).toBeLessThan(openInOrcaIndex)
|
||||
expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
|
||||
expect(markup).not.toContain('aria-label="Unlink PR"')
|
||||
expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR'))
|
||||
})
|
||||
|
||||
it('puts issue edit before open actions and keeps GitHub last', () => {
|
||||
it('puts issue copy menu before edit and open actions and keeps GitHub last', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCardDetailsHover
|
||||
issue={{
|
||||
|
|
@ -136,11 +138,17 @@ describe('WorktreeCardDetailsHover', () => {
|
|||
</WorktreeCardDetailsHover>
|
||||
)
|
||||
|
||||
const moreActionsIndex = markup.indexOf('aria-label="More issue actions"')
|
||||
const copyLinkIndex = markup.indexOf('Copy link')
|
||||
const editIssueIndex = markup.indexOf('aria-label="Edit issue"')
|
||||
const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"')
|
||||
const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"')
|
||||
|
||||
expect(moreActionsIndex).toBeGreaterThan(-1)
|
||||
expect(copyLinkIndex).toBeGreaterThan(-1)
|
||||
expect(editIssueIndex).toBeGreaterThan(-1)
|
||||
expect(moreActionsIndex).toBeLessThan(editIssueIndex)
|
||||
expect(copyLinkIndex).toBeLessThan(editIssueIndex)
|
||||
expect(editIssueIndex).toBeLessThan(openInOrcaIndex)
|
||||
expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
|
||||
import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
WorktreeCardDetailSectionContent
|
||||
} from './WorktreeCardDetailSection'
|
||||
import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls'
|
||||
import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
|
||||
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
|
||||
import type {
|
||||
|
|
@ -25,6 +26,7 @@ import type {
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection'
|
||||
import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection'
|
||||
import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection'
|
||||
|
||||
export type {
|
||||
WorktreeCardIssueDisplay,
|
||||
|
|
@ -155,8 +157,10 @@ export function WorktreeCardDetailsHover({
|
|||
const internalHoverControl = useWorktreeCardDetailsHoverControl()
|
||||
const {
|
||||
hoverOpen,
|
||||
issueMenuOpen,
|
||||
reviewMenuOpen,
|
||||
handleHoverOpenChange,
|
||||
handleIssueMenuOpenChange,
|
||||
handleReviewMenuOpenChange,
|
||||
closeHover
|
||||
} = hoverControl ?? internalHoverControl
|
||||
|
|
@ -167,6 +171,43 @@ export function WorktreeCardDetailsHover({
|
|||
},
|
||||
[closeHover]
|
||||
)
|
||||
const copyLinkedWorkItemLink = React.useCallback(async (url: string, label: string) => {
|
||||
try {
|
||||
// Why: Electron clipboard IPC remains reliable from nested hover/dropdown
|
||||
// overlays where browser clipboard activation can be lost.
|
||||
await window.api.ui.writeClipboardText(url)
|
||||
toast.success(
|
||||
translate('auto.components.sidebar.WorktreeCardMeta.copyLinkSuccess', '{{value0}} copied', {
|
||||
value0: label
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate('auto.components.sidebar.WorktreeCardMeta.copyLinkFailure', 'Failed to copy link')
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
const handleCopyIssueLink = React.useCallback((): void => {
|
||||
if (!issue?.url) {
|
||||
return
|
||||
}
|
||||
closeHover()
|
||||
void copyLinkedWorkItemLink(
|
||||
issue.url,
|
||||
translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link')
|
||||
)
|
||||
}, [closeHover, copyLinkedWorkItemLink, issue?.url])
|
||||
const handleCopyReviewLink = React.useCallback((): void => {
|
||||
if (!review?.url) {
|
||||
return
|
||||
}
|
||||
void copyLinkedWorkItemLink(
|
||||
review.url,
|
||||
translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', {
|
||||
value0: getReviewLabel(review)
|
||||
})
|
||||
)
|
||||
}, [copyLinkedWorkItemLink, review])
|
||||
|
||||
const showIdentityHeader = Boolean(branchName || workspaceTitle)
|
||||
|
||||
|
|
@ -178,7 +219,6 @@ export function WorktreeCardDetailsHover({
|
|||
return children
|
||||
}
|
||||
|
||||
const issueLabels = issue?.labels ?? []
|
||||
const branchIdentity = branchName ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -230,70 +270,16 @@ export function WorktreeCardDetailsHover({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{issue && (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<CircleDot className="size-3 text-muted-foreground" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.e97d8f2876',
|
||||
'Issue #{{value0}}',
|
||||
{ value0: issue.number }
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
{onEditIssue && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.807b13b9ec',
|
||||
'Edit issue'
|
||||
)}
|
||||
onClick={onEditIssue}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{issue.url && onOpenGitHubIssueInOrca && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.2c67730e07',
|
||||
'Open in Orca'
|
||||
)}
|
||||
onClick={dismissAndRun(onOpenGitHubIssueInOrca)}
|
||||
>
|
||||
<MonitorUp className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{issue.url && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.b22f058067',
|
||||
'View on GitHub'
|
||||
)}
|
||||
href={issue.url}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<WorktreeCardDetailSectionContent className="space-y-1.5">
|
||||
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
|
||||
{issue.title}
|
||||
</div>
|
||||
{(issue.state || issueLabels.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.state && <IssueStateBadge state={issue.state} />}
|
||||
{issueLabels.map((label) => (
|
||||
<Badge key={label} variant="outline" className="h-4 px-1.5 text-[9px]">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WorktreeCardDetailSectionContent>
|
||||
</WorktreeCardDetailSection>
|
||||
)}
|
||||
<WorktreeCardIssueDetailSection
|
||||
issue={issue}
|
||||
issueMenuOpen={issueMenuOpen}
|
||||
onIssueMenuOpenChange={handleIssueMenuOpenChange}
|
||||
onCopyIssueLink={issue?.url ? handleCopyIssueLink : undefined}
|
||||
onEditIssue={onEditIssue}
|
||||
onOpenGitHubIssueInOrca={
|
||||
onOpenGitHubIssueInOrca ? dismissAndRun(onOpenGitHubIssueInOrca) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{linearIssue && (
|
||||
<WorktreeCardDetailSection>
|
||||
|
|
@ -357,6 +343,7 @@ export function WorktreeCardDetailsHover({
|
|||
reviewMenuOpen={reviewMenuOpen}
|
||||
onReviewMenuOpenChange={handleReviewMenuOpenChange}
|
||||
onOpenReviewInOrca={onOpenReviewInOrca}
|
||||
onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined}
|
||||
onUnlinkReview={onUnlinkReview}
|
||||
closeHover={closeHover}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react'
|
||||
import { Copy, Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
WorktreeCardDetailSection,
|
||||
|
|
@ -23,6 +23,7 @@ type WorktreeCardReviewDetailSectionProps = {
|
|||
reviewMenuOpen: boolean
|
||||
onReviewMenuOpenChange: (open: boolean) => void
|
||||
onOpenReviewInOrca?: (event: React.MouseEvent) => void
|
||||
onCopyReviewLink?: () => void
|
||||
onUnlinkReview?: () => void
|
||||
closeHover: () => void
|
||||
}
|
||||
|
|
@ -32,6 +33,7 @@ export function WorktreeCardReviewDetailSection({
|
|||
reviewMenuOpen,
|
||||
onReviewMenuOpenChange,
|
||||
onOpenReviewInOrca,
|
||||
onCopyReviewLink,
|
||||
onUnlinkReview,
|
||||
closeHover
|
||||
}: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null {
|
||||
|
|
@ -41,6 +43,25 @@ export function WorktreeCardReviewDetailSection({
|
|||
|
||||
const reviewLabel = getReviewLabel(review)
|
||||
const reviewProvider = getProviderName(review)
|
||||
const moreActionsLabel = translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
|
||||
'More {{value0}} actions',
|
||||
{ value0: reviewLabel }
|
||||
)
|
||||
const moreActionsTrigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={moreActionsLabel}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
const dismissAndOpenReview = (event: React.MouseEvent): void => {
|
||||
closeHover()
|
||||
onOpenReviewInOrca?.(event)
|
||||
|
|
@ -57,53 +78,52 @@ export function WorktreeCardReviewDetailSection({
|
|||
)}
|
||||
actions={
|
||||
<>
|
||||
{onUnlinkReview && (
|
||||
{(onCopyReviewLink || onUnlinkReview) && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={reviewMenuOpen}
|
||||
onOpenChange={onReviewMenuOpenChange}
|
||||
>
|
||||
<Tooltip open={reviewMenuOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
|
||||
'More {{value0}} actions',
|
||||
{ value0: reviewLabel }
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
|
||||
'More {{value0}} actions',
|
||||
{ value0: reviewLabel }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{reviewMenuOpen ? (
|
||||
moreActionsTrigger
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{moreActionsTrigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{moreActionsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onUnlinkReview?.()
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.ae76907ca6',
|
||||
'Unlink {{value0}}',
|
||||
{ value0: reviewLabel }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{onCopyReviewLink && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onCopyReviewLink()
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink',
|
||||
'Copy link'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onUnlinkReview && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onUnlinkReview()
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.ae76907ca6',
|
||||
'Unlink {{value0}}',
|
||||
{ value0: reviewLabel }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,34 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
type WorktreeCardDetailMenu = 'issue' | 'review'
|
||||
|
||||
export function useWorktreeCardDetailsHoverControl() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [reviewMenuOpen, setReviewMenuOpen] = useState(false)
|
||||
const [openMenu, setOpenMenu] = useState<WorktreeCardDetailMenu | null>(null)
|
||||
const pendingHoverCloseRef = useRef(false)
|
||||
|
||||
const closeHover = useCallback(() => {
|
||||
pendingHoverCloseRef.current = false
|
||||
setReviewMenuOpen(false)
|
||||
setOpenMenu(null)
|
||||
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) {
|
||||
// Why: portaled detail menus sit outside HoverCardContent — keep the card
|
||||
// mounted until the menu closes so the menu items stay clickable.
|
||||
if (openMenu) {
|
||||
pendingHoverCloseRef.current = !next
|
||||
return
|
||||
}
|
||||
pendingHoverCloseRef.current = false
|
||||
setOpen(next)
|
||||
},
|
||||
[reviewMenuOpen]
|
||||
[openMenu]
|
||||
)
|
||||
|
||||
const handleReviewMenuOpenChange = useCallback((next: boolean) => {
|
||||
setReviewMenuOpen(next)
|
||||
const setDetailMenuOpen = useCallback((menu: WorktreeCardDetailMenu, next: boolean) => {
|
||||
setOpenMenu(next ? menu : null)
|
||||
if (!next && pendingHoverCloseRef.current) {
|
||||
pendingHoverCloseRef.current = false
|
||||
setOpen(false)
|
||||
|
|
@ -34,10 +36,12 @@ export function useWorktreeCardDetailsHoverControl() {
|
|||
}, [])
|
||||
|
||||
return {
|
||||
hoverOpen: open || reviewMenuOpen,
|
||||
reviewMenuOpen,
|
||||
hoverOpen: open || Boolean(openMenu),
|
||||
issueMenuOpen: openMenu === 'issue',
|
||||
reviewMenuOpen: openMenu === 'review',
|
||||
handleHoverOpenChange,
|
||||
handleReviewMenuOpenChange,
|
||||
handleIssueMenuOpenChange: (next: boolean) => setDetailMenuOpen('issue', next),
|
||||
handleReviewMenuOpenChange: (next: boolean) => setDetailMenuOpen('review', next),
|
||||
closeHover
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3833,6 +3833,7 @@
|
|||
"1b0a156717": "Agents"
|
||||
},
|
||||
"WorktreeCardReviewDetailSection": {
|
||||
"copyLink": "Copy link",
|
||||
"reviewHeader": "{{value0}} #{{value1}}"
|
||||
},
|
||||
"WorktreeCardMeta": {
|
||||
|
|
@ -3849,6 +3850,12 @@
|
|||
"807b13b9ec": "Edit issue",
|
||||
"b22f058067": "View on GitHub",
|
||||
"e97d8f2876": "Issue #{{value0}}",
|
||||
"moreIssueActions": "More issue actions",
|
||||
"copyLink": "Copy link",
|
||||
"copyLinkSuccess": "{{value0}} copied",
|
||||
"copyLinkFailure": "Failed to copy link",
|
||||
"issueLinkLabel": "Issue link",
|
||||
"reviewLinkLabel": "{{value0}} link",
|
||||
"3ea2702e62": "Linked {{value0}} #{{value1}}",
|
||||
"b105fd3057": "Linked Linear {{value0}}",
|
||||
"3f2649eeb8": "Linked issue #{{value0}}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue