diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e9403b41b11..a5e3e053da1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -202,14 +202,12 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split' +import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, - type ExecutionHostId, - parseExecutionHostId + type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -5520,21 +5518,7 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: sortOrder is persisted in each host's worktreeMeta and enriched from // the owner host, so persist each host's ids on that host. const state = useAppStore.getState() - for (const group of splitWorktreeSortOrderByHost(state, sortedIds)) { - const parsed = parseExecutionHostId(group.hostId) - const target = - parsed?.kind === 'runtime' - ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) - : ({ kind: 'local' } as const) - void (target.kind === 'environment' - ? callRuntimeRpc( - target, - 'worktree.persistSortOrder', - { orderedIds: group.orderedIds }, - { timeoutMs: 15_000 } - ) - : window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds })) - } + persistWorktreeSortOrderByHost(state, sortedIds) }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.test.ts b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts new file mode 100644 index 00000000000..150194a572b --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { persistWorktreeSortOrderByHost } from './worktree-sort-order-persistence' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const localPersistSortOrder = vi.fn() +const runtimeCall = vi.mocked(callRuntimeRpc) +const UNHANDLED_REJECTION_SETTLE_MS = 20 + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +async function collectUnhandledRejections(run: () => void): Promise { + const reasons: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + reasons.push(reason) + } + + process.on('unhandledRejection', onUnhandledRejection) + try { + run() + await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + + return reasons +} + +beforeEach(() => { + runtimeCall.mockReset() + localPersistSortOrder.mockReset() + runtimeCall.mockResolvedValue(undefined) + localPersistSortOrder.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + worktrees: { + persistSortOrder: localPersistSortOrder + } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('persistWorktreeSortOrderByHost', () => { + it('persists each owner host through the matching transport', () => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + + expect(runtimeCall).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'worktree.persistSortOrder', + { orderedIds: ['runtime-repo::wt-b'] }, + { timeoutMs: 15_000 } + ) + expect(localPersistSortOrder).toHaveBeenCalledWith({ orderedIds: ['local-repo::wt-a'] }) + }) + + it('handles best-effort persistence rejections from disconnected hosts', async () => { + runtimeCall.mockRejectedValueOnce(new Error('SSH disconnected')) + localPersistSortOrder.mockRejectedValueOnce(new Error('local store unavailable')) + + const unhandledRejections = await collectUnhandledRejections(() => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + }) + + expect(unhandledRejections).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.ts b/src/renderer/src/lib/worktree-sort-order-persistence.ts new file mode 100644 index 00000000000..28e2d8d3556 --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.ts @@ -0,0 +1,35 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split' + +function ignoreSortOrderPersistenceFailure(promise: Promise): void { + void promise.catch(() => { + // Why: sort-order restore is best-effort; SSH disconnects during smart sort + // must not surface as unhandled rejections that pollute crash diagnostics. + }) +} + +export function persistWorktreeSortOrderByHost( + state: WorktreeRuntimeOwnerState, + orderedIds: readonly string[] +): void { + for (const group of splitWorktreeSortOrderByHost(state, orderedIds)) { + const parsed = parseExecutionHostId(group.hostId) + if (parsed?.kind === 'runtime') { + ignoreSortOrderPersistenceFailure( + callRuntimeRpc( + { kind: 'environment', environmentId: parsed.environmentId }, + 'worktree.persistSortOrder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + ) + continue + } + + ignoreSortOrderPersistenceFailure( + window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds }) + ) + } +}