fix(sidebar): preserve manual order during refresh (#12072)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong 2026-08-02 00:46:33 -07:00 committed by GitHub
parent 714bcbe43f
commit 3172002d71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 2 deletions

View File

@ -572,6 +572,55 @@ describe('fetchWorktrees', () => {
expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled()
})
it('does not merge stale manual order over a reorder completed during refresh', async () => {
const store = createTestStore()
const daily = makeWorktree({
id: 'repo1::/path/daily',
repoId: 'repo1',
path: '/path/daily',
manualOrder: 20
})
const relay = makeWorktree({
id: 'repo1::/path/relay',
repoId: 'repo1',
path: '/path/relay',
manualOrder: 10
})
const refreshedDaily = { ...daily, head: 'def456' }
const detected = makeDetectedResult('repo1', [daily, relay])
let resolveListing!: (worktrees: Worktree[]) => void
const listing = new Promise<Worktree[]>((resolve) => {
resolveListing = resolve
})
worktreeListMock.mockReturnValueOnce(listing)
store.setState({
worktreesByRepo: { repo1: [daily, relay] },
detectedWorktreesByRepo: { repo1: detected }
} as Partial<AppState>)
const refresh = store.getState().fetchWorktrees('repo1')
await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1))
await store.getState().updateWorktreesMeta(
new Map([
[daily.id, { manualOrder: 100 }],
[relay.id, { manualOrder: 200 }]
])
)
resolveListing([refreshedDaily, relay])
await refresh
expect(store.getState().worktreesByRepo.repo1.map((worktree) => worktree.manualOrder)).toEqual([
100, 200
])
expect(
store
.getState()
.detectedWorktreesByRepo.repo1.worktrees.map((worktree) => worktree.manualOrder)
).toEqual([100, 200])
expect(store.getState().worktreesByRepo.repo1[0]?.head).toBe('def456')
})
it('updates the repo entry when only the persisted base ref changes', async () => {
const store = createTestStore()
const existing = makeWorktree({

View File

@ -2819,6 +2819,32 @@ function staleDetectedWorktreeProviderResult(
: undefined
}
function preserveConcurrentManualOrder<T extends Worktree>(
incoming: readonly T[],
requestStarted: readonly Worktree[] | undefined,
current: readonly Worktree[] | undefined,
matchesRefreshHost: (worktree: Worktree) => boolean
): T[] {
if (!requestStarted || !current) {
return [...incoming]
}
const startedById = new Map(
requestStarted.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree])
)
const currentById = new Map(
current.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree])
)
return incoming.map((worktree) => {
const started = startedById.get(worktree.id)
const latest = currentById.get(worktree.id)
if (!started || !latest || started.manualOrder === latest.manualOrder) {
return worktree
}
// Why: a refresh response may predate a completed drag; the renderer's optimistic rank is newer.
return { ...worktree, manualOrder: latest.manualOrder }
})
}
type FencedWorktreeMergeArgs = {
repoId: string
hostId: ExecutionHostId
@ -2850,7 +2876,17 @@ function mergeFetchedWorktrees(
}
admitted = true
const matchOptions = worktreeHostMatchOptions(s, args.repoId, args.hostId)
let incoming = toVisibleWorktrees(args.refresh.result, args.hostId, args.setup)
const currentWorktrees = s.worktreesByRepo[args.repoId]
const refreshResult = {
...args.refresh.result,
worktrees: preserveConcurrentManualOrder(
args.refresh.result.worktrees,
args.requestStartedWorktrees,
currentWorktrees,
(worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions)
)
}
let incoming = toVisibleWorktrees(refreshResult, args.hostId, args.setup)
incoming = routeListingBranchSwitchesThroughGitIdentity({
requestStarted: args.requestStartedWorktrees,
current: s.worktreesByRepo[args.repoId],
@ -2868,7 +2904,7 @@ function mergeFetchedWorktrees(
)
const mergedDetected = mergeDetectedWorktreesForHost(
s.detectedWorktreesByRepo[args.repoId],
args.refresh.result,
refreshResult,
args.hostId,
args.setup,
matchOptions