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:
parent
09173d02d2
commit
0702e3dd4c
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue