Show imported worktrees visibility card (#3506)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-30 12:03:59 -07:00 committed by GitHub
parent d77e475e00
commit 18eb3f2d20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1418 additions and 35 deletions

View File

@ -17,7 +17,7 @@ type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'
type RepoKind = 'git' | 'folder'
export function useCreateRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
fetchWorktrees: (repoId: string) => Promise<unknown>,
setStep: (step: DialogStep) => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void,

View File

@ -19,7 +19,7 @@ import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-re
// ── Remote project hook ─────────────────────────────────────────────
export function useRemoteRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
fetchWorktrees: (repoId: string) => Promise<unknown>,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup') => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void,

View File

@ -0,0 +1,117 @@
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.')
})
})

View File

@ -0,0 +1,223 @@
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 }

View File

@ -171,6 +171,13 @@ import {
} from '../../../../shared/worktree-ownership'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard'
import {
keepImportedWorktreesHiddenCard,
showImportedWorktreesCard,
type ImportedWorktreeCardActionState
} from './imported-worktrees-card-actions'
import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-candidates'
import {
buildWorktreeSectionActivitySummaries,
EMPTY_WORKTREE_SECTION_ACTIVITY,
@ -308,6 +315,9 @@ type VirtualizedWorktreeViewportProps = {
handleCreateForRepo: (projectId: string) => void
handleOpenRepoSettings: (projectId: string, sectionId?: string) => void
handleOpenWorktreeVisibility: (projectId: string) => void
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
handleRemoveProject: (repo: Repo) => void
handleCreateGroupFromRepo: (repo: Repo) => void
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
@ -522,7 +532,7 @@ function isWorktreeItemRow(row: Row): row is WorktreeItemRow {
return row.type === 'item'
}
function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean {
export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean {
if (worktreeId === null) {
return false
}
@ -567,17 +577,20 @@ function buildRenderableRows(rows: Row[]): RenderRow[] {
return renderRows
}
function getRenderRowKey(row: RenderRow): string {
export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'header') {
return `hdr:${row.key}`
}
if (row.type === 'lineage-group') {
return `lineage-group:${row.key}`
}
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
return `wt:${row.worktree.id}`
}
function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
const groups: WorktreeDragGroup[] = []
let current: { key: string; ids: string[] } | null = null
@ -587,6 +600,9 @@ function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
groups.push({ key: current.key, worktreeIds: current.ids })
continue
}
if (row.type === 'imported-worktrees-card') {
continue
}
if (!current) {
current = { key: ALL_GROUP_KEY, ids: [] }
groups.push({ key: current.key, worktreeIds: current.ids })
@ -597,6 +613,13 @@ function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
return groups.filter((group) => group.worktreeIds.length > 0)
}
export function canKeepImportedWorktreesHidden(
row: Extract<Row, { type: 'imported-worktrees-card' }>,
actionState: ImportedWorktreeCardActionState | undefined
): boolean {
return row.placement === 'repo-group' && actionState?.forceVisible !== true
}
function getWorktreeDragIndexes(groups: readonly WorktreeDragGroup[]): {
groupKeyByWorktreeId: Map<string, string>
groupIndexByWorktreeId: Map<string, number>
@ -632,6 +655,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleCreateForRepo,
handleOpenRepoSettings,
handleOpenWorktreeVisibility,
handleShowImportedWorktrees,
handleKeepImportedWorktreesHidden,
importedWorktreeCardActionState,
handleRemoveProject,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
@ -2981,6 +3007,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'imported-worktrees-card') {
const actionState = importedWorktreeCardActionState.get(row.repo.id)
return (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<ImportedWorktreesVisibilityCard
repoDisplayName={row.repo.displayName}
hiddenWorktrees={row.hiddenWorktrees}
placement={row.placement}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onShow={() => handleShowImportedWorktrees(row.repo.id)}
onKeepHidden={
canKeepImportedWorktreesHidden(row, actionState)
? () => handleKeepImportedWorktreesHidden(row.repo.id)
: undefined
}
/>
</div>
)
}
const itemWorkspaceStatus =
groupBy === 'workspace-status'
? getWorkspaceStatus(row.worktree, workspaceStatuses)
@ -3049,6 +3106,7 @@ const WorktreeList = React.memo(function WorktreeList({
const worktreeMap = useWorktreeMap()
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const groupBy = useAppStore((s) => s.groupBy)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
@ -3062,6 +3120,8 @@ const WorktreeList = React.memo(function WorktreeList({
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const updateRepo = useAppStore((s) => s.updateRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
@ -3376,6 +3436,22 @@ const WorktreeList = React.memo(function WorktreeList({
repos.forEach((r, i) => map.set(r.id, i))
return map
}, [repos])
const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState<
Map<string, ImportedWorktreeCardActionState>
>(new Map())
const importedWorktreesByRepo = useMemo(() => {
const forceVisibleRepoIds = new Set(
[...importedWorktreeCardActionState.entries()]
.filter(([, state]) => state.forceVisible)
.map(([repoId]) => repoId)
)
return buildImportedWorktreesCardCandidates({
repos,
detectedWorktreesByRepo,
filterRepoIds,
forceVisibleRepoIds
})
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, repos])
const placeholderRepoIds = useMemo(() => {
if (groupBy !== 'repo' || projectGroups.length === 0) {
return new Set<string>()
@ -3460,7 +3536,8 @@ const WorktreeList = React.memo(function WorktreeList({
true,
settings,
projectGroups,
placeholderRepoIds
placeholderRepoIds,
importedWorktreesByRepo
),
[
groupBy,
@ -3475,7 +3552,8 @@ const WorktreeList = React.memo(function WorktreeList({
worktreeMap,
settings,
projectGroups,
placeholderRepoIds
placeholderRepoIds,
importedWorktreesByRepo
]
)
// Why: header/mode changes can shift entire groups, so remount the
@ -3620,6 +3698,45 @@ const WorktreeList = React.memo(function WorktreeList({
[openModal]
)
const setImportedWorktreeCardState = useCallback(
(projectId: string, state: ImportedWorktreeCardActionState | null) => {
setImportedWorktreeCardActionState((previous) => {
const next = new Map(previous)
if (state) {
next.set(projectId, state)
} else {
next.delete(projectId)
}
return next
})
},
[]
)
const handleShowImportedWorktrees = useCallback(
async (projectId: string) => {
await showImportedWorktreesCard({
projectId,
forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true,
updateRepo,
fetchWorktrees,
setCardState: setImportedWorktreeCardState
})
},
[fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo]
)
const handleKeepImportedWorktreesHidden = useCallback(
async (projectId: string) => {
await keepImportedWorktreesHiddenCard({
projectId,
updateRepo,
setCardState: setImportedWorktreeCardState
})
},
[setImportedWorktreeCardState, updateRepo]
)
const handleRemoveProject = useCallback(
(repo: Repo) => {
openModal('confirm-remove-folder', {
@ -3880,7 +3997,11 @@ const WorktreeList = React.memo(function WorktreeList({
}
}, [handleRevealCurrentWorkspaceRequest])
const filtersHideAllRows = hasFilters && worktrees.length === 0 && placeholderRepoIds.size === 0
const filtersHideAllRows =
hasFilters &&
worktrees.length === 0 &&
placeholderRepoIds.size === 0 &&
importedWorktreesByRepo.size === 0
// Why: Project Group headers can render before workspace rows load, but when
// active filters hide everything the Clear Filters empty state must win.
if (rows.length === 0 || filtersHideAllRows) {
@ -3953,6 +4074,9 @@ const WorktreeList = React.memo(function WorktreeList({
handleCreateForRepo={handleCreateForRepo}
handleOpenRepoSettings={handleOpenRepoSettings}
handleOpenWorktreeVisibility={handleOpenWorktreeVisibility}
handleShowImportedWorktrees={handleShowImportedWorktrees}
handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden}
importedWorktreeCardActionState={importedWorktreeCardActionState}
handleRemoveProject={handleRemoveProject}
handleCreateGroupFromRepo={handleCreateGroupFromRepo}
handleMoveProjectToGroup={handleMoveProjectToGroup}

View File

@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR,
IMPORTED_WORKTREES_SHOW_ERROR,
keepImportedWorktreesHiddenCard,
showImportedWorktreesCard,
type ImportedWorktreeCardActionState
} from './imported-worktrees-card-actions'
const projectId = 'repo-1'
describe('imported worktrees card actions', () => {
const updateRepo = vi.fn()
const fetchWorktrees = vi.fn()
const setCardState =
vi.fn<(projectId: string, state: ImportedWorktreeCardActionState | null) => void>()
beforeEach(() => {
vi.clearAllMocks()
updateRepo.mockResolvedValue(true)
fetchWorktrees.mockResolvedValue(true)
})
it('shows imported worktrees only after visibility update and refresh succeed', async () => {
await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState })
expect(updateRepo).toHaveBeenCalledWith(projectId, { externalWorktreeVisibility: 'show' })
expect(fetchWorktrees).toHaveBeenCalledWith(projectId, { requireAuthoritative: true })
expect(setCardState).toHaveBeenNthCalledWith(1, projectId, {
pending: true,
error: null,
forceVisible: true
})
expect(setCardState).toHaveBeenLastCalledWith(projectId, null)
})
it('leaves the card visible when showing fails before refresh', async () => {
updateRepo.mockResolvedValueOnce(false)
await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState })
expect(fetchWorktrees).not.toHaveBeenCalled()
expect(setCardState).toHaveBeenLastCalledWith(projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR
})
})
it('preserves force-visible state during a retry after rollback failure', async () => {
updateRepo.mockResolvedValueOnce(false)
await showImportedWorktreesCard({
projectId,
forceVisible: true,
updateRepo,
fetchWorktrees,
setCardState
})
expect(fetchWorktrees).not.toHaveBeenCalled()
expect(setCardState).toHaveBeenNthCalledWith(1, projectId, {
pending: true,
error: null,
forceVisible: true
})
expect(setCardState).toHaveBeenLastCalledWith(projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR,
forceVisible: true
})
})
it('rolls visibility back and leaves an error when refresh fails after showing', async () => {
fetchWorktrees.mockResolvedValueOnce(false)
await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState })
expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, { externalWorktreeVisibility: 'show' })
expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, { externalWorktreeVisibility: 'hide' })
expect(setCardState).toHaveBeenLastCalledWith(projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR
})
})
it('keeps the card force-visible when rollback fails after a refresh failure', async () => {
fetchWorktrees.mockResolvedValueOnce(false)
updateRepo.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState })
expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, { externalWorktreeVisibility: 'show' })
expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, { externalWorktreeVisibility: 'hide' })
expect(setCardState).toHaveBeenLastCalledWith(projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR,
forceVisible: true
})
})
it('dismisses the card when keep-hidden update succeeds', async () => {
await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState })
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeVisibilityPromptDismissedAt: expect.any(Number)
})
expect(setCardState).toHaveBeenLastCalledWith(projectId, null)
})
it('leaves the card visible when keep-hidden update fails', async () => {
updateRepo.mockResolvedValueOnce(false)
await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState })
expect(setCardState).toHaveBeenLastCalledWith(projectId, {
pending: false,
error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR
})
})
})

View File

@ -0,0 +1,75 @@
import type { Repo } from '../../../../shared/types'
export type ImportedWorktreeCardActionState = {
pending: boolean
error: string | null
forceVisible?: boolean
}
type ImportedWorktreeCardActionDeps = {
projectId: string
forceVisible?: boolean
setCardState: (projectId: string, state: ImportedWorktreeCardActionState | null) => void
updateRepo: (
projectId: string,
updates: Partial<
Pick<Repo, 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt'>
>
) => Promise<boolean>
fetchWorktrees: (
projectId: string,
options?: { requireAuthoritative?: boolean }
) => Promise<boolean>
}
export const IMPORTED_WORKTREES_SHOW_ERROR = 'Could not show imported worktrees. Try again.'
export const IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR =
'Could not keep imported worktrees hidden. Try again.'
export async function showImportedWorktreesCard(
args: ImportedWorktreeCardActionDeps
): Promise<void> {
const forceVisible = args.forceVisible === true
args.setCardState(args.projectId, {
pending: true,
error: null,
forceVisible: true
})
const updated = await args.updateRepo(args.projectId, { externalWorktreeVisibility: 'show' })
if (!updated) {
args.setCardState(args.projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR,
...(forceVisible ? { forceVisible: true } : {})
})
return
}
const refreshed = await args.fetchWorktrees(args.projectId, { requireAuthoritative: true })
if (!refreshed) {
const rolledBack = await args.updateRepo(args.projectId, { externalWorktreeVisibility: 'hide' })
args.setCardState(args.projectId, {
pending: false,
error: IMPORTED_WORKTREES_SHOW_ERROR,
...(rolledBack ? {} : { forceVisible: true })
})
return
}
args.setCardState(args.projectId, null)
}
export async function keepImportedWorktreesHiddenCard(
args: Omit<ImportedWorktreeCardActionDeps, 'fetchWorktrees'>
): Promise<void> {
args.setCardState(args.projectId, { pending: true, error: null })
const updated = await args.updateRepo(args.projectId, {
externalWorktreeVisibilityPromptDismissedAt: Date.now()
})
if (!updated) {
args.setCardState(args.projectId, {
pending: false,
error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR
})
return
}
args.setCardState(args.projectId, null)
}

View File

@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from './imported-worktrees-card-candidates'
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000000',
addedAt: Date.UTC(2026, 4, 24),
externalWorktreeVisibility: 'hide'
}
const visibleWorktree: Worktree = {
id: 'repo-1::/repo',
repoId: repo.id,
path: '/repo',
displayName: 'main',
branch: 'refs/heads/main',
head: 'abc123',
isBare: false,
isMainWorktree: true,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
function detectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree {
return {
...visibleWorktree,
id: 'repo-1::/repo-worktree',
path: '/repo-worktree',
displayName: 'repo-worktree',
isMainWorktree: false,
ownership: 'external',
selectedCheckout: false,
visible: false,
...overrides
}
}
function detectedResult(
worktrees: DetectedWorktree[],
overrides: Partial<DetectedWorktreeListResult> = {}
): DetectedWorktreeListResult {
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees,
...overrides
}
}
describe('getHiddenImportedWorktrees', () => {
it('returns only authoritative hidden external worktrees', () => {
const hidden = detectedWorktree({ id: 'hidden' })
const result = getHiddenImportedWorktrees(
detectedResult([
hidden,
detectedWorktree({ id: 'visible', visible: true }),
detectedWorktree({ id: 'selected', selectedCheckout: true }),
detectedWorktree({ id: 'orca-managed', ownership: 'orca-managed' })
])
)
expect(result).toEqual([hidden])
})
it('suppresses non-authoritative results', () => {
expect(
getHiddenImportedWorktrees(detectedResult([detectedWorktree()], { authoritative: false }))
).toEqual([])
})
})
describe('buildImportedWorktreesCardCandidates', () => {
it('builds a candidate for hidden imported worktrees in a visible repo', () => {
const candidates = buildImportedWorktreesCardCandidates({
repos: [repo],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) }
})
expect(candidates.get(repo.id)).toMatchObject({
repo: { id: repo.id },
hiddenWorktrees: [{ id: 'repo-1::/repo-worktree' }]
})
})
it('suppresses candidates after show, dismissal, folder repos, or repo filters exclude the repo', () => {
const detectedWorktreesByRepo = { [repo.id]: detectedResult([detectedWorktree()]) }
expect(
buildImportedWorktreesCardCandidates({
repos: [{ ...repo, externalWorktreeVisibility: 'show' }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).size
).toBe(0)
expect(
buildImportedWorktreesCardCandidates({
repos: [{ ...repo, externalWorktreeVisibilityPromptDismissedAt: 1 }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).size
).toBe(0)
expect(
buildImportedWorktreesCardCandidates({
repos: [{ ...repo, kind: 'folder' }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).size
).toBe(0)
expect(
buildImportedWorktreesCardCandidates({
repos: [repo],
detectedWorktreesByRepo,
filterRepoIds: ['other-repo']
}).size
).toBe(0)
})
it('keeps candidates visible after a rollback failure forces a shown repo to render the card', () => {
const candidates = buildImportedWorktreesCardCandidates({
repos: [{ ...repo, externalWorktreeVisibility: 'show' }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) },
forceVisibleRepoIds: new Set([repo.id])
})
expect(candidates.get(repo.id)).toMatchObject({
repo: { id: repo.id },
hiddenWorktrees: [{ id: 'repo-1::/repo-worktree' }]
})
})
it('builds candidates even when workspace-row filters hide every visible worktree', () => {
const candidates = buildImportedWorktreesCardCandidates({
repos: [repo],
detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) }
})
expect(candidates.has(repo.id)).toBe(true)
})
})

View File

@ -0,0 +1,64 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
import type { ImportedWorktreesCardCandidate } from './worktree-list-groups'
export function getHiddenImportedWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) =>
!worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
)
}
export function buildImportedWorktreesCardCandidates(args: {
repos: readonly Repo[]
visibleWorktrees?: readonly Worktree[]
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
filterRepoIds?: readonly string[]
forceVisibleRepoIds?: ReadonlySet<string>
}): Map<string, ImportedWorktreesCardCandidate> {
const visibleRepoIds = args.visibleWorktrees
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
: null
const filterRepoIds = args.filterRepoIds?.length ? new Set(args.filterRepoIds) : null
const candidates = new Map<string, ImportedWorktreesCardCandidate>()
for (const repo of args.repos) {
if (filterRepoIds && !filterRepoIds.has(repo.id)) {
continue
}
if (visibleRepoIds && !visibleRepoIds.has(repo.id)) {
continue
}
if (!isGitRepoKind(repo)) {
continue
}
if (typeof repo.externalWorktreeVisibilityPromptDismissedAt === 'number') {
continue
}
const visibility = effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo)
)
if (visibility !== 'hide' && !args.forceVisibleRepoIds?.has(repo.id)) {
continue
}
const hiddenWorktrees = getHiddenImportedWorktrees(args.detectedWorktreesByRepo[repo.id])
if (hiddenWorktrees.length > 0) {
candidates.set(repo.id, { repo, hiddenWorktrees })
}
}
return candidates
}

View File

@ -12,6 +12,10 @@ function item(id: string, depth = 0): { type: 'item'; worktree: { id: string };
return { type: 'item', worktree: { id }, depth }
}
function importedCard(): { type: 'imported-worktrees-card' } {
return { type: 'imported-worktrees-card' }
}
describe('getWorktreeDragUnitGroups', () => {
it('treats expanded lineage descendants as part of the parent drag unit', () => {
const groups = getWorktreeDragUnitGroups([
@ -33,6 +37,34 @@ describe('getWorktreeDragUnitGroups', () => {
}
])
})
it('ignores imported worktree card rows without splitting drag groups', () => {
const groups = getWorktreeDragUnitGroups([
header('repo:one'),
item('main'),
importedCard(),
item('feature'),
header('repo:two'),
importedCard(),
item('other')
])
expect(groups).toEqual([
{
key: 'repo:one',
worktreeIds: ['main', 'feature'],
units: [
{ worktreeId: 'main', worktreeIds: ['main'] },
{ worktreeId: 'feature', worktreeIds: ['feature'] }
]
},
{
key: 'repo:two',
worktreeIds: ['other'],
units: [{ worktreeId: 'other', worktreeIds: ['other'] }]
}
])
})
})
describe('getFullDropIndexForWorktreeDragUnit', () => {

View File

@ -8,6 +8,7 @@ export type WorktreeDragUnitGroup = WorktreeDragGroup & {
type WorktreeDragUnitRow =
| { type: 'header'; key: string }
| { type: 'item'; worktree: { id: string }; depth: number }
| { type: 'imported-worktrees-card' }
export function getWorktreeDragUnitGroups(
rows: readonly WorktreeDragUnitRow[]
@ -25,6 +26,9 @@ export function getWorktreeDragUnitGroups(
})
continue
}
if (row.type === 'imported-worktrees-card') {
continue
}
if (!current) {
current = { key: ALL_GROUP_KEY, units: [] }
groups.push({

View File

@ -12,7 +12,13 @@ import {
getPRGroupKey,
getProjectGroupOrdering
} from './worktree-list-groups'
import type { Repo, ProjectGroup, Worktree, WorktreeLineage } from '../../../../shared/types'
import type {
DetectedWorktree,
Repo,
ProjectGroup,
Worktree,
WorktreeLineage
} from '../../../../shared/types'
const repo: Repo = {
id: 'repo-1',
@ -44,6 +50,19 @@ const worktree: Worktree = {
const repoMap = new Map([[repo.id, repo]])
function makeDetectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree {
return {
...worktree,
id: overrides.id ?? `${repo.id}::/tmp/${overrides.displayName ?? 'hidden'}`,
path: overrides.path ?? `/tmp/${overrides.displayName ?? 'hidden'}`,
displayName: overrides.displayName ?? 'hidden',
visible: false,
selectedCheckout: false,
ownership: 'external',
...overrides
}
}
describe('getPRGroupKey', () => {
it('puts merged PRs in the done group', () => {
const prCache = {
@ -234,6 +253,230 @@ describe('buildRows with pinned worktrees', () => {
expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' })
})
it('emits an imported worktrees card at the top of repo-group rows', () => {
const hidden = [
makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }),
makeDetectedWorktree({ id: 'hidden-2', displayName: 'auth-cache-debug' }),
makeDetectedWorktree({ id: 'hidden-3', displayName: 'legacy-oauth-fix' })
]
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: hidden }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1' },
{
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:repo-group:repo-1',
placement: 'repo-group',
repo: { id: 'repo-1' },
hiddenWorktrees: [{ id: 'hidden-1' }, { id: 'hidden-2' }, { id: 'hidden-3' }]
},
{ type: 'item', worktree: { id: 'wt-1' } }
])
})
it('suppresses the repo-group imported worktrees card when the repo group is collapsed', () => {
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(['repo:repo-1']),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }])
})
it('emits a repo header and imported worktrees card when no visible worktree rows remain', () => {
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1', count: 0 },
{
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:repo-group:repo-1',
placement: 'repo-group'
}
])
})
it('does not emit unpinned imported worktree cards outside repo grouping', () => {
const rows = buildRows(
'workspace-status',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false)
})
it('emits pinned-only imported worktree fallback cards after the repo final pinned row', () => {
const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
const pinnedTwo = {
...worktree,
id: 'repo-2-pinned',
repoId: repoTwo.id,
isPinned: true,
displayName: 'auth-main'
}
const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true }
const rows = buildRows(
'repo',
[pinnedOneA, pinnedTwo, pinnedOneB],
new Map([
[repo.id, repo],
[repoTwo.id, repoTwo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[pinnedOneA.id, pinnedOneA],
[pinnedTwo.id, pinnedTwo],
[pinnedOneB.id, pinnedOneB]
]),
false,
undefined,
[],
new Set(),
new Map([
[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }],
[
repoTwo.id,
{
repo: repoTwo,
hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })]
}
]
])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned', count: 3 },
{ type: 'item', worktree: { id: 'repo-1-pinned-a' } },
{ type: 'item', worktree: { id: 'repo-2-pinned' } },
{
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:pinned-fallback:repo-2',
placement: 'pinned-fallback'
},
{ type: 'item', worktree: { id: 'repo-1-pinned-b' } },
{
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:pinned-fallback:repo-1',
placement: 'pinned-fallback'
}
])
})
it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => {
const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true }
const rows = buildRows(
'repo',
[pinnedWorktree, worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[pinnedWorktree.id, pinnedWorktree],
[worktree.id, worktree]
]),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows.filter((row) => row.type === 'imported-worktrees-card')).toMatchObject([
{ placement: 'repo-group' }
])
})
it('suppresses pinned imported worktree fallback when Pinned is collapsed', () => {
const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true }
const rows = buildRows(
'repo',
[pinnedWorktree],
repoMap,
null,
new Set(['pinned']),
undefined,
undefined,
undefined,
{},
new Map([[pinnedWorktree.id, pinnedWorktree]]),
false,
undefined,
[],
new Set(),
new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([{ type: 'header', key: 'pinned' }])
})
it('groups folder-mode workspaces under their folder name', () => {
const folderRepo: Repo = {
...repo,

View File

@ -2,6 +2,7 @@
import { CircleX, FolderTree, List, Pin } from 'lucide-react'
import type React from 'react'
import type {
DetectedWorktree,
Repo,
ProjectGroup,
Worktree,
@ -63,7 +64,21 @@ export type WorktreeRow = {
lineageGroupKey?: string
lineageCollapsed?: boolean
}
export type Row = GroupHeaderRow | WorktreeRow
export type ImportedWorktreesCardCandidate = {
repo: Repo
hiddenWorktrees: DetectedWorktree[]
}
export type ImportedWorktreesCardRow = {
type: 'imported-worktrees-card'
key: string
repo: Repo
hiddenWorktrees: DetectedWorktree[]
placement: 'repo-group' | 'pinned-fallback'
}
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
@ -209,9 +224,9 @@ export function getPRGroupKey(
function emitPinnedGroup(
worktrees: Worktree[],
repoMap: Map<string, Repo>,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
collapsedGroups: Set<string>,
visibleUnpinnedRepoIds: ReadonlySet<string>,
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>,
result: Row[]
): Set<string> {
const pinned = worktrees.filter((w) => w.isPinned)
@ -228,14 +243,36 @@ function emitPinnedGroup(
icon: PINNED_GROUP_META.icon
})
if (!collapsedGroups.has(PINNED_GROUP_KEY)) {
appendWorktreeRows(result, pinned, repoMap, lineageById, worktreeMap, {
nestLineage: false,
collapsedGroups
})
const lastPinnedIndexByRepoId = new Map<string, number>()
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
for (const [index, worktree] of pinned.entries()) {
result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false))
const candidate = importedWorktreesByRepo.get(worktree.repoId)
if (
candidate &&
!visibleUnpinnedRepoIds.has(worktree.repoId) &&
lastPinnedIndexByRepoId.get(worktree.repoId) === index
) {
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
}
}
}
return new Set(pinned.map((w) => w.id))
}
function buildImportedWorktreesCardRow(
candidate: ImportedWorktreesCardCandidate,
placement: ImportedWorktreesCardRow['placement']
): ImportedWorktreesCardRow {
return {
type: 'imported-worktrees-card',
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
repo: candidate.repo,
hiddenWorktrees: candidate.hiddenWorktrees,
placement
}
}
function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
@ -364,16 +401,23 @@ export function buildRows(
nestLineage = false,
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set()
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map()
): Row[] {
const result: Row[] = []
const visibleUnpinnedRepoIds = new Set(
worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId)
)
const visiblePinnedRepoIds = new Set(
worktrees.filter((worktree) => worktree.isPinned).map((worktree) => worktree.repoId)
)
const pinnedIds = emitPinnedGroup(
worktrees,
repoMap,
lineageById,
worktreeMap,
collapsedGroups,
visibleUnpinnedRepoIds,
importedWorktreesByRepo,
result
)
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
@ -433,6 +477,18 @@ export function buildRows(
}
}
}
if (groupBy === 'repo') {
for (const [repoId, candidate] of importedWorktreesByRepo) {
const key = `repo:${repoId}`
if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) {
grouped.set(key, {
label: candidate.repo.displayName,
items: [],
repo: candidate.repo
})
}
}
}
const orderedGroups: [string, { label: string; items: Worktree[]; repo?: Repo }][] = []
if (groupBy === 'pr-status') {
@ -531,6 +587,12 @@ export function buildRows(
result.push(header)
if (!isCollapsed) {
if (groupBy === 'repo' && repo) {
const candidate = importedWorktreesByRepo.get(repo.id)
if (candidate) {
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
}
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups

View File

@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import {
canKeepImportedWorktreesHidden,
countRecordKeysByReference,
getRenderRowKey,
getScrollTopToRevealBounds,
getWorktreeDragGroups,
renderRowContainsWorktree,
resolvePendingSidebarReveal,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET,
shouldAdjustWorktreeSidebarMeasuredRowScroll
@ -11,15 +15,64 @@ import {
GROUP_HEADER_ROW_HEIGHT,
getActiveStickyHeaderIndexForScroll
} from './worktree-list-virtual-rows'
import type { Repo, Worktree } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
const makeHeaderRow = (key: string) =>
({
type: 'header',
key,
label: key,
count: 0,
tone: 'text-foreground'
}) as const
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000',
addedAt: 1
}
const makeHeaderRow = (key: string): Extract<Row, { type: 'header' }> => ({
type: 'header',
key,
label: key,
count: 0,
tone: 'text-foreground'
})
const makeWorktree = (id: string): Worktree => ({
id,
repoId: repo.id,
path: `/repo/${id}`,
head: 'abc123',
branch: `refs/heads/${id}`,
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
})
const makeWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
type: 'item',
worktree: makeWorktree(id),
repo,
depth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
})
const makeImportedCardRow = (): Extract<Row, { type: 'imported-worktrees-card' }> => ({
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:repo-group:repo-1',
repo,
hiddenWorktrees: [],
placement: 'repo-group'
})
const makeScrollContainer = (scrollTop: number, clientHeight: number): HTMLElement =>
({ scrollTop, clientHeight }) as HTMLElement
@ -177,6 +230,12 @@ describe('estimateRenderRowSize', () => {
expect(activeSize).toBe(36)
})
it('estimates imported worktree card rows with a stable larger height', () => {
const rows = [makeHeaderRow('repo:repo-1'), makeImportedCardRow()]
expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(224)
})
it('keeps the previous header active until the secondary header row reaches the top', () => {
expect(
getActiveStickyHeaderIndexForScroll({
@ -202,3 +261,40 @@ describe('estimateRenderRowSize', () => {
).toBe(1)
})
})
describe('imported worktree virtual rows', () => {
it('uses stable imported row keys and does not match worktree ids', () => {
const card = makeImportedCardRow()
expect(getRenderRowKey(card)).toBe('imported:imported-worktrees-card:repo-group:repo-1')
expect(renderRowContainsWorktree(card, 'wt-1')).toBe(false)
})
it('keeps imported card rows out of worktree drag groups', () => {
expect(
getWorktreeDragGroups([
makeHeaderRow('repo:repo-1'),
makeWorktreeRow('main'),
makeImportedCardRow(),
makeWorktreeRow('feature')
])
).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }])
})
it('suppresses keep-hidden actions for force-visible rollback failure cards', () => {
expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true)
expect(
canKeepImportedWorktreesHidden(makeImportedCardRow(), {
pending: false,
error: 'Could not show imported worktrees.',
forceVisible: true
})
).toBe(false)
expect(
canKeepImportedWorktreesHidden(
{ ...makeImportedCardRow(), placement: 'pinned-fallback' },
undefined
)
).toBe(false)
})
})

