Show current branch in Source Control header (#9787)
* feat(source-control): show current branch in header * fix(source-control): keep header focused on branch * fix(source-control): compact detached head identity * fix(source-control): make branch identity keyboard accessible * fix(source-control): keep create review in checks
This commit is contained in:
parent
405b9f245a
commit
56a31a5af0
|
|
@ -12,13 +12,15 @@ type DetachedHeadBadgeProps = {
|
|||
label?: 'sidebar' | 'source-control'
|
||||
side?: React.ComponentProps<typeof TooltipContent>['side']
|
||||
className?: string
|
||||
tabIndex?: number
|
||||
}
|
||||
|
||||
export function DetachedHeadBadge({
|
||||
display,
|
||||
label = 'source-control',
|
||||
side = 'right',
|
||||
className
|
||||
className,
|
||||
tabIndex
|
||||
}: DetachedHeadBadgeProps): React.JSX.Element {
|
||||
const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel
|
||||
|
||||
|
|
@ -27,6 +29,8 @@ export function DetachedHeadBadge({
|
|||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
aria-label={display.tooltip}
|
||||
tabIndex={tabIndex}
|
||||
className={cn(
|
||||
'h-[18px] shrink-0 gap-1 rounded px-1.5 text-[10px] font-medium leading-none',
|
||||
'border-[color:color-mix(in_srgb,var(--git-decoration-modified)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-modified)_8%,transparent)] text-[color:var(--git-decoration-modified)]',
|
||||
|
|
@ -34,7 +38,7 @@ export function DetachedHeadBadge({
|
|||
)}
|
||||
>
|
||||
<GitCommitHorizontal className="size-2.5" />
|
||||
{visibleLabel}
|
||||
<span className="min-w-0 truncate">{visibleLabel}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} sideOffset={8}>
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
|
||||
const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/http-link-routing', () => ({
|
||||
openHttpLink: openHttpLinkMock,
|
||||
registerHttpLinkStoreAccessor: vi.fn()
|
||||
}))
|
||||
|
||||
function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
||||
return {
|
||||
provider: 'github',
|
||||
number: 2192,
|
||||
title: 'Open PR in checks',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/2192',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-05-17T00:00:00Z',
|
||||
mergeable: 'UNKNOWN',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type MinimalClickEvent = Pick<
|
||||
React.MouseEvent<HTMLButtonElement>,
|
||||
'nativeEvent' | 'stopPropagation'
|
||||
>
|
||||
type ClickModifiers = Partial<Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>>
|
||||
|
||||
function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent {
|
||||
return {
|
||||
nativeEvent: {
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
shiftKey: modifiers.shiftKey ?? false
|
||||
} as MouseEvent,
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
openHttpLinkMock.mockReset()
|
||||
})
|
||||
|
||||
describe('HostedReviewHeaderLink', () => {
|
||||
it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview(),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
const markup = renderToStaticMarkup(element)
|
||||
|
||||
expect(markup).toContain('<button')
|
||||
expect(markup).toContain('PR #2192')
|
||||
expect(markup).toContain('underline decoration-border underline-offset-2')
|
||||
expect(markup).not.toContain('href=')
|
||||
expect(markup).not.toContain('target="_blank"')
|
||||
expect(markup).not.toContain('system browser')
|
||||
expect(markup).not.toContain('⌘+click')
|
||||
|
||||
const event = clickEvent()
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[string, ClickModifiers]>([
|
||||
['Cmd-click', { metaKey: true }],
|
||||
['Ctrl-click', { ctrlKey: true }],
|
||||
['Shift+Cmd-click', { metaKey: true, shiftKey: true }],
|
||||
['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }]
|
||||
])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview(),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
|
||||
const event = clickEvent(modifiers)
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview({
|
||||
provider: 'gitlab',
|
||||
number: 31,
|
||||
url: 'https://gitlab.com/acme/widgets/-/merge_requests/31'
|
||||
}),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
const markup = renderToStaticMarkup(element)
|
||||
|
||||
expect(markup).toContain('<button')
|
||||
expect(markup).not.toContain('href=')
|
||||
expect(markup).toContain('MR #31')
|
||||
|
||||
const event = clickEvent()
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[string, ClickModifiers]>([
|
||||
['Cmd-click', { metaKey: true }],
|
||||
['Ctrl-click', { ctrlKey: true }],
|
||||
['Shift+Cmd-click', { metaKey: true, shiftKey: true }],
|
||||
['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }]
|
||||
])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview({
|
||||
provider: 'gitlab',
|
||||
number: 31,
|
||||
url: 'https://gitlab.com/acme/widgets/-/merge_requests/31'
|
||||
}),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
|
||||
const event = clickEvent(modifiers)
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps other provider reviews as external hosted-review links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostedReviewHeaderLink
|
||||
review={makeReview({
|
||||
provider: 'bitbucket',
|
||||
number: 31,
|
||||
url: 'https://bitbucket.org/acme/widgets/pull-requests/31'
|
||||
})}
|
||||
onOpenHostedReviewInChecks={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('<a')
|
||||
expect(markup).toContain('href="https://bitbucket.org/acme/widgets/pull-requests/31"')
|
||||
expect(markup).toContain('target="_blank"')
|
||||
expect(markup).toContain('PR #31')
|
||||
})
|
||||
})
|
||||
|
|
@ -45,7 +45,6 @@ import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
|||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -265,7 +264,6 @@ import {
|
|||
resolveCreatePrIntentRemoteStep,
|
||||
type CreatePrIntentRunToken
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
|
||||
import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action'
|
||||
import {
|
||||
buildLoadingHostedReviewCreationEligibility,
|
||||
|
|
@ -290,7 +288,6 @@ import {
|
|||
} from './source-control-hosted-review-push-target'
|
||||
import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url'
|
||||
import { parseRemoteRepo } from './source-control-remote-repo'
|
||||
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
import {
|
||||
createRunningCommitMessageGenerationRecord,
|
||||
getCommitMessageGenerationRecordKey,
|
||||
|
|
@ -809,7 +806,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const activeRepoConnectionId = activeRepo?.connectionId ?? null
|
||||
const activeRepoExecutionHostId = activeRepo?.executionHostId ?? null
|
||||
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
|
||||
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
|
||||
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const entries = useAppStore((s) =>
|
||||
activeWorktreeId
|
||||
|
|
@ -2806,11 +2802,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
]
|
||||
)
|
||||
|
||||
const openHostedReviewInChecks = useCallback(() => {
|
||||
setRightSidebarOpen(true)
|
||||
setRightSidebarTab('checks')
|
||||
}, [setRightSidebarOpen, setRightSidebarTab])
|
||||
|
||||
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
|
||||
// Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft.
|
||||
await refreshActiveGitStatusAfterMutation()
|
||||
|
|
@ -4239,10 +4230,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
(!createPrHeaderAction.disabled || isCreatingPr || prGenerating)
|
||||
? createPrHeaderAction
|
||||
: null
|
||||
const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction
|
||||
})
|
||||
|
||||
const dropdownItems: DropdownEntry[] = useMemo(
|
||||
() =>
|
||||
resolveDropdownItems({
|
||||
|
|
@ -4710,19 +4697,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
runCreatePrIntent
|
||||
])
|
||||
|
||||
const handleCreatePrHeaderClick = useCallback((): void => {
|
||||
if (!createPrHeaderAction || createPrHeaderAction.disabled) {
|
||||
return
|
||||
}
|
||||
if (createPrHeaderAction.kind === 'create_pr') {
|
||||
void handleCreatePullRequest()
|
||||
return
|
||||
}
|
||||
if (createPrHeaderAction.kind === 'create_pr_intent') {
|
||||
void runCreatePrIntent()
|
||||
}
|
||||
}, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent])
|
||||
|
||||
const branchCompareInFlightRef = useRef(false)
|
||||
const branchCompareRerunRef = useRef(false)
|
||||
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
|
||||
|
|
@ -5452,16 +5426,11 @@ function SourceControlInner(): React.JSX.Element {
|
|||
<>
|
||||
<div ref={setSourceControlRoot} className="relative flex h-full flex-col overflow-hidden">
|
||||
<SourceControlHeaderToolbar
|
||||
gitIdentityDisplay={gitIdentityDisplay}
|
||||
filterQuery={filterQuery}
|
||||
filterExpanded={filterExpanded}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onFilterExpandedChange={setFilterExpanded}
|
||||
visibleCreatePrHeaderAction={visibleCreatePrHeaderAction}
|
||||
hostedReview={hostedReview}
|
||||
isCreatePrIntentInFlight={isCreatePrIntentInFlight}
|
||||
isCreatingPr={isCreatingPr || prGenerating}
|
||||
onCreatePrHeaderClick={handleCreatePrHeaderClick}
|
||||
onOpenHostedReviewInChecks={openHostedReviewInChecks}
|
||||
sourceControlViewMode={sourceControlViewMode}
|
||||
viewModeToggleDisabled={settings === null}
|
||||
onToggleViewMode={handleToggleSourceControlViewMode}
|
||||
|
|
@ -5476,12 +5445,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
manualReviewUrl={manualReviewUrl}
|
||||
/>
|
||||
|
||||
{detachedHeadDisplay && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */}
|
||||
{activeWorktreeId && worktreePath && diffCommentCount > 0 && (
|
||||
<div className="border-b border-border">
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import React from 'react'
|
||||
import { GitMerge } from 'lucide-react'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PullRequestIcon } from './checks-panel-content'
|
||||
|
||||
function hostedReviewStateClass(review: HostedReviewInfo): string {
|
||||
if (review.state === 'merged') {
|
||||
return 'text-purple-500/80'
|
||||
}
|
||||
if (review.state === 'open') {
|
||||
return 'text-emerald-500/80'
|
||||
}
|
||||
if (review.state === 'closed') {
|
||||
return 'text-muted-foreground/60'
|
||||
}
|
||||
return 'text-muted-foreground/50'
|
||||
}
|
||||
|
||||
export function HostedReviewIcon({
|
||||
review,
|
||||
className
|
||||
}: {
|
||||
review: HostedReviewInfo
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
|
||||
return <Icon className={cn(className, hostedReviewStateClass(review))} />
|
||||
}
|
||||
|
||||
function hostedReviewLabel(review: HostedReviewInfo): string {
|
||||
return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}`
|
||||
}
|
||||
|
||||
export function HostedReviewHeaderLink({
|
||||
review,
|
||||
onOpenHostedReviewInChecks
|
||||
}: {
|
||||
review: HostedReviewInfo
|
||||
onOpenHostedReviewInChecks: () => void
|
||||
}): React.JSX.Element {
|
||||
const label = hostedReviewLabel(review)
|
||||
const className =
|
||||
'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground'
|
||||
|
||||
if (review.provider === 'github' || review.provider === 'gitlab') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Why: GitHub PR and GitLab MR details live in Orca's Checks tab; keep
|
||||
// the sidebar workflow in-app instead of opening the browser.
|
||||
onOpenHostedReviewInChecks()
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={review.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={className}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
|
||||
import type { PrimaryAction } from './source-control-primary-action-types'
|
||||
|
||||
const disabledCreatePrAction: PrimaryAction = {
|
||||
kind: 'create_pr',
|
||||
label: 'Create PR',
|
||||
title: 'Publish commits before creating a pull request.',
|
||||
disabled: true
|
||||
}
|
||||
|
||||
const enabledCreatePrAction: PrimaryAction = {
|
||||
kind: 'create_pr',
|
||||
label: 'Create PR',
|
||||
title: 'Create a pull request for this branch',
|
||||
disabled: false
|
||||
}
|
||||
|
||||
describe('resolveVisibleCreatePrHeaderAction', () => {
|
||||
it('returns null when no header action is available', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a disabled Create PR header visible as a stable toolbar anchor', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: disabledCreatePrAction
|
||||
})
|
||||
).toEqual(disabledCreatePrAction)
|
||||
})
|
||||
|
||||
it('keeps an enabled Create PR header visible even when the body composer is open', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: enabledCreatePrAction
|
||||
})
|
||||
).toEqual(enabledCreatePrAction)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import type { PrimaryAction } from './source-control-primary-action-types'
|
||||
import {
|
||||
resolveCreateReviewIntentEligibility,
|
||||
type CreateReviewIntentEligibility,
|
||||
|
|
@ -11,13 +10,3 @@ export type CreatePrIntentKind = CreateReviewIntentKind
|
|||
export type CreatePrIntentEligibility = CreateReviewIntentEligibility
|
||||
|
||||
export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility
|
||||
|
||||
export function resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction
|
||||
}: {
|
||||
createPrHeaderAction: PrimaryAction | null
|
||||
}): PrimaryAction | null {
|
||||
// Why: keep a stable header anchor; disable Create PR when the branch is not
|
||||
// ready instead of hiding it and shifting the toolbar layout.
|
||||
return createPrHeaderAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { SourceControlHeaderToolbar } from './source-control-header-toolbar'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('./source-control-header-overflow-menu', () => ({
|
||||
SourceControlHeaderOverflowMenu: () => <button type="button">More actions</button>
|
||||
}))
|
||||
|
||||
vi.mock('./source-control-branch-context-row', () => ({
|
||||
shouldShowSourceControlBranchContextRow: () => false,
|
||||
SourceControlBranchContextRow: () => null
|
||||
}))
|
||||
|
||||
function renderToolbar(
|
||||
gitIdentityDisplay: WorktreeGitIdentityDisplay | null = {
|
||||
kind: 'branch',
|
||||
branchName: 'brennanb2025/source-control-branch-name'
|
||||
}
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<SourceControlHeaderToolbar
|
||||
gitIdentityDisplay={gitIdentityDisplay}
|
||||
filterQuery=""
|
||||
filterExpanded={false}
|
||||
onFilterQueryChange={vi.fn()}
|
||||
onFilterExpandedChange={vi.fn()}
|
||||
sourceControlViewMode="list"
|
||||
viewModeToggleDisabled={false}
|
||||
onToggleViewMode={vi.fn()}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRefreshBranchCompare={vi.fn()}
|
||||
branchCompareRefreshDisabled={false}
|
||||
diffCommentCount={0}
|
||||
onExpandNotes={vi.fn()}
|
||||
branchSummary={null}
|
||||
compareBaseRef={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SourceControlHeaderToolbar', () => {
|
||||
it('renders the truncating branch identity before source control actions', () => {
|
||||
const markup = renderToolbar()
|
||||
const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name')
|
||||
const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"')
|
||||
|
||||
expect(branchIndex).toBeGreaterThan(-1)
|
||||
expect(filterIndex).toBeGreaterThan(branchIndex)
|
||||
expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"')
|
||||
expect(markup).toContain('tabindex="0"')
|
||||
expect(markup).toContain('min-w-0 truncate')
|
||||
expect(markup).not.toContain('Create PR')
|
||||
})
|
||||
|
||||
it('renders detached HEAD in the same identity slot', () => {
|
||||
const markup = renderToolbar({
|
||||
kind: 'detached',
|
||||
shortHead: '8cec248',
|
||||
sidebarLabel: 'Detached HEAD @ 8cec248',
|
||||
sourceControlLabel: 'Detached HEAD · 8cec248',
|
||||
tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.'
|
||||
})
|
||||
const identityIndex = markup.indexOf('Detached HEAD · 8cec248')
|
||||
const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"')
|
||||
|
||||
expect(markup).not.toContain('aria-label="Current branch:')
|
||||
expect(identityIndex).toBeGreaterThan(-1)
|
||||
expect(filterIndex).toBeGreaterThan(identityIndex)
|
||||
expect(markup).toContain(
|
||||
'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."'
|
||||
)
|
||||
expect(markup).toContain('tabindex="0"')
|
||||
expect(markup).toContain('lucide-git-commit-horizontal')
|
||||
})
|
||||
|
||||
it('keeps the identity slot empty until git identity is known', () => {
|
||||
const markup = renderToolbar(null)
|
||||
|
||||
expect(markup).not.toContain('aria-label="Current branch:')
|
||||
expect(markup).not.toContain('Detached HEAD')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react'
|
||||
import { GitBranch, Search, X } from 'lucide-react'
|
||||
import type {
|
||||
GitBranchCompareSummary,
|
||||
GitUpstreamStatus,
|
||||
SourceControlViewMode
|
||||
} from '../../../../shared/types'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { PrimaryAction } from './source-control-primary-action'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome'
|
||||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import {
|
||||
shouldShowSourceControlBranchContextRow,
|
||||
SourceControlBranchContextRow
|
||||
|
|
@ -19,16 +18,11 @@ import {
|
|||
import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu'
|
||||
|
||||
type SourceControlHeaderToolbarProps = {
|
||||
gitIdentityDisplay: WorktreeGitIdentityDisplay | null
|
||||
filterQuery: string
|
||||
filterExpanded: boolean
|
||||
onFilterQueryChange: (value: string) => void
|
||||
onFilterExpandedChange: (expanded: boolean) => void
|
||||
visibleCreatePrHeaderAction: PrimaryAction | null
|
||||
hostedReview: HostedReviewInfo | null
|
||||
isCreatePrIntentInFlight: boolean
|
||||
isCreatingPr: boolean
|
||||
onCreatePrHeaderClick: () => void
|
||||
onOpenHostedReviewInChecks: () => void
|
||||
sourceControlViewMode: SourceControlViewMode
|
||||
viewModeToggleDisabled: boolean
|
||||
onToggleViewMode: () => void
|
||||
|
|
@ -43,65 +37,45 @@ type SourceControlHeaderToolbarProps = {
|
|||
manualReviewUrl?: string | null
|
||||
}
|
||||
|
||||
function HostedReviewToolbarLink({
|
||||
review,
|
||||
onOpenHostedReviewInChecks,
|
||||
compact
|
||||
function SourceControlGitIdentityLabel({
|
||||
display
|
||||
}: {
|
||||
review: HostedReviewInfo
|
||||
onOpenHostedReviewInChecks: () => void
|
||||
compact?: boolean
|
||||
display: WorktreeGitIdentityDisplay
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 text-[11.5px] leading-none',
|
||||
compact ? 'max-w-[72px] shrink-0' : 'flex-1'
|
||||
)}
|
||||
>
|
||||
<HostedReviewIcon review={review} className="size-3 shrink-0" />
|
||||
<HostedReviewHeaderLink
|
||||
review={review}
|
||||
onOpenHostedReviewInChecks={onOpenHostedReviewInChecks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (display.kind === 'detached') {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 items-center">
|
||||
<DetachedHeadBadge
|
||||
display={display}
|
||||
side="bottom"
|
||||
className="min-w-0 max-w-full shrink"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const branchName = display.branchName
|
||||
const label = translate(
|
||||
'auto.components.right.sidebar.SourceControl.a4e93c21d7',
|
||||
'Current branch: {{value0}}',
|
||||
{ value0: branchName }
|
||||
)
|
||||
|
||||
function CreatePrHeaderButton({
|
||||
action,
|
||||
isCreatePrIntentInFlight,
|
||||
isCreatingPr,
|
||||
onClick
|
||||
}: {
|
||||
action: PrimaryAction
|
||||
isCreatePrIntentInFlight: boolean
|
||||
isCreatingPr: boolean
|
||||
onClick: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={action.disabled}
|
||||
onClick={onClick}
|
||||
className="h-6 shrink-0 px-2 text-[11px]"
|
||||
title={action.title}
|
||||
>
|
||||
{isCreatePrIntentInFlight || isCreatingPr ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequestArrow className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
<span
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded-sm font-mono text-xs font-medium leading-none text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
>
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{branchName}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
|
||||
{action.title}
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
|
||||
{branchName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
@ -124,16 +98,11 @@ function renderOverflowMenu(
|
|||
}
|
||||
|
||||
export function SourceControlHeaderToolbar({
|
||||
gitIdentityDisplay,
|
||||
filterQuery,
|
||||
filterExpanded,
|
||||
onFilterQueryChange,
|
||||
onFilterExpandedChange,
|
||||
visibleCreatePrHeaderAction,
|
||||
hostedReview,
|
||||
isCreatePrIntentInFlight,
|
||||
isCreatingPr,
|
||||
onCreatePrHeaderClick,
|
||||
onOpenHostedReviewInChecks,
|
||||
sourceControlViewMode,
|
||||
viewModeToggleDisabled,
|
||||
onToggleViewMode,
|
||||
|
|
@ -196,25 +165,11 @@ export function SourceControlHeaderToolbar({
|
|||
>
|
||||
{showCollapsedToolbar ? (
|
||||
<>
|
||||
{hostedReview ? (
|
||||
<HostedReviewToolbarLink
|
||||
review={hostedReview}
|
||||
onOpenHostedReviewInChecks={onOpenHostedReviewInChecks}
|
||||
/>
|
||||
) : visibleCreatePrHeaderAction ? (
|
||||
<CreatePrHeaderButton
|
||||
action={visibleCreatePrHeaderAction}
|
||||
isCreatePrIntentInFlight={isCreatePrIntentInFlight}
|
||||
isCreatingPr={isCreatingPr}
|
||||
onClick={onCreatePrHeaderClick}
|
||||
/>
|
||||
{gitIdentityDisplay ? (
|
||||
<SourceControlGitIdentityLabel display={gitIdentityDisplay} />
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" aria-hidden="true" />
|
||||
)}
|
||||
{visibleCreatePrHeaderAction && !hostedReview ? (
|
||||
// Why: keep filter/overflow pinned right without stretching Create PR.
|
||||
<span className="min-w-0 flex-1" aria-hidden="true" />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="source-control-filter-toggle"
|
||||
|
|
@ -237,7 +192,7 @@ export function SourceControlHeaderToolbar({
|
|||
) : (
|
||||
<>
|
||||
{/* Why: expanded filter owns the toolbar row so typing isn't squeezed
|
||||
beside PR links or overflow actions — collapse to reach those. */}
|
||||
beside branch identity or header actions — collapse to reach those. */}
|
||||
<div className="flex min-w-0 w-full flex-1 items-center gap-1.5">
|
||||
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -9789,7 +9789,8 @@
|
|||
"834cb3f23d": "Fix with AI",
|
||||
"783a808870": "Close"
|
||||
},
|
||||
"97e7124eac": "Could not refresh Source Control. Try again."
|
||||
"97e7124eac": "Could not refresh Source Control. Try again.",
|
||||
"a4e93c21d7": "Current branch: {{value0}}"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "Could not start the selected agent.",
|
||||
|
|
|
|||
|
|
@ -9766,7 +9766,8 @@
|
|||
"834cb3f23d": "Corregir con AI",
|
||||
"783a808870": "Cerrar"
|
||||
},
|
||||
"97e7124eac": "No se pudo actualizar Source Control. Vuelve a intentarlo."
|
||||
"97e7124eac": "No se pudo actualizar Source Control. Vuelve a intentarlo.",
|
||||
"a4e93c21d7": "Rama actual: {{value0}}"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
|
||||
|
|
|
|||
|
|
@ -9766,7 +9766,8 @@
|
|||
"834cb3f23d": "AIで修正",
|
||||
"783a808870": "閉じる"
|
||||
},
|
||||
"97e7124eac": "Source Control を更新できませんでした。もう一度お試しください。"
|
||||
"97e7124eac": "Source Control を更新できませんでした。もう一度お試しください。",
|
||||
"a4e93c21d7": "現在のブランチ: {{value0}}"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "選択した agent を開始できませんでした。",
|
||||
|
|
|
|||
|
|
@ -9766,7 +9766,8 @@
|
|||
"834cb3f23d": "AI로 수정",
|
||||
"783a808870": "닫기"
|
||||
},
|
||||
"97e7124eac": "Source Control을 새로 고칠 수 없습니다. 다시 시도하세요."
|
||||
"97e7124eac": "Source Control을 새로 고칠 수 없습니다. 다시 시도하세요.",
|
||||
"a4e93c21d7": "현재 브랜치: {{value0}}"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
|
||||
|
|
|
|||
|
|
@ -9766,7 +9766,8 @@
|
|||
"834cb3f23d": "使用 AI 修复",
|
||||
"783a808870": "关闭"
|
||||
},
|
||||
"97e7124eac": "无法刷新 Source Control。请重试。"
|
||||
"97e7124eac": "无法刷新 Source Control。请重试。",
|
||||
"a4e93c21d7": "当前分支:{{value0}}"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "无法启动选定的智能体。",
|
||||
|
|
|
|||
Loading…
Reference in New Issue