Keep project group headers sticky in worktree list (#4008)

* Keep project group headers sticky over nested repo headers

- Prevent nested repo headers from replacing their top-level project group
  as the pinned sidebar header
- Split imported-row tests from scroll adjustment coverage
- Add sticky header tests for manual and built row hierarchies

* Tell users to commit before publishing dirty new branches
This commit is contained in:
Jinjing 2026-05-31 00:41:50 -07:00 committed by GitHub
parent a6c658c325
commit e071cab1bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 274 additions and 85 deletions

View File

@ -410,6 +410,22 @@ describe('resolveDropdownItems', () => {
expect(byKind.publish.disabled).toBe(true)
})
it('points an unpublished dirty branch with no commits at committing first', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.publish.label).toBe('Commit Changes First')
expect(byKind.publish.title).toBe('Commit changes before publishing the branch')
expect(byKind.publish.disabled).toBe(true)
})
it('does not mention Publish Branch when the linked PR is already merged', () => {
const items = resolveDropdownItems(
inputs({

View File

@ -139,6 +139,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
const publishBlockedByMergedPR = !hasUpstream && prState === 'merged'
const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading
const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0
const publishBlockedByUncommittedChanges = publishBlockedByNoBranchCommits && hasDirtyLocalChanges
const ahead = upstreamStatus?.ahead ?? 0
const behind = upstreamStatus?.behind ?? 0
const shouldForcePushWithLease = shouldForcePushWithLeaseForUpstream(upstreamStatus)
@ -384,20 +385,24 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
label:
publishBlockedByMergedPR || publishBlockedByPRLoading
? 'PR Status'
: publishBlockedByNoBranchCommits
? 'No Branch Changes'
: 'Publish Branch',
: publishBlockedByUncommittedChanges
? 'Commit Changes First'
: publishBlockedByNoBranchCommits
? 'No Branch Changes'
: 'Publish Branch',
title: upstreamLoading
? 'Checking branch status…'
: publishBlockedByPRLoading
? 'Checking PR status…'
: publishBlockedByMergedPR
? 'PR is already merged'
: publishBlockedByNoBranchCommits
? 'Nothing to publish'
: hasUpstream
? 'Branch is already published'
: 'Publish this branch to origin',
: publishBlockedByUncommittedChanges
? 'Commit changes before publishing the branch'
: publishBlockedByNoBranchCommits
? 'Nothing to publish'
: hasUpstream
? 'Branch is already published'
: 'Publish this branch to origin',
disabled:
globalBusy ||
upstreamLoading ||

View File

@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import {
canKeepImportedWorktreesHidden,
getRenderRowKey,
getWorktreeDragGroups,
renderRowContainsWorktree
} from './WorktreeList'
import type { Repo, Worktree } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
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'
})
describe('imported worktree virtual rows', () => {
it('uses stable imported row keys and does not match worktree ids', () => {
const card = makeImportedCardRow()
expect(getRenderRowKey(card)).toBe('imported:imported-worktrees-card:repo-group:repo-1')
expect(renderRowContainsWorktree(card, 'wt-1')).toBe(false)
})
it('keeps imported card rows out of worktree drag groups', () => {
expect(
getWorktreeDragGroups([
makeHeaderRow('repo:repo-1'),
makeWorktreeRow('main'),
makeImportedCardRow(),
makeWorktreeRow('feature')
])
).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }])
})
it('suppresses keep-hidden actions for force-visible rollback failure cards', () => {
expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true)
expect(
canKeepImportedWorktreesHidden(makeImportedCardRow(), {
pending: false,
error: 'Could not show imported worktrees.',
forceVisible: true
})
).toBe(false)
expect(
canKeepImportedWorktreesHidden(
{ ...makeImportedCardRow(), placement: 'pinned-fallback' },
undefined
)
).toBe(false)
})
})

View File

