native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #122 from onevcat/fix/layout-restore-snapshot-phase-race

Fix layout restore skipped when snapshot matches disk state

authored by

Wei Wang and committed by
GitHub
04f2ea47 be93db6c

+72 -2
+1 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 461 461 selectedWorktree: selectedWorktree 462 462 ) 463 463 var allEffects: [Effect<Action>] = [] 464 - if repositoriesChanged { 464 + if repositoriesChanged || wasRestoringSnapshot { 465 465 allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 466 466 } 467 467 if selectionChanged {
+1 -1
supacode/Features/Repositories/Views/SidebarListView.swift
··· 32 32 return nextSelections 33 33 }, 34 34 set: { newValue in 35 - var nextSelections = newValue 35 + let nextSelections = newValue 36 36 let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in 37 37 guard case .repository(let repositoryID) = selection else { return nil } 38 38 return repositoryID
+39
supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift
··· 43 43 ) 44 44 } 45 45 46 + @Test(.dependencies) func repositoriesChangedDuringRestoringPhaseDoesNotTriggerRestore() async { 47 + let worktree = makeWorktree() 48 + let repository = makeRepository(worktrees: [worktree]) 49 + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 50 + repositoriesState.snapshotPersistencePhase = .restoring 51 + var settings = SettingsFeature.State() 52 + settings.restoreTerminalLayoutOnLaunch = true 53 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 54 + 55 + let store = TestStore( 56 + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) 57 + ) { 58 + AppFeature() 59 + } withDependencies: { 60 + $0.terminalClient.send = { command in 61 + sentCommands.withValue { $0.append(command) } 62 + } 63 + $0.worktreeInfoWatcher.send = { _ in } 64 + } 65 + store.exhaustivity = .off 66 + 67 + // repositoriesChanged arrives while phase is still .restoring (from snapshot load). 68 + // Layout restore must NOT trigger yet — only after phase becomes .active. 69 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) 70 + await store.finish() 71 + 72 + #expect( 73 + sentCommands.value.contains { 74 + if case .restoreLayoutSnapshot = $0 { 75 + return true 76 + } 77 + return false 78 + } == false 79 + ) 80 + // launchRestoreMode should remain .restoreLayout so the next repositoriesChanged 81 + // (after phase → .active) still has a chance to trigger the restore. 82 + #expect(store.state.launchRestoreMode == .restoreLayout) 83 + } 84 + 46 85 @Test(.dependencies) func repositoriesChangedSkipsRestoreWhenDisabled() async { 47 86 let worktree = makeWorktree() 48 87 let repository = makeRepository(worktrees: [worktree])
+31
supacodeTests/RepositoriesFeatureTests.swift
··· 67 67 } 68 68 } 69 69 70 + @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { 71 + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") 72 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 73 + var initialState = makeState(repositories: [repository]) 74 + initialState.snapshotPersistencePhase = .restoring 75 + 76 + let store = TestStore(initialState: initialState) { 77 + RepositoriesFeature() 78 + } 79 + 80 + await store.send( 81 + .repositoriesLoaded( 82 + [repository], 83 + failures: [], 84 + roots: [repository.rootURL], 85 + animated: false 86 + ) 87 + ) { 88 + $0.isRefreshingWorktrees = false 89 + $0.isInitialLoadComplete = true 90 + $0.snapshotPersistencePhase = .active 91 + } 92 + // Even though repos didn't change, the delegate must fire because we 93 + // transitioned from .restoring → .active. Layout restore depends on 94 + // receiving repositoriesChanged while phase is .active. 95 + await store.receive(\.delegate.repositoriesChanged) 96 + } 97 + 70 98 @Test func taskRestoresRepositorySnapshotBeforeLiveRefreshCompletes() async { 71 99 let repoRoot = "/tmp/repo" 72 100 let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) ··· 114 142 await store.receive(\.repositoriesLoaded) { 115 143 $0.snapshotPersistencePhase = .active 116 144 } 145 + // After the fix, repositoriesLoaded also emits repositoriesChanged 146 + // when transitioning from .restoring → .active (even if repos are identical). 147 + await store.receive(\.delegate.repositoriesChanged) 117 148 await store.finish() 118 149 } 119 150