diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 6ae2fb25a..423f7d547 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -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({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 8c3754980..d4f648f94 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -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 || diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts new file mode 100644 index 000000000..96e5dae93 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -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 => ({ + 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 => ({ + type: 'item', + worktree: makeWorktree(id), + repo, + depth: 0, + lineageTrail: [], + isLastLineageChild: false, + lineageChildCount: 0 +}) + +const makeImportedCardRow = (): Extract => ({ + 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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index b57850dc0..924a4edc8 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -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 => ({ +const makeHeaderRow = ( + key: string, + overrides: Partial> = {} +): Extract => ({ 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 => ({ - type: 'item', - worktree: makeWorktree(id), - repo, - depth: 0, - lineageTrail: [], - isLastLineageChild: false, - lineageChildCount: 0 + tone: 'text-foreground', + ...overrides }) const makeImportedCardRow = (): Extract => ({ @@ -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) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts new file mode 100644 index 000000000..0ccd595f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts @@ -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 => ({ + 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 => ({ + 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]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index bc03c221d..ff28d2b4e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -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) } })