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:
parent
8ff5a3cc08
commit
99bb6d0bf4
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue