mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Show full details for child worktree cards
This commit is contained in:
@@ -121,7 +121,11 @@ function RepoIdentityChip({
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-worktree-sidebar-border bg-worktree-sidebar-accent/55"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeCard.35ccfe2475", "Project {{value0}}", { value0: repo.displayName })}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCard.35ccfe2475',
|
||||
'Project {{value0}}',
|
||||
{ value0: repo.displayName }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -484,6 +488,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (isDeleting) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
// Why: route sidebar clicks through the shared activation path so the
|
||||
// back/forward stack stays complete for the primary worktree navigation
|
||||
// surface instead of only recording palette-driven switches.
|
||||
@@ -494,7 +503,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
}
|
||||
onActivate?.()
|
||||
},
|
||||
[worktree.id, isSshDisconnected, onActivate, onImmediateActivate, onSelectionGesture]
|
||||
[
|
||||
worktree.id,
|
||||
isDeleting,
|
||||
isSshDisconnected,
|
||||
onActivate,
|
||||
onImmediateActivate,
|
||||
onSelectionGesture
|
||||
]
|
||||
)
|
||||
|
||||
const handleRenameTitle = useCallback(
|
||||
@@ -837,7 +853,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm border border-border/50">
|
||||
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
|
||||
{translate("auto.components.sidebar.WorktreeCard.691ccfd622", "Deleting…")}</div>
|
||||
{translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -890,7 +907,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{isSshDisconnected ? translate("auto.components.sidebar.WorktreeCard.021538e1d1", "SSH disconnected") : translate("auto.components.sidebar.WorktreeCard.ca74db7550", "Remote project via SSH")}
|
||||
{isSshDisconnected
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeCard.021538e1d1',
|
||||
'SSH disconnected'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeCard.ca74db7550',
|
||||
'Remote project via SSH'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -933,13 +958,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
onClick={handleOpenRenameErrorDialog}
|
||||
onDoubleClick={handleOpenRenameErrorDialog}
|
||||
className="h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive has-[>svg]:!px-0.5"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeCard.02e19349f4", "Auto-rename failed: view error")}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCard.02e19349f4',
|
||||
'Auto-rename failed: view error'
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="size-2.5" />
|
||||
{translate("auto.components.sidebar.WorktreeCard.74522ee457", "rename failed")}</Button>
|
||||
{translate('auto.components.sidebar.WorktreeCard.74522ee457', 'rename failed')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{translate("auto.components.sidebar.WorktreeCard.4eba2ea99e", "Auto-name failed. Click to see details.")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.4eba2ea99e',
|
||||
'Auto-name failed. Click to see details.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : worktree.pendingFirstAgentMessageRename === true && !titleRenaming ? (
|
||||
<Tooltip>
|
||||
@@ -951,13 +984,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
onClick={handlePendingFirstAgentMessageRenameInfo}
|
||||
onDoubleClick={handlePendingFirstAgentMessageRenameInfo}
|
||||
className="h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none text-muted-foreground border border-worktree-sidebar-border/60 bg-worktree-sidebar-accent/45 hover:bg-worktree-sidebar-accent hover:text-foreground has-[>svg]:!px-0.5"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeCard.c6833b5187", "Will be renamed from first agent message")}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCard.c6833b5187',
|
||||
'Will be renamed from first agent message'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-2.5" />
|
||||
{translate("auto.components.sidebar.WorktreeCard.f62a3dadbc", "rename pending")}</Button>
|
||||
{translate('auto.components.sidebar.WorktreeCard.f62a3dadbc', 'rename pending')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{translate("auto.components.sidebar.WorktreeCard.c6833b5187", "Will be renamed from first agent message")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.c6833b5187',
|
||||
'Will be renamed from first agent message'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@@ -968,10 +1009,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
variant="outline"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-foreground/70 border-foreground/20 bg-foreground/[0.06]"
|
||||
>
|
||||
{translate("auto.components.sidebar.WorktreeCard.7d517f82e2", "primary")}</Badge>
|
||||
{translate('auto.components.sidebar.WorktreeCard.7d517f82e2', 'primary')}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{translate("auto.components.sidebar.WorktreeCard.0777de5970", "Primary worktree (original clone directory)")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.0777de5970',
|
||||
'Primary worktree (original clone directory)'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -982,11 +1028,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
variant="outline"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-amber-700 dark:text-amber-300 border-amber-500/30 bg-amber-500/5"
|
||||
>
|
||||
{translate("auto.components.sidebar.WorktreeCard.4f964d5e8c", "sparse")}</Badge>
|
||||
{translate('auto.components.sidebar.WorktreeCard.4f964d5e8c', 'sparse')}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-72">
|
||||
<div className="space-y-1">
|
||||
<div>{translate("auto.components.sidebar.WorktreeCard.0f33af979b", "Partial checkout. Files outside these paths are not on disk.")}</div>
|
||||
<div>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.0f33af979b',
|
||||
'Partial checkout. Files outside these paths are not on disk.'
|
||||
)}
|
||||
</div>
|
||||
{worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? (
|
||||
<div className="font-mono text-[11px] opacity-80">
|
||||
{formatSparseDirectoryPreview(worktree.sparseDirectories)}
|
||||
@@ -1017,13 +1069,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeCard.0d224eff10", "Primary worktree")}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCard.0d224eff10',
|
||||
'Primary worktree'
|
||||
)}
|
||||
>
|
||||
<Star className="size-3 fill-amber-400 text-amber-400" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{translate("auto.components.sidebar.WorktreeCard.0777de5970", "Primary worktree (original clone directory)")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.0777de5970',
|
||||
'Primary worktree (original clone directory)'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1040,13 +1099,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
'group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
|
||||
'text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive'
|
||||
)}
|
||||
aria-label={translate("auto.components.sidebar.WorktreeCard.6f09f58541", "Delete workspace")}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeCard.6f09f58541',
|
||||
'Delete workspace'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{translate("auto.components.sidebar.WorktreeCard.6f09f58541", "Delete workspace")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCard.6f09f58541',
|
||||
'Delete workspace'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -1070,7 +1136,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
variant="secondary"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
|
||||
>
|
||||
{repo ? getRepoKindLabel(repo) : translate("auto.components.sidebar.WorktreeCard.93aebe4529", "Folder")}
|
||||
{repo
|
||||
? getRepoKindLabel(repo)
|
||||
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
|
||||
</Badge>
|
||||
) : showBranch ? (
|
||||
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
|
||||
@@ -1112,7 +1180,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
<div className="mt-0.5 flex items-start gap-1.5 rounded border border-amber-500/25 bg-amber-500/5 px-1.5 py-1 text-[10.5px] leading-snug text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-[1px] size-3 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{remoteBranchConflict.remote}/{remoteBranchConflict.branchName} {translate("auto.components.sidebar.WorktreeCard.a88c92d0e3", "already exists.")}</span>
|
||||
{remoteBranchConflict.remote}/{remoteBranchConflict.branchName}{' '}
|
||||
{translate('auto.components.sidebar.WorktreeCard.a88c92d0e3', 'already exists.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1159,7 +1229,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{lineageCollapsed ? translate("auto.components.sidebar.WorktreeCard.8cb634cda6", "Show child workspaces") : translate("auto.components.sidebar.WorktreeCard.57eaa61b55", "Hide child workspaces")}
|
||||
{lineageCollapsed
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeCard.8cb634cda6',
|
||||
'Show child workspaces'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeCard.57eaa61b55',
|
||||
'Hide child workspaces'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -59,25 +59,75 @@ vi.mock('./project-header-drag', () => ({
|
||||
vi.mock('./WorktreeCard', () => ({
|
||||
default: ({
|
||||
worktree,
|
||||
repo,
|
||||
isActive,
|
||||
contentIndent,
|
||||
flushSurface,
|
||||
lineageChildCount,
|
||||
lineageCollapsed,
|
||||
lineageChildren
|
||||
}: {
|
||||
worktree: Worktree
|
||||
repo?: Repo
|
||||
isActive?: boolean
|
||||
contentIndent?: number
|
||||
flushSurface?: boolean
|
||||
lineageChildCount?: number
|
||||
lineageCollapsed?: boolean
|
||||
lineageChildren?: React.ReactNode
|
||||
}) =>
|
||||
React.createElement(
|
||||
}) => {
|
||||
const deleteStateByWorktreeId =
|
||||
(mockStore.state.deleteStateByWorktreeId as Record<
|
||||
string,
|
||||
{ isDeleting?: boolean } | undefined
|
||||
>) ?? {}
|
||||
const cardProps = (mockStore.state.worktreeCardProperties as string[] | undefined) ?? []
|
||||
const sshState =
|
||||
repo?.connectionId && mockStore.state.sshConnectionStates instanceof Map
|
||||
? mockStore.state.sshConnectionStates.get(repo.connectionId)
|
||||
: null
|
||||
const isDeleting = deleteStateByWorktreeId[worktree.id]?.isDeleting === true
|
||||
const showSshDialog = isActive && repo?.connectionId && sshState?.status !== 'connected'
|
||||
|
||||
return React.createElement(
|
||||
'section',
|
||||
{
|
||||
'data-worktree-card-id': worktree.id,
|
||||
'data-worktree-card-active': isActive ? 'true' : undefined,
|
||||
'data-content-indent': contentIndent,
|
||||
'data-flush-surface': flushSurface ? 'true' : undefined
|
||||
'data-flush-surface': flushSurface ? 'true' : undefined,
|
||||
'data-lineage-child-count': lineageChildCount,
|
||||
'data-lineage-collapsed':
|
||||
lineageCollapsed === undefined ? undefined : String(lineageCollapsed),
|
||||
'data-linked-pr': worktree.linkedPR ?? undefined,
|
||||
'data-linked-gitlab-mr': worktree.linkedGitLabMR ?? undefined,
|
||||
'aria-busy': isDeleting ? 'true' : undefined
|
||||
},
|
||||
React.createElement('h2', null, worktree.displayName),
|
||||
isDeleting ? React.createElement('span', null, 'Deleting') : null,
|
||||
cardProps.includes('unread') && worktree.isUnread
|
||||
? React.createElement('button', { 'aria-label': 'Mark as read' }, 'Unread')
|
||||
: null,
|
||||
lineageChildCount
|
||||
? React.createElement(
|
||||
'button',
|
||||
{
|
||||
'data-lineage-toggle-for': worktree.id,
|
||||
'aria-expanded': lineageCollapsed ? 'false' : 'true'
|
||||
},
|
||||
`${lineageChildCount} ${lineageChildCount === 1 ? 'child' : 'children'}`
|
||||
)
|
||||
: null,
|
||||
showSshDialog
|
||||
? React.createElement('aside', {
|
||||
'data-worktree-card-ssh-dialog': 'open',
|
||||
'data-ssh-status': sshState?.status ?? 'disconnected',
|
||||
'data-ssh-target-id': repo?.connectionId
|
||||
})
|
||||
: null,
|
||||
lineageChildren
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeCardAgents', () => ({
|
||||
@@ -206,6 +256,7 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
|
||||
function setLineageFixtureState(
|
||||
groupBy: 'none' | 'repo' = 'none',
|
||||
options: {
|
||||
childWorktreeOverrides?: Partial<Worktree>
|
||||
deletingWorktreeIds?: string[]
|
||||
projectGrouped?: boolean
|
||||
unreadWorktreeIds?: string[]
|
||||
@@ -241,6 +292,7 @@ function setLineageFixtureState(
|
||||
branch: 'child-branch',
|
||||
sortOrder: 20
|
||||
})
|
||||
Object.assign(child, options.childWorktreeOverrides)
|
||||
const grandchild = makeWorktree({
|
||||
id: 'grandchild',
|
||||
instanceId: 'grandchild-instance',
|
||||
@@ -453,6 +505,18 @@ async function renderWorktreeListMarkup(): Promise<string> {
|
||||
)
|
||||
}
|
||||
|
||||
function getCardOpeningTag(markup: string, worktreeId: string): string {
|
||||
return (
|
||||
markup.match(new RegExp(`<section[^>]*data-worktree-card-id="${worktreeId}"[^>]*>`))?.[0] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionOpeningTag(markup: string, worktreeId: string): string {
|
||||
return (
|
||||
markup.match(new RegExp(`<div[^>]*id="worktree-list-option-${worktreeId}"[^>]*>`))?.[0] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorktreeList lineage child card renderer', () => {
|
||||
beforeAll(async () => {
|
||||
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
|
||||
@@ -492,63 +556,76 @@ describe('WorktreeList lineage child card renderer', () => {
|
||||
expect(markup).not.toContain('empty-project')
|
||||
})
|
||||
|
||||
it('renders nested inline agent rows before the nested child-count toggle', async () => {
|
||||
it('renders recursive lineage descendants through WorktreeCard once', async () => {
|
||||
setLineageFixtureState()
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const childStart = markup.indexOf('lineage child with agent')
|
||||
const agentRowIndex = markup.indexOf('Review fixture prompt', childStart)
|
||||
const childToggleIndex = markup.indexOf('1 child', childStart)
|
||||
expect(markup.match(/data-worktree-card-id="parent"/g)).toHaveLength(1)
|
||||
expect(markup.match(/data-worktree-card-id="child"/g)).toHaveLength(1)
|
||||
expect(markup.match(/data-worktree-card-id="grandchild"/g)).toHaveLength(1)
|
||||
|
||||
expect(childStart).toBeGreaterThan(-1)
|
||||
expect(agentRowIndex).toBeGreaterThan(childStart)
|
||||
expect(childToggleIndex).toBeGreaterThan(childStart)
|
||||
expect(agentRowIndex).toBeLessThan(childToggleIndex)
|
||||
const parentIndex = markup.indexOf('data-worktree-card-id="parent"')
|
||||
const childIndex = markup.indexOf('data-worktree-card-id="child"')
|
||||
const grandchildIndex = markup.indexOf('data-worktree-card-id="grandchild"')
|
||||
|
||||
expect(parentIndex).toBeGreaterThan(-1)
|
||||
expect(childIndex).toBeGreaterThan(parentIndex)
|
||||
expect(grandchildIndex).toBeGreaterThan(childIndex)
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-lineage-child-count="1"')
|
||||
})
|
||||
|
||||
it('renders nested child titles through the inline rename surface', async () => {
|
||||
setLineageFixtureState()
|
||||
it('passes child review details through the shared WorktreeCard path', async () => {
|
||||
setLineageFixtureState('none', {
|
||||
childWorktreeOverrides: { linkedPR: 456, linkedGitLabMR: 42 }
|
||||
})
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
const childCard = getCardOpeningTag(markup, 'child')
|
||||
|
||||
expect(markup).toContain('data-worktree-title-inline-rename=""')
|
||||
expect(markup).toContain('lineage child with agent')
|
||||
expect(childCard).toContain('data-linked-pr="456"')
|
||||
expect(childCard).toContain('data-linked-gitlab-mr="42"')
|
||||
})
|
||||
|
||||
it('nests the first-level child workspace card surface under its parent', async () => {
|
||||
it('uses shared nested-row indentation for child and grandchild cards', async () => {
|
||||
setLineageFixtureState()
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('<div style="padding-left:14px"><div id="worktree-list-option-child"')
|
||||
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="0"')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
|
||||
expect(getOptionOpeningTag(markup, 'grandchild')).toContain('padding-left:28px')
|
||||
expect(getCardOpeningTag(markup, 'grandchild')).toContain('data-content-indent="0"')
|
||||
expect(getCardOpeningTag(markup, 'grandchild')).toContain('data-flush-surface="true"')
|
||||
})
|
||||
|
||||
it('shows deleting feedback on nested lineage child cards', async () => {
|
||||
setLineageFixtureState('none', { deletingWorktreeIds: ['child'] })
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const childCard =
|
||||
markup.match(/<div id="worktree-list-option-child"[\s\S]*?lineage child with agent/)?.[0] ??
|
||||
''
|
||||
const childCard = getCardOpeningTag(markup, 'child')
|
||||
const childIndex = markup.indexOf('data-worktree-card-id="child"')
|
||||
const childMarkup = markup.slice(
|
||||
childIndex,
|
||||
markup.indexOf('data-worktree-card-id="grandchild"')
|
||||
)
|
||||
|
||||
expect(childCard).toContain('aria-busy="true"')
|
||||
expect(childCard).toContain('cursor-not-allowed opacity-50 grayscale')
|
||||
expect(childCard).toContain('animate-spin')
|
||||
expect(childCard).toContain('Deleting')
|
||||
expect(childMarkup).toContain('Deleting')
|
||||
})
|
||||
|
||||
it('shows the unread bell action on unread nested lineage child cards', async () => {
|
||||
setLineageFixtureState('none', { unreadWorktreeIds: ['child'] })
|
||||
mockStore.state.worktreeCardProperties = ['status', 'unread', 'inline-agents']
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
const childIndex = markup.indexOf('data-worktree-card-id="child"')
|
||||
const childMarkup = markup.slice(
|
||||
childIndex,
|
||||
markup.indexOf('data-worktree-card-id="grandchild"')
|
||||
)
|
||||
|
||||
const childCard =
|
||||
markup.match(/<div id="worktree-list-option-child"[\s\S]*?lineage child with agent/)?.[0] ??
|
||||
''
|
||||
|
||||
expect(childCard).toContain('aria-label="Mark as read"')
|
||||
expect(childCard).not.toContain('aria-label="Mark as unread"')
|
||||
expect(childMarkup).toContain('aria-label="Mark as read"')
|
||||
expect(childMarkup).not.toContain('aria-label="Mark as unread"')
|
||||
})
|
||||
|
||||
it('opens the reconnect dialog for an active disconnected lineage child during render', async () => {
|
||||
it('lets WorktreeCard own the reconnect dialog for an active disconnected lineage child', async () => {
|
||||
setLineageFixtureState()
|
||||
const repo = (mockStore.state.repos as Repo[])[0]!
|
||||
repo.connectionId = 'ssh-target-1'
|
||||
@@ -558,17 +635,18 @@ describe('WorktreeList lineage child card renderer', () => {
|
||||
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-lineage-ssh-dialog="open"')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-worktree-card-active="true"')
|
||||
expect(markup).toContain('data-worktree-card-ssh-dialog="open"')
|
||||
expect(markup).not.toContain('data-lineage-ssh-dialog="open"')
|
||||
expect(markup).toContain('data-ssh-status="disconnected"')
|
||||
expect(markup).toContain('data-ssh-target-id="ssh-target-1"')
|
||||
expect(markup).toContain('data-ssh-target-label="Remote target"')
|
||||
})
|
||||
|
||||
it('does not add group indentation when grouping is disabled', async () => {
|
||||
setLineageFixtureState('none')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
|
||||
const parentRow = getOptionOpeningTag(markup, 'parent')
|
||||
|
||||
expect(parentRow).toContain('id="worktree-list-option-parent"')
|
||||
expect(parentRow).not.toContain('padding-left')
|
||||
@@ -578,23 +656,39 @@ describe('WorktreeList lineage child card renderer', () => {
|
||||
setLineageFixtureState('repo')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
|
||||
const parentRow = getOptionOpeningTag(markup, 'parent')
|
||||
|
||||
expect(parentRow).not.toContain('padding-left')
|
||||
expect(markup).toContain(
|
||||
'<section data-worktree-card-id="parent" data-content-indent="20" data-flush-surface="true">'
|
||||
)
|
||||
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="20"')
|
||||
expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"')
|
||||
})
|
||||
|
||||
it('keeps nested card inner padding aligned with grouped parent cards', async () => {
|
||||
setLineageFixtureState('repo')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="20"')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
|
||||
})
|
||||
|
||||
it('keeps nested card inner padding aligned inside project groups', async () => {
|
||||
setLineageFixtureState('repo', { projectGrouped: true })
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="38"')
|
||||
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
|
||||
})
|
||||
|
||||
it('adds project group depth to workspace card content indentation', async () => {
|
||||
setLineageFixtureState('repo', { projectGrouped: true })
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
|
||||
const parentRow = getOptionOpeningTag(markup, 'parent')
|
||||
|
||||
expect(parentRow).not.toContain('padding-left')
|
||||
expect(markup).toContain(
|
||||
'<section data-worktree-card-id="parent" data-content-indent="38" data-flush-surface="true">'
|
||||
)
|
||||
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="38"')
|
||||
expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type {
|
||||
Repo,
|
||||
Worktree,
|
||||
WorktreeCardProperty,
|
||||
WorktreeLineage
|
||||
} from '../../../../shared/types'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
state: {} as Record<string, unknown>,
|
||||
activateWorktreeFromSidebar: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
fetchHostedReviewForBranch: vi.fn(),
|
||||
fetchIssue: vi.fn(),
|
||||
fetchLinearIssue: vi.fn(),
|
||||
openTaskPage: vi.fn()
|
||||
}))
|
||||
|
||||
type WorktreeListComponent = React.ComponentType<{
|
||||
scrollOffsetRef: React.RefObject<number>
|
||||
scrollAnchorRef: React.RefObject<unknown>
|
||||
}>
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector(mockStore.state)) as ((
|
||||
selector: (state: Record<string, unknown>) => unknown
|
||||
) => unknown) & {
|
||||
getState: () => Record<string, unknown>
|
||||
}
|
||||
useAppStore.getState = () => mockStore.state
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) =>
|
||||
Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index),
|
||||
measureElement: () => 32,
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
elementsCache: new Map(),
|
||||
getTotalSize: () => count * 96,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
start: index * 96
|
||||
})),
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({
|
||||
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor',
|
||||
useVirtualizedScrollAnchor: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./project-header-drag', () => ({
|
||||
useRepoHeaderDrag: () => ({
|
||||
state: { draggingRepoId: null, dropIndicatorY: null },
|
||||
onHandlePointerDown: vi.fn()
|
||||
}),
|
||||
isRepoHeaderActionTarget: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/hover-card', () => ({
|
||||
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
HoverCardContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-hover-card-content="">{children}</div>
|
||||
),
|
||||
HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/sidebar-worktree-activation', () => ({
|
||||
activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' }),
|
||||
callRuntimeRpc: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./CacheTimer', () => ({
|
||||
default: () => null,
|
||||
usePromptCacheCountdownStartedAt: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeCardAgents', () => ({
|
||||
default: ({ worktreeId }: { worktreeId: string }) => (
|
||||
<div data-agent-worktree-id={worktreeId}>Agent row</div>
|
||||
),
|
||||
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
|
||||
}))
|
||||
|
||||
vi.mock('./SshDisconnectedDialog', () => ({
|
||||
SshDisconnectedDialog: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeContextMenu', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
|
||||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
|
||||
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
|
||||
}))
|
||||
|
||||
function makeRepo(): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/tmp/lineage-real-card',
|
||||
displayName: 'lineage-real-card',
|
||||
badgeColor: '#999999',
|
||||
addedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(args: {
|
||||
id: string
|
||||
displayName: string
|
||||
branch: string
|
||||
sortOrder: number
|
||||
instanceId: string
|
||||
overrides?: Partial<Worktree>
|
||||
}): Worktree {
|
||||
return {
|
||||
id: args.id,
|
||||
instanceId: args.instanceId,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/lineage-real-card/${args.id}`,
|
||||
displayName: args.displayName,
|
||||
branch: args.branch,
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: args.sortOrder,
|
||||
lastActivityAt: args.sortOrder,
|
||||
...args.overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
|
||||
return {
|
||||
worktreeId: worktree.id,
|
||||
worktreeInstanceId: worktree.instanceId!,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId!,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
||||
return {
|
||||
provider: 'gitlab',
|
||||
number: 42,
|
||||
title: 'Child GitLab MR',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/42',
|
||||
status: 'success',
|
||||
updatedAt: '2026-06-09T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function setLineageState(options: { deletingChild?: boolean } = {}): void {
|
||||
const repo = makeRepo()
|
||||
const parent = makeWorktree({
|
||||
id: 'parent',
|
||||
instanceId: 'parent-instance',
|
||||
displayName: 'lineage parent',
|
||||
branch: 'parent-branch',
|
||||
sortOrder: 20
|
||||
})
|
||||
const child = makeWorktree({
|
||||
id: 'child',
|
||||
instanceId: 'child-instance',
|
||||
displayName: 'lineage child',
|
||||
branch: 'child-branch',
|
||||
sortOrder: 10,
|
||||
overrides: {
|
||||
linkedGitLabMR: 42,
|
||||
comment: 'Child handoff note'
|
||||
}
|
||||
})
|
||||
mockStore.state = {
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
browserTabsByWorktree: {},
|
||||
clearPendingRevealWorktreeId: vi.fn(),
|
||||
collapsedGroups: new Set<string>(),
|
||||
deleteStateByWorktreeId: options.deletingChild
|
||||
? { [child.id]: { isDeleting: true, error: null, canForceDelete: false } }
|
||||
: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch,
|
||||
fetchIssue: mockStore.fetchIssue,
|
||||
fetchLinearIssue: mockStore.fetchLinearIssue,
|
||||
filterRepoIds: [],
|
||||
gitConflictOperationByWorktree: {},
|
||||
groupBy: 'none',
|
||||
hideDefaultBranchWorkspace: false,
|
||||
hostedReviewCache: {
|
||||
'local::repo-1::child-branch': {
|
||||
data: makeHostedReview(),
|
||||
fetchedAt: Date.now(),
|
||||
linkedReviewHintKey: 'gitlab:42'
|
||||
}
|
||||
},
|
||||
issueCache: {},
|
||||
linearIssueCache: {},
|
||||
linearStatus: null,
|
||||
migrationUnsupportedByPtyId: {},
|
||||
openModal: mockStore.openModal,
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: null,
|
||||
openTaskPage: mockStore.openTaskPage,
|
||||
pendingRevealWorktree: null,
|
||||
prCache: {},
|
||||
projectGroups: [],
|
||||
ptyIdsByTabId: {},
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
reorderRepos: vi.fn(),
|
||||
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
|
||||
repos: [repo],
|
||||
retainedAgentsByPaneKey: {},
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
runtimePaneTitlesByTabId: {},
|
||||
setFilterRepoIds: vi.fn(),
|
||||
setHideDefaultBranchWorkspace: vi.fn(),
|
||||
setRenamingWorktreeId: vi.fn(),
|
||||
setShowSleepingWorkspaces: vi.fn(),
|
||||
setSortBy: vi.fn(),
|
||||
setWorktreesPinnedAndReveal: vi.fn(),
|
||||
settings: null,
|
||||
showSleepingWorkspaces: true,
|
||||
sortBy: 'manual',
|
||||
sortEpoch: 0,
|
||||
sshConnectedGeneration: 0,
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
toggleCollapsedGroup: vi.fn(),
|
||||
updateRepo: vi.fn(),
|
||||
updateWorktreeMeta: mockStore.updateWorktreeMeta,
|
||||
updateWorktreesMeta: vi.fn(),
|
||||
workspacePortScan: null,
|
||||
workspaceStatuses: [],
|
||||
worktreeCardProperties: [
|
||||
'status',
|
||||
'pr',
|
||||
'comment',
|
||||
'inline-agents'
|
||||
] satisfies WorktreeCardProperty[],
|
||||
worktreeLineageById: {
|
||||
[child.id]: makeLineage(child, parent)
|
||||
},
|
||||
worktreesByRepo: {
|
||||
[repo.id]: [parent, child]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
async function renderWorktreeList(): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
describe('WorktreeList real child WorktreeCard integration', () => {
|
||||
beforeAll(async () => {
|
||||
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
|
||||
}, 20_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setLineageState()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders GitLab MR metadata from a child through the real WorktreeCard path', async () => {
|
||||
const container = await renderWorktreeList()
|
||||
const childOption = container.querySelector('#worktree-list-option-child')
|
||||
|
||||
expect(childOption?.textContent).toContain('MR #42')
|
||||
expect(childOption?.textContent).toContain('Child GitLab MR')
|
||||
expect(childOption?.textContent).toContain('Child handoff note')
|
||||
})
|
||||
|
||||
it('double-clicking a nested child opens edit metadata for the child only', async () => {
|
||||
const container = await renderWorktreeList()
|
||||
const childCard = container.querySelector<HTMLElement>(
|
||||
'#worktree-list-option-child [data-worktree-card-surface="true"]'
|
||||
)
|
||||
|
||||
expect(childCard).not.toBeNull()
|
||||
await act(async () => {
|
||||
childCard!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mockStore.openModal).toHaveBeenCalledTimes(1)
|
||||
expect(mockStore.openModal).toHaveBeenCalledWith(
|
||||
'edit-meta',
|
||||
expect.objectContaining({
|
||||
worktreeId: 'child',
|
||||
currentDisplayName: 'lineage child',
|
||||
currentComment: 'Child handoff note'
|
||||
})
|
||||
)
|
||||
expect(mockStore.openModal).not.toHaveBeenCalledWith(
|
||||
'edit-meta',
|
||||
expect.objectContaining({ worktreeId: 'parent' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not activate a nested child while it is deleting', async () => {
|
||||
setLineageState({ deletingChild: true })
|
||||
const container = await renderWorktreeList()
|
||||
const childCard = container.querySelector<HTMLElement>(
|
||||
'#worktree-list-option-child [data-worktree-card-surface="true"]'
|
||||
)
|
||||
|
||||
expect(childCard?.textContent).toContain('Deleting')
|
||||
await act(async () => {
|
||||
childCard!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mockStore.activateWorktreeFromSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
Plus,
|
||||
Shapes,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Workflow
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -30,12 +28,7 @@ import {
|
||||
} from '@/store/selectors'
|
||||
import WorktreeCard from './WorktreeCard'
|
||||
import { PendingWorktreeRow } from './PendingWorktreeRow'
|
||||
import WorktreeCardAgents, {
|
||||
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT
|
||||
} from './WorktreeCardAgents'
|
||||
import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename'
|
||||
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
|
||||
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
|
||||
import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
@@ -117,14 +110,12 @@ import {
|
||||
type VirtualizedScrollAnchor
|
||||
} from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import {
|
||||
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
|
||||
type ScrollToCurrentWorkspaceRevealRequestDetail
|
||||
} from '@/lib/scroll-to-current-workspace-status'
|
||||
import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import {
|
||||
buildManualOrderUpdatesForGroupDrop,
|
||||
buildManualOrderUpdatesForVisibleGroups,
|
||||
@@ -176,7 +167,6 @@ import {
|
||||
pruneWorktreeSelection,
|
||||
updateWorktreeSelection
|
||||
} from './worktree-multi-selection'
|
||||
import { branchDisplayName } from './WorktreeCardHelpers'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getRepoHeaderCreateState } from './repo-header-create-state'
|
||||
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
|
||||
@@ -191,7 +181,6 @@ import {
|
||||
} from '../../../../shared/worktree-ownership'
|
||||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine'
|
||||
import {
|
||||
keepImportedWorktreesHiddenCard,
|
||||
@@ -362,10 +351,10 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
|
||||
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
|
||||
}
|
||||
|
||||
// Why: child workspace cards are already nested inside the parent card body;
|
||||
// using the full tree step makes the second-level card drift too far right.
|
||||
const LINEAGE_INDENT = 14
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
// Why: nested child worktree cards sit inside the parent card body. Preserve
|
||||
// the legacy lineage surface offset instead of using the full sidebar tree step.
|
||||
const NESTED_LINEAGE_CARD_INDENT = 14
|
||||
|
||||
type VirtualizedWorktreeViewportProps = {
|
||||
rows: Row[]
|
||||
@@ -396,7 +385,6 @@ type VirtualizedWorktreeViewportProps = {
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
|
||||
onImmediateWorktreeActivate: (worktreeId: string) => void
|
||||
onToggleWorktreeUnread: (worktree: Worktree) => void
|
||||
onContextMenuSelect: (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
@@ -439,7 +427,6 @@ type VirtualizedWorktreeViewportProps = {
|
||||
draggedIds: readonly string[]
|
||||
dropIndex: number
|
||||
}) => void
|
||||
showInlineAgentCards: boolean
|
||||
// Why: broad grouping changes still remount the viewport, while add/delete
|
||||
// stays mounted for row-key anchoring and layout animation. These refs bridge
|
||||
// both paths so the virtualizer never falls back to scrollTop 0.
|
||||
@@ -744,7 +731,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
selectedWorktrees,
|
||||
onSelectionGesture,
|
||||
onImmediateWorktreeActivate,
|
||||
onToggleWorktreeUnread,
|
||||
onContextMenuSelect,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
@@ -762,7 +748,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onDropWorktreesOnWorkspaceBoard,
|
||||
shouldShowWorkspaceBoardDropIndicator,
|
||||
onReorderWorktrees,
|
||||
showInlineAgentCards,
|
||||
scrollOffsetRef,
|
||||
scrollAnchorRef
|
||||
}: VirtualizedWorktreeViewportProps) {
|
||||
@@ -771,7 +756,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const directScrollInputUntilRef = useRef(0)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
|
||||
const [pinDragOver, setPinDragOver] = useState(false)
|
||||
const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState<string | null>(null)
|
||||
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
|
||||
WORKTREE_ROW_DRAG_INITIAL_STATE
|
||||
)
|
||||
@@ -780,9 +764,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId)
|
||||
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
|
||||
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
|
||||
const worktreePointerAutoscrollFrameIdRef = useRef<number | null>(null)
|
||||
@@ -851,7 +833,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
|
||||
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
|
||||
const reorderRepos = useAppStore((s) => s.reorderRepos)
|
||||
|
||||
useEffect(
|
||||
@@ -1015,41 +996,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
() => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)),
|
||||
[renderRows, activeWorktreeId]
|
||||
)
|
||||
const activeLineageChildRow = useMemo(() => {
|
||||
if (activeWorktreeId === null) {
|
||||
return null
|
||||
}
|
||||
for (const row of renderRows) {
|
||||
if (row.type !== 'lineage-group') {
|
||||
continue
|
||||
}
|
||||
const child = row.rows.slice(1).find((item) => item.worktree.id === activeWorktreeId)
|
||||
if (child) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [activeWorktreeId, renderRows])
|
||||
const activeLineageChildWorktreeId = activeLineageChildRow?.worktree.id ?? null
|
||||
const activeLineageChildConnectionId = activeLineageChildRow?.repo?.connectionId ?? null
|
||||
const activeLineageChildSshStatus = useAppStore((s) =>
|
||||
activeLineageChildConnectionId
|
||||
? (s.sshConnectionStates.get(activeLineageChildConnectionId)?.status ?? 'disconnected')
|
||||
: null
|
||||
)
|
||||
const activeLineageChildTargetLabel = useAppStore((s) =>
|
||||
activeLineageChildConnectionId ? s.sshTargetLabels.get(activeLineageChildConnectionId) : null
|
||||
)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const activeLineageChildSshDisconnected =
|
||||
activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected'
|
||||
const lineageReconnectPromptKey =
|
||||
activeLineageChildWorktreeId && activeLineageChildSshDisconnected
|
||||
? activeLineageChildWorktreeId
|
||||
: null
|
||||
const [lastLineageReconnectPromptKey, setLastLineageReconnectPromptKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const renderRowsRef = useRef(renderRows)
|
||||
renderRowsRef.current = renderRows
|
||||
const getVirtualItemKey = useCallback(
|
||||
@@ -1536,16 +1483,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [activeModal, keybindings, markDirectScrollInput, navigateWorktree])
|
||||
|
||||
// Why: lightweight nested cards do not mount WorktreeCard, so the viewport
|
||||
// owns the SSH reconnect prompt for an active lineage child. The prompt key
|
||||
// keeps dismissals sticky until the active/disconnected child changes.
|
||||
if (lineageReconnectPromptKey !== lastLineageReconnectPromptKey) {
|
||||
setLastLineageReconnectPromptKey(lineageReconnectPromptKey)
|
||||
if (lineageReconnectPromptKey) {
|
||||
setLineageReconnectWorktreeId(lineageReconnectPromptKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContainerKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
@@ -2753,7 +2690,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
data-worktree-sidebar
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeList.bfbedc547b", "Worktrees")}
|
||||
aria-label={translate('auto.components.sidebar.WorktreeList.bfbedc547b', 'Worktrees')}
|
||||
aria-orientation="vertical"
|
||||
aria-multiselectable="true"
|
||||
aria-activedescendant={activeDescendantId}
|
||||
@@ -2770,24 +2707,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
className="worktree-sidebar-scrollbar h-full overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
|
||||
style={WORKTREE_SIDEBAR_SCROLL_STYLE}
|
||||
>
|
||||
{activeLineageChildConnectionId && activeLineageChildSshStatus ? (
|
||||
<SshDisconnectedDialog
|
||||
open={
|
||||
lineageReconnectWorktreeId === activeLineageChildWorktreeId &&
|
||||
activeLineageChildSshDisconnected
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setLineageReconnectWorktreeId(null)
|
||||
}
|
||||
}}
|
||||
targetId={activeLineageChildConnectionId}
|
||||
targetLabel={
|
||||
activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? ''
|
||||
}
|
||||
status={activeLineageChildSshStatus}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
role="presentation"
|
||||
className="relative w-full"
|
||||
@@ -3006,7 +2925,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
size="icon-xs"
|
||||
data-repo-header-action=""
|
||||
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeList.79465e9034", "Group actions for {{value0}}", { value0: row.label })}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeList.79465e9034',
|
||||
'Group actions for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={stopRepoHeaderKeyboardToggle}
|
||||
onPointerDown={handleRepoHeaderActionPointerDown}
|
||||
@@ -3035,7 +2958,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate("auto.components.sidebar.WorktreeList.4d7b73658c", "Rename group")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.4d7b73658c',
|
||||
'Rename group'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
@@ -3044,12 +2971,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate("auto.components.sidebar.WorktreeList.902115cdbe", "Delete group")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.902115cdbe',
|
||||
'Delete group'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{row.repo && groupBy === "repo" ? (
|
||||
{row.repo && groupBy === 'repo' ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -3060,7 +2991,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
size="icon-xs"
|
||||
data-repo-header-action=""
|
||||
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeList.609633a9e6", "Project actions for {{value0}}", { value0: row.label })}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeList.609633a9e6',
|
||||
'Project actions for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={stopRepoHeaderKeyboardToggle}
|
||||
onPointerDown={handleRepoHeaderActionPointerDown}
|
||||
@@ -3070,7 +3005,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.sidebar.WorktreeList.2ef41bf9a7", "Project actions")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.2ef41bf9a7',
|
||||
'Project actions'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
@@ -3094,7 +3033,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.2cdffbc728", "Project Settings")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.2cdffbc728',
|
||||
'Project Settings'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (row.repo) {
|
||||
@@ -3106,7 +3049,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<Shapes className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.e82d3589a1", "Change Project Icon")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.e82d3589a1',
|
||||
'Change Project Icon'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{row.repo && isGitRepoKind(row.repo) ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
@@ -3127,12 +3074,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.cbfd565f83", "New group from project")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.cbfd565f83',
|
||||
'New group from project'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{projectGroups.length > 0 ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.4a08fb55f2", "Move to group")}</DropdownMenuSubTrigger>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.4a08fb55f2',
|
||||
'Move to group'
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{projectGroups.map((group) => (
|
||||
<DropdownMenuItem
|
||||
@@ -3159,7 +3114,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<CircleX className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.64e55f7f01", "Remove from group")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.64e55f7f01',
|
||||
'Remove from group'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -3171,12 +3130,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.c83968f87f", "Remove Project")}</DropdownMenuItem>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeList.c83968f87f',
|
||||
'Remove Project'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{row.repo && groupBy === "repo" ? (
|
||||
{row.repo && groupBy === 'repo' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{createState?.disabled ? (
|
||||
@@ -3208,7 +3171,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
data-repo-header-action=""
|
||||
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100"
|
||||
aria-label={
|
||||
createState?.ariaLabel ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label })
|
||||
createState?.ariaLabel ??
|
||||
translate(
|
||||
'auto.components.sidebar.WorktreeList.bb85cd86ba',
|
||||
'Create workspace for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)
|
||||
}
|
||||
onKeyDown={stopRepoHeaderKeyboardToggle}
|
||||
onClick={(event) => {
|
||||
@@ -3224,7 +3192,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{createState?.tooltip ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label })}
|
||||
{createState?.tooltip ??
|
||||
translate(
|
||||
'auto.components.sidebar.WorktreeList.bb85cd86ba',
|
||||
'Create workspace for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -3240,9 +3213,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
forceActiveSurface = false
|
||||
) => {
|
||||
const lineageToggleGroupKey = itemRow.lineageGroupKey
|
||||
// Why: child cards render inside the parent card body, so their
|
||||
// first nested level starts flush with that inset.
|
||||
// Why: child card rows own lineage depth, while WorktreeCard
|
||||
// still owns the project/group inset inside each card surface.
|
||||
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
|
||||
const nestedCardPaddingLeft = nested
|
||||
? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT
|
||||
: 0
|
||||
const inheritedCardContentIndent = getWorktreeCardContentIndent({
|
||||
isGrouped: groupBy !== 'none',
|
||||
groupDepth: itemRow.groupDepth,
|
||||
lineageDepth: 0
|
||||
})
|
||||
// Why: grouped rows inherit their project/group header depth,
|
||||
// while the card surface still spans the full hit/background row.
|
||||
const paddingLeft = getWorktreeCardContentIndent({
|
||||
@@ -3250,6 +3231,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
groupDepth: itemRow.groupDepth,
|
||||
lineageDepth: paddingDepth
|
||||
})
|
||||
const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft
|
||||
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
|
||||
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
|
||||
const revealHighlightTone =
|
||||
@@ -3286,7 +3268,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id)
|
||||
}
|
||||
style={{
|
||||
paddingLeft: nested && paddingLeft > 0 ? `${paddingLeft}px` : undefined
|
||||
paddingLeft:
|
||||
nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined
|
||||
}}
|
||||
>
|
||||
<WorktreeCard
|
||||
@@ -3302,8 +3285,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
revealHighlightTone={revealHighlightTone}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={false}
|
||||
contentIndent={nested ? 0 : paddingLeft}
|
||||
flushSurface={!nested}
|
||||
contentIndent={cardContentIndent}
|
||||
flushSurface
|
||||
onImmediateActivate={onImmediateWorktreeActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
@@ -3330,199 +3313,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
)
|
||||
}
|
||||
|
||||
const renderLineageChildCard = (child: WorktreeItemRow) => {
|
||||
const isActive = activeWorktreeId === child.worktree.id
|
||||
const isDeleting = deleteStateByWorktreeId[child.worktree.id]?.isDeleting ?? false
|
||||
const revealHighlightTone =
|
||||
agentSendTargetWorktreeId === child.worktree.id ? 'ai' : 'default'
|
||||
const showStatus = cardProps.includes('status')
|
||||
const showUnreadQuickAction = cardProps.includes('unread')
|
||||
const unreadTooltip = child.worktree.isUnread ? 'Mark read' : 'Mark unread'
|
||||
const stopQuickActionPointerPropagation = (
|
||||
event: React.PointerEvent<HTMLButtonElement>
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
const handleToggleUnreadQuick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleWorktreeUnread(child.worktree)
|
||||
}
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (isDeleting) {
|
||||
return
|
||||
const renderLineageDescendants = (
|
||||
parent: WorktreeItemRow,
|
||||
descendants: readonly WorktreeItemRow[]
|
||||
): React.ReactNode | undefined => {
|
||||
const childNodes: React.ReactNode[] = []
|
||||
let cursor = 0
|
||||
while (cursor < descendants.length) {
|
||||
const child = descendants[cursor]
|
||||
if (!child || child.depth !== parent.depth + 1) {
|
||||
cursor++
|
||||
continue
|
||||
}
|
||||
const selectionOnly = onSelectionGesture(event, child.worktree.id)
|
||||
if (selectionOnly) {
|
||||
return
|
||||
}
|
||||
onImmediateWorktreeActivate(child.worktree.id)
|
||||
activateWorktreeFromSidebar(child.worktree.id)
|
||||
if (child.repo?.connectionId) {
|
||||
const sshStatus =
|
||||
useAppStore.getState().sshConnectionStates.get(child.repo.connectionId)
|
||||
?.status ?? 'disconnected'
|
||||
if (sshStatus !== 'connected') {
|
||||
setLineageReconnectWorktreeId(child.worktree.id)
|
||||
}
|
||||
|
||||
let nextSiblingIndex = cursor + 1
|
||||
while (
|
||||
nextSiblingIndex < descendants.length &&
|
||||
descendants[nextSiblingIndex]!.depth > child.depth
|
||||
) {
|
||||
nextSiblingIndex++
|
||||
}
|
||||
|
||||
const childLineageChildren = renderLineageDescendants(
|
||||
child,
|
||||
descendants.slice(cursor + 1, nextSiblingIndex)
|
||||
)
|
||||
childNodes.push(renderWorktreeRow(child, true, childLineageChildren))
|
||||
cursor = nextSiblingIndex
|
||||
}
|
||||
const lineageToggleGroupKey = child.lineageGroupKey
|
||||
const childCardIndent = Math.max(0, child.depth) * LINEAGE_INDENT
|
||||
const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT
|
||||
return (
|
||||
<div
|
||||
key={child.worktree.id}
|
||||
// Why: lineage child workspaces need their whole card surface
|
||||
// nested, not only the text/status content inside the card.
|
||||
style={childCardIndent > 0 ? { paddingLeft: `${childCardIndent}px` } : undefined}
|
||||
>
|
||||
<WorktreeContextMenu
|
||||
worktree={child.worktree}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onContextMenuSelect={(event) => onContextMenuSelect(event, child.worktree)}
|
||||
>
|
||||
<div
|
||||
id={getWorktreeOptionId(child.worktree.id)}
|
||||
role="option"
|
||||
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-busy={isDeleting}
|
||||
data-worktree-card-surface="true"
|
||||
data-worktree-card-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer items-start gap-1.5 rounded-lg border border-transparent py-1.5 pr-2 transition-colors',
|
||||
highlightedRevealWorktreeId === child.worktree.id && [
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
revealHighlightTone === 'ai' &&
|
||||
'scroll-to-current-workspace-reveal-highlight--ai'
|
||||
],
|
||||
isActive
|
||||
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
|
||||
: 'worktree-sidebar-card-hover',
|
||||
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
|
||||
)}
|
||||
data-scroll-reveal-highlight={
|
||||
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isDeleting && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/50 bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm">
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
{translate("auto.components.sidebar.WorktreeList.5fc9d1891b", "Deleting…")}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-start gap-1.5 pl-2"
|
||||
style={
|
||||
childContentIndent > 0
|
||||
? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
|
||||
<WorktreeCardStatusSlot
|
||||
worktreeId={child.worktree.id}
|
||||
showStatus={showStatus}
|
||||
showUnreadAction={showUnreadQuickAction}
|
||||
isUnread={child.worktree.isUnread}
|
||||
unreadTooltip={unreadTooltip}
|
||||
onPointerDown={stopQuickActionPointerPropagation}
|
||||
onToggleUnread={handleToggleUnreadQuick}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<WorktreeTitleInlineRename
|
||||
displayName={child.worktree.displayName}
|
||||
className="text-[12px]"
|
||||
onRename={(displayName) =>
|
||||
updateWorktreeMeta(child.worktree.id, { displayName })
|
||||
}
|
||||
beginEditing={renamingWorktreeId === child.worktree.id}
|
||||
onBeginEditingConsumed={() => setRenamingWorktreeId(null)}
|
||||
/>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5">
|
||||
{child.repo && groupBy !== "repo" ? (
|
||||
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
|
||||
<RepoBadgeMark color={child.repo.badgeColor} />
|
||||
<span className="max-w-[6rem] truncate lowercase">
|
||||
{child.repo.displayName}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
|
||||
{branchDisplayName(child.worktree.branch)}
|
||||
</span>
|
||||
</div>
|
||||
{child.worktree.linkedIssue || child.worktree.comment ? (
|
||||
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
|
||||
{child.worktree.linkedIssue ? (
|
||||
<span className="font-medium text-foreground/80">
|
||||
#{child.worktree.linkedIssue}
|
||||
</span>
|
||||
) : null}
|
||||
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
|
||||
{child.worktree.comment}
|
||||
</div>
|
||||
) : null}
|
||||
{showInlineAgentCards ? (
|
||||
// Why: nested lineage children use this lightweight
|
||||
// renderer instead of WorktreeCard, so their inline
|
||||
// agent rows must be mounted here explicitly.
|
||||
<WorktreeCardAgents
|
||||
worktreeId={child.worktree.id}
|
||||
className="mt-1 divide-y-0"
|
||||
/>
|
||||
) : null}
|
||||
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
|
||||
<div className="mt-1.5 flex min-w-0 justify-start">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-worktree-sidebar-border bg-worktree-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
|
||||
aria-label={translate("auto.components.sidebar.WorktreeList.0c6ee14f23", "{{value0}} {{value1}} child {{value2}}", { value0: child.lineageCollapsed ? 'Show' : 'Hide', value1: child.lineageChildCount, value2: child.lineageChildCount === 1 ? 'workspace' : 'workspaces' })}
|
||||
aria-expanded={!child.lineageCollapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
|
||||
}}
|
||||
>
|
||||
<Workflow className="size-2.5" />
|
||||
<span className="truncate">
|
||||
{child.lineageChildCount}{' '}
|
||||
{child.lineageChildCount === 1 ? translate("auto.components.sidebar.WorktreeList.0c6ee14f23", "child") : translate("auto.components.sidebar.WorktreeList.045a8aed48", "children")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-2.5 transition-transform',
|
||||
child.lineageCollapsed && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{child.lineageCollapsed
|
||||
? translate("auto.components.sidebar.WorktreeList.84a2238242", "Show child workspaces")
|
||||
: translate("auto.components.sidebar.WorktreeList.ebc5c7dcef", "Hide child workspaces")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorktreeContextMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
return childNodes.length > 0 ? childNodes : undefined
|
||||
}
|
||||
|
||||
if (row.type === 'lineage-group') {
|
||||
@@ -3554,9 +3374,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
? renderWorktreeRow(
|
||||
parent,
|
||||
false,
|
||||
children.length > 0
|
||||
? children.map((child) => renderLineageChildCard(child))
|
||||
: undefined,
|
||||
renderLineageDescendants(parent, children),
|
||||
childIsActive
|
||||
)
|
||||
: null}
|
||||
@@ -4456,13 +4274,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
[updateWorktreeMeta, worktreeMap, workspaceStatuses]
|
||||
)
|
||||
|
||||
const toggleWorktreeUnread = useCallback(
|
||||
(worktree: Worktree) => {
|
||||
void updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
|
||||
},
|
||||
[updateWorktreeMeta]
|
||||
)
|
||||
|
||||
const moveWorktreesToStatus = useCallback(
|
||||
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
|
||||
const updates = new Map<string, { workspaceStatus: WorkspaceStatus }>()
|
||||
@@ -4711,14 +4522,17 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
>
|
||||
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek pt-px">
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
|
||||
<span>{translate("auto.components.sidebar.WorktreeList.b7acbf038b", "No workspaces found")}</span>
|
||||
<span>
|
||||
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')}
|
||||
</span>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-[11px] px-2.5 py-1 rounded-md cursor-pointer hover:bg-accent transition-colors"
|
||||
>
|
||||
<CircleX className="size-3.5" />
|
||||
{translate("auto.components.sidebar.WorktreeList.370c6a55dd", "Clear Filters")}</button>
|
||||
{translate('auto.components.sidebar.WorktreeList.370c6a55dd', 'Clear Filters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -4731,12 +4545,20 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
<ProjectGroupNameDialog
|
||||
open={projectGroupNameDialog !== null}
|
||||
title={
|
||||
projectGroupNameDialog?.type === 'rename' ? translate("auto.components.sidebar.WorktreeList.f9dc6cc5d3", "Rename Project Group") : translate("auto.components.sidebar.WorktreeList.13757c053c", "New Project Group")
|
||||
projectGroupNameDialog?.type === 'rename'
|
||||
? translate('auto.components.sidebar.WorktreeList.f9dc6cc5d3', 'Rename Project Group')
|
||||
: translate('auto.components.sidebar.WorktreeList.13757c053c', 'New Project Group')
|
||||
}
|
||||
description={
|
||||
projectGroupNameDialog?.type === 'rename'
|
||||
? translate("auto.components.sidebar.WorktreeList.bc1460beb3", "Update the group name shown in the sidebar.")
|
||||
: translate("auto.components.sidebar.WorktreeList.d880ea0744", "Create a group and move this project into it.")
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeList.bc1460beb3',
|
||||
'Update the group name shown in the sidebar.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeList.d880ea0744',
|
||||
'Create a group and move this project into it.'
|
||||
)
|
||||
}
|
||||
initialName={
|
||||
projectGroupNameDialog?.type === 'rename'
|
||||
@@ -4793,7 +4615,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onImmediateWorktreeActivate={handleImmediateWorktreeActivate}
|
||||
onToggleWorktreeUnread={toggleWorktreeUnread}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
repoMap={repoMap}
|
||||
worktreeMap={worktreeMap}
|
||||
@@ -4811,7 +4632,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard}
|
||||
shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator}
|
||||
onReorderWorktrees={reorderWorktrees}
|
||||
showInlineAgentCards={cardProps.includes('inline-agents')}
|
||||
scrollOffsetRef={scrollOffsetRef}
|
||||
scrollAnchorRef={scrollAnchorRef}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user