Show imported worktrees visibility card (#3506)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d77e475e00
commit
18eb3f2d20
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'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 }
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue