diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 73726d09ae0..df2250c3c0f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 { @@ -5686,9 +5689,7 @@ const WorktreeList = React.memo(function WorktreeList({ }) }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { - const map = new Map() - repos.forEach((r, i) => map.set(r.id, i)) - return map + return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< Map diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 70a40d6d250..a988db5bd3d 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -53,6 +53,24 @@ 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('commits projectGroupOrder when project groups are present', () => { const onCommitProjectGroupOrder = vi.fn() const repos = [ diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 43c513905a6..57cc5c63a09 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,5 +1,6 @@ import { applyAllRepoInsertAt, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt @@ -46,9 +47,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, diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index dee5572e02f..c0382a5f162 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -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() }) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 00b7169b97e..be012eb72ce 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -54,6 +54,20 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( return buckets } +export function getLogicalRepoOrderRankById( + orderedRepoIds: readonly string[] +): Map { + const rankById = new Map() + 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 } diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 449290d81f4..23325aa73e9 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -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({