View File

@ -4,6 +4,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
type WorktreeItemRow = Extract<Row, { type: 'item' }>
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
@ -41,6 +42,9 @@ export function estimateRenderRowSize(
if (row?.type === 'lineage-group') {
return 100 + Math.max(0, row.rows.length - 1) * 96
}
if (row?.type === 'imported-worktrees-card') {
return IMPORTED_WORKTREES_CARD_ROW_HEIGHT
}
return 116
}

View File

@ -70,7 +70,7 @@ export type WorktreeSlice = {
*/
hasHydratedWorktreePurge: boolean
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
fetchWorktrees: (repoId: string) => Promise<void>
fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<boolean>
fetchAllWorktrees: () => Promise<void>
fetchWorktreeLineage: () => Promise<void>
updateWorktreeLineage: (

View File

@ -267,12 +267,13 @@ describe('fetchWorktrees', () => {
} as Partial<AppState>)
const unsubscribe = store.subscribe(subscriber)
await store.getState().fetchWorktrees('repo1')
const result = await store.getState().fetchWorktrees('repo1')
unsubscribe()
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().sortEpoch).toBe(7)
expect(subscriber).not.toHaveBeenCalled()
expect(result).toBe(true)
})
it('updates the repo entry and bumps sortEpoch when git reports a branch change', async () => {
@ -338,10 +339,59 @@ describe('fetchWorktrees', () => {
)
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
const result = await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().sortEpoch).toBe(7)
expect(result).toBe(false)
})
it('reports unchanged non-authoritative refreshes as not fully refreshed', async () => {
const store = createTestStore()
const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('repo1', [existing], {
authoritative: false,
source: 'metadata-fallback'
})
)
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial<AppState>)
const result = await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().sortEpoch).toBe(7)
expect(result).toBe(false)
})
it('does not publish non-authoritative rows when an authoritative refresh is required', async () => {
const store = createTestStore()
const existing = makeWorktree({
id: 'repo1::/path/existing',
repoId: 'repo1',
path: '/path/existing'
})
const fallback = makeWorktree({
id: 'repo1::/path/fallback',
repoId: 'repo1',
path: '/path/fallback'
})
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('repo1', [fallback], {
authoritative: false,
source: 'metadata-fallback'
})
)
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial<AppState>)
const result = await store.getState().fetchWorktrees('repo1', { requireAuthoritative: true })
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().detectedWorktreesByRepo.repo1).toBeUndefined()
expect(store.getState().sortEpoch).toBe(7)
expect(result).toBe(false)
})
it('purges remembered right sidebar tabs for worktrees removed by a committed refresh', async () => {
@ -494,7 +544,7 @@ describe('fetchWorktrees', () => {
}
} as unknown as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
const result = await store.getState().fetchWorktrees('repo1')
expect(store.getState().rightSidebarTabByWorktree).toEqual({
[missingFromFallback.id]: 'search',
@ -505,6 +555,7 @@ describe('fetchWorktrees', () => {
])
expect(store.getState().worktreesByRepo.repo1).toEqual([fallback])
expect(store.getState().sortEpoch).toBe(8)
expect(result).toBe(false)
})
it('does not purge remembered right sidebar tabs on a transient empty refresh', async () => {
@ -523,11 +574,12 @@ describe('fetchWorktrees', () => {
rightSidebarTabByWorktree: { [existing.id]: 'search' }
} as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
const result = await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().rightSidebarTabByWorktree).toEqual({ [existing.id]: 'search' })
expect(store.getState().sortEpoch).toBe(7)
expect(result).toBe(false)
})
it('accepts an empty refresh when the repo had no cached worktrees', async () => {

View File

@ -731,10 +731,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
fetchWorktrees: async (repoId) => {
fetchWorktrees: async (repoId, options) => {
try {
const settings = get().settings
const detected = await listDetectedWorktreesForRepo(settings, repoId)
if (options?.requireAuthoritative && !detected.authoritative) {
return false
}
const worktrees = toVisibleWorktrees(detected)
const current = get().worktreesByRepo[repoId]
if (areWorktreesEqual(current, worktrees)) {
@ -752,7 +755,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})
await refreshRemoteWorktreeLineageBestEffort(settings, set)
return
return detected.authoritative
}
// Why: `git worktree list` can fail transiently (e.g. concurrent git
@ -766,7 +769,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
set((s) => ({
detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }
}))
return
return false
}
set((s) => {
@ -786,8 +789,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})
await refreshRemoteWorktreeLineageBestEffort(settings, set)
return detected.authoritative
} catch (err) {
console.error(`Failed to fetch worktrees for repo ${repoId}:`, err)
return false
}
},