Compact imported worktrees visibility prompt

This commit is contained in:
Brennan Benson
2026-05-30 14:20:12 -07:00
committed by GitHub
parent 8519f98655
commit d281eba96d
7 changed files with 326 additions and 347 deletions
@@ -1,117 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard'
import { TooltipProvider } from '@/components/ui/tooltip'
const hiddenWorktrees = [
{
id: 'hidden-1',
displayName: 'payments-refactor',
path: '/worktrees/demo-project/payments-refactor',
branch: 'refs/heads/payments-refactor'
},
{
id: 'hidden-2',
displayName: 'auth-cache-debug',
path: '/worktrees/demo-project/auth-cache-debug',
branch: 'refs/heads/auth-cache-debug'
},
{
id: 'hidden-3',
displayName: 'legacy-oauth-fix',
path: '/worktrees/legacy/legacy-oauth-fix',
branch: 'refs/heads/legacy-oauth-fix'
},
{
id: 'hidden-4',
displayName: 'ssh-worktree',
path: '/srv/repos/orca/ssh-worktree',
branch: 'refs/heads/ssh-worktree'
}
]
function renderCard(
overrides: Partial<ComponentProps<typeof ImportedWorktreesVisibilityCard>> = {}
): string {
return renderToStaticMarkup(
<TooltipProvider>
<ImportedWorktreesVisibilityCard
repoDisplayName="orca"
hiddenWorktrees={hiddenWorktrees}
placement="repo-group"
pending={false}
error={null}
onShow={vi.fn()}
onKeepHidden={vi.fn()}
{...overrides}
/>
</TooltipProvider>
)
}
describe('ImportedWorktreesVisibilityCard', () => {
it('renders the required repo-group copy, three-item preview, actions, and repo menu hint', () => {
const markup = renderCard()
expect(markup).toContain('Imported 4 existing worktrees')
expect(markup).toContain(
'Orca found 4 worktrees and imported them automatically into this repo.'
)
expect(markup).toContain('payments-refactor')
expect(markup).toContain('auth-cache-debug')
expect(markup).toContain('legacy-oauth-fix')
expect(markup).toContain('/worktrees/demo-project')
expect(markup).toContain('/worktrees/legacy')
expect((markup.match(/>hidden</g) ?? []).length).toBe(3)
expect(markup).toContain('Show 1 more')
expect(markup).not.toContain('ssh-worktree')
expect(markup).not.toContain('refs/heads/payments-refactor')
expect(markup).not.toContain('/worktrees/demo-project/payments-refactor')
expect(markup).toContain('repo options')
expect(markup).toContain('Keep hidden')
expect(markup).toContain('Show')
})
it('scopes pinned fallback copy to the repo name', () => {
const markup = renderCard({ placement: 'pinned-fallback' })
expect(markup).toContain('Imported 4 existing worktrees in orca')
expect(markup).toContain('imported them automatically into orca.')
expect(markup).toContain('Showing them restores the imported worktrees to the repo list.')
expect(markup).not.toContain('repo options')
})
it('preserves Windows parent path separators in the preview', () => {
const markup = renderCard({
hiddenWorktrees: [
{
id: 'windows-hidden',
displayName: 'FeatureX',
path: 'C:\\Repos\\Orca\\FeatureX'
}
]
})
expect(markup).toContain('C:\\Repos\\Orca')
expect(markup).not.toContain('C:/Repos/Orca')
})
it('does not expose Keep hidden in the pinned-only fallback state', () => {
const markup = renderCard({ placement: 'pinned-fallback', onKeepHidden: undefined })
expect(markup).not.toContain('Keep hidden')
expect(markup).toContain('Use Show to restore this repo')
expect(markup).toContain('Show')
})
it('disables actions while pending and renders inline errors', () => {
const markup = renderCard({ pending: true, error: 'Could not show imported worktrees.' })
expect(markup).toContain('aria-busy="true"')
expect(markup).toContain('disabled=""')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Could not show imported worktrees.')
})
})
@@ -1,223 +0,0 @@
import React, { useState } from 'react'
import { Ellipsis } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { dirname } from '@/lib/path'
import { cn } from '@/lib/utils'
export type ImportedWorktreesVisibilityPlacement = 'repo-group' | 'pinned-fallback'
export type ImportedWorktreeVisibilityPreview = {
id?: string
displayName: string
path?: string
branch?: string
}
type ImportedWorktreesVisibilityCardProps = {
repoDisplayName: string
hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[]
placement: ImportedWorktreesVisibilityPlacement
pending: boolean
error: string | null
onShow: () => void
onKeepHidden?: () => void
className?: string
}
const PREVIEW_LIMIT = 3
const UNKNOWN_LOCATION_LABEL = 'Unknown location'
type ImportedWorktreePathGroup = {
path: string
worktrees: ImportedWorktreeVisibilityPreview[]
}
function pluralizeWorktree(count: number): string {
return count === 1 ? 'worktree' : 'worktrees'
}
function getWorktreeKey(
worktree: ImportedWorktreeVisibilityPreview,
index: number,
prefix: string
): string {
return worktree.id ?? worktree.path ?? `${prefix}-${worktree.displayName}-${index}`
}
function getParentPath(path: string | undefined): string {
if (!path) {
return UNKNOWN_LOCATION_LABEL
}
const parentPath = dirname(path)
if (!parentPath || parentPath === '.') {
return UNKNOWN_LOCATION_LABEL
}
return parentPath
}
function groupWorktreesByParentPath(
worktrees: readonly ImportedWorktreeVisibilityPreview[]
): ImportedWorktreePathGroup[] {
const groups: ImportedWorktreePathGroup[] = []
const groupByPath = new Map<string, ImportedWorktreePathGroup>()
for (const worktree of worktrees) {
const path = getParentPath(worktree.path)
const existing = groupByPath.get(path)
if (existing) {
existing.worktrees.push(worktree)
continue
}
const group = { path, worktrees: [worktree] }
groupByPath.set(path, group)
groups.push(group)
}
return groups
}
export default function ImportedWorktreesVisibilityCard({
repoDisplayName,
hiddenWorktrees,
placement,
pending,
error,
onShow,
onKeepHidden,
className
}: ImportedWorktreesVisibilityCardProps): React.JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(false)
const hiddenCount = hiddenWorktrees.length
const worktreeNoun = pluralizeWorktree(hiddenCount)
const visibleWorktrees = isExpanded ? hiddenWorktrees : hiddenWorktrees.slice(0, PREVIEW_LIMIT)
const visibleWorktreeGroups = groupWorktreesByParentPath(visibleWorktrees)
const remainingCount = Math.max(0, hiddenWorktrees.length - visibleWorktrees.length)
if (hiddenCount === 0) {
return null
}
const title =
placement === 'pinned-fallback'
? `Imported ${hiddenCount} existing ${worktreeNoun} in ${repoDisplayName}`
: `Imported ${hiddenCount} existing ${worktreeNoun}`
const subtitle =
placement === 'pinned-fallback'
? `Orca found ${hiddenCount} ${worktreeNoun} and imported them automatically into ${repoDisplayName}.`
: `Orca found ${hiddenCount} ${worktreeNoun} and imported them automatically into this repo.`
return (
<section
aria-busy={pending}
className={cn(
'mx-1 my-1.5 rounded-lg border border-sidebar-border bg-sidebar-accent/60 p-2.5 text-sidebar-foreground',
placement === 'repo-group' ? 'ml-9' : 'ml-7',
className
)}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="truncate text-[13px] font-semibold leading-5">{title}</h3>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{subtitle}</p>
</div>
</div>
<div className="mt-2 grid gap-1.5" aria-label="Imported worktree preview">
{visibleWorktreeGroups.map((group) => (
<div key={group.path} className="grid min-w-0 gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="block w-full min-w-0 truncate px-1 font-mono text-[10px] leading-4 text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
>
{group.path}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{group.path}
</TooltipContent>
</Tooltip>
{group.worktrees.map((worktree, index) => (
<div
key={getWorktreeKey(worktree, index, 'preview')}
className="flex min-h-6 min-w-0 items-center justify-between gap-2 rounded-md bg-sidebar px-2 text-xs"
>
<span className="min-w-0 truncate font-medium text-sidebar-foreground">
{worktree.displayName}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">hidden</span>
</div>
))}
</div>
))}
</div>
{remainingCount > 0 ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={pending}
onClick={() => setIsExpanded(true)}
className="mt-1.5 h-6 px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
Show {remainingCount} more
</Button>
) : null}
{placement === 'repo-group' ? (
<p className="mt-2 text-[11px] leading-4 text-muted-foreground">
They are currently hidden, but you can show or hide them anytime by clicking{' '}
<span className="inline-flex size-5 align-middle items-center justify-center rounded-md border border-sidebar-border bg-sidebar-accent text-muted-foreground">
<Ellipsis className="size-3" aria-hidden="true" />
<span className="sr-only">repo options</span>
</span>{' '}
on this repo.
</p>
) : (
<p className="mt-2 text-[11px] leading-4 text-muted-foreground">
They are currently hidden in this view. Showing them restores the imported worktrees to
the repo list.
</p>
)}
{error ? (
<p className="mt-2 text-[11px] leading-4 text-destructive" role="alert">
{error}
</p>
) : null}
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-sidebar-border pt-2">
{onKeepHidden ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={pending}
aria-label={`Keep ${hiddenCount} imported ${worktreeNoun} hidden for ${repoDisplayName}`}
onClick={onKeepHidden}
className="text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
Keep hidden
</Button>
) : (
<span className="min-w-0 text-[11px] leading-4 text-muted-foreground">
Use Show to restore this repo&apos;s imported worktrees.
</span>
)}
<Button
type="button"
size="xs"
disabled={pending}
aria-label={`Show ${hiddenCount} imported ${worktreeNoun} for ${repoDisplayName}`}
onClick={onShow}
>
Show
</Button>
</div>
</section>
)
}
export type { ImportedWorktreesVisibilityCardProps }
@@ -0,0 +1,103 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ImportedWorktreesVisibilityLine, {
groupWorktreesByParentPath
} from './ImportedWorktreesVisibilityLine'
import { TooltipProvider } from '@/components/ui/tooltip'
const hiddenWorktrees = [
{
id: 'hidden-1',
displayName: 'payments-refactor',
path: '/worktrees/demo-project/payments-refactor',
branch: 'refs/heads/payments-refactor'
},
{
id: 'hidden-2',
displayName: 'auth-cache-debug',
path: '/worktrees/demo-project/auth-cache-debug',
branch: 'refs/heads/auth-cache-debug'
},
{
id: 'hidden-3',
displayName: 'legacy-oauth-fix',
path: '/worktrees/legacy/legacy-oauth-fix',
branch: 'refs/heads/legacy-oauth-fix'
},
{
id: 'hidden-4',
displayName: 'ssh-worktree',
path: '/srv/repos/orca/ssh-worktree',
branch: 'refs/heads/ssh-worktree'
}
]
function renderLine(
overrides: Partial<ComponentProps<typeof ImportedWorktreesVisibilityLine>> = {}
): string {
return renderToStaticMarkup(
<TooltipProvider>
<ImportedWorktreesVisibilityLine
repoDisplayName="orca"
hiddenWorktrees={hiddenWorktrees}
placement="repo-group"
pending={false}
error={null}
onShow={vi.fn()}
onKeepHidden={vi.fn()}
{...overrides}
/>
</TooltipProvider>
)
}
describe('ImportedWorktreesVisibilityLine', () => {
it('renders the compact repo-group line with inline show and dismiss actions', () => {
const markup = renderLine()
expect(markup).toContain('Hiding 4 discovered worktrees')
expect(markup).toContain('Show all')
expect(markup).toContain('Show all 4 discovered worktrees for orca')
expect(markup).toContain(
'Keep 4 discovered worktrees hidden for orca; recover from the repo menu'
)
expect(markup).toContain('aria-expanded="false"')
expect(markup).not.toContain('Imported 4 existing worktrees')
expect(markup).not.toContain('Orca found 4 worktrees')
expect(markup).not.toContain('repo options')
expect(markup).not.toContain('payments-refactor')
expect(markup).not.toContain('/worktrees/demo-project')
})
it('scopes pinned fallback copy to the repo name without a dismiss action', () => {
const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined })
expect(markup).toContain('Hiding 4 discovered worktrees in orca')
expect(markup).toContain('Show all')
expect(markup).not.toContain('Keep hidden - recover from the repo menu')
})
it('preserves Windows parent path separators in preview groups', () => {
const groups = groupWorktreesByParentPath([
{
id: 'windows-hidden',
displayName: 'FeatureX',
path: 'C:\\Repos\\Orca\\FeatureX'
}
])
expect(groups).toMatchObject([{ path: 'C:\\Repos\\Orca' }])
expect(groups[0]?.path).not.toBe('C:/Repos/Orca')
})
it('disables actions while pending and renders inline errors', () => {
const markup = renderLine({ pending: true, error: 'Could not show imported worktrees.' })
expect(markup).toContain('aria-busy="true"')
expect(markup).toContain('disabled=""')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Could not show imported worktrees.')
})
})
@@ -0,0 +1,216 @@
import React, { useState } from 'react'
import { ChevronRight, EyeOff, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { dirname } from '@/lib/path'
import { cn } from '@/lib/utils'
export type ImportedWorktreesVisibilityPlacement = 'repo-group' | 'pinned-fallback'
export type ImportedWorktreeVisibilityPreview = {
id?: string
displayName: string
path?: string
branch?: string
}
type ImportedWorktreesVisibilityLineProps = {
repoDisplayName: string
hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[]
placement: ImportedWorktreesVisibilityPlacement
pending: boolean
error: string | null
onShow: () => void
onKeepHidden?: () => void
className?: string
}
const PREVIEW_LIMIT = 3
const UNKNOWN_LOCATION_LABEL = 'Unknown location'
const KEEP_HIDDEN_LABEL = 'Keep hidden - recover from the repo menu'
type ImportedWorktreePathGroup = {
path: string
worktrees: ImportedWorktreeVisibilityPreview[]
}
function pluralizeWorktree(count: number): string {
return count === 1 ? 'worktree' : 'worktrees'
}
function getWorktreeKey(
worktree: ImportedWorktreeVisibilityPreview,
index: number,
prefix: string
): string {
return worktree.id ?? worktree.path ?? `${prefix}-${worktree.displayName}-${index}`
}
function getParentPath(path: string | undefined): string {
if (!path) {
return UNKNOWN_LOCATION_LABEL
}
const parentPath = dirname(path)
if (!parentPath || parentPath === '.') {
return UNKNOWN_LOCATION_LABEL
}
return parentPath
}
export function groupWorktreesByParentPath(
worktrees: readonly ImportedWorktreeVisibilityPreview[]
): ImportedWorktreePathGroup[] {
const groups: ImportedWorktreePathGroup[] = []
const groupByPath = new Map<string, ImportedWorktreePathGroup>()
for (const worktree of worktrees) {
const path = getParentPath(worktree.path)
const existing = groupByPath.get(path)
if (existing) {
existing.worktrees.push(worktree)
continue
}
const group = { path, worktrees: [worktree] }
groupByPath.set(path, group)
groups.push(group)
}
return groups
}
export default function ImportedWorktreesVisibilityLine({
repoDisplayName,
hiddenWorktrees,
placement,
pending,
error,
onShow,
onKeepHidden,
className
}: ImportedWorktreesVisibilityLineProps): React.JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(false)
const hiddenCount = hiddenWorktrees.length
const worktreeNoun = pluralizeWorktree(hiddenCount)
const visibleWorktrees = hiddenWorktrees.slice(0, PREVIEW_LIMIT)
const visibleWorktreeGroups = groupWorktreesByParentPath(visibleWorktrees)
const remainingCount = Math.max(0, hiddenWorktrees.length - visibleWorktrees.length)
const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the repo menu`
if (hiddenCount === 0) {
return null
}
const lineText =
placement === 'pinned-fallback'
? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}`
: `Hiding ${hiddenCount} discovered ${worktreeNoun}`
return (
<section
aria-busy={pending}
className={cn('mx-1 my-0.5 ml-5 text-sidebar-foreground', className)}
>
<div
className={cn(
'flex min-h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-[11px] leading-none text-muted-foreground transition-colors',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} hidden worktrees for ${repoDisplayName}`}
onClick={() => setIsExpanded((value) => !value)}
className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<ChevronRight
className={cn('size-3 transition-transform', isExpanded && 'rotate-90')}
aria-hidden="true"
/>
</Button>
<EyeOff className="size-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{lineText}</span>
<Button
type="button"
variant="ghost"
size="xs"
disabled={pending}
aria-label={`Show all ${hiddenCount} discovered ${worktreeNoun} for ${repoDisplayName}`}
onClick={onShow}
className="h-6 shrink-0 px-1.5 text-[11px] font-medium text-sidebar-primary hover:bg-sidebar-accent hover:text-sidebar-primary"
>
Show all
</Button>
{onKeepHidden ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-label={keepHiddenAriaLabel}
onClick={onKeepHidden}
className="shrink-0 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<X className="size-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{KEEP_HIDDEN_LABEL}
</TooltipContent>
</Tooltip>
) : null}
</div>
{isExpanded ? (
<div className="mt-0.5 grid gap-0.5 pb-1" aria-label="Hidden worktree preview">
{visibleWorktreeGroups.map((group) => (
<div key={group.path} className="grid min-w-0 gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="block min-w-0 truncate py-1 pl-7 pr-2 font-mono text-[10px] leading-4 text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
>
{group.path}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{group.path}
</TooltipContent>
</Tooltip>
{group.worktrees.map((worktree, index) => (
<div
key={getWorktreeKey(worktree, index, 'preview')}
className="flex min-h-7 min-w-0 items-center gap-2 rounded-md py-0 pl-5 pr-2 text-xs text-muted-foreground hover:bg-sidebar-accent"
>
<span
className="size-2 shrink-0 rounded-full border border-dashed border-muted-foreground/50"
aria-hidden="true"
/>
<span className="min-w-0 truncate font-medium">{worktree.displayName}</span>
</div>
))}
</div>
))}
{remainingCount > 0 ? (
<div className="py-1 pl-7 pr-2 text-[11px] leading-4 text-muted-foreground">
+ {remainingCount} more
</div>
) : null}
</div>
) : null}
{error ? (
<p className="px-1.5 pb-1 pt-0.5 text-[11px] leading-4 text-destructive" role="alert">
{error}
</p>
) : null}
</section>
)
}
export type { ImportedWorktreesVisibilityLineProps }
@@ -170,7 +170,7 @@ import {
} from '../../../../shared/worktree-ownership'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard'
import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine'
import {
keepImportedWorktreesHiddenCard,
showImportedWorktreesCard,
@@ -293,7 +293,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
repo,
isLegacyRepoForExternalWorktreeVisibility(repo)
)
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Import Worktrees'
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
}
const LINEAGE_INDENT = 18
@@ -3016,7 +3016,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<ImportedWorktreesVisibilityCard
<ImportedWorktreesVisibilityLine
repoDisplayName={row.repo.displayName}
hiddenWorktrees={row.hiddenWorktrees}
placement={row.placement}
@@ -251,10 +251,10 @@ describe('estimateRenderRowSize', () => {
expect(activeSize).toBe(36)
})
it('estimates imported worktree card rows with a stable larger height', () => {
it('estimates imported worktree line rows with a stable compact height', () => {
const rows = [makeHeaderRow('repo:repo-1'), makeImportedCardRow()]
expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(224)
expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(36)
})
it('keeps the previous header active until the secondary header row reaches the top', () => {
@@ -5,7 +5,7 @@ import { PINNED_GROUP_KEY } from './worktree-list-groups'
export const GROUP_HEADER_ROW_HEIGHT = 28
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8
const IMPORTED_WORKTREES_CARD_ROW_HEIGHT = 224
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
type WorktreeItemRow = Extract<Row, { type: 'item' }>
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
@@ -44,7 +44,7 @@ export function estimateRenderRowSize(
return 100 + Math.max(0, row.rows.length - 1) * 96
}
if (row?.type === 'imported-worktrees-card') {
return IMPORTED_WORKTREES_CARD_ROW_HEIGHT
return IMPORTED_WORKTREES_LINE_ROW_HEIGHT
}
return 116
}