Show repo icon on pinned worktree cards (#4517)
The Pinned section mixes worktrees from every repo, yet cards identified their repo only by a color dot — and showed no repo cue at all when the sidebar was grouped by repo (hideRepoBadge). Render each repo's configured icon (lucide/emoji/image) as a leading glyph on pinned cards regardless of grouping, so a mixed-repo pinned list is scannable at a glance. Extract a shared RepoIdentityChip so the pinned icon and the compact inline badge use one chip + tooltip shell.
This commit is contained in:
parent
fef6e042f5
commit
da3c359a74
|
|
@ -0,0 +1,145 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
|
||||
|
||||
const fetchHostedReviewForBranch = vi.fn()
|
||||
const fetchIssue = vi.fn()
|
||||
const fetchLinearIssue = vi.fn()
|
||||
const openModal = vi.fn()
|
||||
const updateWorktreeMeta = vi.fn()
|
||||
|
||||
let worktreeCardProperties: WorktreeCardProperty[] = []
|
||||
let settings: Partial<GlobalSettings> | null = null
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
deleteStateByWorktreeId: {},
|
||||
fetchHostedReviewForBranch,
|
||||
fetchIssue,
|
||||
fetchLinearIssue,
|
||||
gitConflictOperationByWorktree: {},
|
||||
hostedReviewCache: {},
|
||||
issueCache: {},
|
||||
linearIssueCache: {},
|
||||
openModal,
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
settings,
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
updateWorktreeMeta,
|
||||
workspacePortScan: null,
|
||||
worktreeCardProperties
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('./use-worktree-activity-status', () => ({
|
||||
useWorktreeActivityStatus: () => 'idle'
|
||||
}))
|
||||
|
||||
vi.mock('./CacheTimer', () => ({
|
||||
default: () => null,
|
||||
usePromptCacheCountdownStartedAt: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeCardAgents', () => ({
|
||||
default: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./SshDisconnectedDialog', () => ({
|
||||
SshDisconnectedDialog: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeContextMenu', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
|
||||
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu',
|
||||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
|
||||
}))
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#999999',
|
||||
repoIcon: { type: 'emoji', emoji: '🦊' },
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/worktrees/pinned',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktrees/pinned',
|
||||
displayName: 'Pinned tree',
|
||||
branch: 'feature/pinned',
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: true,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorktreeCard pinned repo icon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
worktreeCardProperties = []
|
||||
settings = null
|
||||
})
|
||||
|
||||
it('shows the configured repo icon for pinned cards even when the repo badge is hidden', async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree()}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
inPinnedSection
|
||||
// grouped-by-repo hides the normal badge; the pinned icon must still show
|
||||
hideRepoBadge
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('🦊')
|
||||
expect(markup).toContain('Project orca')
|
||||
})
|
||||
|
||||
it('does not render the leading pinned repo icon for non-pinned cards', async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({ isPinned: false })}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('🦊')
|
||||
expect(markup).not.toContain('Project orca')
|
||||
})
|
||||
})
|
||||
|
|
@ -45,6 +45,8 @@ import { writeWorkspaceDragData } from './workspace-status'
|
|||
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { resolveRepoHeaderColor } from './project-header-color'
|
||||
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
|
||||
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
|
||||
import { runWorktreeDelete } from './delete-worktree-flow'
|
||||
|
|
@ -67,6 +69,7 @@ type WorktreeCardProps = {
|
|||
revealHighlightTone?: 'default' | 'ai'
|
||||
selectedWorktrees?: readonly Worktree[]
|
||||
hideRepoBadge?: boolean
|
||||
inPinnedSection?: boolean
|
||||
contentIndent?: number
|
||||
flushSurface?: boolean
|
||||
lineageChildCount?: number
|
||||
|
|
@ -100,6 +103,32 @@ function isWebClient(): boolean {
|
|||
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
|
||||
}
|
||||
|
||||
// Why: the pinned repo icon and the compact inline badge share one chip shell;
|
||||
// keep the box + tooltip identical so both repo cues read as the same affordance.
|
||||
function RepoIdentityChip({
|
||||
repo,
|
||||
children
|
||||
}: {
|
||||
repo: Repo
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-sidebar-border bg-sidebar-accent/55"
|
||||
aria-label={`Project ${repo.displayName}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{repo.displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const WorktreeCard = React.memo(function WorktreeCard({
|
||||
worktree,
|
||||
repo,
|
||||
|
|
@ -117,6 +146,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
onCardDragEnd,
|
||||
nativeDragEnabled = true,
|
||||
hideRepoBadge,
|
||||
inPinnedSection = false,
|
||||
contentIndent = 0,
|
||||
flushSurface = false,
|
||||
lineageChildCount = 0,
|
||||
|
|
@ -591,8 +621,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
const hasPorts = showPorts && workspacePorts.length > 0
|
||||
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
|
||||
const cacheTtlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
|
||||
const showInlineRepoBadge = compactCards && !!repo && !hideRepoBadge && !isFolder
|
||||
const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge
|
||||
// Why: pinned trees mix repos in one section; a leading repo icon keeps the
|
||||
// list scannable, so it shows regardless of groupBy's hideRepoBadge.
|
||||
const showPinnedRepoIcon = inPinnedSection && !!repo
|
||||
const showInlineRepoBadge =
|
||||
compactCards && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon
|
||||
const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge && !showPinnedRepoIcon
|
||||
const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null
|
||||
const showBranch =
|
||||
!isFolder && branch.length > 0 && (!compactCards || branch !== worktree.displayName)
|
||||
|
|
@ -778,6 +812,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
{/* Header row: Title */}
|
||||
<div className="flex items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{showPinnedRepoIcon && (
|
||||
<RepoIdentityChip repo={repo}>
|
||||
<RepoIconGlyph
|
||||
repoIcon={repo.repoIcon}
|
||||
color={resolveRepoHeaderColor(repo.badgeColor)}
|
||||
className="size-full"
|
||||
iconClassName="size-3"
|
||||
/>
|
||||
</RepoIdentityChip>
|
||||
)}
|
||||
|
||||
{repo?.connectionId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -796,19 +841,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
)}
|
||||
|
||||
{showInlineRepoBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-sidebar-border bg-sidebar-accent/55"
|
||||
aria-label={`Project ${repo.displayName}`}
|
||||
>
|
||||
<RepoBadgeMark color={repo.badgeColor} className="size-2 rounded-[2px]" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{repo.displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<RepoIdentityChip repo={repo}>
|
||||
<RepoBadgeMark color={repo.badgeColor} className="size-2 rounded-[2px]" />
|
||||
</RepoIdentityChip>
|
||||
)}
|
||||
|
||||
{/* Why: weight alone carries the unread signal; color stays
|
||||
|
|
|
|||
|
|
@ -3142,6 +3142,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
onCardDragStart={handleWorktreeCardDragStart}
|
||||
onCardDragEnd={clearWorktreeDrag}
|
||||
hideRepoBadge={groupBy === 'repo'}
|
||||
// Why: pinned worktrees only render in the Pinned group, so
|
||||
// isPinned marks the mixed-repo pinned section that needs icons.
|
||||
inPinnedSection={itemRow.worktree.isPinned}
|
||||
lineageChildCount={itemRow.lineageChildCount}
|
||||
lineageCollapsed={itemRow.lineageCollapsed}
|
||||
lineageChildren={lineageChildren}
|
||||
|
|
|
|||
Loading…
Reference in New Issue