@ -1,11 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
canKeepImportedWorktreesHidden,
countRecordKeysByReference,
getRenderRowKey,
getScrollTopToRevealBounds,
getWorktreeDragGroups,
renderRowContainsWorktree,
resolvePendingSidebarReveal,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET,
shouldAdjustWorktreeSidebarMeasuredRowScroll
@ -16,7 +12,7 @@ import {
GROUP_HEADER_ROW_HEIGHT,
getActiveStickyHeaderIndexForScroll
} from './worktree-list-virtual-rows'
import type { Repo, Worktree } from '../../../../shared/types'
import type { Repo } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
const repo: Repo = {
@ -27,44 +23,16 @@ const repo: Repo = {
addedAt: 1
}
const makeHeaderRow = (key: string): Extract<Row, { type: 'header' }> => ({
const makeHeaderRow = (
key: string,
overrides: Partial<Extract<Row, { type: 'header' }>> = {}
): 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
tone: 'text-foreground',
...overrides
})
const makeImportedCardRow = (): Extract<Row, { type: 'imported-worktrees-card' }> => ({
@ -282,40 +250,3 @@ describe('estimateRenderRowSize', () => {
).toBe(1)
})
})
describe('imported worktree virtual rows', () => {
it('uses stable imported row keys and does not match worktree ids', () => {
const card = makeImportedCardRow()
expect(getRenderRowKey(card)).toBe('imported:imported-worktrees-card:repo-group:repo-1')
expect(renderRowContainsWorktree(card, 'wt-1')).toBe(false)
})
it('keeps imported card rows out of worktree drag groups', () => {
expect(
getWorktreeDragGroups([
makeHeaderRow('repo:repo-1'),
makeWorktreeRow('main'),
makeImportedCardRow(),
makeWorktreeRow('feature')
])
).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }])
})
it('suppresses keep-hidden actions for force-visible rollback failure cards', () => {
expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true)
expect(
canKeepImportedWorktreesHidden(makeImportedCardRow(), {
pending: false,
error: 'Could not show imported worktrees.',
forceVisible: true
})
).toBe(false)
expect(
canKeepImportedWorktreesHidden(
{ ...makeImportedCardRow(), placement: 'pinned-fallback' },
undefined
)
).toBe(false)
})
})

View File

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { buildRows } from './worktree-list-groups'
import { getStickyHeaderIndexes } from './worktree-list-virtual-rows'
import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000',
addedAt: 1
}
const makeHeaderRow = (
key: string,
overrides: Partial<Extract<Row, { type: 'header' }>> = {}
): Extract<Row, { type: 'header' }> => ({
type: 'header',
key,
label: key,
count: 0,
tone: 'text-foreground',
...overrides
})
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
})
describe('getStickyHeaderIndexes', () => {
it('keeps nested project rows from replacing their top-level project group header', () => {
expect(
getStickyHeaderIndexes([
makeHeaderRow('project-group:personal', { projectGroupDepth: 0 }),
makeHeaderRow('repo:autogenie', { projectGroupDepth: 1 }),
makeWorktreeRow('main'),
makeHeaderRow('repo:ungrouped', { projectGroupDepth: 0 })
])
).toEqual([0, 3])
})
it('uses the real project-group hierarchy when choosing sticky headers', () => {
const projectGroup: ProjectGroup = {
id: 'group-personal',
name: 'personal',
parentPath: '/workspace',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const groupedRepo: Repo = {
...repo,
id: 'repo-autogenie',
displayName: 'AutoGenie',
projectGroupId: projectGroup.id,
projectGroupOrder: 0
}
const ungroupedRepo: Repo = { ...repo, id: 'repo-orca', displayName: 'orca' }
const groupedWorktree: Worktree = {
...makeWorktree('main'),
id: 'wt-autogenie-main',
repoId: groupedRepo.id,
isMainWorktree: true
}
const ungroupedWorktree: Worktree = {
...makeWorktree('main'),
id: 'wt-orca-main',
repoId: ungroupedRepo.id,
isMainWorktree: true
}
const rows = buildRows(
'repo',
[groupedWorktree, ungroupedWorktree],
new Map([
[groupedRepo.id, groupedRepo],
[ungroupedRepo.id, ungroupedRepo]
]),
null,
new Set(),
new Map([
[groupedRepo.id, 0],
[ungroupedRepo.id, 1]
]),
undefined,
'manual',
undefined,
undefined,
false,
undefined,
[projectGroup]
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-personal',
'repo:repo-autogenie',
'repo:repo-orca'
])
expect(getStickyHeaderIndexes(rows)).toEqual([0, 3])
})
})

View File

@ -56,7 +56,9 @@ export function getVirtualRowTransform(start: number): string {
export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] {
const indexes: number[] = []
rows.forEach((row, index) => {
if (row.type === 'header') {
// Why: project groups are the top-level repo sidebar context; nested repo
// headers should not replace their containing group as the pinned header.
if (row.type === 'header' && (row.projectGroupDepth ?? 0) === 0) {
indexes.push(index)
}
})