mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
Unify single and multiple PR hover details (#32284)
* Unify single and multiple PR hover details * Refine review metadata actions and hover scrolling titles * Place review checks after identifiers and show full URL tooltips * Show GitHub mark before review identifiers and fix localization checks * Reuse the existing GitHub mark across reference and share cards * Update compact hover expectation for split reference metadata
This commit is contained in:
@@ -3409,3 +3409,28 @@ html.native-shell .app-layout {
|
||||
.icon-superscript-count[data-multiple-digits='true'] {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.workspace-reference-title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The positioning wrapper must also let pointer events reach the controls below it. */
|
||||
[data-radix-popper-content-wrapper]:has(> [data-cursor-tooltip]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-reference-title > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.workspace-reference-title[data-overflow='true']:is(:hover, :focus-visible) > span {
|
||||
width: max-content;
|
||||
overflow: visible;
|
||||
transform: translateX(var(--reference-title-offset));
|
||||
transition: transform var(--reference-title-duration) linear 400ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ import { useWorktreeCardReviewDetails } from '../sidebar/use-worktree-card-revie
|
||||
import { useWorktreeCardLinkedDetails } from '../sidebar/use-worktree-card-linked-details'
|
||||
import { useWorktreeCardLifecycleEffects } from '../sidebar/use-worktree-card-lifecycle-effects'
|
||||
import { useWorktreeCardSecondaryDetails } from '../sidebar/use-worktree-card-secondary-details'
|
||||
import { getReviewLabel } from '../sidebar/worktree-review-helpers'
|
||||
import { useWorkspaceReferenceLinkActions } from '../sidebar/use-workspace-reference-link-actions'
|
||||
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
|
||||
import { ActivityThreadHoverCardSummary } from './activity-thread-hover-card-summary'
|
||||
import { ActivityThreadHoverCardTrigger } from './activity-thread-hover-card-trigger'
|
||||
import type { AgentPaneThread } from './activity-thread-types'
|
||||
@@ -54,6 +55,10 @@ export function ActivityThreadHoverCard({
|
||||
}: ActivityThreadHoverCardProps): React.JSX.Element {
|
||||
const detailsHoverControl = useWorktreeCardDetailsHoverControl()
|
||||
const open = detailsHoverControl.hoverOpen && !suppressed
|
||||
const { linkActionRequest, onOpenLink, closeLinkActions } = useWorkspaceReferenceLinkActions(
|
||||
thread.worktree.id,
|
||||
detailsHoverControl.closeHover
|
||||
)
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
@@ -62,6 +67,9 @@ export function ActivityThreadHoverCard({
|
||||
openDelay={openDelay}
|
||||
closeDelay={closeDelay}
|
||||
>
|
||||
<span className="contents" onClick={(event) => event.stopPropagation()}>
|
||||
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
|
||||
</span>
|
||||
<ActivityThreadHoverCardTrigger
|
||||
open={open}
|
||||
onOpenChange={detailsHoverControl.handleHoverOpenChange}
|
||||
@@ -71,6 +79,7 @@ export function ActivityThreadHoverCard({
|
||||
{open ? (
|
||||
<ActivityThreadHoverCardContent
|
||||
thread={thread}
|
||||
onOpenLink={onOpenLink}
|
||||
detailsHoverControl={detailsHoverControl}
|
||||
onJumpToWorkspace={onJumpToWorkspace}
|
||||
canJumpToWorkspace={canJumpToWorkspace}
|
||||
@@ -82,11 +91,13 @@ export function ActivityThreadHoverCard({
|
||||
|
||||
function ActivityThreadHoverCardContent({
|
||||
thread,
|
||||
onOpenLink,
|
||||
detailsHoverControl,
|
||||
onJumpToWorkspace,
|
||||
canJumpToWorkspace
|
||||
}: {
|
||||
thread: AgentPaneThread
|
||||
onOpenLink: (url: string, event: React.MouseEvent) => void
|
||||
detailsHoverControl: ReturnType<typeof useWorktreeCardDetailsHoverControl>
|
||||
onJumpToWorkspace?: (thread: AgentPaneThread) => void
|
||||
canJumpToWorkspace?: boolean
|
||||
@@ -194,18 +205,6 @@ function ActivityThreadHoverCardContent({
|
||||
)
|
||||
}, [copyLinkedWorkItemLink, detailsHoverControl, secondary.hoverIssue?.url])
|
||||
|
||||
const handleCopyReviewLink = useCallback(() => {
|
||||
if (!secondary.hoverReview?.url) {
|
||||
return
|
||||
}
|
||||
void copyLinkedWorkItemLink(
|
||||
secondary.hoverReview.url,
|
||||
translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', {
|
||||
value0: getReviewLabel(secondary.hoverReview)
|
||||
})
|
||||
)
|
||||
}, [copyLinkedWorkItemLink, secondary.hoverReview])
|
||||
|
||||
const dismissAndRun = useCallback(
|
||||
(handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
|
||||
detailsHoverControl.closeHover()
|
||||
@@ -237,6 +236,7 @@ function ActivityThreadHoverCardContent({
|
||||
{/* GitHub / GitLab Issue */}
|
||||
<WorktreeCardIssueDetailSection
|
||||
issue={secondary.hoverIssue}
|
||||
onManageLinks={dismissAndRun(foundation.handleManageLinks)}
|
||||
issueMenuOpen={detailsHoverControl.issueMenuOpen}
|
||||
onIssueMenuOpenChange={detailsHoverControl.handleIssueMenuOpenChange}
|
||||
onCopyIssueLink={secondary.hoverIssue?.url ? handleCopyIssueLink : undefined}
|
||||
@@ -348,13 +348,11 @@ function ActivityThreadHoverCardContent({
|
||||
{/* Pull Request / Review */}
|
||||
<WorktreeCardReviewDetailSection
|
||||
review={secondary.hoverReview}
|
||||
onManageLinks={dismissAndRun(foundation.handleManageLinks)}
|
||||
reviewMenuOpen={detailsHoverControl.reviewMenuOpen}
|
||||
onReviewMenuOpenChange={detailsHoverControl.handleReviewMenuOpenChange}
|
||||
onOpenReviewInOrca={secondary.handleOpenReviewInOrca}
|
||||
onOpenReviewInBrowser={
|
||||
secondary.hoverReview?.url ? secondary.handleOpenReviewInBrowser : undefined
|
||||
}
|
||||
onCopyReviewLink={secondary.hoverReview?.url ? handleCopyReviewLink : undefined}
|
||||
onOpenLink={onOpenLink}
|
||||
onUnlinkReview={secondary.canUnlinkReview ? secondary.handleUnlinkReview : undefined}
|
||||
closeHover={detailsHoverControl.closeHover}
|
||||
/>
|
||||
|
||||
@@ -101,6 +101,7 @@ export function FilePathCursorTooltip({
|
||||
{/* Anchored under the cursor the way a system tooltip is, rather than to
|
||||
the row. Collision shifting still applies near the viewport edge. */}
|
||||
<TooltipContent
|
||||
data-cursor-tooltip=""
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={offset.side}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -293,7 +293,8 @@ describe('WorktreeCard compact hover details', () => {
|
||||
expect(markup).toContain('data-hover-open-delay="100"')
|
||||
expectIdentityBodyIsHoverTrigger(markup)
|
||||
expect(markup).toContain('Issue #123')
|
||||
expect(markup).toContain('ENG-123 · Linear')
|
||||
expect(markup).toContain('ENG-123</span>')
|
||||
expect(markup).toContain('· Linear')
|
||||
expect(markup).toContain('Reviewer handoff note')
|
||||
expect(markup).toContain('Live Ports')
|
||||
expect(markup).toContain('58941')
|
||||
|
||||
@@ -25,6 +25,7 @@ type WorktreeCardIssueDetailSectionProps = {
|
||||
onOpenIssueInBrowser?: (url: string) => void
|
||||
onEditIssue?: (event: React.MouseEvent) => void
|
||||
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
|
||||
onManageLinks?: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function WorktreeCardIssueDetailSection({
|
||||
@@ -34,7 +35,8 @@ export function WorktreeCardIssueDetailSection({
|
||||
onCopyIssueLink,
|
||||
onOpenIssueInBrowser,
|
||||
onEditIssue,
|
||||
onOpenGitHubIssueInOrca
|
||||
onOpenGitHubIssueInOrca,
|
||||
onManageLinks
|
||||
}: WorktreeCardIssueDetailSectionProps): React.JSX.Element | null {
|
||||
if (!issue) {
|
||||
return null
|
||||
@@ -63,6 +65,7 @@ export function WorktreeCardIssueDetailSection({
|
||||
return (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
onAddReference={onManageLinks}
|
||||
icon={<CircleDot className="size-3 text-muted-foreground" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.e97d8f2876',
|
||||
|
||||
@@ -136,10 +136,11 @@ describe('workspace linked references in hover details', () => {
|
||||
}
|
||||
const { container } = render(<Fixture onManage={vi.fn()} referenceDetails={details} />)
|
||||
expect(screen.getByText('Merged sibling')).toBeTruthy()
|
||||
expect(screen.getByText('State: Merged')).toBeTruthy()
|
||||
expect(screen.getByLabelText('State: Merged')).toBeTruthy()
|
||||
expect(screen.queryByText('State: Merged')).toBeNull()
|
||||
expect(screen.getByText('Target: release')).toBeTruthy()
|
||||
expect(screen.getByText('State: In Progress')).toBeTruthy()
|
||||
expect(screen.getAllByText('Checks: Passing')).toHaveLength(1)
|
||||
expect(screen.getAllByLabelText('Checks: Passing')).toHaveLength(1)
|
||||
expect(screen.queryByText('Used for checks')).toBeNull()
|
||||
const siblingRow = Array.from(
|
||||
container.querySelectorAll('[data-workspace-hover-reference]')
|
||||
@@ -153,18 +154,26 @@ describe('workspace linked references in hover details', () => {
|
||||
expect(screen.getByText('Reviews · 2')).toBeTruthy()
|
||||
expect(screen.getByText('Issues & tasks · 2')).toBeTruthy()
|
||||
expect(screen.getAllByText('Active review')).toHaveLength(1)
|
||||
expect(screen.getByText('MR !42 · GitLab · gitlab.example.com/team/orca')).toBeTruthy()
|
||||
expect(screen.getByText('ENG-215 · Linear · acme')).toBeTruthy()
|
||||
expect(screen.getByText('MR !42')).toBeTruthy()
|
||||
expect(screen.getByText('· GitLab · gitlab.example.com/team/orca')).toBeTruthy()
|
||||
expect(screen.getByText('ENG-215')).toBeTruthy()
|
||||
expect(screen.getByText('· Linear · acme')).toBeTruthy()
|
||||
const checks = screen.getByLabelText('Checks: Passing')
|
||||
expect(checks.previousElementSibling?.textContent).toBe('PR #57')
|
||||
expect(checks.previousElementSibling?.previousElementSibling?.getAttribute('aria-label')).toBe(
|
||||
'GitHub'
|
||||
)
|
||||
expect(checks.nextElementSibling?.textContent).not.toContain('GitHub')
|
||||
const key = getWorkspaceAttachmentKey(attachments[0])
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('[data-workspace-hover-reference]')).find(
|
||||
(node) => node.getAttribute('data-workspace-hover-reference') === key
|
||||
)?.textContent
|
||||
).toContain('Checks: Passing')
|
||||
Array.from(container.querySelectorAll('[data-workspace-hover-reference]'))
|
||||
.find((node) => node.getAttribute('data-workspace-hover-reference') === key)
|
||||
?.querySelector('[aria-label="Checks: Passing"]')
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText('Used for checks')).toBeNull()
|
||||
expect(screen.getAllByText('Checks: Passing')).toHaveLength(1)
|
||||
expect(screen.getAllByLabelText('Checks: Passing')).toHaveLength(1)
|
||||
})
|
||||
it('opens a reference without activating the parent and dismisses hover', () => {
|
||||
it('offers Orca link destinations without activating the parent and dismisses hover', () => {
|
||||
const openUrl = vi.fn()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { shell: { openUrl } } })
|
||||
const parentClick = vi.fn()
|
||||
@@ -175,22 +184,29 @@ describe('workspace linked references in hover details', () => {
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
|
||||
parentClick.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open MR !42' }))
|
||||
expect(openUrl).toHaveBeenCalledWith('https://gitlab.example.com/team/orca/-/merge_requests/42')
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Open MR !42' }))
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: /System Browser/ })).toBeTruthy()
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
it('dismisses hover before managing links and exposes no second picker', () => {
|
||||
it('opens link management from either section header and dismisses hover', () => {
|
||||
const onManage = vi.fn()
|
||||
const { container } = render(<Fixture onManage={onManage} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manage links…' }))
|
||||
expect(onManage).toHaveBeenCalledTimes(1)
|
||||
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add reference' })
|
||||
expect(addButtons).toHaveLength(2)
|
||||
expect(screen.queryByText('Manage links…')).toBeNull()
|
||||
for (const button of addButtons) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
|
||||
fireEvent.click(button)
|
||||
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
}
|
||||
expect(onManage).toHaveBeenCalledTimes(2)
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
})
|
||||
it('opens metadata management for a URL-less reference and handles attachment-only folders', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CircleDot, ExternalLink, GitMerge, GitPullRequest, Link2, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
@@ -10,14 +9,9 @@ import {
|
||||
import { getWorkspaceAttachmentSourceLabel } from './workspace-attachment-source-label'
|
||||
import { WorktreeCardDetailSection } from './WorktreeCardDetailSection'
|
||||
import { DetailHeader } from './WorktreeCardMetadataControls'
|
||||
import {
|
||||
IssueStateBadge,
|
||||
LinearStateBadge,
|
||||
ReviewChecksBadge,
|
||||
ReviewStateBadge
|
||||
} from './WorktreeCardMetadataStatusBadges'
|
||||
import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import { WorkspaceAttachmentOrigins } from './WorkspaceAttachmentOrigins'
|
||||
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
|
||||
import { WorktreeCardReferenceRow } from './WorktreeCardReferenceRow'
|
||||
import type { WorkspaceReferenceDetailsMap } from './workspace-reference-details'
|
||||
import { getWorkspaceLinkedItemRows } from './workspace-hover-attachments'
|
||||
import type {
|
||||
@@ -36,7 +30,8 @@ export function WorktreeCardLinkedItemsSection({
|
||||
jiraIssue,
|
||||
showItems,
|
||||
onManage,
|
||||
onOpen
|
||||
onOpen,
|
||||
renderReviewActions
|
||||
}: {
|
||||
workspace?: Worktree
|
||||
referenceDetails?: WorkspaceReferenceDetailsMap
|
||||
@@ -46,7 +41,11 @@ export function WorktreeCardLinkedItemsSection({
|
||||
jiraIssue?: WorktreeCardJiraIssueDisplay | null
|
||||
showItems: boolean
|
||||
onManage?: (event: React.MouseEvent) => void
|
||||
onOpen?: (url: string) => void
|
||||
onOpen?: (url: string, event: React.MouseEvent) => void
|
||||
renderReviewActions?: (
|
||||
item: WorkspaceAttachment,
|
||||
review: WorktreeCardPrDisplay | null
|
||||
) => React.ReactNode
|
||||
}): React.JSX.Element | null {
|
||||
if (!workspace) {
|
||||
return null
|
||||
@@ -66,15 +65,20 @@ export function WorktreeCardLinkedItemsSection({
|
||||
rows: rows.filter((row) => row.item.type === 'issue')
|
||||
}
|
||||
].filter((group) => group.rows.length)
|
||||
if (!showItems && (rows.length > 0 || review || issue || linearIssue || jiraIssue || !onManage)) {
|
||||
return null
|
||||
}
|
||||
const open = (item: WorkspaceAttachment, event: React.MouseEvent): void => {
|
||||
event.stopPropagation()
|
||||
if (item.url) {
|
||||
if (onOpen) {
|
||||
onOpen(item.url)
|
||||
onOpen(item.url, event)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
void window.api.shell.openUrl(item.url)
|
||||
}
|
||||
} else {
|
||||
event.preventDefault()
|
||||
onManage?.(event)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +90,7 @@ export function WorktreeCardLinkedItemsSection({
|
||||
<DetailHeader
|
||||
icon={<Link2 className="size-3" />}
|
||||
label={`${group.label} · ${group.rows.length}`}
|
||||
onAddReference={onManage}
|
||||
/>
|
||||
<div className="divide-y divide-border">
|
||||
{group.rows.map(({ item, key, url, detail, live }) => {
|
||||
@@ -97,54 +102,32 @@ export function WorktreeCardLinkedItemsSection({
|
||||
className="space-y-1 py-1.5"
|
||||
data-workspace-hover-reference={key}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{live ? (
|
||||
<ReviewIcon review={live} className="mt-0.5 size-3.5 shrink-0" />
|
||||
) : item.type === 'mr' ? (
|
||||
<GitMerge className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : item.type === 'pr' ? (
|
||||
<GitPullRequest className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 rounded-sm text-left outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={!url && !onManage}
|
||||
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
|
||||
label
|
||||
})}
|
||||
onClick={(event) => open({ ...item, url }, event)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p className="line-clamp-2 text-xs font-medium leading-snug">
|
||||
{detail?.title || item.title || (live?.url ? live.title : label)}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{label} · {workspaceAttachmentProviderLabel(item)}
|
||||
{source ? ` · ${source}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
{url ? (
|
||||
<ExternalLink
|
||||
aria-hidden
|
||||
className="mt-0.5 size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{live?.state || live?.status || detail?.stateName || detail?.issueState ? (
|
||||
<WorktreeCardReferenceRow
|
||||
review={live}
|
||||
kind={item.type}
|
||||
title={detail?.title || item.title || (live?.url ? live.title : label)}
|
||||
label={label}
|
||||
provider={workspaceAttachmentProviderLabel(item)}
|
||||
source={source}
|
||||
url={url}
|
||||
onOpen={(url, event) => open({ ...item, url }, event)}
|
||||
onManage={onManage ? (event) => open(item, event) : undefined}
|
||||
actions={
|
||||
item.type !== 'issue' ? renderReviewActions?.(item, live) : undefined
|
||||
}
|
||||
/>
|
||||
{detail?.stale ||
|
||||
(live && 'baseRefName' in live && live.baseRefName) ||
|
||||
detail?.stateName ||
|
||||
detail?.issueState ? (
|
||||
<div className="flex flex-wrap items-center gap-1 pl-5">
|
||||
{live?.state ? (
|
||||
<ReviewStateBadge state={live.state} label={getReviewLabel(live)} />
|
||||
) : null}
|
||||
{live?.status ? <ReviewChecksBadge status={live.status} /> : null}
|
||||
{detail?.stale ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
{translate('workspace.links.lastKnownStatus', 'Last known status')}
|
||||
</span>
|
||||
) : null}
|
||||
{live && 'baseRefName' in live && live.baseRefName ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
{translate('workspace.links.reviewTarget', 'Target: {{branch}}', {
|
||||
branch: live.baseRefName
|
||||
})}
|
||||
@@ -171,19 +154,12 @@ export function WorktreeCardLinkedItemsSection({
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{onManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onManage}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Plus />
|
||||
{rows.length
|
||||
? translate('workspace.links.manage', 'Manage links…')
|
||||
: translate('workspace.links.addReference', 'Add reference')}
|
||||
</Button>
|
||||
{!rows.length && onManage ? (
|
||||
<DetailHeader
|
||||
icon={<Link2 className="size-3" />}
|
||||
label={translate('workspace.links.title', 'Linked work')}
|
||||
onAddReference={onManage}
|
||||
/>
|
||||
) : null}
|
||||
</WorktreeCardDetailSection>
|
||||
)
|
||||
|
||||
@@ -66,8 +66,22 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
<>{children}</>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button type="button" onClick={() => onSelect?.()}>
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
onClick
|
||||
}: {
|
||||
children: ReactNode
|
||||
onSelect?: () => void
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onSelect?.()
|
||||
onClick?.(event)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
@@ -264,98 +278,36 @@ describe('WorktreeCardDetailsHover interactions', () => {
|
||||
).toBe('false')
|
||||
})
|
||||
|
||||
it('copies the review URL and closes the hover from the menu item', async () => {
|
||||
const onUnlinkReview = renderHover()
|
||||
|
||||
it('opens the native review from the dropdown and closes the hover', () => {
|
||||
const onOpenReviewInOrca = vi.fn()
|
||||
container = document.createElement('div')
|
||||
root = createRoot(container)
|
||||
act(() =>
|
||||
root.render(
|
||||
<WorktreeCardDetailsHover
|
||||
issue={null}
|
||||
linearIssue={null}
|
||||
review={reviewFixture}
|
||||
comment={null}
|
||||
onOpenReviewInOrca={onOpenReviewInOrca}
|
||||
>
|
||||
<span>Linked PR</span>
|
||||
</WorktreeCardDetailsHover>
|
||||
)
|
||||
)
|
||||
act(() => {
|
||||
interactionMocks.onHoverOpenChange?.(true)
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Copy link')
|
||||
const nativeAction = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Open in Orca')
|
||||
)
|
||||
|
||||
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(nativeAction).toBeDefined()
|
||||
act(() => nativeAction?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(onOpenReviewInOrca).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')
|
||||
})
|
||||
|
||||
it('opens the review URL in Orca browser and leaves existing actions independent', () => {
|
||||
const onOpenReviewInBrowser = vi.fn()
|
||||
const onUnlinkReview = renderHover(vi.fn(), onOpenReviewInBrowser)
|
||||
|
||||
act(() => {
|
||||
interactionMocks.onHoverOpenChange?.(true)
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
|
||||
const browserButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Open in Orca browser')
|
||||
)
|
||||
|
||||
act(() => {
|
||||
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onOpenReviewInBrowser).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
|
||||
expect(onUnlinkReview).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves repeated-click behavior by forwarding each browser action', () => {
|
||||
const onOpenReviewInBrowser = vi.fn()
|
||||
renderHover(vi.fn(), onOpenReviewInBrowser)
|
||||
|
||||
act(() => {
|
||||
interactionMocks.onReviewMenuOpenChange?.(true)
|
||||
})
|
||||
const browserButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Open in Orca browser')
|
||||
)
|
||||
|
||||
act(() => {
|
||||
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onOpenReviewInBrowser).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('passes a linked issue URL to the embedded-browser action', () => {
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('WorktreeCardDetailsHover', () => {
|
||||
expect(markup.indexOf('feature/local-branch')).toBeLessThan(markup.indexOf('PR #456'))
|
||||
})
|
||||
|
||||
it('puts unlink behind the first PR actions menu and keeps GitHub last', () => {
|
||||
it('puts review-only actions after the metadata and adds references from the header', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCardDetailsHover
|
||||
issue={null}
|
||||
@@ -78,28 +78,33 @@ describe('WorktreeCardDetailsHover', () => {
|
||||
onOpenReviewInOrca={vi.fn()}
|
||||
onOpenReviewInBrowser={vi.fn()}
|
||||
onUnlinkReview={vi.fn()}
|
||||
onManageLinks={vi.fn()}
|
||||
>
|
||||
<span>Linked PR</span>
|
||||
</WorktreeCardDetailsHover>
|
||||
)
|
||||
|
||||
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('Copy link')
|
||||
expect(markup).toContain('Reviews · 1')
|
||||
expect(markup).toContain('aria-label="More PR actions"')
|
||||
expect(markup.indexOf('PR #456</span>')).toBeGreaterThan(-1)
|
||||
expect(markup.indexOf('PR #456</span>')).toBeLessThan(
|
||||
markup.indexOf('aria-label="Checks: Passing"')
|
||||
)
|
||||
expect(markup.indexOf('role="img" aria-label="GitHub"')).toBeLessThan(
|
||||
markup.indexOf('PR #456</span>')
|
||||
)
|
||||
expect(markup).not.toContain('· GitHub')
|
||||
expect(markup).toContain('aria-label="Add reference"')
|
||||
expect(markup).not.toContain('Manage links…')
|
||||
expect(markup).toContain('href="https://github.com/acme/orca/pull/456"')
|
||||
expect(markup).toContain('Open in Orca')
|
||||
expect(markup).toContain('Unlink PR from workspace')
|
||||
expect(markup).toContain(
|
||||
'Orca will hide PR #456 details for this workspace. The PR and branch on GitHub won’t be changed.'
|
||||
)
|
||||
expect(markup).toContain('Open in Orca browser')
|
||||
expect(moreActionsIndex).toBeLessThan(openInOrcaIndex)
|
||||
expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
|
||||
expect(markup.indexOf('Open in Orca browser')).toBeLessThan(markup.indexOf('Copy link'))
|
||||
expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR from workspace'))
|
||||
expect(markup).not.toContain('aria-label="Unlink PR from workspace"')
|
||||
expect(markup).not.toContain('Copy link')
|
||||
expect(markup).not.toContain('Open in Orca browser')
|
||||
expect(markup).not.toContain('View on GitHub')
|
||||
})
|
||||
|
||||
it('puts issue copy menu before edit and open actions and keeps GitHub last', () => {
|
||||
@@ -169,8 +174,8 @@ describe('WorktreeCardDetailsHover', () => {
|
||||
expect(markup).toContain(
|
||||
'Orca will hide MR !77 details for this workspace. The MR and branch on GitLab won’t be changed.'
|
||||
)
|
||||
expect(markup).toContain('View on GitLab')
|
||||
expect(markup).toContain('Open in Orca browser')
|
||||
expect(markup).toContain('href="https://gitlab.com/acme/orca/-/merge_requests/77"')
|
||||
expect(markup).not.toContain('Open in Orca browser')
|
||||
})
|
||||
|
||||
it('hides the embedded-browser action when a linked review has no URL', () => {
|
||||
@@ -194,7 +199,7 @@ describe('WorktreeCardDetailsHover', () => {
|
||||
expect(markup).not.toContain('Open in Orca browser')
|
||||
})
|
||||
|
||||
it('keeps the embedded-browser action provider-neutral for unsupported review URLs', () => {
|
||||
it('keeps the title link for unsupported review URLs', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCardDetailsHover
|
||||
issue={null}
|
||||
@@ -216,7 +221,7 @@ describe('WorktreeCardDetailsHover', () => {
|
||||
</WorktreeCardDetailsHover>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Open in Orca browser')
|
||||
expect(markup).toContain('href="https://code.example.test/reviews/12"')
|
||||
})
|
||||
|
||||
it('displays Linear issue details with link', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import { useWorkspaceReferenceLinkActions } from './use-workspace-reference-link-actions'
|
||||
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
|
||||
import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments'
|
||||
import { WorktreeCardLinkedItemsSection } from './WorktreeCardLinkedItemsSection'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -17,7 +19,7 @@ import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls
|
||||
import { hasWorktreeCardDetails, WorktreeCardMetaBadges } from './WorktreeCardMetaBadges'
|
||||
import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
|
||||
import { getReviewLabel } from './worktree-review-helpers'
|
||||
import { WorktreeCardLinkedReviewActions } from './WorktreeCardReviewActions'
|
||||
import type {
|
||||
WorktreeCardIssueDisplay,
|
||||
WorktreeCardJiraIssueDisplay,
|
||||
@@ -81,7 +83,6 @@ export function WorktreeCardDetailsHover({
|
||||
onOpenIssueInBrowser,
|
||||
onOpenLinearIssueInOrca,
|
||||
onOpenReviewInOrca,
|
||||
onOpenReviewInBrowser,
|
||||
onUnlinkReview,
|
||||
onOpenAutomation,
|
||||
onOpenAutomationRun,
|
||||
@@ -97,6 +98,10 @@ export function WorktreeCardDetailsHover({
|
||||
handleReviewMenuOpenChange,
|
||||
closeHover
|
||||
} = hoverControl ?? internalHoverControl
|
||||
const { linkActionRequest, onOpenLink, closeLinkActions } = useWorkspaceReferenceLinkActions(
|
||||
workspace?.id,
|
||||
closeHover
|
||||
)
|
||||
const [workspaceTitleEditing, setWorkspaceTitleEditing] = React.useState(false)
|
||||
const pendingWorkspaceTitleCloseRef = React.useRef(false)
|
||||
const handleWorkspaceTitleEditingChange = React.useCallback(
|
||||
@@ -154,18 +159,6 @@ export function WorktreeCardDetailsHover({
|
||||
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)
|
||||
const attachments = getWorkspaceAttachments(workspace ?? {})
|
||||
const showAttachmentCollection =
|
||||
@@ -197,6 +190,9 @@ export function WorktreeCardDetailsHover({
|
||||
openDelay={openDelay}
|
||||
closeDelay={closeDelay}
|
||||
>
|
||||
<span className="contents" onClick={(event) => event.stopPropagation()}>
|
||||
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
|
||||
</span>
|
||||
<HoverCardTrigger
|
||||
asChild
|
||||
onPointerEnter={hasComment(comment) ? preloadCommentMarkdown : undefined}
|
||||
@@ -228,6 +224,7 @@ export function WorktreeCardDetailsHover({
|
||||
<>
|
||||
<WorktreeCardIssueDetailSection
|
||||
issue={issue}
|
||||
onManageLinks={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
|
||||
issueMenuOpen={issueMenuOpen}
|
||||
onIssueMenuOpenChange={handleIssueMenuOpenChange}
|
||||
onCopyIssueLink={issue?.url ? handleCopyIssueLink : undefined}
|
||||
@@ -249,6 +246,7 @@ export function WorktreeCardDetailsHover({
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<LinearIcon className="size-3 text-muted-foreground" />}
|
||||
onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.5e982e6128',
|
||||
'Linear {{value0}}',
|
||||
@@ -309,6 +307,7 @@ export function WorktreeCardDetailsHover({
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<JiraIcon className="size-3 text-muted-foreground" />}
|
||||
onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.jiraIssue',
|
||||
'Jira {{value0}}',
|
||||
@@ -336,13 +335,11 @@ export function WorktreeCardDetailsHover({
|
||||
|
||||
<WorktreeCardReviewDetailSection
|
||||
review={review}
|
||||
onManageLinks={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
|
||||
reviewMenuOpen={reviewMenuOpen}
|
||||
onReviewMenuOpenChange={handleReviewMenuOpenChange}
|
||||
onOpenReviewInOrca={onOpenReviewInOrca}
|
||||
onOpenReviewInBrowser={
|
||||
onOpenReviewInBrowser && review?.url ? onOpenReviewInBrowser : undefined
|
||||
}
|
||||
onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined}
|
||||
onOpenLink={onOpenLink}
|
||||
onUnlinkReview={onUnlinkReview}
|
||||
closeHover={closeHover}
|
||||
/>
|
||||
@@ -357,10 +354,17 @@ export function WorktreeCardDetailsHover({
|
||||
jiraIssue={jiraIssue}
|
||||
showItems={showAttachmentCollection}
|
||||
onManage={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
|
||||
onOpen={(url) => {
|
||||
closeHover()
|
||||
void window.api.shell.openUrl(url)
|
||||
}}
|
||||
onOpen={onOpenLink}
|
||||
renderReviewActions={(item, live) => (
|
||||
<WorktreeCardLinkedReviewActions
|
||||
item={item}
|
||||
review={live}
|
||||
onOpenChange={handleReviewMenuOpenChange}
|
||||
onOpenInOrca={onOpenReviewInOrca}
|
||||
onUnlink={onUnlinkReview}
|
||||
closeHover={closeHover}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{automationProvenance && (
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function AddReferenceAction({
|
||||
onClick
|
||||
}: {
|
||||
onClick?: (event: React.MouseEvent) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (!onClick) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<MetadataActionIcon
|
||||
label={translate('workspace.links.addReference', 'Add reference')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetaIconBadge({
|
||||
label,
|
||||
@@ -19,11 +39,13 @@ export function MetaIconBadge({
|
||||
export function DetailHeader({
|
||||
icon,
|
||||
label,
|
||||
actions
|
||||
actions,
|
||||
onAddReference
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
actions?: React.ReactNode
|
||||
onAddReference?: (event: React.MouseEvent) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -31,7 +53,12 @@ export function DetailHeader({
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-0.5">{actions}</div> : null}
|
||||
{actions || onAddReference ? (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{actions}
|
||||
<AddReferenceAction onClick={onAddReference} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getReviewChecksTone } from './worktree-review-helpers'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CircleCheck, CircleDot, CircleX, Clock, GitMerge } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -112,10 +114,7 @@ export function ReviewStateBadge({
|
||||
if (state === 'merged') {
|
||||
return (
|
||||
<MetadataStatusBadge
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e',
|
||||
'State: Merged'
|
||||
)}
|
||||
label={reviewStateLabel(state, label)}
|
||||
className="border-purple-500/25 bg-purple-500/5 text-purple-600 dark:text-purple-300"
|
||||
>
|
||||
<GitMerge />
|
||||
@@ -126,10 +125,7 @@ export function ReviewStateBadge({
|
||||
if (state === 'closed') {
|
||||
return (
|
||||
<MetadataStatusBadge
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.e888362def',
|
||||
'State: Closed'
|
||||
)}
|
||||
label={reviewStateLabel(state, label)}
|
||||
className="border-rose-500/25 bg-rose-500/5 text-rose-600 dark:text-rose-300"
|
||||
>
|
||||
<CircleX />
|
||||
@@ -140,11 +136,7 @@ export function ReviewStateBadge({
|
||||
if (state === 'draft') {
|
||||
return (
|
||||
<MetadataStatusBadge
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.2931b42b09',
|
||||
'State: Draft {{value0}}',
|
||||
{ value0: label }
|
||||
)}
|
||||
label={reviewStateLabel(state, label)}
|
||||
className="border-border bg-muted/30 text-muted-foreground"
|
||||
>
|
||||
<CircleDot />
|
||||
@@ -154,10 +146,7 @@ export function ReviewStateBadge({
|
||||
|
||||
return (
|
||||
<MetadataStatusBadge
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.fe188062a1',
|
||||
'State: Open'
|
||||
)}
|
||||
label={reviewStateLabel(state, label)}
|
||||
className="border-emerald-500/25 bg-emerald-500/5 text-emerald-600 dark:text-emerald-300"
|
||||
>
|
||||
{label === 'MR' ? <GitMerge /> : <PullRequestIcon />}
|
||||
@@ -207,3 +196,66 @@ export function ReviewChecksBadge({
|
||||
</MetadataStatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
export function reviewStateLabel(
|
||||
state: WorktreeCardPrDisplay['state'],
|
||||
label: 'MR' | 'PR'
|
||||
): string {
|
||||
if (state === 'merged') {
|
||||
return translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e',
|
||||
'State: Merged'
|
||||
)
|
||||
}
|
||||
if (state === 'closed') {
|
||||
return translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.e888362def',
|
||||
'State: Closed'
|
||||
)
|
||||
}
|
||||
if (state === 'draft') {
|
||||
return translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.2931b42b09',
|
||||
'State: Draft {{value0}}',
|
||||
{ value0: label }
|
||||
)
|
||||
}
|
||||
if (state === 'open') {
|
||||
return translate(
|
||||
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.fe188062a1',
|
||||
'State: Open'
|
||||
)
|
||||
}
|
||||
return translate('workspace.links.checksUnknown', 'Unknown')
|
||||
}
|
||||
|
||||
export function ReviewChecksIndicator({
|
||||
status
|
||||
}: {
|
||||
status: WorktreeCardPrDisplay['status']
|
||||
}): React.JSX.Element | null {
|
||||
if (!status || status === 'neutral') {
|
||||
return null
|
||||
}
|
||||
const label = `Checks: ${checksLabel(status)}`
|
||||
const Icon = status === 'success' ? CircleCheck : status === 'failure' ? CircleX : Clock
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
getReviewChecksTone(status)
|
||||
)}
|
||||
>
|
||||
<Icon className="size-2.5" aria-hidden />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { CircleDot, GitMerge, GitPullRequest } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { GitHubIcon } from '@/components/icons/GitHubIcon'
|
||||
import { FilePathCursorTooltip } from '@/components/file-path-cursor-tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ReviewChecksIndicator, reviewStateLabel } from './WorktreeCardMetadataStatusBadges'
|
||||
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
|
||||
function measureTitleOverflow(element: HTMLElement): void {
|
||||
const text = element.firstElementChild
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const overflow = Math.max(0, text.scrollWidth - element.clientWidth)
|
||||
element.dataset.overflow = String(overflow > 0)
|
||||
element.style.setProperty('--reference-title-offset', `${-overflow}px`)
|
||||
element.style.setProperty('--reference-title-duration', `${Math.max(1.5, overflow / 35)}s`)
|
||||
}
|
||||
|
||||
export function WorktreeCardReferenceRow({
|
||||
review,
|
||||
kind,
|
||||
title,
|
||||
label,
|
||||
provider,
|
||||
source,
|
||||
url,
|
||||
onOpen,
|
||||
onManage,
|
||||
actions
|
||||
}: {
|
||||
review: WorktreeCardPrDisplay | null
|
||||
kind: 'pr' | 'mr' | 'issue'
|
||||
title: string
|
||||
label: string
|
||||
provider: string
|
||||
source?: string | null
|
||||
url?: string
|
||||
onOpen: (url: string, event: MouseEvent) => void
|
||||
onManage?: (event: MouseEvent) => void
|
||||
actions?: ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="mt-0.5 flex w-3.5 shrink-0 items-center">
|
||||
{review ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={reviewStateLabel(review.state, getReviewLabel(review))}
|
||||
className="inline-flex shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ReviewIcon review={review} className="size-3.5" showChecks={false} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{reviewStateLabel(review.state, getReviewLabel(review))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : kind === 'mr' ? (
|
||||
<GitMerge className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : kind === 'pr' ? (
|
||||
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{url ? (
|
||||
<FilePathCursorTooltip path={url}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="workspace-reference-title block w-full rounded-sm text-xs font-medium leading-snug underline decoration-muted-foreground/50 underline-offset-2 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onPointerEnter={(event) => measureTitleOverflow(event.currentTarget)}
|
||||
onFocus={(event) => measureTitleOverflow(event.currentTarget)}
|
||||
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
|
||||
label
|
||||
})}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpen(url, event)
|
||||
}}
|
||||
onAuxClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span>{title}</span>
|
||||
</a>
|
||||
</FilePathCursorTooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="workspace-reference-title block w-full rounded-sm text-left text-xs font-medium leading-snug outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onPointerEnter={(event) => measureTitleOverflow(event.currentTarget)}
|
||||
onFocus={(event) => measureTitleOverflow(event.currentTarget)}
|
||||
disabled={!onManage}
|
||||
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
|
||||
label
|
||||
})}
|
||||
onClick={onManage}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-2xs text-muted-foreground">
|
||||
{provider === 'GitHub' ? (
|
||||
<span className="inline-flex shrink-0" role="img" aria-label={provider}>
|
||||
<GitHubIcon className="size-3" />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="shrink-0">{label}</span>
|
||||
{review?.status ? <ReviewChecksIndicator status={review.status} /> : null}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{provider === 'GitHub' ? null : `· ${provider}`}
|
||||
{source ? ` · ${source}` : ''}
|
||||
</span>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, type MouseEvent } from 'react'
|
||||
import type { WorkspaceAttachment } from '../../../../shared/worktree/types'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import { workspaceAttachmentProviderLabel } from './worktree-attachment-editing'
|
||||
import { Ellipsis, MonitorUp } 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 { HostedReviewUnlinkMenuItem } from '@/components/HostedReviewUnlinkMenuItem'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function WorktreeCardReviewActions({
|
||||
label,
|
||||
number,
|
||||
provider,
|
||||
open,
|
||||
onOpenChange,
|
||||
onOpenInOrca,
|
||||
onUnlink,
|
||||
closeHover
|
||||
}: {
|
||||
label: 'PR' | 'MR'
|
||||
number: number
|
||||
provider: string
|
||||
open?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenInOrca?: (event: MouseEvent) => void
|
||||
onUnlink?: () => void
|
||||
closeHover: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const isOpen = open ?? internalOpen
|
||||
if (!onOpenInOrca && !onUnlink) {
|
||||
return null
|
||||
}
|
||||
const moreActionsLabel = translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
|
||||
'More {{value0}} actions',
|
||||
{ value0: label }
|
||||
)
|
||||
const trigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-5 shrink-0"
|
||||
aria-label={moreActionsLabel}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
onOpenChange={(next) => {
|
||||
setInternalOpen(next)
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
{isOpen ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{moreActionsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-52"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{onOpenInOrca ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
closeHover()
|
||||
onOpenInOrca(event)
|
||||
}}
|
||||
>
|
||||
<MonitorUp className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeCardMeta.2c67730e07', 'Open in Orca')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onUnlink ? (
|
||||
<HostedReviewUnlinkMenuItem
|
||||
reviewLabel={label}
|
||||
reviewIdentifier={`${label === 'MR' ? '!' : '#'}${number}`}
|
||||
providerLabel={provider}
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onUnlink()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorktreeCardLinkedReviewActions({
|
||||
item,
|
||||
review,
|
||||
onOpenChange,
|
||||
onOpenInOrca,
|
||||
onUnlink,
|
||||
closeHover
|
||||
}: {
|
||||
item: WorkspaceAttachment
|
||||
review: WorktreeCardPrDisplay | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenInOrca?: (event: MouseEvent, review?: WorktreeCardPrDisplay) => void
|
||||
onUnlink?: (item?: WorkspaceAttachment) => void
|
||||
closeHover: () => void
|
||||
}): React.JSX.Element {
|
||||
const url = item.url ?? review?.url
|
||||
const label = item.type === 'mr' ? 'MR' : 'PR'
|
||||
return (
|
||||
<WorktreeCardReviewActions
|
||||
label={label}
|
||||
number={item.number}
|
||||
provider={workspaceAttachmentProviderLabel(item)}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenInOrca={
|
||||
url && item.provider === 'github' && onOpenInOrca
|
||||
? (event) =>
|
||||
onOpenInOrca(
|
||||
event,
|
||||
review ?? {
|
||||
provider: 'github',
|
||||
number: item.number,
|
||||
title: item.title ?? '',
|
||||
url
|
||||
}
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onUnlink={onUnlink ? () => onUnlink(item) : undefined}
|
||||
closeHover={closeHover}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +1,79 @@
|
||||
import React from '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 { Copy, Ellipsis, ExternalLink, Globe, MonitorUp } from 'lucide-react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
WorktreeCardDetailSection,
|
||||
WorktreeCardDetailSectionContent
|
||||
} from './WorktreeCardDetailSection'
|
||||
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
|
||||
import { ReviewChecksBadge, ReviewStateBadge } from './WorktreeCardMetadataStatusBadges'
|
||||
import { WorktreeCardDetailSection } from './WorktreeCardDetailSection'
|
||||
import { DetailHeader } from './WorktreeCardMetadataControls'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import { getProviderName, getReviewLabel, ReviewIcon } from './worktree-review-helpers'
|
||||
import { HostedReviewUnlinkMenuItem } from '@/components/HostedReviewUnlinkMenuItem'
|
||||
|
||||
type WorktreeCardReviewDetailSectionProps = {
|
||||
review: WorktreeCardPrDisplay | null
|
||||
reviewMenuOpen: boolean
|
||||
onReviewMenuOpenChange: (open: boolean) => void
|
||||
onOpenReviewInOrca?: (event: React.MouseEvent) => void
|
||||
onCopyReviewLink?: () => void
|
||||
onOpenReviewInBrowser?: (url: string) => void
|
||||
onUnlinkReview?: () => void
|
||||
closeHover: () => void
|
||||
}
|
||||
import { getProviderName, getReviewLabel } from './worktree-review-helpers'
|
||||
import { WorktreeCardReferenceRow } from './WorktreeCardReferenceRow'
|
||||
import { WorktreeCardReviewActions } from './WorktreeCardReviewActions'
|
||||
import { getWorkspaceAttachmentSourceLabel } from './workspace-attachment-source-label'
|
||||
|
||||
export function WorktreeCardReviewDetailSection({
|
||||
review,
|
||||
reviewMenuOpen,
|
||||
onReviewMenuOpenChange,
|
||||
onOpenReviewInOrca,
|
||||
onCopyReviewLink,
|
||||
onOpenReviewInBrowser,
|
||||
onUnlinkReview,
|
||||
closeHover
|
||||
}: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null {
|
||||
closeHover,
|
||||
onOpenLink,
|
||||
onManageLinks
|
||||
}: {
|
||||
review: WorktreeCardPrDisplay | null
|
||||
reviewMenuOpen: boolean
|
||||
onReviewMenuOpenChange: (open: boolean) => void
|
||||
onOpenReviewInOrca?: (event: MouseEvent) => void
|
||||
onUnlinkReview?: () => void
|
||||
closeHover: () => void
|
||||
onOpenLink: (url: string, event: MouseEvent) => void
|
||||
onManageLinks?: (event: MouseEvent) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (!review) {
|
||||
return null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const label = getReviewLabel(review)
|
||||
const source =
|
||||
review.provider === 'unsupported'
|
||||
? null
|
||||
: getWorkspaceAttachmentSourceLabel({
|
||||
provider: review.provider,
|
||||
type: label === 'MR' ? 'mr' : 'pr',
|
||||
number: review.number,
|
||||
url: review.url
|
||||
})
|
||||
return (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<ReviewIcon review={review} className="size-3" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardReviewDetailSection.reviewHeader',
|
||||
'{{value0}} #{{value1}}',
|
||||
{ value0: reviewLabel, value1: review.number }
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
{(onCopyReviewLink || onOpenReviewInBrowser || onUnlinkReview) && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={reviewMenuOpen}
|
||||
onOpenChange={onReviewMenuOpenChange}
|
||||
>
|
||||
{reviewMenuOpen ? (
|
||||
moreActionsTrigger
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{moreActionsTrigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{moreActionsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
{onOpenReviewInBrowser && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onOpenReviewInBrowser(review.url!)
|
||||
}}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.openInOrcaBrowser',
|
||||
'Open in Orca browser'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCopyReviewLink && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onCopyReviewLink()
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink',
|
||||
'Copy link'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onUnlinkReview && (
|
||||
<HostedReviewUnlinkMenuItem
|
||||
reviewLabel={reviewLabel}
|
||||
reviewIdentifier={`${reviewLabel === 'MR' ? '!' : '#'}${review.number}`}
|
||||
providerLabel={reviewProvider}
|
||||
onSelect={() => {
|
||||
closeHover()
|
||||
onUnlinkReview()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{review.url && onOpenReviewInOrca && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.2c67730e07',
|
||||
'Open in Orca'
|
||||
)}
|
||||
onClick={dismissAndOpenReview}
|
||||
>
|
||||
<MonitorUp className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{review.url && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.ad25c3ff05',
|
||||
'View on {{value0}}',
|
||||
{ value0: reviewProvider }
|
||||
)}
|
||||
href={review.url}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
icon={<Link2 className="size-3" />}
|
||||
label={`${translate('workspace.links.reviews', 'Reviews')} · 1`}
|
||||
onAddReference={onManageLinks}
|
||||
/>
|
||||
<WorktreeCardDetailSectionContent className="space-y-1.5">
|
||||
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
|
||||
{review.title}
|
||||
</div>
|
||||
{(review.state || (review.status && review.status !== 'neutral')) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<ReviewStateBadge state={review.state} label={reviewLabel} />
|
||||
<ReviewChecksBadge status={review.status} />
|
||||
</div>
|
||||
)}
|
||||
</WorktreeCardDetailSectionContent>
|
||||
<div className="py-1.5">
|
||||
<WorktreeCardReferenceRow
|
||||
review={review}
|
||||
kind={label === 'MR' ? 'mr' : 'pr'}
|
||||
title={review.title}
|
||||
label={`${label} ${label === 'MR' ? '!' : '#'}${review.number}`}
|
||||
provider={getProviderName(review)}
|
||||
source={source}
|
||||
url={review.url}
|
||||
onOpen={onOpenLink}
|
||||
actions={
|
||||
<WorktreeCardReviewActions
|
||||
label={label}
|
||||
number={review.number}
|
||||
provider={getProviderName(review)}
|
||||
open={reviewMenuOpen}
|
||||
onOpenChange={onReviewMenuOpenChange}
|
||||
onOpenInOrca={
|
||||
review.url && review.provider === 'github' ? onOpenReviewInOrca : undefined
|
||||
}
|
||||
onUnlink={onUnlinkReview}
|
||||
closeHover={closeHover}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</WorktreeCardDetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, type MouseEvent } from 'react'
|
||||
import type { LinkActionRequest } from '@/components/link-actions/link-action-request'
|
||||
import { handleNativeChatWebLink } from '@/components/native-chat/native-chat-web-link-actions'
|
||||
import { resolveNativeChatHttpLinkRouting } from '@/components/native-chat/native-chat-link-target-actions'
|
||||
import { nativeChatPlainLinkClickBehavior } from '@/components/native-chat/native-chat-link-click-behavior'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export function useWorkspaceReferenceLinkActions(
|
||||
workspaceId: string | undefined,
|
||||
closeHover: () => void
|
||||
) {
|
||||
const [linkActionRequest, setLinkActionRequest] = useState<LinkActionRequest | null>(null)
|
||||
const onOpenLink = (url: string, event: MouseEvent): void => {
|
||||
if (!workspaceId) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void window.api.shell.openUrl(url)
|
||||
closeHover()
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const anchor = event.currentTarget
|
||||
const plainClickBehavior = nativeChatPlainLinkClickBehavior(state.settings)
|
||||
handleNativeChatWebLink(event, url, {
|
||||
worktreeId: workspaceId,
|
||||
...resolveNativeChatHttpLinkRouting(state, workspaceId),
|
||||
actionsEnabled: plainClickBehavior === 'actions',
|
||||
plainClickBehavior,
|
||||
restoreFocus: () => {
|
||||
if (anchor instanceof HTMLElement && anchor.isConnected) {
|
||||
anchor.focus({ preventScroll: true })
|
||||
}
|
||||
},
|
||||
request: setLinkActionRequest
|
||||
})
|
||||
closeHover()
|
||||
}
|
||||
return { linkActionRequest, onOpenLink, closeLinkActions: () => setLinkActionRequest(null) }
|
||||
}
|
||||
+39
-1
@@ -7,7 +7,7 @@ import { getWorktreeCardDetails } from '../../../../shared/worktree/card-propert
|
||||
import { useAppStore } from '@/store'
|
||||
import { readStoreListenerCount } from '@/store/store-listener-census'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { Worktree, WorkspaceAttachment } from '../../../../shared/worktree/types'
|
||||
import { usePromptCacheCountdownStartedAt } from './CacheTimer'
|
||||
import { useWorktreeCardSecondaryDetails } from './use-worktree-card-secondary-details'
|
||||
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
|
||||
@@ -222,4 +222,42 @@ describe('useWorktreeCardSecondaryDetails store subscriptions', () => {
|
||||
'Update the remote runtime to unlink GitHub pull requests'
|
||||
)
|
||||
})
|
||||
it('unlinks the chosen sibling while retaining the selected review and other sources', async () => {
|
||||
const primary: WorkspaceAttachment = {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 42,
|
||||
url: 'https://github.com/acme/orca/pull/42'
|
||||
}
|
||||
const sibling: WorkspaceAttachment = {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 42,
|
||||
url: 'https://github.com/other/orca/pull/42'
|
||||
}
|
||||
const updateWorktreeMeta = vi.fn().mockResolvedValue({ ok: true })
|
||||
let unlink: ((item?: WorkspaceAttachment) => Promise<void>) | null = null
|
||||
function Probe(): null {
|
||||
const details = useWorktreeCardSecondaryDetails({
|
||||
...secondaryDetailsArgs(makeSettings(300_000)),
|
||||
worktree: {
|
||||
...makeWorktree(),
|
||||
hostId: 'ssh:builder',
|
||||
linkedPR: 42,
|
||||
linkedItems: [primary, sibling]
|
||||
},
|
||||
prDisplay: { provider: 'github', number: 42, title: 'Primary', url: primary.url },
|
||||
updateWorktreeMeta
|
||||
})
|
||||
unlink = details.handleUnlinkReview
|
||||
return null
|
||||
}
|
||||
mount(<Probe />)
|
||||
await act(() => unlink?.(sibling))
|
||||
expect(updateWorktreeMeta).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
{ linkedItemsBase: [primary, sibling], linkedItems: [primary] },
|
||||
{ executionHostId: 'ssh:builder' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { WorkspaceAttachment } from '../../../../shared/worktree/types'
|
||||
import { getWorkspaceAttachmentKey } from '../../../../shared/workspace-attachment-normalization'
|
||||
import { buildWorkspaceAttachmentEdits } from './worktree-attachment-editing'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -123,22 +127,22 @@ export function useWorktreeCardSecondaryDetails({
|
||||
[hoverIssue, openTaskPage, repo]
|
||||
)
|
||||
const handleOpenReviewInOrca = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
(e: React.MouseEvent, review: WorktreeCardPrDisplay | null = hoverReview) => {
|
||||
e.stopPropagation()
|
||||
if (!repo || !hoverReview?.url || hoverReview.provider !== 'github') {
|
||||
if (!repo || !review?.url || review.provider !== 'github') {
|
||||
return
|
||||
}
|
||||
const item: GitHubWorkItem = {
|
||||
id: hoverReview.url,
|
||||
id: review.url,
|
||||
type: 'pr',
|
||||
number: hoverReview.number,
|
||||
title: hoverReview.title,
|
||||
state: hoverReview.state ?? 'open',
|
||||
url: hoverReview.url,
|
||||
number: review.number,
|
||||
title: review.title,
|
||||
state: review.state ?? 'open',
|
||||
url: review.url,
|
||||
labels: [],
|
||||
updatedAt: 'updatedAt' in hoverReview ? hoverReview.updatedAt : new Date().toISOString(),
|
||||
updatedAt: 'updatedAt' in review ? review.updatedAt : new Date().toISOString(),
|
||||
author: null,
|
||||
headSha: 'headSha' in hoverReview ? hoverReview.headSha : undefined,
|
||||
headSha: 'headSha' in review ? review.headSha : undefined,
|
||||
repoId: repo.id
|
||||
}
|
||||
openTaskPage({ taskSource: 'github', preselectedRepoId: repo.id, openGitHubWorkItem: item })
|
||||
@@ -186,38 +190,63 @@ export function useWorktreeCardSecondaryDetails({
|
||||
(hoverReviewProvider === 'bitbucket' && linkedBitbucketPR !== null) ||
|
||||
(hoverReviewProvider === 'azure-devops' && linkedAzureDevOpsPR !== null) ||
|
||||
(hoverReviewProvider === 'gitea' && linkedGiteaPR !== null)
|
||||
const handleUnlinkReview = useCallback(async () => {
|
||||
const options = { executionHostId: worktree.hostId ?? 'local' }
|
||||
switch (hoverReviewProvider) {
|
||||
case 'github':
|
||||
if (hoverReview) {
|
||||
const result = await updateWorktreeMeta(
|
||||
worktree.id,
|
||||
{ linkedPR: null, suppressedGitHubPR: hoverReview.number },
|
||||
options
|
||||
)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
const handleUnlinkReview = useCallback(
|
||||
async (item?: WorkspaceAttachment) => {
|
||||
const options = { executionHostId: worktree.hostId ?? 'local' }
|
||||
if (item) {
|
||||
const initial = getWorkspaceAttachments(worktree)
|
||||
const key = getWorkspaceAttachmentKey(item)
|
||||
const draft = initial.filter((entry) => getWorkspaceAttachmentKey(entry) !== key)
|
||||
const result = await updateWorktreeMeta(
|
||||
worktree.id,
|
||||
{
|
||||
...buildWorkspaceAttachmentEdits({ initial, draft }),
|
||||
...(item.provider === 'github' &&
|
||||
hoverReview?.provider === 'github' &&
|
||||
hoverReview.number === item.number &&
|
||||
(!item.url || item.url === hoverReview.url)
|
||||
? { suppressedGitHubPR: item.number }
|
||||
: {})
|
||||
},
|
||||
options
|
||||
)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
return
|
||||
case 'gitlab':
|
||||
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options)
|
||||
return
|
||||
case 'bitbucket':
|
||||
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options)
|
||||
return
|
||||
case 'azure-devops':
|
||||
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options)
|
||||
return
|
||||
case 'gitea':
|
||||
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options)
|
||||
break
|
||||
case 'unsupported':
|
||||
case undefined:
|
||||
break
|
||||
}
|
||||
}, [hoverReview, hoverReviewProvider, updateWorktreeMeta, worktree.hostId, worktree.id])
|
||||
}
|
||||
switch (hoverReviewProvider) {
|
||||
case 'github':
|
||||
if (hoverReview) {
|
||||
const result = await updateWorktreeMeta(
|
||||
worktree.id,
|
||||
{ linkedPR: null, suppressedGitHubPR: hoverReview.number },
|
||||
options
|
||||
)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
}
|
||||
return
|
||||
case 'gitlab':
|
||||
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options)
|
||||
return
|
||||
case 'bitbucket':
|
||||
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options)
|
||||
return
|
||||
case 'azure-devops':
|
||||
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options)
|
||||
return
|
||||
case 'gitea':
|
||||
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options)
|
||||
break
|
||||
case 'unsupported':
|
||||
case undefined:
|
||||
break
|
||||
}
|
||||
},
|
||||
[hoverReview, hoverReviewProvider, updateWorktreeMeta, worktree]
|
||||
)
|
||||
const handleOpenLinearIssueInOrca = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -68,9 +68,9 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
|
||||
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
|
||||
onOpenIssueInBrowser?: (url: string) => void
|
||||
onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
|
||||
onOpenReviewInOrca?: (event: React.MouseEvent) => void
|
||||
onOpenReviewInOrca?: (event: React.MouseEvent, review?: WorktreeCardPrDisplay) => void
|
||||
onOpenReviewInBrowser?: (url: string) => void
|
||||
onUnlinkReview?: () => void
|
||||
onUnlinkReview?: (item?: WorkspaceAttachment) => void
|
||||
onOpenAutomation?: (event: React.MouseEvent) => void
|
||||
onOpenAutomationRun?: (event: React.MouseEvent) => void
|
||||
hoverControl?: WorktreeCardDetailsHoverControl
|
||||
|
||||
@@ -20,6 +20,7 @@ export function WorktreeCardParentContent({
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
worktree,
|
||||
repo,
|
||||
affiliateListMode,
|
||||
newCardStyle,
|
||||
showStatus,
|
||||
@@ -110,13 +111,17 @@ export function WorktreeCardParentContent({
|
||||
hoverIssue && 'url' in hoverIssue && hoverIssue.url ? handleOpenIssueInBrowser : undefined
|
||||
}
|
||||
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
|
||||
onOpenReviewInOrca={
|
||||
hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined
|
||||
}
|
||||
onOpenReviewInOrca={repo ? handleOpenReviewInOrca : undefined}
|
||||
onOpenReviewInBrowser={hoverReview?.url ? handleOpenReviewInBrowser : undefined}
|
||||
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
|
||||
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
|
||||
onUnlinkReview={!affiliateListMode && canUnlinkReview ? handleUnlinkReview : undefined}
|
||||
onUnlinkReview={
|
||||
!affiliateListMode &&
|
||||
(canUnlinkReview ||
|
||||
getWorkspaceAttachments(worktree).some((item) => item.type !== 'issue'))
|
||||
? handleUnlinkReview
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{identityContent}
|
||||
</WorktreeCardDetailsHover>
|
||||
|
||||
@@ -236,13 +236,17 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
|
||||
metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenIssueInBrowser : undefined
|
||||
}
|
||||
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
|
||||
onOpenReviewInOrca={
|
||||
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
|
||||
}
|
||||
onOpenReviewInOrca={repo ? handleOpenReviewInOrca : undefined}
|
||||
onOpenReviewInBrowser={metaReview?.url ? handleOpenReviewInBrowser : undefined}
|
||||
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
|
||||
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
|
||||
onUnlinkReview={!affiliateListMode && canUnlinkReview ? handleUnlinkReview : undefined}
|
||||
onUnlinkReview={
|
||||
!affiliateListMode &&
|
||||
(canUnlinkReview ||
|
||||
getWorkspaceAttachments(worktree).some((item) => item.type !== 'issue'))
|
||||
? handleUnlinkReview
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{detailsAndPortsContent}
|
||||
</WorktreeCardDetailsHover>
|
||||
|
||||
@@ -69,16 +69,21 @@ function getStateTone(state: WorktreeCardPrDisplay['state']): string {
|
||||
export function ReviewIcon({
|
||||
review,
|
||||
className,
|
||||
variant = 'provider'
|
||||
variant = 'provider',
|
||||
showChecks = true
|
||||
}: {
|
||||
review: WorktreeCardPrDisplay
|
||||
className?: string
|
||||
variant?: 'provider' | 'generic'
|
||||
showChecks?: boolean
|
||||
}): React.JSX.Element {
|
||||
const providerIcon =
|
||||
variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon
|
||||
const Icon = getReviewStateIcon(review.state) ?? providerIcon
|
||||
return createElement(Icon, {
|
||||
className: cn(className, getCheckTone(review) ?? getStateTone(review.state))
|
||||
className: cn(
|
||||
className,
|
||||
(showChecks ? getCheckTone(review) : null) ?? getStateTone(review.state)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../../../../shared/claude-usage-types'
|
||||
import type { CodexUsageDailyPoint, CodexUsageSummary } from '../../../../shared/codex-usage-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { GitHubIcon } from '@/components/icons/GitHubIcon'
|
||||
|
||||
export type ClaudeShareData = {
|
||||
provider: 'claude'
|
||||
@@ -202,7 +203,12 @@ export function CardFooter(props: {
|
||||
</div>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
|
||||
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<GitHubIcon />
|
||||
<GitHubIcon
|
||||
width={13}
|
||||
height={13}
|
||||
fill="#888"
|
||||
style={{ opacity: 0.6, verticalAlign: 'middle' }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
@@ -222,17 +228,3 @@ export function CardFooter(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={13}
|
||||
height={13}
|
||||
viewBox="0 0 16 16"
|
||||
fill="#888"
|
||||
style={{ opacity: 0.6, verticalAlign: 'middle' }}
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-1
@@ -2166,8 +2166,10 @@
|
||||
"ca74db7550": "Project on SSH host"
|
||||
},
|
||||
"WorktreeCardMeta": {
|
||||
"ad25c3ff05": "View on {{value0}}",
|
||||
"ae76907ca6": "Unlink {{value0}}",
|
||||
"eace1d2cf6": "neutral"
|
||||
"eace1d2cf6": "neutral",
|
||||
"reviewLinkLabel": "{{value0}} link"
|
||||
},
|
||||
"WorktreeCardMetadataStatusBadges": {
|
||||
"29df45afa2": "MR"
|
||||
@@ -2176,6 +2178,10 @@
|
||||
"34f733dda2": "Go to Worktree",
|
||||
"3e5f66564e": "Workspace unavailable"
|
||||
},
|
||||
"WorktreeCardReviewDetailSection": {
|
||||
"copyLink": "Copy link",
|
||||
"reviewHeader": "{{value0}} #{{value1}}"
|
||||
},
|
||||
"WorktreeIssueLinkField": {
|
||||
"0a7a2c6efd": "Not a GitHub issue number or issue URL.",
|
||||
"161b2d053a": "Open linked issue",
|
||||
@@ -3476,6 +3482,11 @@
|
||||
"resumed_other": "{{count}} agent sessions resumed."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"links": {
|
||||
"manage": "Manage links…"
|
||||
}
|
||||
},
|
||||
"workspaceSpace": {
|
||||
"chart": {
|
||||
"workspaceSizes": "Workspace sizes"
|
||||
|
||||
Reference in New Issue
Block a user