From 3172002d7137d09feea322925ea760bf8e3c89ce Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:46:33 -0700 Subject: [PATCH] fix(sidebar): preserve manual order during refresh (#12072) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../src/store/slices/worktrees.test.ts | 49 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 40 ++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 595e0828c92..94782b75fc0 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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((resolve) => { + resolveListing = resolve + }) + worktreeListMock.mockReturnValueOnce(listing) + store.setState({ + worktreesByRepo: { repo1: [daily, relay] }, + detectedWorktreesByRepo: { repo1: detected } + } as Partial) + + 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({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index b80d5d4e87b..92f6feb6a15 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2819,6 +2819,32 @@ function staleDetectedWorktreeProviderResult( : undefined } +function preserveConcurrentManualOrder( + 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