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:
parent
a6c658c325
commit
e071cab1bb
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue