Fix paired-host manual project header ordering (#8919)

* Fix paired-host project header reordering

* fix(sidebar): keep unchanged paired project drops inert
This commit is contained in:
Brennan Benson 2026-07-16 15:00:09 -07:00 committed by GitHub
parent 09173d02d2
commit 0702e3dd4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 135 additions and 15 deletions

View File

@ -142,7 +142,10 @@ import {
type ScrollToCurrentWorkspaceRevealRequestDetail
} from '@/lib/scroll-to-current-workspace-status'
import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag'
import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop'
import {
getLogicalRepoOrderRankById,
getSidebarOrderedRepoHeaderIdsByBucket
} from './project-header-drop'
import { useProjectGroupHeaderDrag } from './project-group-header-drag'
import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop'
import {
@ -5670,9 +5673,7 @@ const WorktreeList = React.memo(function WorktreeList({
})
}, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet])
const repoOrder = useMemo(() => {
const map = new Map<string, number>()
repos.forEach((r, i) => map.set(r.id, i))
return map
return getLogicalRepoOrderRankById(repos.map((repo) => repo.id))
}, [repos])
const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState<
Map<string, ImportedWorktreeCardActionState>

View File

@ -53,6 +53,42 @@ describe('commitProjectHeaderDragDrop', () => {
expect(onCommitRepoOrder).toHaveBeenCalledWith(['c', 'a', 'b'])
})
it('moves a merged paired-host header upward as one stable block', () => {
const onCommitRepoOrder = vi.fn()
const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')]
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
commitProjectHeaderDragDrop({
session: makeSession('same', ['b', 'same', 'c']),
sidebarDropIndex: 0,
orderedRepoIds: ['b', 'same', 'c', 'same'],
repoById,
usesProjectGroupOrdering: false,
onCommitRepoOrder,
onCommitProjectGroupOrder: vi.fn()
})
expect(onCommitRepoOrder).toHaveBeenCalledWith(['same', 'same', 'b', 'c'])
})
it('does not reorder host occurrences when a merged header stays in place', () => {
const onCommitRepoOrder = vi.fn()
const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')]
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
commitProjectHeaderDragDrop({
session: makeSession('same', ['b', 'same', 'c']),
sidebarDropIndex: 2,
orderedRepoIds: ['b', 'same', 'c', 'same'],
repoById,
usesProjectGroupOrdering: false,
onCommitRepoOrder,
onCommitProjectGroupOrder: vi.fn()
})
expect(onCommitRepoOrder).not.toHaveBeenCalled()
})
it('commits projectGroupOrder when project groups are present', () => {
const onCommitProjectGroupOrder = vi.fn()
const repos = [

View File

@ -1,5 +1,6 @@
import {
applyAllRepoInsertAt,
getLogicalRepoOrderRankById,
getProjectGroupOrderForSidebarDrop,
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex,
mapSidebarRepoDropIndexToAllRepoInsertAt
@ -23,7 +24,13 @@ export function commitProjectHeaderDragDrop(args: {
const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds
const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId)
if (args.sidebarDropIndex === sourceIndex) {
// Why: both slots bordering the dragged header are visual no-ops. In
// particular, do not compact paired-host occurrences on an unchanged drop.
if (
sourceIndex === -1 ||
args.sidebarDropIndex === sourceIndex ||
args.sidebarDropIndex === sourceIndex + 1
) {
return
}
@ -46,9 +53,7 @@ export function commitProjectHeaderDragDrop(args: {
if (siblingDropIndex === sourceIndexInSiblings) {
return
}
const repoOrderRankById = new Map(
args.orderedRepoIds.map((repoId, index) => [repoId, index] as const)
)
const repoOrderRankById = getLogicalRepoOrderRankById(args.orderedRepoIds)
const order = getProjectGroupOrderForSidebarDrop({
siblings,
dropIndex: siblingDropIndex,

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import {
applyAllRepoInsertAt,
computeProjectHeaderDropPreview,
getLogicalRepoOrderRankById,
getProjectGroupOrderForSidebarDrop,
getProjectHeaderDragBucketKey,
getSidebarOrderedRepoHeaderIdsByBucket,
@ -53,6 +54,20 @@ describe('getSidebarOrderedRepoHeaderIdsByBucket', () => {
})
})
describe('getLogicalRepoOrderRankById', () => {
it('anchors a merged paired-host header to its first persisted occurrence', () => {
const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same'])
expect(rankById).toEqual(
new Map([
['b', 0],
['same', 1],
['c', 2]
])
)
})
})
describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => {
const sidebar = ['a', 'b', 'c']
@ -320,6 +335,15 @@ describe('applyAllRepoInsertAt', () => {
])
})
it('moves duplicate host occurrences as one stable logical-project block', () => {
expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([
'same',
'same',
'b',
'c'
])
})
it('returns null for no-op reorders', () => {
expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull()
})

View File

@ -54,6 +54,20 @@ export function getSidebarOrderedRepoHeaderIdsByBucket(
return buckets
}
export function getLogicalRepoOrderRankById(
orderedRepoIds: readonly string[]
): Map<string, number> {
const rankById = new Map<string, number>()
orderedRepoIds.forEach((repoId, index) => {
// Why: paired hosts can contribute the same logical project ID; its merged
// header must anchor to the first occurrence instead of whichever host loaded last.
if (!rankById.has(repoId)) {
rankById.set(repoId, index)
}
})
return rankById
}
export function getProjectGroupOrderForSidebarDrop(args: {
siblings: readonly Repo[]
dropIndex: number
@ -203,16 +217,20 @@ export function applyAllRepoInsertAt(
draggedRepoId: string,
insertAt: number
): string[] | null {
const fromIndex = allRepoIds.indexOf(draggedRepoId)
if (fromIndex === -1 || insertAt < 0 || insertAt > allRepoIds.length) {
if (!allRepoIds.includes(draggedRepoId) || insertAt < 0 || insertAt > allRepoIds.length) {
return null
}
const next = allRepoIds.slice()
next.splice(fromIndex, 1)
const adjustedInsertAt = insertAt > fromIndex ? insertAt - 1 : insertAt
if (adjustedInsertAt === fromIndex) {
// Why: one merged header controls every host-qualified occurrence; moving
// them together prevents a drag from persisting a cross-host split project.
const draggedBlock = allRepoIds.filter((repoId) => repoId === draggedRepoId)
const removedBeforeInsert = allRepoIds
.slice(0, insertAt)
.filter((repoId) => repoId === draggedRepoId).length
const adjustedInsertAt = insertAt - removedBeforeInsert
const next = allRepoIds.filter((repoId) => repoId !== draggedRepoId)
next.splice(adjustedInsertAt, 0, ...draggedBlock)
if (next.every((repoId, index) => repoId === allRepoIds[index])) {
return null
}
next.splice(adjustedInsertAt, 0, draggedRepoId)
return next
}

View File

@ -496,6 +496,42 @@ describe('repo slice host identity routing', () => {
})
})
it('persists a moved paired-host project block without reversing its host occurrences', async () => {
reposReorderForHost.mockResolvedValue({ status: 'applied' })
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-paired-project-reorder',
ok: true,
result: { status: 'applied' },
_meta: { runtimeId: 'runtime-remote' }
})
const bravo = { ...localDuplicate, id: 'bravo' }
const charlie = { ...remoteDuplicate, id: 'charlie' }
const store = createTestStore()
store.setState({ repos: [bravo, localDuplicate, charlie, remoteDuplicate] })
await store.getState().reorderRepos(['same-repo', 'same-repo', 'bravo', 'charlie'])
expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate, bravo, charlie])
expect(uiSet).toHaveBeenCalledWith({
manualRepoOrder: [
{ hostId: 'local', repoId: 'same-repo' },
{ hostId: 'runtime:env-1', repoId: 'same-repo' },
{ hostId: 'local', repoId: 'bravo' },
{ hostId: 'runtime:env-1', repoId: 'charlie' }
]
})
expect(reposReorderForHost).toHaveBeenCalledWith({
hostId: 'local',
orderedIds: ['same-repo', 'bravo']
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'repo.reorder',
params: { orderedIds: ['same-repo', 'charlie'] },
timeoutMs: 15_000
})
})
it('persists a complete cross-host overlay alongside host-local permutations', async () => {
reposReorderForHost.mockResolvedValue({ status: 'applied' })
runtimeEnvironmentCall.mockResolvedValue({