Fall back to global repo order for group projects with unset rank (#5905)

Uses a new `getEffectiveProjectGroupManualRank` helper to ensure that
drag-and-drop midpoint calculations and sidebar list rendering remain
aligned. When a project's `projectGroupOrder` is unset, it now falls back
consistently to its global repo order instead of defaulting to infinity in
the list but a scaled rank during drag-and-drop.
This commit is contained in:
Jinjing 2026-06-20 14:54:43 -07:00 committed by GitHub
parent 8ff5a3cc08
commit 99bb6d0bf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 258 additions and 18 deletions

View File

@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { commitProjectHeaderDragDrop } from './project-header-drag-commit'
import type { ProjectHeaderDragSession } from './project-header-drag-contract'
import type { Repo } from '../../../../shared/types'
function makeRepo(id: string, overrides: Partial<Repo> = {}): Repo {
return {
id,
path: `/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 0,
...overrides
} as Repo
}
function makeSession(
repoId: string,
sidebarRepoHeaderIds: readonly string[]
): ProjectHeaderDragSession {
return {
repoId,
bucketKey: 'ungrouped',
sidebarRepoHeaderIds,
pointerId: 1,
headerRects: [],
handleEl: document.createElement('div'),
startX: 0,
startY: 0,
latestPointerY: 0,
promoted: true
}
}
describe('commitProjectHeaderDragDrop', () => {
it('commits whole-repo reordering when project groups are absent', () => {
const onCommitRepoOrder = vi.fn()
const repos = [makeRepo('a'), makeRepo('b'), makeRepo('c')]
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
commitProjectHeaderDragDrop({
session: makeSession('c', ['a', 'b', 'c']),
sidebarDropIndex: 0,
orderedRepoIds: ['a', 'b', 'c'],
repoById,
usesProjectGroupOrdering: false,
onCommitRepoOrder,
onCommitProjectGroupOrder: vi.fn()
})
expect(onCommitRepoOrder).toHaveBeenCalledWith(['c', 'a', 'b'])
})
it('commits projectGroupOrder when project groups are present', () => {
const onCommitProjectGroupOrder = vi.fn()
const repos = [
makeRepo('a', { projectGroupId: 'group-1' }),
makeRepo('b', { projectGroupId: 'group-1' }),
makeRepo('c', { projectGroupId: 'group-1' })
]
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
commitProjectHeaderDragDrop({
session: makeSession('c', ['a', 'b', 'c']),
sidebarDropIndex: 0,
orderedRepoIds: ['a', 'b', 'c'],
repoById,
usesProjectGroupOrdering: true,
onCommitRepoOrder: vi.fn(),
onCommitProjectGroupOrder
})
expect(onCommitProjectGroupOrder).toHaveBeenCalledWith('c', 'group-1', -1)
})
})

View File

@ -188,4 +188,19 @@ describe('getProjectGroupOrderForSidebarDrop', () => {
})
).toBe(1)
})
it('assigns an order that sorts before siblings ranked by repo order', () => {
const order = getProjectGroupOrderForSidebarDrop({
siblings: [repo('a'), repo('b')],
dropIndex: 1,
repoOrderRankById: new Map([
['a', 0],
['b', 1],
['c', 2]
])
})
expect(order).toBeGreaterThan(0)
expect(order).toBeLessThan(2000)
})
})

View File

@ -1,3 +1,4 @@
import { getEffectiveProjectGroupManualRank } from '../../../../shared/project-groups'
import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll'
import type { Row } from './worktree-list-groups'
import type { Repo } from '../../../../shared/types'
@ -67,12 +68,7 @@ export function getProjectGroupOrderForSidebarDrop(args: {
if (!repo) {
return undefined
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
return order
}
const repoRank = args.repoOrderRankById?.get(repo.id)
return (repoRank ?? fallbackIndex) * 1000
return getEffectiveProjectGroupManualRank(repo, args.repoOrderRankById, fallbackIndex)
}
const before = getEffectiveOrder(ordered[args.dropIndex - 1], args.dropIndex - 1)
const after = getEffectiveOrder(ordered[args.dropIndex], args.dropIndex)

View File

@ -1500,6 +1500,122 @@ describe('project groups', () => {
])
})
it('falls back to repoOrder for grouped repos missing projectGroupOrder in manual mode', () => {
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha', projectGroupId: group.id }
const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta', projectGroupId: group.id }
const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma', projectGroupId: group.id }
const groupedMap = new Map([
[repoA.id, repoA],
[repoB.id, repoB],
[repoC.id, repoC]
])
const repoOrder = new Map([
[repoA.id, 0],
[repoB.id, 1],
[repoC.id, 2]
])
const rows = buildRows(
'repo',
[
{ ...worktree, id: 'wt-a', repoId: repoA.id },
{ ...worktree, id: 'wt-b', repoId: repoB.id },
{ ...worktree, id: 'wt-c', repoId: repoC.id }
],
groupedMap,
null,
new Set(),
repoOrder,
undefined,
'manual',
undefined,
undefined,
false,
undefined,
[group]
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-1',
'repo:repo-a',
'repo:repo-b',
'repo:repo-c'
])
})
it('sorts a dragged project between repo-order fallbacks inside a group', () => {
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha', projectGroupId: group.id }
const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta', projectGroupId: group.id }
const repoC: Repo = {
...repo,
id: 'repo-c',
displayName: 'gamma',
projectGroupId: group.id,
projectGroupOrder: 500
}
const groupedMap = new Map([
[repoA.id, repoA],
[repoB.id, repoB],
[repoC.id, repoC]
])
const repoOrder = new Map([
[repoA.id, 0],
[repoB.id, 1],
[repoC.id, 2]
])
const rows = buildRows(
'repo',
[
{ ...worktree, id: 'wt-a', repoId: repoA.id },
{ ...worktree, id: 'wt-b', repoId: repoB.id },
{ ...worktree, id: 'wt-c', repoId: repoC.id }
],
groupedMap,
null,
new Set(),
repoOrder,
undefined,
'manual',
undefined,
undefined,
false,
undefined,
[group]
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-1',
'repo:repo-a',
'repo:repo-c',
'repo:repo-b'
])
})
it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => {
const groupA: ProjectGroup = {
id: 'group-a',

View File

@ -25,10 +25,13 @@ import {
ConductorProgressIcon,
ConductorReviewIcon
} from './workspace-status-icons'
import {
getEffectiveProjectGroupManualRank,
UNGROUPED_PROJECT_GROUP_KEY
} from '../../../../shared/project-groups'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type { AppState } from '../../store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
import { translate } from '@/i18n/i18n'
import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host'
@ -1026,18 +1029,11 @@ export function buildRows(
)
}
// Manual: within a Project Group, projects order by their per-group rank
// (projectGroupOrder), not the global repoOrder.
// (projectGroupOrder), falling back to global repoOrder when unset so drag
// midpoint commits and the rendered order stay aligned.
return [...entries].sort((left, right) => {
const leftOrder = left[1].repo?.projectGroupOrder
const rightOrder = right[1].repo?.projectGroupOrder
const leftRank =
typeof leftOrder === 'number' && Number.isFinite(leftOrder)
? leftOrder
: Number.POSITIVE_INFINITY
const rightRank =
typeof rightOrder === 'number' && Number.isFinite(rightOrder)
? rightOrder
: Number.POSITIVE_INFINITY
const leftRank = getEffectiveProjectGroupManualRank(left[1].repo, repoOrder)
const rightRank = getEffectiveProjectGroupManualRank(right[1].repo, repoOrder)
return leftRank - rightRank
})
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
clearMissingProjectGroupMemberships,
createProjectGroup,
getEffectiveProjectGroupManualRank,
getNextProjectGroupOrder,
getProjectGroupSubtreeIds,
normalizeProjectGroupName,
@ -85,6 +86,20 @@ describe('project-groups', () => {
expect(repos.find((entry) => entry.id === 'missing')?.projectGroupId).toBeNull()
})
it('falls back to global repo order when projectGroupOrder is unset', () => {
const repoOrder = new Map([
['a', 0],
['b', 2]
])
expect(
getEffectiveProjectGroupManualRank(repo({ id: 'a', projectGroupOrder: 5 }), repoOrder)
).toBe(5)
expect(getEffectiveProjectGroupManualRank(repo({ id: 'a' }), repoOrder)).toBe(0)
expect(getEffectiveProjectGroupManualRank(repo({ id: 'b' }), repoOrder)).toBe(2000)
expect(getEffectiveProjectGroupManualRank(repo({ id: 'c' }), repoOrder, 1)).toBe(1000)
})
it('computes the next order inside a group independently from ungrouped repos', () => {
expect(
getNextProjectGroupOrder(

View File

@ -135,6 +135,31 @@ export function getProjectGroupSubtreeIds(
return subtreeIds
}
/** Manual rank for a project inside a group bucket. Explicit
* `projectGroupOrder` wins; otherwise fall back to global repo order so drag
* midpoint math and sidebar sorting stay aligned. */
export function getEffectiveProjectGroupManualRank(
repo: Pick<Repo, 'id' | 'projectGroupOrder'> | undefined,
repoOrderRankById?: ReadonlyMap<string, number>,
siblingFallbackIndex?: number
): number {
if (!repo) {
return Number.POSITIVE_INFINITY
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
return order
}
const repoRank = repoOrderRankById?.get(repo.id)
if (repoRank !== undefined) {
return repoRank * 1000
}
if (siblingFallbackIndex !== undefined) {
return siblingFallbackIndex * 1000
}
return Number.POSITIVE_INFINITY
}
export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number {
let max = -1
for (const repo of repos) {