fix(sidebar): indent worktree rows under their project header (#2841)

* fix(sidebar): indent worktree rows under their project header

Top-level worktree rows had no left padding, so their status dots sat
flush with the project folder icon. Indent each worktree group by one
LINEAGE_INDENT step so the dots nest under the folder icon, giving clear
parent-to-child visual hierarchy. Nested/lineage cards inherit the base
inset and keep their relative indentation.

* Avoid ungrouped workspace indentation

* review: cover grouped sidebar indentation

- reuse the WorktreeList render helper in the lineage test

- add the grouped project assertion that fails if the indent is reverted

- mock DropdownMenuSeparator for grouped project rendering

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Trevin Chow 2026-05-26 11:14:32 -07:00 committed by GitHub
parent 6eb475afaa
commit 64af9a01c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 11 deletions

View File

@ -100,6 +100,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
React.createElement(React.Fragment, null, children),
DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DropdownMenuSeparator: () => React.createElement('hr'),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children)
}))
@ -155,7 +156,7 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
}
}
function setLineageFixtureState(): void {
function setLineageFixtureState(groupBy: 'none' | 'repo' = 'none'): void {
const repo = makeRepo()
const parent = makeWorktree({
id: 'parent',
@ -188,7 +189,7 @@ function setLineageFixtureState(): void {
clearPendingRevealWorktreeId: vi.fn(),
collapsedGroups: new Set<string>(),
filterRepoIds: [],
groupBy: 'none',
groupBy,
hideDefaultBranchWorkspace: false,
issueCache: {},
migrationUnsupportedByPtyId: {},
@ -229,17 +230,21 @@ function setLineageFixtureState(): void {
}
}
async function renderWorktreeListMarkup(): Promise<string> {
const { default: WorktreeList } = await import('./WorktreeList')
return renderToStaticMarkup(
React.createElement(WorktreeList, {
scrollOffsetRef: { current: 0 },
scrollAnchorRef: { current: null }
})
)
}
describe('WorktreeList lineage child card renderer', () => {
it('renders nested inline agent rows before the nested child-count toggle', async () => {
setLineageFixtureState()
const { default: WorktreeList } = await import('./WorktreeList')
const markup = renderToStaticMarkup(
React.createElement(WorktreeList, {
scrollOffsetRef: { current: 0 },
scrollAnchorRef: { current: null }
})
)
const markup = await renderWorktreeListMarkup()
const childStart = markup.indexOf('lineage child with agent')
const agentRowIndex = markup.indexOf('Review fixture prompt', childStart)
@ -250,4 +255,23 @@ describe('WorktreeList lineage child card renderer', () => {
expect(childToggleIndex).toBeGreaterThan(childStart)
expect(agentRowIndex).toBeLessThan(childToggleIndex)
})
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] ?? ''
expect(parentRow).toContain('id="worktree-list-option-parent"')
expect(parentRow).not.toContain('padding-left')
})
it('adds one group indentation step when grouped by project', async () => {
setLineageFixtureState('repo')
const markup = await renderWorktreeListMarkup()
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
expect(parentRow).toContain('style="padding-left:18px"')
})
})

View File

@ -274,6 +274,9 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
}
const LINEAGE_INDENT = 18
// Why: top-level worktrees are children of their project header; indent the
// group one step so the status dots nest under the folder icon for hierarchy.
const WORKTREE_GROUP_INDENT = 18
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
type VirtualizedWorktreeViewportProps = {
@ -2230,6 +2233,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// Why: child cards render inside the parent card body, so their
// first nested level starts flush with that inset.
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
// Why: ungrouped mode keeps workspace cards flush; grouped modes
// indent top-level cards under their visible section header.
const basePadding = !nested && groupBy !== 'none' ? WORKTREE_GROUP_INDENT : 0
const paddingLeft = basePadding + paddingDepth * LINEAGE_INDENT
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
return (
@ -2262,7 +2269,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id)
}
style={{
paddingLeft: paddingDepth > 0 ? `${paddingDepth * LINEAGE_INDENT}px` : undefined
paddingLeft: paddingLeft > 0 ? `${paddingLeft}px` : undefined
}}
>
<WorktreeCard