diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx index 116cee843fd..908c925ac99 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx @@ -10,6 +10,7 @@ import type { Worktree } from '../../../../../../shared/worktree/types' import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures' import type { SortBy } from '../../smart-sort' import { useSidebarWorktreeSortOrder } from './use-sort-order' +import { makeTerminalTab } from '../../../../store/slices/worktrees-slice-test-fixtures' const initialState = useAppStore.getInitialState() const repo = makeRepo() @@ -36,6 +37,19 @@ function replaceWorktrees(worktrees: Worktree[]): void { })) } +// Same shape as another client's order save landing here: sortOrder changes, sortEpoch does not. +function publishSortOrders(sortOrderById: Record): void { + useAppStore.setState((s) => ({ + worktreesByRepo: { + [repo.id]: (s.worktreesByRepo[repo.id] ?? []).map((worktree) => ({ + ...worktree, + sortOrder: sortOrderById[worktree.id] ?? worktree.sortOrder + })) + }, + sortOrderEpoch: s.sortOrderEpoch + 1 + })) +} + function renderSortOrder(options?: { strict?: boolean }) { const renders = { count: 0 } const hook = renderHook( @@ -308,4 +322,42 @@ describe('useSidebarWorktreeSortOrder', () => { expect(useAppStore.getState().settledSortEpoch).toBe(useAppStore.getState().sortEpoch) expect(vi.getTimerCount()).toBe(0) }) + + it.each(['smart', 'manual'] as const)( + 'follows another client order save while %s ranks by persisted sortOrder', + (sortBy) => { + seed( + [makeWorktree('a', 'A', { sortOrder: 2 }), makeWorktree('b', 'B', { sortOrder: 1 })], + sortBy + ) + const { result } = renderSortOrder() + expect(result.current).toEqual(['a', 'b']) + act(() => publishSortOrders({ a: 1, b: 2 })) + expect(result.current).toEqual(['b', 'a']) + expect(useAppStore.getState().sortEpoch).toBe(1) + } + ) + + it('keeps live Smart order steady when only persisted sortOrder changes', () => { + const persistSortOrder = vi.fn(async () => ({ updated: 0 })) + vi.stubGlobal('api', { worktrees: { persistSortOrder } }) + try { + seed( + [makeWorktree('a', 'A', { sortOrder: 2 }), makeWorktree('b', 'B', { sortOrder: 1 })], + 'smart' + ) + useAppStore.setState({ + tabsByWorktree: { a: [makeTerminalTab({ id: 'tab-a', worktreeId: 'a' })] }, + ptyIdsByTabId: { 'tab-a': ['pty-a'] } + }) + const { result } = renderSortOrder() + const before = result.current + const savesBefore = persistSortOrder.mock.calls.length + act(() => publishSortOrders({ a: 1, b: 2 })) + expect(result.current).toBe(before) + expect(persistSortOrder.mock.calls.length).toBe(savesBefore) + } finally { + vi.unstubAllGlobals() + } + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts index 78d5fce41a3..3d1b5ec5761 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts @@ -58,9 +58,15 @@ export function useSidebarWorktreeSortOrder(args: { const { repoMap, sortBy } = args // Why settled (not live): the store coalesces bump bursts so rows don't jump (store/settled-sort-epoch.ts). const settledSortEpoch = useAppStore((s) => s.settledSortEpoch) + const sortOrderEpoch = useAppStore((s) => s.sortOrderEpoch) // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. const sessionHasHadLiveSmartSignal = useRef(false) + // Why: cold Smart and Manual fallback rows rank by sortOrder and never persist it, so they can follow another client's save without looping. + const persistedRankTrigger = + sortBy === 'manual' || (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) + ? sortOrderEpoch + : 0 const recomputedSort = useMemo(() => { const state = useAppStore.getState() @@ -119,7 +125,7 @@ export function useSidebarWorktreeSortOrder(args: { } // settledSortEpoch is an intentional trigger not read in the memo; its change signals a recompute. // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [settledSortEpoch, repoMap, sortBy]) + }, [settledSortEpoch, persistedRankTrigger, repoMap, sortBy]) // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. const sortedIds = useReusedArrayIdentity(recomputedSort.sortedIds) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index af1520f49c2..a3aa6173b76 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -136,6 +136,8 @@ export type WorktreeSlice = { sortEpoch: number /** sortEpoch after the settle window; the sidebar sort reads this (see store/settled-sort-epoch.ts). */ settledSortEpoch: number + /** Bumped by sortOrder-only changes, which skip sortEpoch so live Smart can't loop on its own saves. */ + sortOrderEpoch: number /** * Worktree IDs that have been activated at least once during this app * session. The first activation of a worktree is special: its diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 0d3055c970f..1a47f7e3702 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -256,7 +256,7 @@ export function mergeFetchedWorktrees( }, // Why: an order save re-reads as a sortOrder-only change; re-sorting on it would schedule the next save (#27268). ...(worktreesDifferOnlyInSortOrder(s.worktreesByRepo[args.repoId], mergedWorktrees) - ? {} + ? { sortOrderEpoch: s.sortOrderEpoch + 1 } : { sortEpoch: s.sortEpoch + 1 }) } : {}), diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts index 75f2ea01b55..104fe46b69d 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts @@ -5,14 +5,14 @@ import { worktreeHostMatchOptions, worktreeMatchesHost } from './worktree-host-o /** * Applies a host's published order save to one repo's rows in place. - * Why no sortEpoch bump: an order save must not schedule the next Smart re-sort (#27268). + * Why sortOrderEpoch, not sortEpoch: an order save must not schedule the next live Smart re-sort (#27268). */ export function patchWorktreeSortOrders( state: AppState, repoId: string, hostId: ExecutionHostId, sortOrders: readonly WorktreeSortOrderUpdate[] -): Pick | AppState { +): Pick | AppState { const current = state.worktreesByRepo[repoId] if (!current || sortOrders.length === 0) { return state @@ -33,7 +33,12 @@ export function patchWorktreeSortOrders( changed = true return { ...worktree, sortOrder } }) - return changed ? { worktreesByRepo: { ...state.worktreesByRepo, [repoId]: next } } : state + return changed + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: next }, + sortOrderEpoch: state.sortOrderEpoch + 1 + } + : state } /** False when a published row is missing here, so the listing needs a refetch rather than a patch. */ diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts index c1683af84c3..19f760ae738 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts @@ -18,6 +18,7 @@ export const worktreeSliceInitialState: Pick< | 'remoteBranchConflictByWorktreeId' | 'sortEpoch' | 'settledSortEpoch' + | 'sortOrderEpoch' | 'everActivatedWorktreeIds' | 'lastVisitedAtByWorktreeId' | 'hasHydratedWorktreePurge' @@ -39,6 +40,7 @@ export const worktreeSliceInitialState: Pick< remoteBranchConflictByWorktreeId: {}, sortEpoch: 0, settledSortEpoch: 0, + sortOrderEpoch: 0, everActivatedWorktreeIds: new Set(), lastVisitedAtByWorktreeId: {}, hasHydratedWorktreePurge: false,