native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge branch 'main' into onevclaw/issue-107-cli-list-runtime

# Conflicts:
# supacodeTests/GitAutomaticBaseRefIntegrationTests.swift

onevcat 540fb8b6 1010d424

+2913 -2653
+5 -1
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 59 59 XCTAssertEqual(result.exitCode, 0) 60 60 let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 61 61 if case .open(let input) = envelope.command { 62 - XCTAssertEqual(input.path, repoRoot.path) 62 + let openedPath = try XCTUnwrap(input.path) 63 + XCTAssertEqual( 64 + URL(fileURLWithPath: openedPath).resolvingSymlinksInPath().path, 65 + repoRoot.resolvingSymlinksInPath().path 66 + ) 63 67 } else { 64 68 XCTFail("Expected open command envelope") 65 69 }
+1 -1
supacode/App/ContentView.swift
··· 58 58 ) { result in 59 59 switch result { 60 60 case .success(let urls): 61 - store.send(.repositories(.openRepositories(urls))) 61 + store.send(.repositories(.repositoryManagement(.openRepositories(urls)))) 62 62 case .failure: 63 63 store.send( 64 64 .repositories(
+4 -3
supacode/CLIService/CLISocketServer.swift
··· 1 1 // supacode/CLIService/CLISocketServer.swift 2 2 // Unix domain socket server that listens for CLI command requests. 3 3 4 + import Foundation 5 + 4 6 #if canImport(Darwin) 5 - import Darwin 7 + import Darwin 6 8 #elseif canImport(Glibc) 7 - import Glibc 9 + import Glibc 8 10 #endif 9 - import Foundation 10 11 11 12 @MainActor 12 13 final class CLISocketServer {
+1 -1
supacode/Commands/WorktreeCommands.swift
··· 78 78 .help(helpText(title: "Open Pull Request on GitHub", commandID: AppShortcuts.CommandID.openPullRequest)) 79 79 .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 80 80 Button("New Worktree", systemImage: "plus") { 81 - store.send(.repositories(.createRandomWorktree)) 81 + store.send(.repositories(.worktreeCreation(.createRandomWorktree))) 82 82 } 83 83 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.newWorktree))) 84 84 .help(helpText(title: "New Worktree", commandID: AppShortcuts.CommandID.newWorktree))
+22 -18
supacode/Features/App/Reducer/AppFeature.swift
··· 406 406 customCommands: state.selectedCustomCommands 407 407 ) 408 408 return .merge( 409 - .send(.repositories(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled))), 409 + .send(.repositories(.githubIntegration(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled)))), 410 410 .send( 411 411 .repositories( 412 - .setAutomaticallyArchiveMergedWorktrees( 413 - settings.automaticallyArchiveMergedWorktrees 412 + .githubIntegration( 413 + .setAutomaticallyArchiveMergedWorktrees( 414 + settings.automaticallyArchiveMergedWorktrees 415 + ) 414 416 ) 415 417 ) 416 418 ), 417 419 .send( 418 420 .repositories( 419 - .setMoveNotifiedWorktreeToTop( 420 - settings.moveNotifiedWorktreeToTop 421 + .worktreeOrdering( 422 + .setMoveNotifiedWorktreeToTop( 423 + settings.moveNotifiedWorktreeToTop 424 + ) 421 425 ) 422 426 ) 423 427 ), ··· 814 818 ) 815 819 816 820 case .commandPalette(.delegate(.newWorktree)): 817 - return .send(.repositories(.createRandomWorktree)) 821 + return .send(.repositories(.worktreeCreation(.createRandomWorktree))) 818 822 819 823 case .commandPalette(.delegate(.openRepository)): 820 824 return .send(.repositories(.setOpenPanelPresented(true))) 821 825 822 826 case .commandPalette(.delegate(.removeWorktree(let worktreeID, let repositoryID))): 823 - return .send(.repositories(.requestDeleteWorktree(worktreeID, repositoryID))) 827 + return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) 824 828 825 829 case .commandPalette(.delegate(.archiveWorktree(let worktreeID, let repositoryID))): 826 - return .send(.repositories(.requestArchiveWorktree(worktreeID, repositoryID))) 830 + return .send(.repositories(.worktreeLifecycle(.requestArchiveWorktree(worktreeID, repositoryID)))) 827 831 828 832 case .commandPalette(.delegate(.refreshWorktrees)): 829 833 return .send(.repositories(.refreshWorktrees)) ··· 837 841 } 838 842 839 843 case .commandPalette(.delegate(.openPullRequest(let worktreeID))): 840 - return .send(.repositories(.pullRequestAction(worktreeID, .openOnGithub))) 844 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnGithub)))) 841 845 842 846 case .commandPalette(.delegate(.markPullRequestReady(let worktreeID))): 843 - return .send(.repositories(.pullRequestAction(worktreeID, .markReadyForReview))) 847 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .markReadyForReview)))) 844 848 845 849 case .commandPalette(.delegate(.mergePullRequest(let worktreeID))): 846 - return .send(.repositories(.pullRequestAction(worktreeID, .merge))) 850 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .merge)))) 847 851 848 852 case .commandPalette(.delegate(.closePullRequest(let worktreeID))): 849 - return .send(.repositories(.pullRequestAction(worktreeID, .close))) 853 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .close)))) 850 854 851 855 case .commandPalette(.delegate(.copyFailingJobURL(let worktreeID))): 852 - return .send(.repositories(.pullRequestAction(worktreeID, .copyFailingJobURL))) 856 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyFailingJobURL)))) 853 857 854 858 case .commandPalette(.delegate(.copyCiFailureLogs(let worktreeID))): 855 - return .send(.repositories(.pullRequestAction(worktreeID, .copyCiFailureLogs))) 859 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyCiFailureLogs)))) 856 860 857 861 case .commandPalette(.delegate(.rerunFailedJobs(let worktreeID))): 858 - return .send(.repositories(.pullRequestAction(worktreeID, .rerunFailedJobs))) 862 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .rerunFailedJobs)))) 859 863 860 864 case .commandPalette(.delegate(.openFailingCheckDetails(let worktreeID))): 861 - return .send(.repositories(.pullRequestAction(worktreeID, .openFailingCheckDetails))) 865 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openFailingCheckDetails)))) 862 866 863 867 #if DEBUG 864 868 case .commandPalette(.delegate(.debugTestToast(let toast))): ··· 870 874 871 875 case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 872 876 var effects: [Effect<Action>] = [ 873 - .send(.repositories(.worktreeNotificationReceived(worktreeID))) 877 + .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) 874 878 ] 875 879 if state.settings.systemNotificationsEnabled { 876 880 effects.append( ··· 922 926 } 923 927 return .send(.commandPalette(.setPresented(true))) 924 928 case .terminalEvent(.setupScriptConsumed(let worktreeID)): 925 - return .send(.repositories(.consumeSetupScript(worktreeID))) 929 + return .send(.repositories(.worktreeCreation(.consumeSetupScript(worktreeID)))) 926 930 927 931 case .terminalEvent(.fontSizeChanged(let fontSize)): 928 932 return .send(.settings(.setTerminalFontSize(fontSize)))
+3 -2
supacode/Features/Canvas/Views/CanvasSidebarButton.swift
··· 21 21 ShortcutHintView(text: shortcut, color: .secondary) 22 22 } 23 23 } 24 + .padding(.horizontal, 12) 25 + .padding(.vertical, 6) 26 + .contentShape(.rect) 24 27 } 25 28 .buttonStyle(.plain) 26 - .padding(.horizontal, 12) 27 - .padding(.vertical, 6) 28 29 .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) 29 30 .help( 30 31 AppShortcuts.helpText(
+551
supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + 5 + extension RepositoriesFeature { 6 + // swiftlint:disable:next cyclomatic_complexity function_body_length 7 + func reduceGithubIntegration( 8 + state: inout State, 9 + action: GithubIntegrationAction 10 + ) -> Effect<Action> { 11 + switch action { 12 + case .delayedPullRequestRefresh(let worktreeID): 13 + guard let worktree = state.worktree(for: worktreeID), 14 + let repositoryID = state.repositoryID(containing: worktreeID), 15 + let repository = state.repositories[id: repositoryID] 16 + else { 17 + return .none 18 + } 19 + let repositoryRootURL = worktree.repositoryRootURL 20 + let worktreeIDs = repository.worktrees.map(\.id) 21 + return .run { send in 22 + try? await ContinuousClock().sleep(for: .seconds(2)) 23 + await send( 24 + .worktreeInfoEvent( 25 + .repositoryPullRequestRefresh( 26 + repositoryRootURL: repositoryRootURL, 27 + worktreeIDs: worktreeIDs 28 + ) 29 + ) 30 + ) 31 + } 32 + .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true) 33 + 34 + case .repositoryPullRequestRefreshRequested(let repositoryRootURL, let worktreeIDs): 35 + let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) } 36 + guard let firstWorktree = worktrees.first, 37 + let repositoryID = state.repositoryID(containing: firstWorktree.id) 38 + else { 39 + return .none 40 + } 41 + var seen = Set<String>() 42 + let branches = 43 + worktrees 44 + .map(\.name) 45 + .filter { !$0.isEmpty && seen.insert($0).inserted } 46 + guard !branches.isEmpty else { 47 + return .none 48 + } 49 + switch state.githubIntegrationAvailability { 50 + case .available: 51 + if state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) { 52 + queuePullRequestRefresh( 53 + repositoryID: repositoryID, 54 + repositoryRootURL: repositoryRootURL, 55 + worktreeIDs: worktreeIDs, 56 + refreshesByRepositoryID: &state.queuedPullRequestRefreshByRepositoryID 57 + ) 58 + return .none 59 + } 60 + state.inFlightPullRequestRefreshRepositoryIDs.insert(repositoryID) 61 + return refreshRepositoryPullRequests( 62 + repositoryID: repositoryID, 63 + repositoryRootURL: repositoryRootURL, 64 + worktrees: worktrees, 65 + branches: branches 66 + ) 67 + case .unknown: 68 + queuePullRequestRefresh( 69 + repositoryID: repositoryID, 70 + repositoryRootURL: repositoryRootURL, 71 + worktreeIDs: worktreeIDs, 72 + refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 73 + ) 74 + return .send(.githubIntegration(.refreshGithubIntegrationAvailability)) 75 + case .checking: 76 + queuePullRequestRefresh( 77 + repositoryID: repositoryID, 78 + repositoryRootURL: repositoryRootURL, 79 + worktreeIDs: worktreeIDs, 80 + refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 81 + ) 82 + return .none 83 + case .unavailable: 84 + queuePullRequestRefresh( 85 + repositoryID: repositoryID, 86 + repositoryRootURL: repositoryRootURL, 87 + worktreeIDs: worktreeIDs, 88 + refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 89 + ) 90 + return .none 91 + case .disabled: 92 + return .none 93 + } 94 + 95 + case .refreshGithubIntegrationAvailability: 96 + guard state.githubIntegrationAvailability != .checking, 97 + state.githubIntegrationAvailability != .disabled 98 + else { 99 + return .none 100 + } 101 + state.githubIntegrationAvailability = .checking 102 + let githubIntegration = githubIntegration 103 + return .run { send in 104 + let isAvailable = await githubIntegration.isAvailable() 105 + await send(.githubIntegration(.githubIntegrationAvailabilityUpdated(isAvailable))) 106 + } 107 + .cancellable(id: CancelID.githubIntegrationAvailability, cancelInFlight: true) 108 + 109 + case .githubIntegrationAvailabilityUpdated(let isAvailable): 110 + guard state.githubIntegrationAvailability != .disabled else { 111 + return .none 112 + } 113 + state.githubIntegrationAvailability = isAvailable ? .available : .unavailable 114 + guard isAvailable else { 115 + for (repositoryID, queued) in state.queuedPullRequestRefreshByRepositoryID { 116 + queuePullRequestRefresh( 117 + repositoryID: repositoryID, 118 + repositoryRootURL: queued.repositoryRootURL, 119 + worktreeIDs: queued.worktreeIDs, 120 + refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 121 + ) 122 + } 123 + state.queuedPullRequestRefreshByRepositoryID.removeAll() 124 + state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 125 + return .run { send in 126 + while !Task.isCancelled { 127 + try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) 128 + guard !Task.isCancelled else { 129 + return 130 + } 131 + await send(.githubIntegration(.refreshGithubIntegrationAvailability)) 132 + } 133 + } 134 + .cancellable(id: CancelID.githubIntegrationRecovery, cancelInFlight: true) 135 + } 136 + let pendingRefreshes = state.pendingPullRequestRefreshByRepositoryID.values.sorted { 137 + $0.repositoryRootURL.path(percentEncoded: false) 138 + < $1.repositoryRootURL.path(percentEncoded: false) 139 + } 140 + state.pendingPullRequestRefreshByRepositoryID.removeAll() 141 + return .merge( 142 + .cancel(id: CancelID.githubIntegrationRecovery), 143 + .merge( 144 + pendingRefreshes.map { pending in 145 + .send( 146 + .worktreeInfoEvent( 147 + .repositoryPullRequestRefresh( 148 + repositoryRootURL: pending.repositoryRootURL, 149 + worktreeIDs: pending.worktreeIDs 150 + ) 151 + ) 152 + ) 153 + } 154 + ) 155 + ) 156 + 157 + case .repositoryPullRequestRefreshCompleted(let repositoryID): 158 + state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) 159 + guard state.githubIntegrationAvailability == .available, 160 + let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( 161 + forKey: repositoryID 162 + ) 163 + else { 164 + return .none 165 + } 166 + return .send( 167 + .worktreeInfoEvent( 168 + .repositoryPullRequestRefresh( 169 + repositoryRootURL: pending.repositoryRootURL, 170 + worktreeIDs: pending.worktreeIDs 171 + ) 172 + ) 173 + ) 174 + 175 + case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): 176 + guard let repository = state.repositories[id: repositoryID] else { 177 + return .none 178 + } 179 + var archiveWorktreeIDs: [Worktree.ID] = [] 180 + for worktreeID in pullRequestsByWorktreeID.keys.sorted() { 181 + guard let worktree = repository.worktrees[id: worktreeID] else { 182 + continue 183 + } 184 + let pullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil 185 + let previousPullRequest = state.worktreeInfoByID[worktreeID]?.pullRequest 186 + guard previousPullRequest != pullRequest else { 187 + continue 188 + } 189 + let previousMerged = previousPullRequest?.state == "MERGED" 190 + let nextMerged = pullRequest?.state == "MERGED" 191 + updateWorktreePullRequest( 192 + worktreeID: worktreeID, 193 + pullRequest: pullRequest, 194 + state: &state 195 + ) 196 + if state.automaticallyArchiveMergedWorktrees, 197 + !previousMerged, 198 + nextMerged, 199 + !state.isMainWorktree(worktree), 200 + !state.isWorktreeArchived(worktreeID), 201 + !state.deletingWorktreeIDs.contains(worktreeID) 202 + { 203 + archiveWorktreeIDs.append(worktreeID) 204 + } 205 + } 206 + guard !archiveWorktreeIDs.isEmpty else { 207 + return .none 208 + } 209 + return .merge( 210 + archiveWorktreeIDs.map { worktreeID in 211 + .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) 212 + } 213 + ) 214 + 215 + case .pullRequestAction(let worktreeID, let action): 216 + guard let worktree = state.worktree(for: worktreeID), 217 + let repositoryID = state.repositoryID(containing: worktreeID), 218 + let repository = state.repositories[id: repositoryID], 219 + let pullRequest = state.worktreeInfo(for: worktreeID)?.pullRequest 220 + else { 221 + return .send( 222 + .presentAlert( 223 + title: "Pull request not available", 224 + message: "Prowl could not find a pull request for this worktree." 225 + ) 226 + ) 227 + } 228 + let repoRoot = worktree.repositoryRootURL 229 + let worktreeRoot = worktree.workingDirectory 230 + let pullRequestRefresh = WorktreeInfoWatcherClient.Event.repositoryPullRequestRefresh( 231 + repositoryRootURL: repoRoot, 232 + worktreeIDs: repository.worktrees.map(\.id) 233 + ) 234 + let branchName = pullRequest.headRefName ?? worktree.name 235 + let failingCheckDetailsURL = (pullRequest.statusCheckRollup?.checks ?? []).first { 236 + $0.checkState == .failure && $0.detailsUrl != nil 237 + }?.detailsUrl 238 + switch action { 239 + case .openOnGithub: 240 + guard let url = URL(string: pullRequest.url) else { 241 + return .send( 242 + .presentAlert( 243 + title: "Invalid pull request URL", 244 + message: "Prowl could not open the pull request URL." 245 + ) 246 + ) 247 + } 248 + return .run { @MainActor _ in 249 + NSWorkspace.shared.open(url) 250 + } 251 + 252 + case .copyFailingJobURL: 253 + guard let failingCheckDetailsURL, !failingCheckDetailsURL.isEmpty else { 254 + return .send( 255 + .presentAlert( 256 + title: "Failing check not found", 257 + message: "Prowl could not find a failing check URL." 258 + ) 259 + ) 260 + } 261 + return .run { send in 262 + await MainActor.run { 263 + NSPasteboard.general.clearContents() 264 + NSPasteboard.general.setString(failingCheckDetailsURL, forType: .string) 265 + } 266 + await send(.showToast(.success("Failing job URL copied"))) 267 + } 268 + 269 + case .openFailingCheckDetails: 270 + guard let failingCheckDetailsURL, let url = URL(string: failingCheckDetailsURL) else { 271 + return .send( 272 + .presentAlert( 273 + title: "Failing check not found", 274 + message: "Prowl could not find a failing check with details." 275 + ) 276 + ) 277 + } 278 + return .run { @MainActor _ in 279 + NSWorkspace.shared.open(url) 280 + } 281 + 282 + case .markReadyForReview: 283 + let githubCLI = githubCLI 284 + let githubIntegration = githubIntegration 285 + return .run { send in 286 + guard await githubIntegration.isAvailable() else { 287 + await send( 288 + .presentAlert( 289 + title: "GitHub integration unavailable", 290 + message: "Enable GitHub integration to mark a pull request as ready." 291 + ) 292 + ) 293 + return 294 + } 295 + await send(.showToast(.inProgress("Marking PR ready…"))) 296 + do { 297 + try await githubCLI.markPullRequestReady(worktreeRoot, pullRequest.number) 298 + await send(.showToast(.success("Pull request marked ready"))) 299 + await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) 300 + } catch { 301 + await send(.dismissToast) 302 + await send( 303 + .presentAlert( 304 + title: "Failed to mark pull request ready", 305 + message: error.localizedDescription 306 + ) 307 + ) 308 + } 309 + } 310 + 311 + case .merge: 312 + let githubCLI = githubCLI 313 + let githubIntegration = githubIntegration 314 + return .run { send in 315 + guard await githubIntegration.isAvailable() else { 316 + await send( 317 + .presentAlert( 318 + title: "GitHub integration unavailable", 319 + message: "Enable GitHub integration to merge a pull request." 320 + ) 321 + ) 322 + return 323 + } 324 + @Shared(.repositorySettings(repoRoot)) var repositorySettings 325 + let strategy = repositorySettings.pullRequestMergeStrategy 326 + await send(.showToast(.inProgress("Merging pull request…"))) 327 + do { 328 + try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy) 329 + await send(.showToast(.success("Pull request merged"))) 330 + await send(.worktreeInfoEvent(pullRequestRefresh)) 331 + await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) 332 + } catch { 333 + await send(.dismissToast) 334 + await send( 335 + .presentAlert( 336 + title: "Failed to merge pull request", 337 + message: error.localizedDescription 338 + ) 339 + ) 340 + } 341 + } 342 + 343 + case .close: 344 + let githubCLI = githubCLI 345 + let githubIntegration = githubIntegration 346 + return .run { send in 347 + guard await githubIntegration.isAvailable() else { 348 + await send( 349 + .presentAlert( 350 + title: "GitHub integration unavailable", 351 + message: "Enable GitHub integration to close a pull request." 352 + ) 353 + ) 354 + return 355 + } 356 + await send(.showToast(.inProgress("Closing pull request…"))) 357 + do { 358 + try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) 359 + await send(.showToast(.success("Pull request closed"))) 360 + await send(.worktreeInfoEvent(pullRequestRefresh)) 361 + await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) 362 + } catch { 363 + await send(.dismissToast) 364 + await send( 365 + .presentAlert( 366 + title: "Failed to close pull request", 367 + message: error.localizedDescription 368 + ) 369 + ) 370 + } 371 + } 372 + 373 + case .copyCiFailureLogs: 374 + let githubCLI = githubCLI 375 + let githubIntegration = githubIntegration 376 + return .run { send in 377 + guard await githubIntegration.isAvailable() else { 378 + await send( 379 + .presentAlert( 380 + title: "GitHub integration unavailable", 381 + message: "Enable GitHub integration to copy CI failure logs." 382 + ) 383 + ) 384 + return 385 + } 386 + guard !branchName.isEmpty else { 387 + await send( 388 + .presentAlert( 389 + title: "Branch name unavailable", 390 + message: "Prowl could not determine the pull request branch." 391 + ) 392 + ) 393 + return 394 + } 395 + await send(.showToast(.inProgress("Fetching CI logs…"))) 396 + do { 397 + guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { 398 + await send(.dismissToast) 399 + await send( 400 + .presentAlert( 401 + title: "No workflow runs found", 402 + message: "Prowl could not find any workflow runs for this branch." 403 + ) 404 + ) 405 + return 406 + } 407 + guard run.conclusion?.lowercased() == "failure" else { 408 + await send(.dismissToast) 409 + await send( 410 + .presentAlert( 411 + title: "No failing workflow run", 412 + message: "Prowl could not find a failing workflow run to copy logs from." 413 + ) 414 + ) 415 + return 416 + } 417 + let failedLogs = try await githubCLI.failedRunLogs(worktreeRoot, run.databaseId) 418 + let logs = 419 + if failedLogs.isEmpty { 420 + try await githubCLI.runLogs(worktreeRoot, run.databaseId) 421 + } else { 422 + failedLogs 423 + } 424 + guard !logs.isEmpty else { 425 + await send(.dismissToast) 426 + await send( 427 + .presentAlert( 428 + title: "No CI logs available", 429 + message: "The workflow run failed but produced no logs." 430 + ) 431 + ) 432 + return 433 + } 434 + await MainActor.run { 435 + NSPasteboard.general.clearContents() 436 + NSPasteboard.general.setString(logs, forType: .string) 437 + } 438 + await send(.showToast(.success("CI failure logs copied"))) 439 + } catch { 440 + await send(.dismissToast) 441 + await send( 442 + .presentAlert( 443 + title: "Failed to copy CI failure logs", 444 + message: error.localizedDescription 445 + ) 446 + ) 447 + } 448 + } 449 + 450 + case .rerunFailedJobs: 451 + let githubCLI = githubCLI 452 + let githubIntegration = githubIntegration 453 + return .run { send in 454 + guard await githubIntegration.isAvailable() else { 455 + await send( 456 + .presentAlert( 457 + title: "GitHub integration unavailable", 458 + message: "Enable GitHub integration to re-run failed jobs." 459 + ) 460 + ) 461 + return 462 + } 463 + guard !branchName.isEmpty else { 464 + await send( 465 + .presentAlert( 466 + title: "Branch name unavailable", 467 + message: "Prowl could not determine the pull request branch." 468 + ) 469 + ) 470 + return 471 + } 472 + await send(.showToast(.inProgress("Re-running failed jobs…"))) 473 + do { 474 + guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { 475 + await send(.dismissToast) 476 + await send( 477 + .presentAlert( 478 + title: "No workflow runs found", 479 + message: "Prowl could not find any workflow runs for this branch." 480 + ) 481 + ) 482 + return 483 + } 484 + guard run.conclusion?.lowercased() == "failure" else { 485 + await send(.dismissToast) 486 + await send( 487 + .presentAlert( 488 + title: "No failing workflow run", 489 + message: "Prowl could not find a failing workflow run to re-run." 490 + ) 491 + ) 492 + return 493 + } 494 + try await githubCLI.rerunFailedJobs(worktreeRoot, run.databaseId) 495 + await send(.showToast(.success("Failed jobs re-run started"))) 496 + await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) 497 + } catch { 498 + await send(.dismissToast) 499 + await send( 500 + .presentAlert( 501 + title: "Failed to re-run failed jobs", 502 + message: error.localizedDescription 503 + ) 504 + ) 505 + } 506 + } 507 + } 508 + 509 + case .setGithubIntegrationEnabled(let isEnabled): 510 + if isEnabled { 511 + state.githubIntegrationAvailability = .unknown 512 + state.pendingPullRequestRefreshByRepositoryID.removeAll() 513 + state.queuedPullRequestRefreshByRepositoryID.removeAll() 514 + state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 515 + return .merge( 516 + .cancel(id: CancelID.githubIntegrationRecovery), 517 + .send(.githubIntegration(.refreshGithubIntegrationAvailability)) 518 + ) 519 + } 520 + state.githubIntegrationAvailability = .disabled 521 + state.pendingPullRequestRefreshByRepositoryID.removeAll() 522 + state.queuedPullRequestRefreshByRepositoryID.removeAll() 523 + state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 524 + let worktreeIDs = Array(state.worktreeInfoByID.keys) 525 + for worktreeID in worktreeIDs { 526 + updateWorktreePullRequest( 527 + worktreeID: worktreeID, 528 + pullRequest: nil, 529 + state: &state 530 + ) 531 + } 532 + return .merge( 533 + .cancel(id: CancelID.githubIntegrationAvailability), 534 + .cancel(id: CancelID.githubIntegrationRecovery) 535 + ) 536 + 537 + case .setAutomaticallyArchiveMergedWorktrees(let isEnabled): 538 + state.automaticallyArchiveMergedWorktrees = isEnabled 539 + return .none 540 + } 541 + } 542 + 543 + var githubIntegrationReducer: some ReducerOf<Self> { 544 + Reduce { state, action in 545 + guard case .githubIntegration(let action) = action else { 546 + return .none 547 + } 548 + return reduceGithubIntegration(state: &state, action: action) 549 + } 550 + } 551 + }
+222
supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + extension RepositoriesFeature { 5 + // swiftlint:disable:next cyclomatic_complexity function_body_length 6 + func reduceRepositoryManagement( 7 + state: inout State, 8 + action: RepositoryManagementAction 9 + ) -> Effect<Action> { 10 + switch action { 11 + case .openRepositories(let urls): 12 + analyticsClient.capture("repository_added", ["count": urls.count]) 13 + state.alert = nil 14 + return .run { send in 15 + let existingEntries = await loadPersistedRepositoryEntries() 16 + var resolvedEntries: [PersistedRepositoryEntry] = [] 17 + var invalidRoots: [String] = [] 18 + var openFailures: [String] = [] 19 + for url in urls { 20 + do { 21 + let root = try await gitClient.repoRoot(url) 22 + resolvedEntries.append( 23 + PersistedRepositoryEntry( 24 + path: root.path(percentEncoded: false), 25 + kind: .git 26 + ) 27 + ) 28 + } catch { 29 + let normalizedPath = url.standardizedFileURL.path(percentEncoded: false) 30 + if normalizedPath.isEmpty { 31 + invalidRoots.append(url.path(percentEncoded: false)) 32 + } else if Self.isNotGitRepositoryError(error) { 33 + resolvedEntries.append( 34 + PersistedRepositoryEntry( 35 + path: normalizedPath, 36 + kind: .plain 37 + ) 38 + ) 39 + } else { 40 + openFailures.append( 41 + Self.openRepositoryFailureMessage( 42 + path: normalizedPath, 43 + error: error 44 + ) 45 + ) 46 + } 47 + } 48 + } 49 + let mergedEntries = RepositoryEntryNormalizer.normalize(existingEntries + resolvedEntries) 50 + let mergedRoots = mergedEntries.map { URL(fileURLWithPath: $0.path) } 51 + await repositoryPersistence.saveRepositoryEntries(mergedEntries) 52 + let (repositories, failures) = await loadRepositoriesData(mergedEntries) 53 + await send( 54 + .repositoryManagement( 55 + .openRepositoriesFinished( 56 + repositories, 57 + failures: failures, 58 + invalidRoots: invalidRoots, 59 + openFailures: openFailures, 60 + roots: mergedRoots 61 + ) 62 + ) 63 + ) 64 + } 65 + .cancellable(id: CancelID.load, cancelInFlight: true) 66 + 67 + case .openRepositoriesFinished( 68 + let repositories, 69 + let failures, 70 + let invalidRoots, 71 + let openFailures, 72 + let roots 73 + ): 74 + state.isRefreshingWorktrees = false 75 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 76 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 77 + state.snapshotPersistencePhase = .active 78 + } 79 + let previousSelection = state.selectedWorktreeID 80 + let previousSelectedWorktree = state.worktree(for: previousSelection) 81 + let applyResult = applyRepositories( 82 + repositories, 83 + roots: roots, 84 + shouldPruneArchivedWorktreeIDs: failures.isEmpty, 85 + state: &state, 86 + animated: false 87 + ) 88 + state.repositoryRoots = roots 89 + state.isInitialLoadComplete = true 90 + state.loadFailuresByID = Dictionary( 91 + uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } 92 + ) 93 + let openFailureMessages = invalidRoots.map { "\($0) is not a Git repository." } + openFailures 94 + if !openFailureMessages.isEmpty { 95 + state.alert = messageAlert( 96 + title: "Some folders couldn't be opened", 97 + message: openFailureMessages.joined(separator: "\n") 98 + ) 99 + } 100 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 101 + let selectionChanged = selectionDidChange( 102 + previousSelectionID: previousSelection, 103 + previousSelectedWorktree: previousSelectedWorktree, 104 + selectedWorktreeID: state.selectedWorktreeID, 105 + selectedWorktree: selectedWorktree 106 + ) 107 + var allEffects: [Effect<Action>] = [ 108 + .send(.delegate(.repositoriesChanged(state.repositories))) 109 + ] 110 + if selectionChanged { 111 + allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 112 + } 113 + if applyResult.didPrunePinned { 114 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 115 + allEffects.append( 116 + .run { _ in 117 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 118 + }) 119 + } 120 + if applyResult.didPruneRepositoryOrder { 121 + let repositoryOrderIDs = state.repositoryOrderIDs 122 + allEffects.append( 123 + .run { _ in 124 + await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 125 + }) 126 + } 127 + if applyResult.didPruneWorktreeOrder { 128 + let worktreeOrderByRepository = state.worktreeOrderByRepository 129 + allEffects.append( 130 + .run { _ in 131 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 132 + }) 133 + } 134 + if applyResult.didPruneArchivedWorktreeIDs { 135 + let archivedWorktreeIDs = state.archivedWorktreeIDs 136 + allEffects.append( 137 + .run { _ in 138 + await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 139 + } 140 + ) 141 + } 142 + if failures.isEmpty, !wasRestoringSnapshot { 143 + let repositories = Array(state.repositories) 144 + allEffects.append( 145 + .run { _ in 146 + await repositoryPersistence.saveRepositorySnapshot(repositories) 147 + } 148 + ) 149 + } 150 + return .merge(allEffects) 151 + 152 + case .requestRemoveRepository(let repositoryID): 153 + state.alert = confirmationAlertForRepositoryRemoval(repositoryID: repositoryID, state: state) 154 + return .none 155 + 156 + case .removeFailedRepository(let repositoryID): 157 + state.alert = nil 158 + state.loadFailuresByID.removeValue(forKey: repositoryID) 159 + state.repositoryRoots.removeAll { 160 + $0.standardizedFileURL.path(percentEncoded: false) == repositoryID 161 + } 162 + let remainingRoots = state.repositoryRoots 163 + return .run { send in 164 + let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) 165 + let remainingEntries = loadedEntries.filter { $0.path != repositoryID } 166 + await repositoryPersistence.saveRepositoryEntries(remainingEntries) 167 + let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } 168 + let (repositories, failures) = await loadRepositoriesData(remainingEntries) 169 + await send( 170 + .repositoriesLoaded( 171 + repositories, 172 + failures: failures, 173 + roots: roots, 174 + animated: true 175 + ) 176 + ) 177 + } 178 + .cancellable(id: CancelID.load, cancelInFlight: true) 179 + 180 + case .repositoryRemoved(let repositoryID, let selectionWasRemoved): 181 + analyticsClient.capture("repository_removed", [String: Any]?.none) 182 + state.removingRepositoryIDs.remove(repositoryID) 183 + if selectionWasRemoved { 184 + state.selection = nil 185 + state.shouldSelectFirstAfterReload = true 186 + } 187 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 188 + let remainingRoots = state.repositoryRoots 189 + return .merge( 190 + .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), 191 + .run { send in 192 + let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) 193 + let remainingEntries = loadedEntries.filter { $0.path != repositoryID } 194 + await repositoryPersistence.saveRepositoryEntries(remainingEntries) 195 + let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } 196 + let (repositories, failures) = await loadRepositoriesData(remainingEntries) 197 + await send( 198 + .repositoriesLoaded( 199 + repositories, 200 + failures: failures, 201 + roots: roots, 202 + animated: true 203 + ) 204 + ) 205 + } 206 + .cancellable(id: CancelID.load, cancelInFlight: true) 207 + ) 208 + 209 + case .openRepositorySettings(let repositoryID): 210 + return .send(.delegate(.openRepositorySettings(repositoryID))) 211 + } 212 + } 213 + 214 + var repositoryManagementReducer: some ReducerOf<Self> { 215 + Reduce { state, action in 216 + guard case .repositoryManagement(let action) = action else { 217 + return .none 218 + } 219 + return reduceRepositoryManagement(state: &state, action: action) 220 + } 221 + } 222 + }
+619
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + extension RepositoriesFeature { 5 + // swiftlint:disable:next cyclomatic_complexity function_body_length 6 + func reduceWorktreeCreation( 7 + state: inout State, 8 + action: WorktreeCreationAction 9 + ) -> Effect<Action> { 10 + switch action { 11 + case .promptCanceled, .promptDismissed: 12 + state.worktreeCreationPrompt = nil 13 + return .merge( 14 + .cancel(id: CancelID.worktreePromptLoad), 15 + .cancel(id: CancelID.worktreePromptValidation) 16 + ) 17 + 18 + case .createRandomWorktree: 19 + if let selectedRepository = state.selectedRepository, 20 + !selectedRepository.capabilities.supportsWorktrees 21 + { 22 + state.alert = messageAlert( 23 + title: "Unable to create worktree", 24 + message: "This folder doesn't support worktrees." 25 + ) 26 + return .none 27 + } 28 + guard let repository = repositoryForWorktreeCreation(state) else { 29 + let message: String 30 + if state.repositories.isEmpty { 31 + message = "Open a repository to create a worktree." 32 + } else if state.selectedWorktreeID == nil && state.repositories.count > 1 { 33 + message = "Select a worktree to choose which repository to use." 34 + } else { 35 + message = "Unable to resolve a repository for the new worktree." 36 + } 37 + state.alert = messageAlert(title: "Unable to create worktree", message: message) 38 + return .none 39 + } 40 + return .send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 41 + 42 + case .createRandomWorktreeInRepository(let repositoryID): 43 + guard let repository = state.repositories[id: repositoryID] else { 44 + state.alert = messageAlert( 45 + title: "Unable to create worktree", 46 + message: "Unable to resolve a repository for the new worktree." 47 + ) 48 + return .none 49 + } 50 + guard repository.capabilities.supportsWorktrees else { 51 + state.alert = messageAlert( 52 + title: "Unable to create worktree", 53 + message: "This folder doesn't support worktrees." 54 + ) 55 + return .none 56 + } 57 + if state.removingRepositoryIDs.contains(repository.id) { 58 + state.alert = messageAlert( 59 + title: "Unable to create worktree", 60 + message: "This repository is being removed." 61 + ) 62 + return .none 63 + } 64 + @Shared(.settingsFile) var settingsFile 65 + if !settingsFile.global.promptForWorktreeCreation { 66 + return .merge( 67 + .cancel(id: CancelID.worktreePromptLoad), 68 + .send( 69 + .worktreeCreation( 70 + .createWorktreeInRepository( 71 + repositoryID: repository.id, 72 + nameSource: .random, 73 + baseRefSource: .repositorySetting 74 + ) 75 + ) 76 + ) 77 + ) 78 + } 79 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 80 + let selectedBaseRef = repositorySettings.worktreeBaseRef 81 + let gitClient = gitClient 82 + let rootURL = repository.rootURL 83 + return .run { send in 84 + let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" 85 + guard !Task.isCancelled else { 86 + return 87 + } 88 + let baseRefOptions: [String] 89 + do { 90 + let refs = try await gitClient.branchRefs(rootURL) 91 + guard !Task.isCancelled else { 92 + return 93 + } 94 + var options = refs 95 + if !automaticBaseRef.isEmpty, !options.contains(automaticBaseRef) { 96 + options.append(automaticBaseRef) 97 + } 98 + if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 99 + options.append(selectedBaseRef) 100 + } 101 + baseRefOptions = options.sorted { $0.localizedStandardCompare($1) == .orderedAscending } 102 + } catch { 103 + guard !Task.isCancelled else { 104 + return 105 + } 106 + var options: [String] = [] 107 + if !automaticBaseRef.isEmpty { 108 + options.append(automaticBaseRef) 109 + } 110 + if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 111 + options.append(selectedBaseRef) 112 + } 113 + baseRefOptions = options 114 + } 115 + guard !Task.isCancelled else { 116 + return 117 + } 118 + let automaticBaseRefLabel = 119 + automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 120 + await send( 121 + .worktreeCreation( 122 + .promptedWorktreeCreationDataLoaded( 123 + repositoryID: repositoryID, 124 + baseRefOptions: baseRefOptions, 125 + automaticBaseRefLabel: automaticBaseRefLabel, 126 + selectedBaseRef: selectedBaseRef 127 + ) 128 + ) 129 + ) 130 + } 131 + .cancellable(id: CancelID.worktreePromptLoad, cancelInFlight: true) 132 + 133 + case .promptedWorktreeCreationDataLoaded( 134 + let repositoryID, 135 + let baseRefOptions, 136 + let automaticBaseRefLabel, 137 + let selectedBaseRef 138 + ): 139 + guard let repository = state.repositories[id: repositoryID] else { 140 + return .none 141 + } 142 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 143 + repositoryID: repository.id, 144 + repositoryName: repository.name, 145 + automaticBaseRefLabel: automaticBaseRefLabel, 146 + baseRefOptions: baseRefOptions, 147 + branchName: "", 148 + selectedBaseRef: selectedBaseRef, 149 + validationMessage: nil 150 + ) 151 + return .none 152 + 153 + case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef): 154 + guard let repository = state.repositories[id: repositoryID] else { 155 + state.worktreeCreationPrompt = nil 156 + state.alert = messageAlert( 157 + title: "Unable to create worktree", 158 + message: "Unable to resolve a repository for the new worktree." 159 + ) 160 + return .none 161 + } 162 + state.worktreeCreationPrompt?.validationMessage = nil 163 + state.worktreeCreationPrompt?.isValidating = true 164 + let normalizedBranchName = branchName.lowercased() 165 + if repository.worktrees.contains(where: { $0.name.lowercased() == normalizedBranchName }) { 166 + state.worktreeCreationPrompt?.isValidating = false 167 + state.worktreeCreationPrompt?.validationMessage = "Branch name already exists." 168 + return .none 169 + } 170 + let gitClient = gitClient 171 + let rootURL = repository.rootURL 172 + return .run { send in 173 + let localBranchNames = (try? await gitClient.localBranchNames(rootURL)) ?? [] 174 + let duplicateMessage = 175 + localBranchNames.contains(normalizedBranchName) 176 + ? "Branch name already exists." 177 + : nil 178 + await send( 179 + .worktreeCreation( 180 + .promptedWorktreeCreationChecked( 181 + repositoryID: repositoryID, 182 + branchName: branchName, 183 + baseRef: baseRef, 184 + duplicateMessage: duplicateMessage 185 + ) 186 + ) 187 + ) 188 + } 189 + .cancellable(id: CancelID.worktreePromptValidation, cancelInFlight: true) 190 + 191 + case .promptedWorktreeCreationChecked( 192 + let repositoryID, 193 + let branchName, 194 + let baseRef, 195 + let duplicateMessage 196 + ): 197 + guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { 198 + return .none 199 + } 200 + state.worktreeCreationPrompt?.isValidating = false 201 + if let duplicateMessage { 202 + state.worktreeCreationPrompt?.validationMessage = duplicateMessage 203 + return .none 204 + } 205 + state.worktreeCreationPrompt = nil 206 + return .send( 207 + .worktreeCreation( 208 + .createWorktreeInRepository( 209 + repositoryID: repositoryID, 210 + nameSource: .explicit(branchName), 211 + baseRefSource: .explicit(baseRef) 212 + ) 213 + ) 214 + ) 215 + 216 + case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource): 217 + guard let repository = state.repositories[id: repositoryID] else { 218 + state.alert = messageAlert( 219 + title: "Unable to create worktree", 220 + message: "Unable to resolve a repository for the new worktree." 221 + ) 222 + return .none 223 + } 224 + if state.removingRepositoryIDs.contains(repository.id) { 225 + state.alert = messageAlert( 226 + title: "Unable to create worktree", 227 + message: "This repository is being removed." 228 + ) 229 + return .none 230 + } 231 + let previousSelection = state.selectedWorktreeID 232 + let pendingID = "pending:\(uuid().uuidString)" 233 + @Shared(.settingsFile) var settingsFile 234 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 235 + let globalDefaultWorktreeBaseDirectoryPath = settingsFile.global.defaultWorktreeBaseDirectoryPath 236 + let worktreeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 237 + for: repository.rootURL, 238 + globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 239 + repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath 240 + ) 241 + let selectedBaseRef = repositorySettings.worktreeBaseRef 242 + let copyIgnoredOnWorktreeCreate = repositorySettings.copyIgnoredOnWorktreeCreate 243 + let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate 244 + state.pendingWorktrees.append( 245 + PendingWorktree( 246 + id: pendingID, 247 + repositoryID: repository.id, 248 + progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 249 + ) 250 + ) 251 + setSingleWorktreeSelection(pendingID, state: &state) 252 + let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) 253 + let createWorktreeStream = gitClient.createWorktreeStream 254 + let isValidBranchName = gitClient.isValidBranchName 255 + return .run { send in 256 + var newWorktreeName: String? 257 + var progress = WorktreeCreationProgress(stage: .loadingLocalBranches) 258 + var progressUpdateThrottle = WorktreeCreationProgressUpdateThrottle( 259 + stride: worktreeCreationProgressUpdateStride 260 + ) 261 + do { 262 + await send( 263 + .worktreeCreation( 264 + .pendingWorktreeProgressUpdated( 265 + id: pendingID, 266 + progress: progress 267 + ) 268 + ) 269 + ) 270 + let branchNames = try await gitClient.localBranchNames(repository.rootURL) 271 + let existing = existingNames.union(branchNames) 272 + let name: String 273 + switch nameSource { 274 + case .random: 275 + progress.stage = .choosingWorktreeName 276 + await send( 277 + .worktreeCreation( 278 + .pendingWorktreeProgressUpdated( 279 + id: pendingID, 280 + progress: progress 281 + ) 282 + ) 283 + ) 284 + let generatedName = await MainActor.run { 285 + WorktreeNameGenerator.nextName(excluding: existing) 286 + } 287 + guard let generatedName else { 288 + let message = 289 + "All default adjective-animal names are already in use. " 290 + + "Delete a worktree or rename a branch, then try again." 291 + await send( 292 + .worktreeCreation( 293 + .createRandomWorktreeFailed( 294 + title: "No available worktree names", 295 + message: message, 296 + pendingID: pendingID, 297 + previousSelection: previousSelection, 298 + repositoryID: repository.id, 299 + name: nil, 300 + baseDirectory: worktreeBaseDirectory 301 + ) 302 + ) 303 + ) 304 + return 305 + } 306 + name = generatedName 307 + case .explicit(let explicitName): 308 + let trimmed = explicitName.trimmingCharacters(in: .whitespacesAndNewlines) 309 + guard !trimmed.isEmpty else { 310 + await send( 311 + .worktreeCreation( 312 + .createRandomWorktreeFailed( 313 + title: "Branch name required", 314 + message: "Enter a branch name to create a worktree.", 315 + pendingID: pendingID, 316 + previousSelection: previousSelection, 317 + repositoryID: repository.id, 318 + name: nil, 319 + baseDirectory: worktreeBaseDirectory 320 + ) 321 + ) 322 + ) 323 + return 324 + } 325 + guard !trimmed.contains(where: \.isWhitespace) else { 326 + await send( 327 + .worktreeCreation( 328 + .createRandomWorktreeFailed( 329 + title: "Branch name invalid", 330 + message: "Branch names can't contain spaces.", 331 + pendingID: pendingID, 332 + previousSelection: previousSelection, 333 + repositoryID: repository.id, 334 + name: nil, 335 + baseDirectory: worktreeBaseDirectory 336 + ) 337 + ) 338 + ) 339 + return 340 + } 341 + guard await isValidBranchName(trimmed, repository.rootURL) else { 342 + await send( 343 + .worktreeCreation( 344 + .createRandomWorktreeFailed( 345 + title: "Branch name invalid", 346 + message: "Enter a valid git branch name and try again.", 347 + pendingID: pendingID, 348 + previousSelection: previousSelection, 349 + repositoryID: repository.id, 350 + name: nil, 351 + baseDirectory: worktreeBaseDirectory 352 + ) 353 + ) 354 + ) 355 + return 356 + } 357 + guard !existing.contains(trimmed.lowercased()) else { 358 + await send( 359 + .worktreeCreation( 360 + .createRandomWorktreeFailed( 361 + title: "Branch name already exists", 362 + message: "Choose a different branch name and try again.", 363 + pendingID: pendingID, 364 + previousSelection: previousSelection, 365 + repositoryID: repository.id, 366 + name: nil, 367 + baseDirectory: worktreeBaseDirectory 368 + ) 369 + ) 370 + ) 371 + return 372 + } 373 + name = trimmed 374 + } 375 + newWorktreeName = name 376 + progress.worktreeName = name 377 + progress.stage = .checkingRepositoryMode 378 + await send( 379 + .worktreeCreation( 380 + .pendingWorktreeProgressUpdated( 381 + id: pendingID, 382 + progress: progress 383 + ) 384 + ) 385 + ) 386 + let isBareRepository = (try? await gitClient.isBareRepository(repository.rootURL)) ?? false 387 + let copyIgnored = isBareRepository ? false : copyIgnoredOnWorktreeCreate 388 + let copyUntracked = isBareRepository ? false : copyUntrackedOnWorktreeCreate 389 + progress.stage = .resolvingBaseReference 390 + await send( 391 + .worktreeCreation( 392 + .pendingWorktreeProgressUpdated( 393 + id: pendingID, 394 + progress: progress 395 + ) 396 + ) 397 + ) 398 + let resolvedBaseRef: String 399 + switch baseRefSource { 400 + case .repositorySetting: 401 + if (selectedBaseRef ?? "").isEmpty { 402 + resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 403 + } else { 404 + resolvedBaseRef = selectedBaseRef ?? "" 405 + } 406 + case .explicit(let explicitBaseRef): 407 + if let explicitBaseRef, !explicitBaseRef.isEmpty { 408 + resolvedBaseRef = explicitBaseRef 409 + } else { 410 + resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 411 + } 412 + } 413 + progress.baseRef = resolvedBaseRef 414 + progress.copyIgnored = copyIgnored 415 + progress.copyUntracked = copyUntracked 416 + progress.ignoredFilesToCopyCount = 417 + copyIgnored ? ((try? await gitClient.ignoredFileCount(repository.rootURL)) ?? 0) : 0 418 + progress.untrackedFilesToCopyCount = 419 + copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 420 + progress.stage = .creatingWorktree 421 + progress.commandText = worktreeCreateCommand( 422 + baseDirectoryURL: worktreeBaseDirectory, 423 + name: name, 424 + copyIgnored: copyIgnored, 425 + copyUntracked: copyUntracked, 426 + baseRef: resolvedBaseRef 427 + ) 428 + await send( 429 + .worktreeCreation( 430 + .pendingWorktreeProgressUpdated( 431 + id: pendingID, 432 + progress: progress 433 + ) 434 + ) 435 + ) 436 + let stream = createWorktreeStream( 437 + name, 438 + repository.rootURL, 439 + worktreeBaseDirectory, 440 + copyIgnored, 441 + copyUntracked, 442 + resolvedBaseRef 443 + ) 444 + for try await event in stream { 445 + switch event { 446 + case .outputLine(let outputLine): 447 + let line = outputLine.text.trimmingCharacters(in: .whitespacesAndNewlines) 448 + guard !line.isEmpty else { 449 + continue 450 + } 451 + progress.appendOutputLine(line, maxLines: worktreeCreationProgressLineLimit) 452 + if progressUpdateThrottle.recordLine() { 453 + await send( 454 + .worktreeCreation( 455 + .pendingWorktreeProgressUpdated( 456 + id: pendingID, 457 + progress: progress 458 + ) 459 + ) 460 + ) 461 + } 462 + case .finished(let newWorktree): 463 + if progressUpdateThrottle.flush() { 464 + await send( 465 + .worktreeCreation( 466 + .pendingWorktreeProgressUpdated( 467 + id: pendingID, 468 + progress: progress 469 + ) 470 + ) 471 + ) 472 + } 473 + await send( 474 + .worktreeCreation( 475 + .createRandomWorktreeSucceeded( 476 + newWorktree, 477 + repositoryID: repository.id, 478 + pendingID: pendingID 479 + ) 480 + ) 481 + ) 482 + return 483 + } 484 + } 485 + throw GitClientError.commandFailed( 486 + command: "wt sw", 487 + message: "Worktree creation finished without a result." 488 + ) 489 + } catch { 490 + if progressUpdateThrottle.flush() { 491 + await send( 492 + .worktreeCreation( 493 + .pendingWorktreeProgressUpdated( 494 + id: pendingID, 495 + progress: progress 496 + ) 497 + ) 498 + ) 499 + } 500 + await send( 501 + .worktreeCreation( 502 + .createRandomWorktreeFailed( 503 + title: "Unable to create worktree", 504 + message: error.localizedDescription, 505 + pendingID: pendingID, 506 + previousSelection: previousSelection, 507 + repositoryID: repository.id, 508 + name: newWorktreeName, 509 + baseDirectory: worktreeBaseDirectory 510 + ) 511 + ) 512 + ) 513 + } 514 + } 515 + 516 + case .pendingWorktreeProgressUpdated(let id, let progress): 517 + updatePendingWorktreeProgress(id, progress: progress, state: &state) 518 + return .none 519 + 520 + case .createRandomWorktreeSucceeded( 521 + let worktree, 522 + let repositoryID, 523 + let pendingID 524 + ): 525 + analyticsClient.capture("worktree_created", [String: Any]?.none) 526 + state.pendingSetupScriptWorktreeIDs.insert(worktree.id) 527 + state.pendingTerminalFocusWorktreeIDs.insert(worktree.id) 528 + removePendingWorktree(pendingID, state: &state) 529 + if state.selection == .worktree(pendingID) { 530 + setSingleWorktreeSelection(worktree.id, state: &state) 531 + } 532 + insertWorktree(worktree, repositoryID: repositoryID, state: &state) 533 + return .merge( 534 + .send(.reloadRepositories(animated: false)), 535 + .send(.delegate(.repositoriesChanged(state.repositories))), 536 + .send(.delegate(.selectedWorktreeChanged(state.worktree(for: state.selectedWorktreeID)))), 537 + .send(.delegate(.worktreeCreated(worktree))) 538 + ) 539 + 540 + case .createRandomWorktreeFailed( 541 + let title, 542 + let message, 543 + let pendingID, 544 + let previousSelection, 545 + let repositoryID, 546 + let name, 547 + let baseDirectory 548 + ): 549 + let previousSelectedWorktree = state.worktree(for: previousSelection) 550 + removePendingWorktree(pendingID, state: &state) 551 + restoreSelection(previousSelection, pendingID: pendingID, state: &state) 552 + let cleanup = cleanupFailedWorktree( 553 + repositoryID: repositoryID, 554 + name: name, 555 + baseDirectory: baseDirectory, 556 + state: &state 557 + ) 558 + state.alert = messageAlert(title: title, message: message) 559 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 560 + let selectionChanged = selectionDidChange( 561 + previousSelectionID: previousSelection, 562 + previousSelectedWorktree: previousSelectedWorktree, 563 + selectedWorktreeID: state.selectedWorktreeID, 564 + selectedWorktree: selectedWorktree 565 + ) 566 + var effects: [Effect<Action>] = [] 567 + if cleanup.didRemoveWorktree { 568 + effects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 569 + } 570 + if selectionChanged { 571 + effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 572 + } 573 + if cleanup.didUpdatePinned { 574 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 575 + effects.append( 576 + .run { _ in 577 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 578 + } 579 + ) 580 + } 581 + if cleanup.didUpdateOrder { 582 + let worktreeOrderByRepository = state.worktreeOrderByRepository 583 + effects.append( 584 + .run { _ in 585 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 586 + } 587 + ) 588 + } 589 + if let cleanupWorktree = cleanup.worktree { 590 + let repositoryRootURL = cleanupWorktree.repositoryRootURL 591 + effects.append( 592 + .run { send in 593 + _ = try? await gitClient.removeWorktree(cleanupWorktree, true) 594 + _ = try? await gitClient.pruneWorktrees(repositoryRootURL) 595 + await send(.reloadRepositories(animated: true)) 596 + } 597 + ) 598 + } 599 + return .merge(effects) 600 + 601 + case .consumeSetupScript(let id): 602 + state.pendingSetupScriptWorktreeIDs.remove(id) 603 + return .none 604 + 605 + case .consumeTerminalFocus(let id): 606 + state.pendingTerminalFocusWorktreeIDs.remove(id) 607 + return .none 608 + } 609 + } 610 + 611 + var worktreeCreationReducer: some ReducerOf<Self> { 612 + Reduce { state, action in 613 + guard case .worktreeCreation(let action) = action else { 614 + return .none 615 + } 616 + return reduceWorktreeCreation(state: &state, action: action) 617 + } 618 + } 619 + }
+505
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + import SwiftUI 4 + 5 + extension RepositoriesFeature { 6 + // swiftlint:disable:next cyclomatic_complexity function_body_length 7 + func reduceWorktreeLifecycle( 8 + state: inout State, 9 + action: WorktreeLifecycleAction 10 + ) -> Effect<Action> { 11 + switch action { 12 + case .requestArchiveWorktree(let worktreeID, let repositoryID): 13 + if state.removingRepositoryIDs.contains(repositoryID) { 14 + return .none 15 + } 16 + guard let repository = state.repositories[id: repositoryID], 17 + let worktree = repository.worktrees[id: worktreeID] 18 + else { 19 + return .none 20 + } 21 + if state.isMainWorktree(worktree) { 22 + return .none 23 + } 24 + if state.deletingWorktreeIDs.contains(worktree.id) { 25 + return .none 26 + } 27 + if state.archivingWorktreeIDs.contains(worktree.id) { 28 + return .none 29 + } 30 + if state.isWorktreeArchived(worktree.id) { 31 + return .none 32 + } 33 + if state.isWorktreeMerged(worktree) { 34 + return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktree.id, repository.id))) 35 + } 36 + state.alert = AlertState { 37 + TextState("Archive worktree?") 38 + } actions: { 39 + ButtonState(role: .destructive, action: .confirmArchiveWorktree(worktree.id, repository.id)) { 40 + TextState("Archive (⌘↩)") 41 + } 42 + ButtonState(role: .cancel) { 43 + TextState("Cancel") 44 + } 45 + } message: { 46 + TextState("Archive \(worktree.name)?") 47 + } 48 + return .none 49 + 50 + case .requestArchiveWorktrees(let targets): 51 + var validTargets: [ArchiveWorktreeTarget] = [] 52 + var seenWorktreeIDs: Set<Worktree.ID> = [] 53 + for target in targets { 54 + guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 55 + if state.removingRepositoryIDs.contains(target.repositoryID) { 56 + continue 57 + } 58 + guard let repository = state.repositories[id: target.repositoryID], 59 + let worktree = repository.worktrees[id: target.worktreeID] 60 + else { 61 + continue 62 + } 63 + if state.isMainWorktree(worktree) 64 + || state.deletingWorktreeIDs.contains(worktree.id) 65 + || state.archivingWorktreeIDs.contains(worktree.id) 66 + || state.isWorktreeArchived(worktree.id) 67 + { 68 + continue 69 + } 70 + validTargets.append(target) 71 + } 72 + guard !validTargets.isEmpty else { 73 + return .none 74 + } 75 + if validTargets.count == 1, let target = validTargets.first { 76 + return .send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) 77 + } 78 + let count = validTargets.count 79 + state.alert = AlertState { 80 + TextState("Archive \(count) worktrees?") 81 + } actions: { 82 + ButtonState(role: .destructive, action: .confirmArchiveWorktrees(validTargets)) { 83 + TextState("Archive \(count) (⌘↩)") 84 + } 85 + ButtonState(role: .cancel) { 86 + TextState("Cancel") 87 + } 88 + } message: { 89 + TextState("Archive \(count) worktrees?") 90 + } 91 + return .none 92 + 93 + case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): 94 + guard let repository = state.repositories[id: repositoryID], 95 + let worktree = repository.worktrees[id: worktreeID] 96 + else { 97 + return .none 98 + } 99 + if state.isWorktreeArchived(worktreeID) || state.archivingWorktreeIDs.contains(worktreeID) { 100 + state.alert = nil 101 + return .none 102 + } 103 + state.alert = nil 104 + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 105 + let script = repositorySettings.archiveScript 106 + let commandText = archiveScriptCommand(script) 107 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 108 + if trimmed.isEmpty { 109 + return .send(.worktreeLifecycle(.archiveWorktreeApply(worktreeID, repositoryID))) 110 + } 111 + state.archivingWorktreeIDs.insert(worktreeID) 112 + state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( 113 + titleText: "Running archive script", 114 + detailText: "Preparing archive script", 115 + commandText: commandText 116 + ) 117 + let shellClient = self.shellClient 118 + let scriptWithEnv = worktree.scriptEnvironmentExportPrefix + script 119 + return .run { send in 120 + let envURL = URL(fileURLWithPath: "/usr/bin/env") 121 + var progress = ArchiveScriptProgress( 122 + titleText: "Running archive script", 123 + detailText: "Running archive script", 124 + commandText: commandText 125 + ) 126 + do { 127 + for try await event in shellClient.runLoginStream( 128 + envURL, 129 + ["bash", "-lc", scriptWithEnv], 130 + worktree.workingDirectory, 131 + log: false 132 + ) { 133 + switch event { 134 + case .line(let line): 135 + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) 136 + guard !text.isEmpty else { continue } 137 + progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) 138 + await send(.worktreeLifecycle(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress))) 139 + case .finished: 140 + await send( 141 + .worktreeLifecycle( 142 + .archiveScriptSucceeded( 143 + worktreeID: worktreeID, 144 + repositoryID: repositoryID 145 + ) 146 + ) 147 + ) 148 + } 149 + } 150 + } catch { 151 + await send( 152 + .worktreeLifecycle( 153 + .archiveScriptFailed( 154 + worktreeID: worktreeID, 155 + message: error.localizedDescription 156 + ) 157 + ) 158 + ) 159 + } 160 + } 161 + .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true) 162 + 163 + case .archiveScriptProgressUpdated(let worktreeID, let progress): 164 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 165 + return .none 166 + } 167 + state.archiveScriptProgressByWorktreeID[worktreeID] = progress 168 + return .none 169 + 170 + case .archiveScriptSucceeded(let worktreeID, let repositoryID): 171 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 172 + return .none 173 + } 174 + state.archivingWorktreeIDs.remove(worktreeID) 175 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 176 + return .send(.worktreeLifecycle(.archiveWorktreeApply(worktreeID, repositoryID))) 177 + 178 + case .archiveScriptFailed(let worktreeID, let message): 179 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 180 + return .none 181 + } 182 + state.archivingWorktreeIDs.remove(worktreeID) 183 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 184 + state.alert = messageAlert(title: "Archive script failed", message: message) 185 + return .none 186 + 187 + case .archiveWorktreeApply(let worktreeID, let repositoryID): 188 + guard let repository = state.repositories[id: repositoryID], 189 + let worktree = repository.worktrees[id: worktreeID] 190 + else { 191 + return .none 192 + } 193 + if state.isWorktreeArchived(worktreeID) { 194 + state.alert = nil 195 + return .none 196 + } 197 + let previousSelection = state.selectedWorktreeID 198 + let previousSelectedWorktree = state.worktree(for: previousSelection) 199 + let selectionWasRemoved = state.selectedWorktreeID == worktree.id 200 + let nextSelection = 201 + selectionWasRemoved 202 + ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) 203 + : nil 204 + var didUpdateWorktreeOrder = false 205 + let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 206 + withAnimation { 207 + state.alert = nil 208 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 209 + if var order = state.worktreeOrderByRepository[repositoryID] { 210 + order.removeAll { $0 == worktreeID } 211 + if order.isEmpty { 212 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 213 + } else { 214 + state.worktreeOrderByRepository[repositoryID] = order 215 + } 216 + didUpdateWorktreeOrder = true 217 + } 218 + state.archivedWorktreeIDs.append(worktreeID) 219 + if selectionWasRemoved { 220 + let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 221 + state.selection = nextWorktreeID.map(SidebarSelection.worktree) 222 + } 223 + } 224 + let archivedWorktreeIDs = state.archivedWorktreeIDs 225 + let repositories = state.repositories 226 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 227 + let selectionChanged = selectionDidChange( 228 + previousSelectionID: previousSelection, 229 + previousSelectedWorktree: previousSelectedWorktree, 230 + selectedWorktreeID: state.selectedWorktreeID, 231 + selectedWorktree: selectedWorktree 232 + ) 233 + var effects: [Effect<Action>] = [ 234 + .send(.delegate(.repositoriesChanged(repositories))), 235 + .run { _ in 236 + await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 237 + }, 238 + ] 239 + if wasPinned { 240 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 241 + effects.append( 242 + .run { _ in 243 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 244 + } 245 + ) 246 + } 247 + if didUpdateWorktreeOrder { 248 + let worktreeOrderByRepository = state.worktreeOrderByRepository 249 + effects.append( 250 + .run { _ in 251 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 252 + } 253 + ) 254 + } 255 + if selectionChanged { 256 + effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 257 + } 258 + return .merge(effects) 259 + 260 + case .unarchiveWorktree(let worktreeID): 261 + if !state.isWorktreeArchived(worktreeID) { 262 + return .none 263 + } 264 + withAnimation { 265 + state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 266 + } 267 + let archivedWorktreeIDs = state.archivedWorktreeIDs 268 + let repositories = state.repositories 269 + return .merge( 270 + .send(.delegate(.repositoriesChanged(repositories))), 271 + .run { _ in 272 + await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 273 + } 274 + ) 275 + 276 + case .requestDeleteWorktree(let worktreeID, let repositoryID): 277 + if state.removingRepositoryIDs.contains(repositoryID) { 278 + return .none 279 + } 280 + guard let repository = state.repositories[id: repositoryID], 281 + let worktree = repository.worktrees[id: worktreeID] 282 + else { 283 + return .none 284 + } 285 + if state.isMainWorktree(worktree) { 286 + state.alert = messageAlert( 287 + title: "Delete not allowed", 288 + message: "Deleting the main worktree is not allowed." 289 + ) 290 + return .none 291 + } 292 + if state.archivingWorktreeIDs.contains(worktree.id) { 293 + return .none 294 + } 295 + if state.deletingWorktreeIDs.contains(worktree.id) { 296 + return .none 297 + } 298 + @Shared(.settingsFile) var settingsFile 299 + let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 300 + let removalMessage = 301 + deleteBranchOnDeleteWorktree 302 + ? "This deletes the worktree directory and its local branch." 303 + : "This deletes the worktree directory and keeps the local branch." 304 + state.alert = AlertState { 305 + TextState("🚨 Delete worktree?") 306 + } actions: { 307 + ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { 308 + TextState("Delete (⌘↩)") 309 + } 310 + ButtonState(role: .cancel) { 311 + TextState("Cancel") 312 + } 313 + } message: { 314 + TextState("Delete \(worktree.name)? " + removalMessage) 315 + } 316 + return .none 317 + 318 + case .requestDeleteWorktrees(let targets): 319 + var validTargets: [DeleteWorktreeTarget] = [] 320 + var seenWorktreeIDs: Set<Worktree.ID> = [] 321 + for target in targets { 322 + guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 323 + if state.removingRepositoryIDs.contains(target.repositoryID) { 324 + continue 325 + } 326 + guard let repository = state.repositories[id: target.repositoryID], 327 + let worktree = repository.worktrees[id: target.worktreeID] 328 + else { 329 + continue 330 + } 331 + if state.isMainWorktree(worktree) 332 + || state.deletingWorktreeIDs.contains(worktree.id) 333 + || state.archivingWorktreeIDs.contains(worktree.id) 334 + { 335 + continue 336 + } 337 + validTargets.append(target) 338 + } 339 + guard !validTargets.isEmpty else { 340 + return .none 341 + } 342 + @Shared(.settingsFile) var settingsFile 343 + let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 344 + let removalMessage = 345 + deleteBranchOnDeleteWorktree 346 + ? "This deletes the worktree directories and their local branches." 347 + : "This deletes the worktree directories and keeps their local branches." 348 + let count = validTargets.count 349 + state.alert = AlertState { 350 + TextState("🚨 Delete \(count) worktrees?") 351 + } actions: { 352 + ButtonState(role: .destructive, action: .confirmDeleteWorktrees(validTargets)) { 353 + TextState("Delete \(count) (⌘↩)") 354 + } 355 + ButtonState(role: .cancel) { 356 + TextState("Cancel") 357 + } 358 + } message: { 359 + TextState("Delete \(count) worktrees? " + removalMessage) 360 + } 361 + return .none 362 + 363 + case .deleteWorktreeConfirmed(let worktreeID, let repositoryID): 364 + guard let repository = state.repositories[id: repositoryID], 365 + let worktree = repository.worktrees[id: worktreeID] 366 + else { 367 + return .none 368 + } 369 + if state.archivingWorktreeIDs.contains(worktree.id) { 370 + return .none 371 + } 372 + if state.deletingWorktreeIDs.contains(worktree.id) { 373 + return .none 374 + } 375 + state.alert = nil 376 + state.deletingWorktreeIDs.insert(worktree.id) 377 + let selectionWasRemoved = state.selectedWorktreeID == worktree.id 378 + let nextSelection = 379 + selectionWasRemoved 380 + ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) 381 + : nil 382 + @Shared(.settingsFile) var settingsFile 383 + let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 384 + return .run { send in 385 + do { 386 + _ = try await gitClient.removeWorktree( 387 + worktree, 388 + deleteBranchOnDeleteWorktree 389 + ) 390 + await send( 391 + .worktreeLifecycle( 392 + .worktreeDeleted( 393 + worktree.id, 394 + repositoryID: repository.id, 395 + selectionWasRemoved: selectionWasRemoved, 396 + nextSelection: nextSelection 397 + ) 398 + ) 399 + ) 400 + } catch { 401 + await send(.worktreeLifecycle(.deleteWorktreeFailed(error.localizedDescription, worktreeID: worktree.id))) 402 + } 403 + } 404 + 405 + case .worktreeDeleted( 406 + let worktreeID, 407 + let repositoryID, 408 + _, 409 + let nextSelection 410 + ): 411 + analyticsClient.capture("worktree_deleted", [String: Any]?.none) 412 + let previousSelection = state.selectedWorktreeID 413 + let previousSelectedWorktree = state.worktree(for: previousSelection) 414 + let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 415 + var didUpdateWorktreeOrder = false 416 + let wasArchived = state.isWorktreeArchived(worktreeID) 417 + withAnimation(.easeOut(duration: 0.2)) { 418 + state.deletingWorktreeIDs.remove(worktreeID) 419 + state.archivingWorktreeIDs.remove(worktreeID) 420 + state.pendingWorktrees.removeAll { $0.id == worktreeID } 421 + state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 422 + state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 423 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 424 + state.worktreeInfoByID.removeValue(forKey: worktreeID) 425 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 426 + state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 427 + if var order = state.worktreeOrderByRepository[repositoryID] { 428 + order.removeAll { $0 == worktreeID } 429 + if order.isEmpty { 430 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 431 + } else { 432 + state.worktreeOrderByRepository[repositoryID] = order 433 + } 434 + didUpdateWorktreeOrder = true 435 + } 436 + _ = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) 437 + let selectionNeedsUpdate = state.selection == .worktree(worktreeID) 438 + if selectionNeedsUpdate { 439 + let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 440 + state.selection = nextWorktreeID.map(SidebarSelection.worktree) 441 + } 442 + } 443 + let roots = state.repositories.map(\.rootURL) 444 + let repositories = state.repositories 445 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 446 + let selectionChanged = selectionDidChange( 447 + previousSelectionID: previousSelection, 448 + previousSelectedWorktree: previousSelectedWorktree, 449 + selectedWorktreeID: state.selectedWorktreeID, 450 + selectedWorktree: selectedWorktree 451 + ) 452 + var immediateEffects: [Effect<Action>] = [ 453 + .send(.delegate(.repositoriesChanged(repositories))) 454 + ] 455 + if selectionChanged { 456 + immediateEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 457 + } 458 + var followupEffects: [Effect<Action>] = [ 459 + roots.isEmpty ? .none : .send(.reloadRepositories(animated: true)) 460 + ] 461 + if wasPinned { 462 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 463 + followupEffects.append( 464 + .run { _ in 465 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 466 + } 467 + ) 468 + } 469 + if wasArchived { 470 + let archivedWorktreeIDs = state.archivedWorktreeIDs 471 + followupEffects.append( 472 + .run { _ in 473 + await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 474 + } 475 + ) 476 + } 477 + if didUpdateWorktreeOrder { 478 + let worktreeOrderByRepository = state.worktreeOrderByRepository 479 + followupEffects.append( 480 + .run { _ in 481 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 482 + } 483 + ) 484 + } 485 + return .concatenate( 486 + .merge(immediateEffects), 487 + .merge(followupEffects) 488 + ) 489 + 490 + case .deleteWorktreeFailed(let message, let worktreeID): 491 + state.deletingWorktreeIDs.remove(worktreeID) 492 + state.alert = messageAlert(title: "Unable to delete worktree", message: message) 493 + return .none 494 + } 495 + } 496 + 497 + var worktreeLifecycleReducer: some ReducerOf<Self> { 498 + Reduce { state, action in 499 + guard case .worktreeLifecycle(let action) = action else { 500 + return .none 501 + } 502 + return reduceWorktreeLifecycle(state: &state, action: action) 503 + } 504 + } 505 + }
+186
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + extension RepositoriesFeature { 5 + // swiftlint:disable:next cyclomatic_complexity function_body_length 6 + func reduceWorktreeOrdering( 7 + state: inout State, 8 + action: WorktreeOrderingAction 9 + ) -> Effect<Action> { 10 + switch action { 11 + case .repositoriesMoved(let source, let destination): 12 + var orderedRepositoryIDs = state.orderedRepositoryIDs() 13 + orderedRepositoryIDs.move(fromOffsets: source, toOffset: destination) 14 + state.repositoryOrderIDs = orderedRepositoryIDs 15 + let repositoryOrderIDs = state.repositoryOrderIDs 16 + return .run { _ in 17 + await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 18 + } 19 + 20 + case .pinnedWorktreesMoved(let repositoryID, let source, let destination): 21 + guard let repository = state.repositories[id: repositoryID] else { 22 + return .none 23 + } 24 + var orderedPinnedWorktreeIDs = state.orderedPinnedWorktreeIDs(in: repository) 25 + orderedPinnedWorktreeIDs.move(fromOffsets: source, toOffset: destination) 26 + state.pinnedWorktreeIDs = state.replacingPinnedWorktreeIDs( 27 + in: repository, 28 + with: orderedPinnedWorktreeIDs 29 + ) 30 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 31 + return .run { _ in 32 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 33 + } 34 + 35 + case .unpinnedWorktreesMoved(let repositoryID, let source, let destination): 36 + guard let repository = state.repositories[id: repositoryID] else { 37 + return .none 38 + } 39 + var orderedUnpinnedWorktreeIDs = state.orderedUnpinnedWorktreeIDs(in: repository) 40 + orderedUnpinnedWorktreeIDs.move(fromOffsets: source, toOffset: destination) 41 + if orderedUnpinnedWorktreeIDs.isEmpty { 42 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 43 + } else { 44 + state.worktreeOrderByRepository[repositoryID] = orderedUnpinnedWorktreeIDs 45 + } 46 + let worktreeOrderByRepository = state.worktreeOrderByRepository 47 + return .run { _ in 48 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 49 + } 50 + 51 + case .pinWorktree(let worktreeID): 52 + if let worktree = state.worktree(for: worktreeID), state.isMainWorktree(worktree) { 53 + let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 54 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 55 + var didUpdateWorktreeOrder = false 56 + if let repositoryID = state.repositoryID(containing: worktreeID), 57 + var order = state.worktreeOrderByRepository[repositoryID] 58 + { 59 + order.removeAll { $0 == worktreeID } 60 + if order.isEmpty { 61 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 62 + } else { 63 + state.worktreeOrderByRepository[repositoryID] = order 64 + } 65 + didUpdateWorktreeOrder = true 66 + } 67 + var effects: [Effect<Action>] = [] 68 + if wasPinned { 69 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 70 + effects.append( 71 + .run { _ in 72 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 73 + } 74 + ) 75 + } 76 + if didUpdateWorktreeOrder { 77 + let worktreeOrderByRepository = state.worktreeOrderByRepository 78 + effects.append( 79 + .run { _ in 80 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 81 + } 82 + ) 83 + } 84 + return .merge(effects) 85 + } 86 + analyticsClient.capture("worktree_pinned", [String: Any]?.none) 87 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 88 + state.pinnedWorktreeIDs.insert(worktreeID, at: 0) 89 + var didUpdateWorktreeOrder = false 90 + if let repositoryID = state.repositoryID(containing: worktreeID), 91 + var order = state.worktreeOrderByRepository[repositoryID] 92 + { 93 + order.removeAll { $0 == worktreeID } 94 + if order.isEmpty { 95 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 96 + } else { 97 + state.worktreeOrderByRepository[repositoryID] = order 98 + } 99 + didUpdateWorktreeOrder = true 100 + } 101 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 102 + var effects: [Effect<Action>] = [ 103 + .run { _ in 104 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 105 + }, 106 + ] 107 + if didUpdateWorktreeOrder { 108 + let worktreeOrderByRepository = state.worktreeOrderByRepository 109 + effects.append( 110 + .run { _ in 111 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 112 + } 113 + ) 114 + } 115 + return .merge(effects) 116 + 117 + case .unpinWorktree(let worktreeID): 118 + analyticsClient.capture("worktree_unpinned", [String: Any]?.none) 119 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 120 + var didUpdateWorktreeOrder = false 121 + if let repositoryID = state.repositoryID(containing: worktreeID) { 122 + var order = state.worktreeOrderByRepository[repositoryID] ?? [] 123 + order.removeAll { $0 == worktreeID } 124 + order.insert(worktreeID, at: 0) 125 + state.worktreeOrderByRepository[repositoryID] = order 126 + didUpdateWorktreeOrder = true 127 + } 128 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 129 + var effects: [Effect<Action>] = [ 130 + .run { _ in 131 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 132 + }, 133 + ] 134 + if didUpdateWorktreeOrder { 135 + let worktreeOrderByRepository = state.worktreeOrderByRepository 136 + effects.append( 137 + .run { _ in 138 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 139 + } 140 + ) 141 + } 142 + return .merge(effects) 143 + 144 + case .worktreeNotificationReceived(let worktreeID): 145 + guard let repositoryID = state.repositoryID(containing: worktreeID), 146 + let repository = state.repositories[id: repositoryID], 147 + let worktree = repository.worktrees[id: worktreeID] 148 + else { 149 + return .none 150 + } 151 + if state.isWorktreeArchived(worktree.id) { 152 + return .none 153 + } 154 + if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 155 + let reordered = reorderedUnpinnedWorktreeIDs( 156 + for: worktreeID, 157 + in: repository, 158 + state: state 159 + ) 160 + if state.worktreeOrderByRepository[repositoryID] != reordered { 161 + withAnimation(.snappy(duration: 0.2)) { 162 + state.worktreeOrderByRepository[repositoryID] = reordered 163 + } 164 + let worktreeOrderByRepository = state.worktreeOrderByRepository 165 + return .run { _ in 166 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 167 + } 168 + } 169 + } 170 + return .none 171 + 172 + case .setMoveNotifiedWorktreeToTop(let isEnabled): 173 + state.moveNotifiedWorktreeToTop = isEnabled 174 + return .none 175 + } 176 + } 177 + 178 + var worktreeOrderingReducer: some ReducerOf<Self> { 179 + Reduce { state, action in 180 + guard case .worktreeOrdering(let action) = action else { 181 + return .none 182 + } 183 + return reduceWorktreeOrdering(state: &state, action: action) 184 + } 185 + } 186 + }
+533 -2404
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 5 5 import PostHog 6 6 import SwiftUI 7 7 8 - private enum CancelID { 9 - static let load = "repositories.load" 10 - static let toastAutoDismiss = "repositories.toastAutoDismiss" 11 - static let githubIntegrationAvailability = "repositories.githubIntegrationAvailability" 12 - static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" 13 - static let worktreePromptLoad = "repositories.worktreePromptLoad" 14 - static let worktreePromptValidation = "repositories.worktreePromptValidation" 15 - static func archiveScript(_ worktreeID: Worktree.ID) -> String { 16 - "repositories.archiveScript.\(worktreeID)" 17 - } 18 - static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 19 - "repositories.delayedPRRefresh.\(worktreeID)" 20 - } 21 - } 22 - 23 - private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 24 - private nonisolated let worktreeCreationProgressLineLimit = 200 25 - private nonisolated let worktreeCreationProgressUpdateStride = 20 26 - private nonisolated let archiveScriptProgressLineLimit = 200 8 + nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 9 + nonisolated let worktreeCreationProgressLineLimit = 200 10 + nonisolated let worktreeCreationProgressUpdateStride = 20 11 + nonisolated let archiveScriptProgressLineLimit = 200 27 12 28 13 nonisolated struct WorktreeCreationProgressUpdateThrottle { 29 14 private let stride: Int ··· 60 45 61 46 @Reducer 62 47 struct RepositoriesFeature { 48 + enum CancelID { 49 + static let load = "repositories.load" 50 + static let toastAutoDismiss = "repositories.toastAutoDismiss" 51 + static let githubIntegrationAvailability = "repositories.githubIntegrationAvailability" 52 + static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" 53 + static let worktreePromptLoad = "repositories.worktreePromptLoad" 54 + static let worktreePromptValidation = "repositories.worktreePromptValidation" 55 + static func archiveScript(_ worktreeID: Worktree.ID) -> String { 56 + "repositories.archiveScript.\(worktreeID)" 57 + } 58 + static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 59 + "repositories.delayedPRRefresh.\(worktreeID)" 60 + } 61 + } 62 + 63 + @CasePathable 64 + enum WorktreeCreationAction: Equatable { 65 + case promptCanceled 66 + case promptDismissed 67 + case createRandomWorktree 68 + case createRandomWorktreeInRepository(Repository.ID) 69 + case createWorktreeInRepository( 70 + repositoryID: Repository.ID, 71 + nameSource: WorktreeCreationNameSource, 72 + baseRefSource: WorktreeCreationBaseRefSource 73 + ) 74 + case promptedWorktreeCreationDataLoaded( 75 + repositoryID: Repository.ID, 76 + baseRefOptions: [String], 77 + automaticBaseRefLabel: String, 78 + selectedBaseRef: String? 79 + ) 80 + case startPromptedWorktreeCreation( 81 + repositoryID: Repository.ID, 82 + branchName: String, 83 + baseRef: String? 84 + ) 85 + case promptedWorktreeCreationChecked( 86 + repositoryID: Repository.ID, 87 + branchName: String, 88 + baseRef: String?, 89 + duplicateMessage: String? 90 + ) 91 + case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) 92 + case createRandomWorktreeSucceeded( 93 + Worktree, 94 + repositoryID: Repository.ID, 95 + pendingID: Worktree.ID 96 + ) 97 + case createRandomWorktreeFailed( 98 + title: String, 99 + message: String, 100 + pendingID: Worktree.ID, 101 + previousSelection: Worktree.ID?, 102 + repositoryID: Repository.ID, 103 + name: String?, 104 + baseDirectory: URL 105 + ) 106 + case consumeSetupScript(Worktree.ID) 107 + case consumeTerminalFocus(Worktree.ID) 108 + } 109 + 110 + @CasePathable 111 + enum WorktreeLifecycleAction: Equatable { 112 + case requestArchiveWorktree(Worktree.ID, Repository.ID) 113 + case requestArchiveWorktrees([ArchiveWorktreeTarget]) 114 + case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 115 + case archiveScriptProgressUpdated(worktreeID: Worktree.ID, progress: ArchiveScriptProgress) 116 + case archiveScriptSucceeded(worktreeID: Worktree.ID, repositoryID: Repository.ID) 117 + case archiveScriptFailed(worktreeID: Worktree.ID, message: String) 118 + case archiveWorktreeApply(Worktree.ID, Repository.ID) 119 + case unarchiveWorktree(Worktree.ID) 120 + case requestDeleteWorktree(Worktree.ID, Repository.ID) 121 + case requestDeleteWorktrees([DeleteWorktreeTarget]) 122 + case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) 123 + case worktreeDeleted( 124 + Worktree.ID, 125 + repositoryID: Repository.ID, 126 + selectionWasRemoved: Bool, 127 + nextSelection: Worktree.ID? 128 + ) 129 + case deleteWorktreeFailed(String, worktreeID: Worktree.ID) 130 + } 131 + 132 + @CasePathable 133 + enum WorktreeOrderingAction: Equatable { 134 + case repositoriesMoved(IndexSet, Int) 135 + case pinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 136 + case unpinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 137 + case pinWorktree(Worktree.ID) 138 + case unpinWorktree(Worktree.ID) 139 + case worktreeNotificationReceived(Worktree.ID) 140 + case setMoveNotifiedWorktreeToTop(Bool) 141 + } 142 + 143 + @CasePathable 144 + enum GithubIntegrationAction: Equatable { 145 + case delayedPullRequestRefresh(Worktree.ID) 146 + case repositoryPullRequestRefreshRequested(repositoryRootURL: URL, worktreeIDs: [Worktree.ID]) 147 + case refreshGithubIntegrationAvailability 148 + case githubIntegrationAvailabilityUpdated(Bool) 149 + case repositoryPullRequestRefreshCompleted(Repository.ID) 150 + case repositoryPullRequestsLoaded( 151 + repositoryID: Repository.ID, 152 + pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] 153 + ) 154 + case setGithubIntegrationEnabled(Bool) 155 + case setAutomaticallyArchiveMergedWorktrees(Bool) 156 + case pullRequestAction(Worktree.ID, PullRequestAction) 157 + } 158 + 159 + @CasePathable 160 + enum RepositoryManagementAction: Equatable { 161 + case openRepositories([URL]) 162 + case openRepositoriesFinished( 163 + [Repository], 164 + failures: [LoadFailure], 165 + invalidRoots: [String], 166 + openFailures: [String], 167 + roots: [URL] 168 + ) 169 + case requestRemoveRepository(Repository.ID) 170 + case removeFailedRepository(Repository.ID) 171 + case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) 172 + case openRepositorySettings(Repository.ID) 173 + } 174 + 63 175 @ObservableState 64 176 struct State: Equatable { 65 177 var repositories: IdentifiedArrayOf<Repository> = [] ··· 125 237 } 126 238 127 239 enum Action { 240 + case worktreeCreation(WorktreeCreationAction) 241 + case worktreeLifecycle(WorktreeLifecycleAction) 242 + case worktreeOrdering(WorktreeOrderingAction) 243 + case githubIntegration(GithubIntegrationAction) 244 + case repositoryManagement(RepositoryManagementAction) 128 245 case task 129 246 case repositorySnapshotLoaded([Repository]?) 130 247 case setOpenPanelPresented(Bool) ··· 141 258 case selectCanvas 142 259 case toggleCanvas 143 260 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 144 - case openRepositories([URL]) 145 - case openRepositoriesFinished( 146 - [Repository], 147 - failures: [LoadFailure], 148 - invalidRoots: [String], 149 - openFailures: [String], 150 - roots: [URL] 151 - ) 152 261 case selectRepository(Repository.ID?) 153 262 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) 154 263 case selectNextWorktree 155 264 case selectPreviousWorktree 156 265 case requestRenameBranch(Worktree.ID, String) 157 - case createRandomWorktree 158 - case createRandomWorktreeInRepository(Repository.ID) 159 - case createWorktreeInRepository( 160 - repositoryID: Repository.ID, 161 - nameSource: WorktreeCreationNameSource, 162 - baseRefSource: WorktreeCreationBaseRefSource 163 - ) 164 - case promptedWorktreeCreationDataLoaded( 165 - repositoryID: Repository.ID, 166 - baseRefOptions: [String], 167 - automaticBaseRefLabel: String, 168 - selectedBaseRef: String? 169 - ) 170 - case startPromptedWorktreeCreation( 171 - repositoryID: Repository.ID, 172 - branchName: String, 173 - baseRef: String? 174 - ) 175 - case promptedWorktreeCreationChecked( 176 - repositoryID: Repository.ID, 177 - branchName: String, 178 - baseRef: String?, 179 - duplicateMessage: String? 180 - ) 181 - case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) 182 - case createRandomWorktreeSucceeded( 183 - Worktree, 184 - repositoryID: Repository.ID, 185 - pendingID: Worktree.ID 186 - ) 187 - case createRandomWorktreeFailed( 188 - title: String, 189 - message: String, 190 - pendingID: Worktree.ID, 191 - previousSelection: Worktree.ID?, 192 - repositoryID: Repository.ID, 193 - name: String?, 194 - baseDirectory: URL 195 - ) 196 - case consumeSetupScript(Worktree.ID) 197 - case consumeTerminalFocus(Worktree.ID) 198 - case requestArchiveWorktree(Worktree.ID, Repository.ID) 199 - case requestArchiveWorktrees([ArchiveWorktreeTarget]) 200 - case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 201 - case archiveScriptProgressUpdated(worktreeID: Worktree.ID, progress: ArchiveScriptProgress) 202 - case archiveScriptSucceeded(worktreeID: Worktree.ID, repositoryID: Repository.ID) 203 - case archiveScriptFailed(worktreeID: Worktree.ID, message: String) 204 - case archiveWorktreeApply(Worktree.ID, Repository.ID) 205 - case unarchiveWorktree(Worktree.ID) 206 - case requestDeleteWorktree(Worktree.ID, Repository.ID) 207 - case requestDeleteWorktrees([DeleteWorktreeTarget]) 208 - case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) 209 - case worktreeDeleted( 210 - Worktree.ID, 211 - repositoryID: Repository.ID, 212 - selectionWasRemoved: Bool, 213 - nextSelection: Worktree.ID? 214 - ) 215 - case repositoriesMoved(IndexSet, Int) 216 - case pinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 217 - case unpinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 218 - case deleteWorktreeFailed(String, worktreeID: Worktree.ID) 219 - case requestRemoveRepository(Repository.ID) 220 - case removeFailedRepository(Repository.ID) 221 - case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) 222 - case pinWorktree(Worktree.ID) 223 - case unpinWorktree(Worktree.ID) 224 266 case presentAlert(title: String, message: String) 225 267 case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) 226 - case worktreeNotificationReceived(Worktree.ID) 227 268 case worktreeBranchNameLoaded(worktreeID: Worktree.ID, name: String) 228 269 case worktreeLineChangesLoaded(worktreeID: Worktree.ID, added: Int, removed: Int) 229 - case refreshGithubIntegrationAvailability 230 - case githubIntegrationAvailabilityUpdated(Bool) 231 - case repositoryPullRequestRefreshCompleted(Repository.ID) 232 - case repositoryPullRequestsLoaded( 233 - repositoryID: Repository.ID, 234 - pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] 235 - ) 236 - case setGithubIntegrationEnabled(Bool) 237 - case setAutomaticallyArchiveMergedWorktrees(Bool) 238 - case setMoveNotifiedWorktreeToTop(Bool) 239 - case pullRequestAction(Worktree.ID, PullRequestAction) 240 270 case showToast(StatusToast) 241 271 case dismissToast 242 - case delayedPullRequestRefresh(Worktree.ID) 243 - case openRepositorySettings(Repository.ID) 244 272 case worktreeCreationPrompt(PresentationAction<WorktreeCreationPromptFeature.Action>) 245 273 case alert(PresentationAction<Alert>) 246 274 case delegate(Delegate) ··· 261 289 let repositoryID: Repository.ID 262 290 } 263 291 264 - private struct ApplyRepositoriesResult { 292 + struct ApplyRepositoriesResult { 265 293 let didPrunePinned: Bool 266 294 let didPruneRepositoryOrder: Bool 267 295 let didPruneWorktreeOrder: Bool ··· 307 335 case worktreeCreated(Worktree) 308 336 } 309 337 310 - @Dependency(TerminalClient.self) private var terminalClient 311 - @Dependency(AnalyticsClient.self) private var analyticsClient 312 - @Dependency(GitClientDependency.self) private var gitClient 313 - @Dependency(GithubCLIClient.self) private var githubCLI 314 - @Dependency(GithubIntegrationClient.self) private var githubIntegration 315 - @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 316 - @Dependency(ShellClient.self) private var shellClient 317 - @Dependency(\.uuid) private var uuid 338 + @Dependency(TerminalClient.self) var terminalClient 339 + @Dependency(AnalyticsClient.self) var analyticsClient 340 + @Dependency(GitClientDependency.self) var gitClient 341 + @Dependency(GithubCLIClient.self) var githubCLI 342 + @Dependency(GithubIntegrationClient.self) var githubIntegration 343 + @Dependency(RepositoryPersistenceClient.self) var repositoryPersistence 344 + @Dependency(ShellClient.self) var shellClient 345 + @Dependency(\.uuid) var uuid 318 346 319 347 var body: some Reducer<State, Action> { 320 - Reduce { state, action in 321 - switch action { 322 - case .task: 323 - state.snapshotPersistencePhase = .restoring 324 - return .run { send in 325 - let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 326 - let archived = await repositoryPersistence.loadArchivedWorktreeIDs() 327 - let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() 328 - let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() 329 - let worktreeOrderByRepository = 330 - await repositoryPersistence.loadWorktreeOrderByRepository() 331 - let repositorySnapshot = await repositoryPersistence.loadRepositorySnapshot() 332 - await send(.pinnedWorktreeIDsLoaded(pinned)) 333 - await send(.archivedWorktreeIDsLoaded(archived)) 334 - await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) 335 - await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) 336 - await send(.lastFocusedWorktreeIDLoaded(lastFocused)) 337 - await send(.repositorySnapshotLoaded(repositorySnapshot)) 338 - await send(.loadPersistedRepositories) 339 - } 340 - 341 - case .repositorySnapshotLoaded(let repositories): 342 - guard let repositories, !repositories.isEmpty else { 348 + CombineReducers { 349 + Reduce { state, action in 350 + switch action { 351 + case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement: 343 352 return .none 344 - } 345 - state.isRefreshingWorktrees = false 346 - let roots = repositories.map(\.rootURL) 347 - let previousSelection = state.selectedWorktreeID 348 - let previousSelectedWorktree = state.worktree(for: previousSelection) 349 - let incomingRepositories = IdentifiedArray(uniqueElements: repositories) 350 - let repositoriesChanged = incomingRepositories != state.repositories 351 - _ = applyRepositories( 352 - repositories, 353 - roots: roots, 354 - shouldPruneArchivedWorktreeIDs: true, 355 - state: &state, 356 - animated: false 357 - ) 358 - state.repositoryRoots = roots 359 - state.isInitialLoadComplete = true 360 - state.loadFailuresByID = [:] 361 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 362 - let selectionChanged = selectionDidChange( 363 - previousSelectionID: previousSelection, 364 - previousSelectedWorktree: previousSelectedWorktree, 365 - selectedWorktreeID: state.selectedWorktreeID, 366 - selectedWorktree: selectedWorktree 367 - ) 368 - var allEffects: [Effect<Action>] = [] 369 - if repositoriesChanged { 370 - allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 371 - } 372 - if selectionChanged { 373 - allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 374 - } 375 - return .merge(allEffects) 376 353 377 - case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): 378 - state.pinnedWorktreeIDs = pinnedWorktreeIDs 379 - return .none 380 - 381 - case .archivedWorktreeIDsLoaded(let archivedWorktreeIDs): 382 - state.archivedWorktreeIDs = archivedWorktreeIDs 383 - return .none 384 - 385 - case .repositoryOrderIDsLoaded(let repositoryOrderIDs): 386 - state.repositoryOrderIDs = repositoryOrderIDs 387 - return .none 388 - 389 - case .worktreeOrderByRepositoryLoaded(let worktreeOrderByRepository): 390 - state.worktreeOrderByRepository = worktreeOrderByRepository 391 - return .none 392 - 393 - case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): 394 - state.lastFocusedWorktreeID = lastFocusedWorktreeID 395 - if state.launchRestoreMode != .restoreLayout { 396 - state.shouldRestoreLastFocusedWorktree = true 397 - } 398 - return .none 354 + case .task: 355 + state.snapshotPersistencePhase = .restoring 356 + return .run { send in 357 + let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 358 + let archived = await repositoryPersistence.loadArchivedWorktreeIDs() 359 + let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() 360 + let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() 361 + let worktreeOrderByRepository = 362 + await repositoryPersistence.loadWorktreeOrderByRepository() 363 + let repositorySnapshot = await repositoryPersistence.loadRepositorySnapshot() 364 + await send(.pinnedWorktreeIDsLoaded(pinned)) 365 + await send(.archivedWorktreeIDsLoaded(archived)) 366 + await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) 367 + await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) 368 + await send(.lastFocusedWorktreeIDLoaded(lastFocused)) 369 + await send(.repositorySnapshotLoaded(repositorySnapshot)) 370 + await send(.loadPersistedRepositories) 371 + } 399 372 400 - case .setOpenPanelPresented(let isPresented): 401 - state.isOpenPanelPresented = isPresented 402 - return .none 403 - 404 - case .loadPersistedRepositories: 405 - state.alert = nil 406 - state.isRefreshingWorktrees = false 407 - return .run { send in 408 - let entries = await loadPersistedRepositoryEntries() 409 - let roots = entries.map { URL(fileURLWithPath: $0.path) } 410 - let (repositories, failures) = await loadRepositoriesData(entries) 411 - await send( 412 - .repositoriesLoaded( 413 - repositories, 414 - failures: failures, 415 - roots: roots, 416 - animated: false 417 - ) 418 - ) 419 - } 420 - .cancellable(id: CancelID.load, cancelInFlight: true) 421 - 422 - case .refreshWorktrees: 423 - state.isRefreshingWorktrees = true 424 - return .send(.reloadRepositories(animated: false)) 425 - 426 - case .reloadRepositories(let animated): 427 - state.alert = nil 428 - let roots = state.repositoryRoots 429 - guard !roots.isEmpty else { 373 + case .repositorySnapshotLoaded(let repositories): 374 + guard let repositories, !repositories.isEmpty else { 375 + return .none 376 + } 430 377 state.isRefreshingWorktrees = false 431 - return .none 432 - } 433 - return loadRepositories(fallbackRoots: roots, animated: animated) 434 - 435 - case .repositoriesLoaded(let repositories, let failures, let roots, let animated): 436 - state.isRefreshingWorktrees = false 437 - let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 438 - if failures.isEmpty, state.snapshotPersistencePhase != .active { 439 - state.snapshotPersistencePhase = .active 440 - } 441 - let previousSelection = state.selectedWorktreeID 442 - let previousSelectedWorktree = state.worktree(for: previousSelection) 443 - let incomingRepositories = IdentifiedArray(uniqueElements: repositories) 444 - let repositoriesChanged = incomingRepositories != state.repositories 445 - let applyResult = applyRepositories( 446 - repositories, 447 - roots: roots, 448 - shouldPruneArchivedWorktreeIDs: failures.isEmpty, 449 - state: &state, 450 - animated: animated 451 - ) 452 - state.repositoryRoots = roots 453 - state.isInitialLoadComplete = true 454 - state.loadFailuresByID = Dictionary( 455 - uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } 456 - ) 457 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 458 - let selectionChanged = selectionDidChange( 459 - previousSelectionID: previousSelection, 460 - previousSelectedWorktree: previousSelectedWorktree, 461 - selectedWorktreeID: state.selectedWorktreeID, 462 - selectedWorktree: selectedWorktree 463 - ) 464 - var allEffects: [Effect<Action>] = [] 465 - if repositoriesChanged || wasRestoringSnapshot { 466 - allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 467 - } 468 - if selectionChanged { 469 - allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 470 - } 471 - if applyResult.didPrunePinned { 472 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 473 - allEffects.append( 474 - .run { _ in 475 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 476 - }) 477 - } 478 - if applyResult.didPruneRepositoryOrder { 479 - let repositoryOrderIDs = state.repositoryOrderIDs 480 - allEffects.append( 481 - .run { _ in 482 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 483 - }) 484 - } 485 - if applyResult.didPruneWorktreeOrder { 486 - let worktreeOrderByRepository = state.worktreeOrderByRepository 487 - allEffects.append( 488 - .run { _ in 489 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 490 - }) 491 - } 492 - if applyResult.didPruneArchivedWorktreeIDs { 493 - let archivedWorktreeIDs = state.archivedWorktreeIDs 494 - allEffects.append( 495 - .run { _ in 496 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 497 - } 378 + let roots = repositories.map(\.rootURL) 379 + let previousSelection = state.selectedWorktreeID 380 + let previousSelectedWorktree = state.worktree(for: previousSelection) 381 + let incomingRepositories = IdentifiedArray(uniqueElements: repositories) 382 + let repositoriesChanged = incomingRepositories != state.repositories 383 + _ = applyRepositories( 384 + repositories, 385 + roots: roots, 386 + shouldPruneArchivedWorktreeIDs: true, 387 + state: &state, 388 + animated: false 498 389 ) 499 - } 500 - if failures.isEmpty, !wasRestoringSnapshot { 501 - let repositories = Array(state.repositories) 502 - allEffects.append( 503 - .run { _ in 504 - await repositoryPersistence.saveRepositorySnapshot(repositories) 505 - } 390 + state.repositoryRoots = roots 391 + state.isInitialLoadComplete = true 392 + state.loadFailuresByID = [:] 393 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 394 + let selectionChanged = selectionDidChange( 395 + previousSelectionID: previousSelection, 396 + previousSelectedWorktree: previousSelectedWorktree, 397 + selectedWorktreeID: state.selectedWorktreeID, 398 + selectedWorktree: selectedWorktree 506 399 ) 507 - } 508 - return .merge(allEffects) 509 - 510 - case .openRepositories(let urls): 511 - analyticsClient.capture("repository_added", ["count": urls.count]) 512 - state.alert = nil 513 - return .run { send in 514 - let existingEntries = await loadPersistedRepositoryEntries() 515 - var resolvedEntries: [PersistedRepositoryEntry] = [] 516 - var invalidRoots: [String] = [] 517 - var openFailures: [String] = [] 518 - for url in urls { 519 - do { 520 - let root = try await gitClient.repoRoot(url) 521 - resolvedEntries.append( 522 - PersistedRepositoryEntry( 523 - path: root.path(percentEncoded: false), 524 - kind: .git 525 - ) 526 - ) 527 - } catch { 528 - let normalizedPath = url.standardizedFileURL.path(percentEncoded: false) 529 - if normalizedPath.isEmpty { 530 - invalidRoots.append(url.path(percentEncoded: false)) 531 - } else if Self.isNotGitRepositoryError(error) { 532 - resolvedEntries.append( 533 - PersistedRepositoryEntry( 534 - path: normalizedPath, 535 - kind: .plain 536 - ) 537 - ) 538 - } else { 539 - openFailures.append( 540 - Self.openRepositoryFailureMessage( 541 - path: normalizedPath, 542 - error: error 543 - ) 544 - ) 545 - } 546 - } 400 + var allEffects: [Effect<Action>] = [] 401 + if repositoriesChanged { 402 + allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 547 403 } 548 - let mergedEntries = RepositoryEntryNormalizer.normalize(existingEntries + resolvedEntries) 549 - let mergedRoots = mergedEntries.map { URL(fileURLWithPath: $0.path) } 550 - await repositoryPersistence.saveRepositoryEntries(mergedEntries) 551 - let (repositories, failures) = await loadRepositoriesData(mergedEntries) 552 - await send( 553 - .openRepositoriesFinished( 554 - repositories, 555 - failures: failures, 556 - invalidRoots: invalidRoots, 557 - openFailures: openFailures, 558 - roots: mergedRoots 559 - ) 560 - ) 561 - } 562 - .cancellable(id: CancelID.load, cancelInFlight: true) 563 - 564 - case .openRepositoriesFinished( 565 - let repositories, 566 - let failures, 567 - let invalidRoots, 568 - let openFailures, 569 - let roots 570 - ): 571 - state.isRefreshingWorktrees = false 572 - let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 573 - if failures.isEmpty, state.snapshotPersistencePhase != .active { 574 - state.snapshotPersistencePhase = .active 575 - } 576 - let previousSelection = state.selectedWorktreeID 577 - let previousSelectedWorktree = state.worktree(for: previousSelection) 578 - let applyResult = applyRepositories( 579 - repositories, 580 - roots: roots, 581 - shouldPruneArchivedWorktreeIDs: failures.isEmpty, 582 - state: &state, 583 - animated: false 584 - ) 585 - state.repositoryRoots = roots 586 - state.isInitialLoadComplete = true 587 - state.loadFailuresByID = Dictionary( 588 - uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } 589 - ) 590 - let openFailureMessages = invalidRoots.map { "\($0) is not a Git repository." } + openFailures 591 - if !openFailureMessages.isEmpty { 592 - state.alert = messageAlert( 593 - title: "Some folders couldn't be opened", 594 - message: openFailureMessages.joined(separator: "\n") 595 - ) 596 - } 597 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 598 - let selectionChanged = selectionDidChange( 599 - previousSelectionID: previousSelection, 600 - previousSelectedWorktree: previousSelectedWorktree, 601 - selectedWorktreeID: state.selectedWorktreeID, 602 - selectedWorktree: selectedWorktree 603 - ) 604 - var allEffects: [Effect<Action>] = [ 605 - .send(.delegate(.repositoriesChanged(state.repositories))) 606 - ] 607 - if selectionChanged { 608 - allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 609 - } 610 - if applyResult.didPrunePinned { 611 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 612 - allEffects.append( 613 - .run { _ in 614 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 615 - }) 616 - } 617 - if applyResult.didPruneRepositoryOrder { 618 - let repositoryOrderIDs = state.repositoryOrderIDs 619 - allEffects.append( 620 - .run { _ in 621 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 622 - }) 623 - } 624 - if applyResult.didPruneWorktreeOrder { 625 - let worktreeOrderByRepository = state.worktreeOrderByRepository 626 - allEffects.append( 627 - .run { _ in 628 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 629 - }) 630 - } 631 - if applyResult.didPruneArchivedWorktreeIDs { 632 - let archivedWorktreeIDs = state.archivedWorktreeIDs 633 - allEffects.append( 634 - .run { _ in 635 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 636 - } 637 - ) 638 - } 639 - if failures.isEmpty, !wasRestoringSnapshot { 640 - let repositories = Array(state.repositories) 641 - allEffects.append( 642 - .run { _ in 643 - await repositoryPersistence.saveRepositorySnapshot(repositories) 644 - } 645 - ) 646 - } 647 - return .merge(allEffects) 648 - 649 - case .selectArchivedWorktrees: 650 - state.selection = .archivedWorktrees 651 - state.sidebarSelectedWorktreeIDs = [] 652 - return .send(.delegate(.selectedWorktreeChanged(nil))) 653 - 654 - case .selectCanvas: 655 - // Remember the current worktree so toggleCanvas can restore it. 656 - state.preCanvasWorktreeID = state.selectedWorktreeID 657 - state.preCanvasTerminalTargetID = state.selectedTerminalWorktree?.id 658 - state.selection = .canvas 659 - state.sidebarSelectedWorktreeIDs = [] 660 - return .run { _ in 661 - await terminalClient.send(.setCanvasMode(true)) 662 - } 663 - 664 - case .toggleCanvas: 665 - if state.isShowingCanvas { 666 - // Exit canvas: prefer the card focused in canvas, then the worktree 667 - // we came from, then the first available worktree. 668 - let targetID = 669 - terminalClient.canvasFocusedWorktreeID() 670 - ?? state.preCanvasTerminalTargetID 671 - ?? state.preCanvasWorktreeID 672 - ?? state.lastFocusedWorktreeID 673 - ?? state.orderedWorktreeRows().first?.id 674 - guard let targetID else { return .none } 675 - if state.worktree(for: targetID) == nil, 676 - let repository = state.repositories[id: targetID], 677 - repository.kind == .plain 678 - { 679 - state.pendingTerminalFocusWorktreeIDs.insert(targetID) 680 - return .send(.selectRepository(targetID)) 404 + if selectionChanged { 405 + allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 681 406 } 682 - return .send(.selectWorktree(targetID, focusTerminal: true)) 683 - } else { 684 - // Enter canvas if there are any open worktrees. 685 - guard !state.orderedWorktreeRows().isEmpty else { return .none } 686 - return .send(.selectCanvas) 687 - } 407 + return .merge(allEffects) 688 408 689 - case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 690 - let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) 691 - var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) 692 - if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { 693 - nextWorktreeIDs.insert(selectedWorktreeID) 694 - } 695 - state.sidebarSelectedWorktreeIDs = nextWorktreeIDs 696 - return .none 697 - 698 - case .selectRepository(let repositoryID): 699 - guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } 700 - state.selection = .repository(repositoryID) 701 - state.sidebarSelectedWorktreeIDs = [] 702 - return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) 703 - 704 - case .selectWorktree(let worktreeID, let focusTerminal): 705 - setSingleWorktreeSelection(worktreeID, state: &state) 706 - if focusTerminal, let worktreeID { 707 - state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) 708 - } 709 - let selectedWorktree = state.worktree(for: worktreeID) 710 - return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) 711 - 712 - case .selectNextWorktree: 713 - guard let id = state.worktreeID(byOffset: 1) else { return .none } 714 - return .send(.selectWorktree(id)) 715 - 716 - case .selectPreviousWorktree: 717 - guard let id = state.worktreeID(byOffset: -1) else { return .none } 718 - return .send(.selectWorktree(id)) 719 - 720 - case .requestRenameBranch(let worktreeID, let branchName): 721 - guard let worktree = state.worktree(for: worktreeID) else { return .none } 722 - let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) 723 - guard !trimmed.isEmpty else { 724 - state.alert = messageAlert( 725 - title: "Branch name required", 726 - message: "Enter a branch name to rename." 727 - ) 409 + case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): 410 + state.pinnedWorktreeIDs = pinnedWorktreeIDs 728 411 return .none 729 - } 730 - guard !trimmed.contains(where: \.isWhitespace) else { 731 - state.alert = messageAlert( 732 - title: "Branch name invalid", 733 - message: "Branch names can't contain spaces." 734 - ) 735 - return .none 736 - } 737 - if trimmed == worktree.name { 738 - return .none 739 - } 740 - analyticsClient.capture("branch_renamed", nil) 741 - return .run { send in 742 - do { 743 - try await gitClient.renameBranch(worktree.workingDirectory, trimmed) 744 - await send(.reloadRepositories(animated: true)) 745 - } catch { 746 - await send( 747 - .presentAlert( 748 - title: "Unable to rename branch", 749 - message: error.localizedDescription 750 - ) 751 - ) 752 - } 753 - } 754 412 755 - case .createRandomWorktree: 756 - if let selectedRepository = state.selectedRepository, 757 - !selectedRepository.capabilities.supportsWorktrees 758 - { 759 - state.alert = messageAlert( 760 - title: "Unable to create worktree", 761 - message: "This folder doesn't support worktrees." 762 - ) 763 - return .none 764 - } 765 - guard let repository = repositoryForWorktreeCreation(state) else { 766 - let message: String 767 - if state.repositories.isEmpty { 768 - message = "Open a repository to create a worktree." 769 - } else if state.selectedWorktreeID == nil && state.repositories.count > 1 { 770 - message = "Select a worktree to choose which repository to use." 771 - } else { 772 - message = "Unable to resolve a repository for the new worktree." 773 - } 774 - state.alert = messageAlert(title: "Unable to create worktree", message: message) 413 + case .archivedWorktreeIDsLoaded(let archivedWorktreeIDs): 414 + state.archivedWorktreeIDs = archivedWorktreeIDs 775 415 return .none 776 - } 777 - return .send(.createRandomWorktreeInRepository(repository.id)) 778 416 779 - case .createRandomWorktreeInRepository(let repositoryID): 780 - guard let repository = state.repositories[id: repositoryID] else { 781 - state.alert = messageAlert( 782 - title: "Unable to create worktree", 783 - message: "Unable to resolve a repository for the new worktree." 784 - ) 785 - return .none 786 - } 787 - guard repository.capabilities.supportsWorktrees else { 788 - state.alert = messageAlert( 789 - title: "Unable to create worktree", 790 - message: "This folder doesn't support worktrees." 791 - ) 792 - return .none 793 - } 794 - if state.removingRepositoryIDs.contains(repository.id) { 795 - state.alert = messageAlert( 796 - title: "Unable to create worktree", 797 - message: "This repository is being removed." 798 - ) 417 + case .repositoryOrderIDsLoaded(let repositoryOrderIDs): 418 + state.repositoryOrderIDs = repositoryOrderIDs 799 419 return .none 800 - } 801 - @Shared(.settingsFile) var settingsFile 802 - if !settingsFile.global.promptForWorktreeCreation { 803 - return .merge( 804 - .cancel(id: CancelID.worktreePromptLoad), 805 - .send( 806 - .createWorktreeInRepository( 807 - repositoryID: repository.id, 808 - nameSource: .random, 809 - baseRefSource: .repositorySetting 810 - ) 811 - ) 812 - ) 813 - } 814 - @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 815 - let selectedBaseRef = repositorySettings.worktreeBaseRef 816 - let gitClient = gitClient 817 - let rootURL = repository.rootURL 818 - return .run { send in 819 - let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" 820 - guard !Task.isCancelled else { 821 - return 822 - } 823 - let baseRefOptions: [String] 824 - do { 825 - let refs = try await gitClient.branchRefs(rootURL) 826 - guard !Task.isCancelled else { 827 - return 828 - } 829 - var options = refs 830 - if !automaticBaseRef.isEmpty, !options.contains(automaticBaseRef) { 831 - options.append(automaticBaseRef) 832 - } 833 - if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 834 - options.append(selectedBaseRef) 835 - } 836 - baseRefOptions = options.sorted { $0.localizedStandardCompare($1) == .orderedAscending } 837 - } catch { 838 - guard !Task.isCancelled else { 839 - return 840 - } 841 - var options: [String] = [] 842 - if !automaticBaseRef.isEmpty { 843 - options.append(automaticBaseRef) 844 - } 845 - if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 846 - options.append(selectedBaseRef) 847 - } 848 - baseRefOptions = options 849 - } 850 - guard !Task.isCancelled else { 851 - return 852 - } 853 - let automaticBaseRefLabel = 854 - automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 855 - await send( 856 - .promptedWorktreeCreationDataLoaded( 857 - repositoryID: repositoryID, 858 - baseRefOptions: baseRefOptions, 859 - automaticBaseRefLabel: automaticBaseRefLabel, 860 - selectedBaseRef: selectedBaseRef 861 - ) 862 - ) 863 - } 864 - .cancellable(id: CancelID.worktreePromptLoad, cancelInFlight: true) 865 420 866 - case .promptedWorktreeCreationDataLoaded( 867 - let repositoryID, 868 - let baseRefOptions, 869 - let automaticBaseRefLabel, 870 - let selectedBaseRef 871 - ): 872 - guard let repository = state.repositories[id: repositoryID] else { 421 + case .worktreeOrderByRepositoryLoaded(let worktreeOrderByRepository): 422 + state.worktreeOrderByRepository = worktreeOrderByRepository 873 423 return .none 874 - } 875 - state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 876 - repositoryID: repository.id, 877 - repositoryName: repository.name, 878 - automaticBaseRefLabel: automaticBaseRefLabel, 879 - baseRefOptions: baseRefOptions, 880 - branchName: "", 881 - selectedBaseRef: selectedBaseRef, 882 - validationMessage: nil 883 - ) 884 - return .none 885 424 886 - case .worktreeCreationPrompt(.presented(.delegate(.cancel))): 887 - state.worktreeCreationPrompt = nil 888 - return .merge( 889 - .cancel(id: CancelID.worktreePromptLoad), 890 - .cancel(id: CancelID.worktreePromptValidation) 891 - ) 892 - 893 - case .worktreeCreationPrompt( 894 - .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) 895 - ): 896 - return .send( 897 - .startPromptedWorktreeCreation( 898 - repositoryID: repositoryID, 899 - branchName: branchName, 900 - baseRef: baseRef 901 - ) 902 - ) 903 - 904 - case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef): 905 - guard let repository = state.repositories[id: repositoryID] else { 906 - state.worktreeCreationPrompt = nil 907 - state.alert = messageAlert( 908 - title: "Unable to create worktree", 909 - message: "Unable to resolve a repository for the new worktree." 910 - ) 425 + case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): 426 + state.lastFocusedWorktreeID = lastFocusedWorktreeID 427 + if state.launchRestoreMode != .restoreLayout { 428 + state.shouldRestoreLastFocusedWorktree = true 429 + } 911 430 return .none 912 - } 913 - state.worktreeCreationPrompt?.validationMessage = nil 914 - state.worktreeCreationPrompt?.isValidating = true 915 - let normalizedBranchName = branchName.lowercased() 916 - if repository.worktrees.contains(where: { $0.name.lowercased() == normalizedBranchName }) { 917 - state.worktreeCreationPrompt?.isValidating = false 918 - state.worktreeCreationPrompt?.validationMessage = "Branch name already exists." 919 - return .none 920 - } 921 - let gitClient = gitClient 922 - let rootURL = repository.rootURL 923 - return .run { send in 924 - let localBranchNames = (try? await gitClient.localBranchNames(rootURL)) ?? [] 925 - let duplicateMessage = 926 - localBranchNames.contains(normalizedBranchName) 927 - ? "Branch name already exists." 928 - : nil 929 - await send( 930 - .promptedWorktreeCreationChecked( 931 - repositoryID: repositoryID, 932 - branchName: branchName, 933 - baseRef: baseRef, 934 - duplicateMessage: duplicateMessage 935 - ) 936 - ) 937 - } 938 - .cancellable(id: CancelID.worktreePromptValidation, cancelInFlight: true) 939 431 940 - case .promptedWorktreeCreationChecked( 941 - let repositoryID, 942 - let branchName, 943 - let baseRef, 944 - let duplicateMessage 945 - ): 946 - guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { 947 - return .none 948 - } 949 - state.worktreeCreationPrompt?.isValidating = false 950 - if let duplicateMessage { 951 - state.worktreeCreationPrompt?.validationMessage = duplicateMessage 432 + case .setOpenPanelPresented(let isPresented): 433 + state.isOpenPanelPresented = isPresented 952 434 return .none 953 - } 954 - state.worktreeCreationPrompt = nil 955 - return .send( 956 - .createWorktreeInRepository( 957 - repositoryID: repositoryID, 958 - nameSource: .explicit(branchName), 959 - baseRefSource: .explicit(baseRef) 960 - ) 961 - ) 962 435 963 - case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource): 964 - guard let repository = state.repositories[id: repositoryID] else { 965 - state.alert = messageAlert( 966 - title: "Unable to create worktree", 967 - message: "Unable to resolve a repository for the new worktree." 968 - ) 969 - return .none 970 - } 971 - if state.removingRepositoryIDs.contains(repository.id) { 972 - state.alert = messageAlert( 973 - title: "Unable to create worktree", 974 - message: "This repository is being removed." 975 - ) 976 - return .none 977 - } 978 - let previousSelection = state.selectedWorktreeID 979 - let pendingID = "pending:\(uuid().uuidString)" 980 - @Shared(.settingsFile) var settingsFile 981 - @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 982 - let globalDefaultWorktreeBaseDirectoryPath = settingsFile.global.defaultWorktreeBaseDirectoryPath 983 - let worktreeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 984 - for: repository.rootURL, 985 - globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 986 - repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath 987 - ) 988 - let selectedBaseRef = repositorySettings.worktreeBaseRef 989 - let copyIgnoredOnWorktreeCreate = repositorySettings.copyIgnoredOnWorktreeCreate 990 - let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate 991 - state.pendingWorktrees.append( 992 - PendingWorktree( 993 - id: pendingID, 994 - repositoryID: repository.id, 995 - progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 996 - ) 997 - ) 998 - setSingleWorktreeSelection(pendingID, state: &state) 999 - let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) 1000 - let createWorktreeStream = gitClient.createWorktreeStream 1001 - let isValidBranchName = gitClient.isValidBranchName 1002 - return .run { send in 1003 - var newWorktreeName: String? 1004 - var progress = WorktreeCreationProgress(stage: .loadingLocalBranches) 1005 - var progressUpdateThrottle = WorktreeCreationProgressUpdateThrottle( 1006 - stride: worktreeCreationProgressUpdateStride 1007 - ) 1008 - do { 1009 - await send( 1010 - .pendingWorktreeProgressUpdated( 1011 - id: pendingID, 1012 - progress: progress 1013 - ) 1014 - ) 1015 - let branchNames = try await gitClient.localBranchNames(repository.rootURL) 1016 - let existing = existingNames.union(branchNames) 1017 - let name: String 1018 - switch nameSource { 1019 - case .random: 1020 - progress.stage = .choosingWorktreeName 1021 - await send( 1022 - .pendingWorktreeProgressUpdated( 1023 - id: pendingID, 1024 - progress: progress 1025 - ) 1026 - ) 1027 - let generatedName = await MainActor.run { 1028 - WorktreeNameGenerator.nextName(excluding: existing) 1029 - } 1030 - guard let generatedName else { 1031 - let message = 1032 - "All default adjective-animal names are already in use. " 1033 - + "Delete a worktree or rename a branch, then try again." 1034 - await send( 1035 - .createRandomWorktreeFailed( 1036 - title: "No available worktree names", 1037 - message: message, 1038 - pendingID: pendingID, 1039 - previousSelection: previousSelection, 1040 - repositoryID: repository.id, 1041 - name: nil, 1042 - baseDirectory: worktreeBaseDirectory 1043 - ) 1044 - ) 1045 - return 1046 - } 1047 - name = generatedName 1048 - case .explicit(let explicitName): 1049 - let trimmed = explicitName.trimmingCharacters(in: .whitespacesAndNewlines) 1050 - guard !trimmed.isEmpty else { 1051 - await send( 1052 - .createRandomWorktreeFailed( 1053 - title: "Branch name required", 1054 - message: "Enter a branch name to create a worktree.", 1055 - pendingID: pendingID, 1056 - previousSelection: previousSelection, 1057 - repositoryID: repository.id, 1058 - name: nil, 1059 - baseDirectory: worktreeBaseDirectory 1060 - ) 1061 - ) 1062 - return 1063 - } 1064 - guard !trimmed.contains(where: \.isWhitespace) else { 1065 - await send( 1066 - .createRandomWorktreeFailed( 1067 - title: "Branch name invalid", 1068 - message: "Branch names can't contain spaces.", 1069 - pendingID: pendingID, 1070 - previousSelection: previousSelection, 1071 - repositoryID: repository.id, 1072 - name: nil, 1073 - baseDirectory: worktreeBaseDirectory 1074 - ) 1075 - ) 1076 - return 1077 - } 1078 - guard await isValidBranchName(trimmed, repository.rootURL) else { 1079 - await send( 1080 - .createRandomWorktreeFailed( 1081 - title: "Branch name invalid", 1082 - message: "Enter a valid git branch name and try again.", 1083 - pendingID: pendingID, 1084 - previousSelection: previousSelection, 1085 - repositoryID: repository.id, 1086 - name: nil, 1087 - baseDirectory: worktreeBaseDirectory 1088 - ) 1089 - ) 1090 - return 1091 - } 1092 - guard !existing.contains(trimmed.lowercased()) else { 1093 - await send( 1094 - .createRandomWorktreeFailed( 1095 - title: "Branch name already exists", 1096 - message: "Choose a different branch name and try again.", 1097 - pendingID: pendingID, 1098 - previousSelection: previousSelection, 1099 - repositoryID: repository.id, 1100 - name: nil, 1101 - baseDirectory: worktreeBaseDirectory 1102 - ) 1103 - ) 1104 - return 1105 - } 1106 - name = trimmed 1107 - } 1108 - newWorktreeName = name 1109 - progress.worktreeName = name 1110 - progress.stage = .checkingRepositoryMode 1111 - await send( 1112 - .pendingWorktreeProgressUpdated( 1113 - id: pendingID, 1114 - progress: progress 1115 - ) 1116 - ) 1117 - let isBareRepository = (try? await gitClient.isBareRepository(repository.rootURL)) ?? false 1118 - let copyIgnored = isBareRepository ? false : copyIgnoredOnWorktreeCreate 1119 - let copyUntracked = isBareRepository ? false : copyUntrackedOnWorktreeCreate 1120 - progress.stage = .resolvingBaseReference 1121 - await send( 1122 - .pendingWorktreeProgressUpdated( 1123 - id: pendingID, 1124 - progress: progress 1125 - ) 1126 - ) 1127 - let resolvedBaseRef: String 1128 - switch baseRefSource { 1129 - case .repositorySetting: 1130 - if (selectedBaseRef ?? "").isEmpty { 1131 - resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 1132 - } else { 1133 - resolvedBaseRef = selectedBaseRef ?? "" 1134 - } 1135 - case .explicit(let explicitBaseRef): 1136 - if let explicitBaseRef, !explicitBaseRef.isEmpty { 1137 - resolvedBaseRef = explicitBaseRef 1138 - } else { 1139 - resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 1140 - } 1141 - } 1142 - progress.baseRef = resolvedBaseRef 1143 - progress.copyIgnored = copyIgnored 1144 - progress.copyUntracked = copyUntracked 1145 - progress.ignoredFilesToCopyCount = 1146 - copyIgnored ? ((try? await gitClient.ignoredFileCount(repository.rootURL)) ?? 0) : 0 1147 - progress.untrackedFilesToCopyCount = 1148 - copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 1149 - progress.stage = .creatingWorktree 1150 - progress.commandText = worktreeCreateCommand( 1151 - baseDirectoryURL: worktreeBaseDirectory, 1152 - name: name, 1153 - copyIgnored: copyIgnored, 1154 - copyUntracked: copyUntracked, 1155 - baseRef: resolvedBaseRef 1156 - ) 1157 - await send( 1158 - .pendingWorktreeProgressUpdated( 1159 - id: pendingID, 1160 - progress: progress 1161 - ) 1162 - ) 1163 - let stream = createWorktreeStream( 1164 - name, 1165 - repository.rootURL, 1166 - worktreeBaseDirectory, 1167 - copyIgnored, 1168 - copyUntracked, 1169 - resolvedBaseRef 1170 - ) 1171 - for try await event in stream { 1172 - switch event { 1173 - case .outputLine(let outputLine): 1174 - let line = outputLine.text.trimmingCharacters(in: .whitespacesAndNewlines) 1175 - guard !line.isEmpty else { 1176 - continue 1177 - } 1178 - progress.appendOutputLine(line, maxLines: worktreeCreationProgressLineLimit) 1179 - if progressUpdateThrottle.recordLine() { 1180 - await send( 1181 - .pendingWorktreeProgressUpdated( 1182 - id: pendingID, 1183 - progress: progress 1184 - ) 1185 - ) 1186 - } 1187 - case .finished(let newWorktree): 1188 - if progressUpdateThrottle.flush() { 1189 - await send( 1190 - .pendingWorktreeProgressUpdated( 1191 - id: pendingID, 1192 - progress: progress 1193 - ) 1194 - ) 1195 - } 1196 - await send( 1197 - .createRandomWorktreeSucceeded( 1198 - newWorktree, 1199 - repositoryID: repository.id, 1200 - pendingID: pendingID 1201 - ) 1202 - ) 1203 - return 1204 - } 1205 - } 1206 - throw GitClientError.commandFailed( 1207 - command: "wt sw", 1208 - message: "Worktree creation finished without a result." 1209 - ) 1210 - } catch { 1211 - if progressUpdateThrottle.flush() { 1212 - await send( 1213 - .pendingWorktreeProgressUpdated( 1214 - id: pendingID, 1215 - progress: progress 1216 - ) 1217 - ) 1218 - } 436 + case .loadPersistedRepositories: 437 + state.alert = nil 438 + state.isRefreshingWorktrees = false 439 + return .run { send in 440 + let entries = await loadPersistedRepositoryEntries() 441 + let roots = entries.map { URL(fileURLWithPath: $0.path) } 442 + let (repositories, failures) = await loadRepositoriesData(entries) 1219 443 await send( 1220 - .createRandomWorktreeFailed( 1221 - title: "Unable to create worktree", 1222 - message: error.localizedDescription, 1223 - pendingID: pendingID, 1224 - previousSelection: previousSelection, 1225 - repositoryID: repository.id, 1226 - name: newWorktreeName, 1227 - baseDirectory: worktreeBaseDirectory 444 + .repositoriesLoaded( 445 + repositories, 446 + failures: failures, 447 + roots: roots, 448 + animated: false 1228 449 ) 1229 450 ) 1230 451 } 1231 - } 1232 - 1233 - case .worktreeCreationPrompt(.dismiss): 1234 - state.worktreeCreationPrompt = nil 1235 - return .merge( 1236 - .cancel(id: CancelID.worktreePromptLoad), 1237 - .cancel(id: CancelID.worktreePromptValidation) 1238 - ) 1239 - 1240 - case .worktreeCreationPrompt: 1241 - return .none 1242 - 1243 - case .pendingWorktreeProgressUpdated(let id, let progress): 1244 - updatePendingWorktreeProgress(id, progress: progress, state: &state) 1245 - return .none 452 + .cancellable(id: CancelID.load, cancelInFlight: true) 1246 453 1247 - case .createRandomWorktreeSucceeded( 1248 - let worktree, 1249 - let repositoryID, 1250 - let pendingID 1251 - ): 1252 - analyticsClient.capture("worktree_created", nil) 1253 - state.pendingSetupScriptWorktreeIDs.insert(worktree.id) 1254 - state.pendingTerminalFocusWorktreeIDs.insert(worktree.id) 1255 - removePendingWorktree(pendingID, state: &state) 1256 - if state.selection == .worktree(pendingID) { 1257 - setSingleWorktreeSelection(worktree.id, state: &state) 1258 - } 1259 - insertWorktree(worktree, repositoryID: repositoryID, state: &state) 1260 - return .merge( 1261 - .send(.reloadRepositories(animated: false)), 1262 - .send(.delegate(.repositoriesChanged(state.repositories))), 1263 - .send(.delegate(.selectedWorktreeChanged(state.worktree(for: state.selectedWorktreeID)))), 1264 - .send(.delegate(.worktreeCreated(worktree))) 1265 - ) 454 + case .refreshWorktrees: 455 + state.isRefreshingWorktrees = true 456 + return .send(.reloadRepositories(animated: false)) 1266 457 1267 - case .createRandomWorktreeFailed( 1268 - let title, 1269 - let message, 1270 - let pendingID, 1271 - let previousSelection, 1272 - let repositoryID, 1273 - let name, 1274 - let baseDirectory 1275 - ): 1276 - let previousSelectedWorktree = state.worktree(for: previousSelection) 1277 - removePendingWorktree(pendingID, state: &state) 1278 - restoreSelection(previousSelection, pendingID: pendingID, state: &state) 1279 - let cleanup = cleanupFailedWorktree( 1280 - repositoryID: repositoryID, 1281 - name: name, 1282 - baseDirectory: baseDirectory, 1283 - state: &state 1284 - ) 1285 - state.alert = messageAlert(title: title, message: message) 1286 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1287 - let selectionChanged = selectionDidChange( 1288 - previousSelectionID: previousSelection, 1289 - previousSelectedWorktree: previousSelectedWorktree, 1290 - selectedWorktreeID: state.selectedWorktreeID, 1291 - selectedWorktree: selectedWorktree 1292 - ) 1293 - var effects: [Effect<Action>] = [] 1294 - if cleanup.didRemoveWorktree { 1295 - effects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 1296 - } 1297 - if selectionChanged { 1298 - effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1299 - } 1300 - if cleanup.didUpdatePinned { 1301 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1302 - effects.append( 1303 - .run { _ in 1304 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1305 - } 1306 - ) 1307 - } 1308 - if cleanup.didUpdateOrder { 1309 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1310 - effects.append( 1311 - .run { _ in 1312 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1313 - } 1314 - ) 1315 - } 1316 - if let cleanupWorktree = cleanup.worktree { 1317 - let repositoryRootURL = cleanupWorktree.repositoryRootURL 1318 - effects.append( 1319 - .run { send in 1320 - _ = try? await gitClient.removeWorktree(cleanupWorktree, true) 1321 - _ = try? await gitClient.pruneWorktrees(repositoryRootURL) 1322 - await send(.reloadRepositories(animated: true)) 1323 - } 1324 - ) 1325 - } 1326 - return .merge(effects) 1327 - 1328 - case .consumeSetupScript(let id): 1329 - state.pendingSetupScriptWorktreeIDs.remove(id) 1330 - return .none 1331 - 1332 - case .consumeTerminalFocus(let id): 1333 - state.pendingTerminalFocusWorktreeIDs.remove(id) 1334 - return .none 1335 - 1336 - case .requestArchiveWorktree(let worktreeID, let repositoryID): 1337 - if state.removingRepositoryIDs.contains(repositoryID) { 1338 - return .none 1339 - } 1340 - guard let repository = state.repositories[id: repositoryID], 1341 - let worktree = repository.worktrees[id: worktreeID] 1342 - else { 1343 - return .none 1344 - } 1345 - if state.isMainWorktree(worktree) { 1346 - return .none 1347 - } 1348 - if state.deletingWorktreeIDs.contains(worktree.id) { 1349 - return .none 1350 - } 1351 - if state.archivingWorktreeIDs.contains(worktree.id) { 1352 - return .none 1353 - } 1354 - if state.isWorktreeArchived(worktree.id) { 1355 - return .none 1356 - } 1357 - if state.isWorktreeMerged(worktree) { 1358 - return .send(.archiveWorktreeConfirmed(worktree.id, repository.id)) 1359 - } 1360 - state.alert = AlertState { 1361 - TextState("Archive worktree?") 1362 - } actions: { 1363 - ButtonState(role: .destructive, action: .confirmArchiveWorktree(worktree.id, repository.id)) { 1364 - TextState("Archive (⌘↩)") 1365 - } 1366 - ButtonState(role: .cancel) { 1367 - TextState("Cancel") 1368 - } 1369 - } message: { 1370 - TextState("Archive \(worktree.name)?") 1371 - } 1372 - return .none 1373 - 1374 - case .requestArchiveWorktrees(let targets): 1375 - var validTargets: [ArchiveWorktreeTarget] = [] 1376 - var seenWorktreeIDs: Set<Worktree.ID> = [] 1377 - for target in targets { 1378 - guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 1379 - if state.removingRepositoryIDs.contains(target.repositoryID) { 1380 - continue 1381 - } 1382 - guard let repository = state.repositories[id: target.repositoryID], 1383 - let worktree = repository.worktrees[id: target.worktreeID] 1384 - else { 1385 - continue 1386 - } 1387 - if state.isMainWorktree(worktree) 1388 - || state.deletingWorktreeIDs.contains(worktree.id) 1389 - || state.archivingWorktreeIDs.contains(worktree.id) 1390 - || state.isWorktreeArchived(worktree.id) 1391 - { 1392 - continue 1393 - } 1394 - validTargets.append(target) 1395 - } 1396 - guard !validTargets.isEmpty else { 1397 - return .none 1398 - } 1399 - if validTargets.count == 1, let target = validTargets.first { 1400 - return .send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 1401 - } 1402 - let count = validTargets.count 1403 - state.alert = AlertState { 1404 - TextState("Archive \(count) worktrees?") 1405 - } actions: { 1406 - ButtonState(role: .destructive, action: .confirmArchiveWorktrees(validTargets)) { 1407 - TextState("Archive \(count) (⌘↩)") 1408 - } 1409 - ButtonState(role: .cancel) { 1410 - TextState("Cancel") 1411 - } 1412 - } message: { 1413 - TextState("Archive \(count) worktrees?") 1414 - } 1415 - return .none 1416 - 1417 - case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): 1418 - return .send(.archiveWorktreeConfirmed(worktreeID, repositoryID)) 1419 - 1420 - case .alert(.presented(.confirmArchiveWorktrees(let targets))): 1421 - return .merge( 1422 - targets.map { target in 1423 - .send(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID)) 1424 - } 1425 - ) 1426 - 1427 - case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): 1428 - guard let repository = state.repositories[id: repositoryID], 1429 - let worktree = repository.worktrees[id: worktreeID] 1430 - else { 1431 - return .none 1432 - } 1433 - if state.isWorktreeArchived(worktreeID) || state.archivingWorktreeIDs.contains(worktreeID) { 458 + case .reloadRepositories(let animated): 1434 459 state.alert = nil 1435 - return .none 1436 - } 1437 - state.alert = nil 1438 - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1439 - let script = repositorySettings.archiveScript 1440 - let commandText = archiveScriptCommand(script) 1441 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1442 - if trimmed.isEmpty { 1443 - return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1444 - } 1445 - state.archivingWorktreeIDs.insert(worktreeID) 1446 - state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( 1447 - titleText: "Running archive script", 1448 - detailText: "Preparing archive script", 1449 - commandText: commandText 1450 - ) 1451 - let shellClient = shellClient 1452 - let scriptWithEnv = worktree.scriptEnvironmentExportPrefix + script 1453 - return .run { send in 1454 - let envURL = URL(fileURLWithPath: "/usr/bin/env") 1455 - var progress = ArchiveScriptProgress( 1456 - titleText: "Running archive script", 1457 - detailText: "Running archive script", 1458 - commandText: commandText 1459 - ) 1460 - do { 1461 - for try await event in shellClient.runLoginStream( 1462 - envURL, 1463 - ["bash", "-lc", scriptWithEnv], 1464 - worktree.workingDirectory, 1465 - log: false 1466 - ) { 1467 - switch event { 1468 - case .line(let line): 1469 - let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) 1470 - guard !text.isEmpty else { continue } 1471 - progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) 1472 - await send(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress)) 1473 - case .finished: 1474 - await send(.archiveScriptSucceeded(worktreeID: worktreeID, repositoryID: repositoryID)) 1475 - } 1476 - } 1477 - } catch { 1478 - await send(.archiveScriptFailed(worktreeID: worktreeID, message: error.localizedDescription)) 460 + let roots = state.repositoryRoots 461 + guard !roots.isEmpty else { 462 + state.isRefreshingWorktrees = false 463 + return .none 1479 464 } 1480 - } 1481 - .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true) 1482 - 1483 - case .archiveScriptProgressUpdated(let worktreeID, let progress): 1484 - guard state.archivingWorktreeIDs.contains(worktreeID) else { 1485 - return .none 1486 - } 1487 - state.archiveScriptProgressByWorktreeID[worktreeID] = progress 1488 - return .none 1489 - 1490 - case .archiveScriptSucceeded(let worktreeID, let repositoryID): 1491 - guard state.archivingWorktreeIDs.contains(worktreeID) else { 1492 - return .none 1493 - } 1494 - state.archivingWorktreeIDs.remove(worktreeID) 1495 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1496 - return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1497 - 1498 - case .archiveScriptFailed(let worktreeID, let message): 1499 - guard state.archivingWorktreeIDs.contains(worktreeID) else { 1500 - return .none 1501 - } 1502 - state.archivingWorktreeIDs.remove(worktreeID) 1503 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1504 - state.alert = messageAlert(title: "Archive script failed", message: message) 1505 - return .none 1506 - 1507 - case .archiveWorktreeApply(let worktreeID, let repositoryID): 1508 - guard let repository = state.repositories[id: repositoryID], 1509 - let worktree = repository.worktrees[id: worktreeID] 1510 - else { 1511 - return .none 1512 - } 1513 - if state.isWorktreeArchived(worktreeID) { 1514 - state.alert = nil 1515 - return .none 1516 - } 1517 - let previousSelection = state.selectedWorktreeID 1518 - let previousSelectedWorktree = state.worktree(for: previousSelection) 1519 - let selectionWasRemoved = state.selectedWorktreeID == worktree.id 1520 - let nextSelection = 1521 - selectionWasRemoved 1522 - ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) 1523 - : nil 1524 - var didUpdateWorktreeOrder = false 1525 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1526 - withAnimation { 1527 - state.alert = nil 1528 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1529 - if var order = state.worktreeOrderByRepository[repositoryID] { 1530 - order.removeAll { $0 == worktreeID } 1531 - if order.isEmpty { 1532 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1533 - } else { 1534 - state.worktreeOrderByRepository[repositoryID] = order 1535 - } 1536 - didUpdateWorktreeOrder = true 1537 - } 1538 - state.archivedWorktreeIDs.append(worktreeID) 1539 - if selectionWasRemoved { 1540 - let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 1541 - state.selection = nextWorktreeID.map(SidebarSelection.worktree) 1542 - } 1543 - } 1544 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1545 - let repositories = state.repositories 1546 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1547 - let selectionChanged = selectionDidChange( 1548 - previousSelectionID: previousSelection, 1549 - previousSelectedWorktree: previousSelectedWorktree, 1550 - selectedWorktreeID: state.selectedWorktreeID, 1551 - selectedWorktree: selectedWorktree 1552 - ) 1553 - var effects: [Effect<Action>] = [ 1554 - .send(.delegate(.repositoriesChanged(repositories))), 1555 - .run { _ in 1556 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1557 - }, 1558 - ] 1559 - if wasPinned { 1560 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1561 - effects.append( 1562 - .run { _ in 1563 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1564 - } 1565 - ) 1566 - } 1567 - if didUpdateWorktreeOrder { 1568 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1569 - effects.append( 1570 - .run { _ in 1571 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1572 - } 1573 - ) 1574 - } 1575 - if selectionChanged { 1576 - effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1577 - } 1578 - return .merge(effects) 465 + return loadRepositories(fallbackRoots: roots, animated: animated) 1579 466 1580 - case .unarchiveWorktree(let worktreeID): 1581 - if !state.isWorktreeArchived(worktreeID) { 1582 - return .none 1583 - } 1584 - withAnimation { 1585 - state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 1586 - } 1587 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1588 - let repositories = state.repositories 1589 - return .merge( 1590 - .send(.delegate(.repositoriesChanged(repositories))), 1591 - .run { _ in 1592 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 467 + case .repositoriesLoaded(let repositories, let failures, let roots, let animated): 468 + state.isRefreshingWorktrees = false 469 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 470 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 471 + state.snapshotPersistencePhase = .active 1593 472 } 1594 - ) 1595 - 1596 - case .requestDeleteWorktree(let worktreeID, let repositoryID): 1597 - if state.removingRepositoryIDs.contains(repositoryID) { 1598 - return .none 1599 - } 1600 - guard let repository = state.repositories[id: repositoryID], 1601 - let worktree = repository.worktrees[id: worktreeID] 1602 - else { 1603 - return .none 1604 - } 1605 - if state.isMainWorktree(worktree) { 1606 - state.alert = messageAlert( 1607 - title: "Delete not allowed", 1608 - message: "Deleting the main worktree is not allowed." 473 + let previousSelection = state.selectedWorktreeID 474 + let previousSelectedWorktree = state.worktree(for: previousSelection) 475 + let incomingRepositories = IdentifiedArray(uniqueElements: repositories) 476 + let repositoriesChanged = incomingRepositories != state.repositories 477 + let applyResult = applyRepositories( 478 + repositories, 479 + roots: roots, 480 + shouldPruneArchivedWorktreeIDs: failures.isEmpty, 481 + state: &state, 482 + animated: animated 1609 483 ) 1610 - return .none 1611 - } 1612 - if state.archivingWorktreeIDs.contains(worktree.id) { 1613 - return .none 1614 - } 1615 - if state.deletingWorktreeIDs.contains(worktree.id) { 1616 - return .none 1617 - } 1618 - @Shared(.settingsFile) var settingsFile 1619 - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 1620 - let removalMessage = 1621 - deleteBranchOnDeleteWorktree 1622 - ? "This deletes the worktree directory and its local branch." 1623 - : "This deletes the worktree directory and keeps the local branch." 1624 - state.alert = AlertState { 1625 - TextState("🚨 Delete worktree?") 1626 - } actions: { 1627 - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { 1628 - TextState("Delete (⌘↩)") 1629 - } 1630 - ButtonState(role: .cancel) { 1631 - TextState("Cancel") 1632 - } 1633 - } message: { 1634 - TextState("Delete \(worktree.name)? " + removalMessage) 1635 - } 1636 - return .none 1637 - 1638 - case .requestDeleteWorktrees(let targets): 1639 - var validTargets: [DeleteWorktreeTarget] = [] 1640 - var seenWorktreeIDs: Set<Worktree.ID> = [] 1641 - for target in targets { 1642 - guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 1643 - if state.removingRepositoryIDs.contains(target.repositoryID) { 1644 - continue 1645 - } 1646 - guard let repository = state.repositories[id: target.repositoryID], 1647 - let worktree = repository.worktrees[id: target.worktreeID] 1648 - else { 1649 - continue 1650 - } 1651 - if state.isMainWorktree(worktree) 1652 - || state.deletingWorktreeIDs.contains(worktree.id) 1653 - || state.archivingWorktreeIDs.contains(worktree.id) 1654 - { 1655 - continue 1656 - } 1657 - validTargets.append(target) 1658 - } 1659 - guard !validTargets.isEmpty else { 1660 - return .none 1661 - } 1662 - @Shared(.settingsFile) var settingsFile 1663 - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 1664 - let removalMessage = 1665 - deleteBranchOnDeleteWorktree 1666 - ? "This deletes the worktree directories and their local branches." 1667 - : "This deletes the worktree directories and keeps their local branches." 1668 - let count = validTargets.count 1669 - state.alert = AlertState { 1670 - TextState("🚨 Delete \(count) worktrees?") 1671 - } actions: { 1672 - ButtonState(role: .destructive, action: .confirmDeleteWorktrees(validTargets)) { 1673 - TextState("Delete \(count) (⌘↩)") 1674 - } 1675 - ButtonState(role: .cancel) { 1676 - TextState("Cancel") 1677 - } 1678 - } message: { 1679 - TextState("Delete \(count) worktrees? " + removalMessage) 1680 - } 1681 - return .none 1682 - 1683 - case .alert(.presented(.confirmDeleteWorktree(let worktreeID, let repositoryID))): 1684 - return .send(.deleteWorktreeConfirmed(worktreeID, repositoryID)) 1685 - 1686 - case .alert(.presented(.confirmDeleteWorktrees(let targets))): 1687 - return .merge( 1688 - targets.map { target in 1689 - .send(.deleteWorktreeConfirmed(target.worktreeID, target.repositoryID)) 1690 - } 1691 - ) 1692 - 1693 - case .deleteWorktreeConfirmed(let worktreeID, let repositoryID): 1694 - guard let repository = state.repositories[id: repositoryID], 1695 - let worktree = repository.worktrees[id: worktreeID] 1696 - else { 1697 - return .none 1698 - } 1699 - if state.archivingWorktreeIDs.contains(worktree.id) { 1700 - return .none 1701 - } 1702 - if state.deletingWorktreeIDs.contains(worktree.id) { 1703 - return .none 1704 - } 1705 - state.alert = nil 1706 - state.deletingWorktreeIDs.insert(worktree.id) 1707 - let selectionWasRemoved = state.selectedWorktreeID == worktree.id 1708 - let nextSelection = 1709 - selectionWasRemoved 1710 - ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) 1711 - : nil 1712 - @Shared(.settingsFile) var settingsFile 1713 - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 1714 - return .run { send in 1715 - do { 1716 - _ = try await gitClient.removeWorktree( 1717 - worktree, 1718 - deleteBranchOnDeleteWorktree 1719 - ) 1720 - await send( 1721 - .worktreeDeleted( 1722 - worktree.id, 1723 - repositoryID: repository.id, 1724 - selectionWasRemoved: selectionWasRemoved, 1725 - nextSelection: nextSelection 1726 - ) 1727 - ) 1728 - } catch { 1729 - await send(.deleteWorktreeFailed(error.localizedDescription, worktreeID: worktree.id)) 1730 - } 1731 - } 1732 - 1733 - case .worktreeDeleted( 1734 - let worktreeID, 1735 - let repositoryID, 1736 - _, 1737 - let nextSelection 1738 - ): 1739 - analyticsClient.capture("worktree_deleted", nil) 1740 - let previousSelection = state.selectedWorktreeID 1741 - let previousSelectedWorktree = state.worktree(for: previousSelection) 1742 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1743 - var didUpdateWorktreeOrder = false 1744 - let wasArchived = state.isWorktreeArchived(worktreeID) 1745 - withAnimation(.easeOut(duration: 0.2)) { 1746 - state.deletingWorktreeIDs.remove(worktreeID) 1747 - state.archivingWorktreeIDs.remove(worktreeID) 1748 - state.pendingWorktrees.removeAll { $0.id == worktreeID } 1749 - state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1750 - state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1751 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1752 - state.worktreeInfoByID.removeValue(forKey: worktreeID) 1753 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1754 - state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 1755 - if var order = state.worktreeOrderByRepository[repositoryID] { 1756 - order.removeAll { $0 == worktreeID } 1757 - if order.isEmpty { 1758 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1759 - } else { 1760 - state.worktreeOrderByRepository[repositoryID] = order 1761 - } 1762 - didUpdateWorktreeOrder = true 1763 - } 1764 - _ = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) 1765 - let selectionNeedsUpdate = state.selection == .worktree(worktreeID) 1766 - if selectionNeedsUpdate { 1767 - let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 1768 - state.selection = nextWorktreeID.map(SidebarSelection.worktree) 1769 - } 1770 - } 1771 - let roots = state.repositories.map(\.rootURL) 1772 - let repositories = state.repositories 1773 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1774 - let selectionChanged = selectionDidChange( 1775 - previousSelectionID: previousSelection, 1776 - previousSelectedWorktree: previousSelectedWorktree, 1777 - selectedWorktreeID: state.selectedWorktreeID, 1778 - selectedWorktree: selectedWorktree 1779 - ) 1780 - var immediateEffects: [Effect<Action>] = [ 1781 - .send(.delegate(.repositoriesChanged(repositories))) 1782 - ] 1783 - if selectionChanged { 1784 - immediateEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1785 - } 1786 - var followupEffects: [Effect<Action>] = [ 1787 - roots.isEmpty ? .none : .send(.reloadRepositories(animated: true)) 1788 - ] 1789 - if wasPinned { 1790 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1791 - followupEffects.append( 1792 - .run { _ in 1793 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1794 - } 1795 - ) 1796 - } 1797 - if wasArchived { 1798 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1799 - followupEffects.append( 1800 - .run { _ in 1801 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1802 - } 1803 - ) 1804 - } 1805 - if didUpdateWorktreeOrder { 1806 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1807 - followupEffects.append( 1808 - .run { _ in 1809 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1810 - } 1811 - ) 1812 - } 1813 - return .concatenate( 1814 - .merge(immediateEffects), 1815 - .merge(followupEffects) 1816 - ) 1817 - 1818 - case .repositoriesMoved(let offsets, let destination): 1819 - var ordered = state.orderedRepositoryIDs() 1820 - ordered.move(fromOffsets: offsets, toOffset: destination) 1821 - withAnimation(.snappy(duration: 0.2)) { 1822 - state.repositoryOrderIDs = ordered 1823 - } 1824 - let repositoryOrderIDs = state.repositoryOrderIDs 1825 - return .run { _ in 1826 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 1827 - } 1828 - 1829 - case .pinnedWorktreesMoved(let repositoryID, let offsets, let destination): 1830 - guard let repository = state.repositories[id: repositoryID] else { return .none } 1831 - let currentPinned = state.orderedPinnedWorktreeIDs(in: repository) 1832 - guard currentPinned.count > 1 else { return .none } 1833 - var reordered = currentPinned 1834 - reordered.move(fromOffsets: offsets, toOffset: destination) 1835 - withAnimation(.snappy(duration: 0.2)) { 1836 - state.pinnedWorktreeIDs = state.replacingPinnedWorktreeIDs( 1837 - in: repository, 1838 - with: reordered 484 + state.repositoryRoots = roots 485 + state.isInitialLoadComplete = true 486 + state.loadFailuresByID = Dictionary( 487 + uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } 1839 488 ) 1840 - } 1841 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1842 - return .run { _ in 1843 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1844 - } 1845 - 1846 - case .unpinnedWorktreesMoved(let repositoryID, let offsets, let destination): 1847 - guard let repository = state.repositories[id: repositoryID] else { return .none } 1848 - let currentUnpinned = state.orderedUnpinnedWorktreeIDs(in: repository) 1849 - guard currentUnpinned.count > 1 else { return .none } 1850 - var reordered = currentUnpinned 1851 - reordered.move(fromOffsets: offsets, toOffset: destination) 1852 - withAnimation(.snappy(duration: 0.2)) { 1853 - state.worktreeOrderByRepository[repositoryID] = reordered 1854 - } 1855 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1856 - return .run { _ in 1857 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1858 - } 1859 - 1860 - case .deleteWorktreeFailed(let message, let worktreeID): 1861 - state.deletingWorktreeIDs.remove(worktreeID) 1862 - state.alert = messageAlert(title: "Unable to delete worktree", message: message) 1863 - return .none 1864 - 1865 - case .requestRemoveRepository(let repositoryID): 1866 - state.alert = confirmationAlertForRepositoryRemoval(repositoryID: repositoryID, state: state) 1867 - return .none 1868 - 1869 - case .removeFailedRepository(let repositoryID): 1870 - state.alert = nil 1871 - state.loadFailuresByID.removeValue(forKey: repositoryID) 1872 - state.repositoryRoots.removeAll { 1873 - $0.standardizedFileURL.path(percentEncoded: false) == repositoryID 1874 - } 1875 - let remainingRoots = state.repositoryRoots 1876 - return .run { send in 1877 - let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) 1878 - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } 1879 - await repositoryPersistence.saveRepositoryEntries(remainingEntries) 1880 - let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } 1881 - let (repositories, failures) = await loadRepositoriesData(remainingEntries) 1882 - await send( 1883 - .repositoriesLoaded( 1884 - repositories, 1885 - failures: failures, 1886 - roots: roots, 1887 - animated: true 1888 - ) 489 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 490 + let selectionChanged = selectionDidChange( 491 + previousSelectionID: previousSelection, 492 + previousSelectedWorktree: previousSelectedWorktree, 493 + selectedWorktreeID: state.selectedWorktreeID, 494 + selectedWorktree: selectedWorktree 1889 495 ) 1890 - } 1891 - .cancellable(id: CancelID.load, cancelInFlight: true) 1892 - 1893 - case .alert(.presented(.confirmRemoveRepository(let repositoryID))): 1894 - guard let repository = state.repositories[id: repositoryID] else { 1895 - return .none 1896 - } 1897 - if state.removingRepositoryIDs.contains(repository.id) { 1898 - return .none 1899 - } 1900 - state.alert = nil 1901 - state.removingRepositoryIDs.insert(repository.id) 1902 - let selectionWasRemoved = 1903 - state.selectedWorktreeID.map { id in 1904 - repository.worktrees.contains(where: { $0.id == id }) 1905 - } ?? false 1906 - return .send(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved)) 1907 - 1908 - case .repositoryRemoved(let repositoryID, let selectionWasRemoved): 1909 - analyticsClient.capture("repository_removed", nil) 1910 - state.removingRepositoryIDs.remove(repositoryID) 1911 - if selectionWasRemoved { 1912 - state.selection = nil 1913 - state.shouldSelectFirstAfterReload = true 1914 - } 1915 - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1916 - let remainingRoots = state.repositoryRoots 1917 - return .merge( 1918 - .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), 1919 - .run { send in 1920 - let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) 1921 - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } 1922 - await repositoryPersistence.saveRepositoryEntries(remainingEntries) 1923 - let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } 1924 - let (repositories, failures) = await loadRepositoriesData(remainingEntries) 1925 - await send( 1926 - .repositoriesLoaded( 1927 - repositories, 1928 - failures: failures, 1929 - roots: roots, 1930 - animated: true 1931 - ) 1932 - ) 496 + var allEffects: [Effect<Action>] = [] 497 + if repositoriesChanged || wasRestoringSnapshot { 498 + allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 1933 499 } 1934 - .cancellable(id: CancelID.load, cancelInFlight: true) 1935 - ) 1936 - 1937 - case .pinWorktree(let worktreeID): 1938 - if let worktree = state.worktree(for: worktreeID), state.isMainWorktree(worktree) { 1939 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1940 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1941 - var didUpdateWorktreeOrder = false 1942 - if let repositoryID = state.repositoryID(containing: worktreeID), 1943 - var order = state.worktreeOrderByRepository[repositoryID] 1944 - { 1945 - order.removeAll { $0 == worktreeID } 1946 - if order.isEmpty { 1947 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1948 - } else { 1949 - state.worktreeOrderByRepository[repositoryID] = order 1950 - } 1951 - didUpdateWorktreeOrder = true 500 + if selectionChanged { 501 + allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1952 502 } 1953 - var effects: [Effect<Action>] = [] 1954 - if wasPinned { 503 + if applyResult.didPrunePinned { 1955 504 let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1956 - effects.append( 505 + allEffects.append( 1957 506 .run { _ in 1958 507 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1959 - } 1960 - ) 508 + }) 509 + } 510 + if applyResult.didPruneRepositoryOrder { 511 + let repositoryOrderIDs = state.repositoryOrderIDs 512 + allEffects.append( 513 + .run { _ in 514 + await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 515 + }) 1961 516 } 1962 - if didUpdateWorktreeOrder { 517 + if applyResult.didPruneWorktreeOrder { 1963 518 let worktreeOrderByRepository = state.worktreeOrderByRepository 1964 - effects.append( 519 + allEffects.append( 1965 520 .run { _ in 1966 521 await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 522 + }) 523 + } 524 + if applyResult.didPruneArchivedWorktreeIDs { 525 + let archivedWorktreeIDs = state.archivedWorktreeIDs 526 + allEffects.append( 527 + .run { _ in 528 + await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1967 529 } 1968 530 ) 1969 531 } 1970 - return .merge(effects) 1971 - } 1972 - analyticsClient.capture("worktree_pinned", nil) 1973 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1974 - state.pinnedWorktreeIDs.insert(worktreeID, at: 0) 1975 - var didUpdateWorktreeOrder = false 1976 - if let repositoryID = state.repositoryID(containing: worktreeID), 1977 - var order = state.worktreeOrderByRepository[repositoryID] 1978 - { 1979 - order.removeAll { $0 == worktreeID } 1980 - if order.isEmpty { 1981 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1982 - } else { 1983 - state.worktreeOrderByRepository[repositoryID] = order 532 + if failures.isEmpty, !wasRestoringSnapshot { 533 + let repositories = Array(state.repositories) 534 + allEffects.append( 535 + .run { _ in 536 + await repositoryPersistence.saveRepositorySnapshot(repositories) 537 + } 538 + ) 1984 539 } 1985 - didUpdateWorktreeOrder = true 1986 - } 1987 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1988 - var effects: [Effect<Action>] = [ 1989 - .run { _ in 1990 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1991 - }, 1992 - ] 1993 - if didUpdateWorktreeOrder { 1994 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1995 - effects.append( 1996 - .run { _ in 1997 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1998 - } 1999 - ) 2000 - } 2001 - return .merge(effects) 540 + return .merge(allEffects) 2002 541 2003 - case .unpinWorktree(let worktreeID): 2004 - analyticsClient.capture("worktree_unpinned", nil) 2005 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 2006 - var didUpdateWorktreeOrder = false 2007 - if let repositoryID = state.repositoryID(containing: worktreeID) { 2008 - var order = state.worktreeOrderByRepository[repositoryID] ?? [] 2009 - order.removeAll { $0 == worktreeID } 2010 - order.insert(worktreeID, at: 0) 2011 - state.worktreeOrderByRepository[repositoryID] = order 2012 - didUpdateWorktreeOrder = true 2013 - } 2014 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 2015 - var effects: [Effect<Action>] = [ 2016 - .run { _ in 2017 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2018 - }, 2019 - ] 2020 - if didUpdateWorktreeOrder { 2021 - let worktreeOrderByRepository = state.worktreeOrderByRepository 2022 - effects.append( 2023 - .run { _ in 2024 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 2025 - } 2026 - ) 2027 - } 2028 - return .merge(effects) 542 + case .selectArchivedWorktrees: 543 + state.selection = .archivedWorktrees 544 + state.sidebarSelectedWorktreeIDs = [] 545 + return .send(.delegate(.selectedWorktreeChanged(nil))) 2029 546 2030 - case .presentAlert(let title, let message): 2031 - state.alert = messageAlert(title: title, message: message) 2032 - return .none 547 + case .selectCanvas: 548 + // Remember the current worktree so toggleCanvas can restore it. 549 + state.preCanvasWorktreeID = state.selectedWorktreeID 550 + state.preCanvasTerminalTargetID = state.selectedTerminalWorktree?.id 551 + state.selection = .canvas 552 + state.sidebarSelectedWorktreeIDs = [] 553 + return .run { _ in 554 + await terminalClient.send(.setCanvasMode(true)) 555 + } 2033 556 2034 - case .showToast(let toast): 2035 - state.statusToast = toast 2036 - switch toast { 2037 - case .inProgress: 2038 - return .cancel(id: CancelID.toastAutoDismiss) 2039 - case .success, .warning: 2040 - return .run { send in 2041 - try? await ContinuousClock().sleep(for: .seconds(2.5)) 2042 - await send(.dismissToast) 557 + case .toggleCanvas: 558 + if state.isShowingCanvas { 559 + // Exit canvas: prefer the card focused in canvas, then the worktree 560 + // we came from, then the first available worktree. 561 + let targetID = 562 + terminalClient.canvasFocusedWorktreeID() 563 + ?? state.preCanvasTerminalTargetID 564 + ?? state.preCanvasWorktreeID 565 + ?? state.lastFocusedWorktreeID 566 + ?? state.orderedWorktreeRows().first?.id 567 + guard let targetID else { return .none } 568 + if state.worktree(for: targetID) == nil, 569 + let repository = state.repositories[id: targetID], 570 + repository.kind == .plain 571 + { 572 + state.pendingTerminalFocusWorktreeIDs.insert(targetID) 573 + return .send(.selectRepository(targetID)) 574 + } 575 + return .send(.selectWorktree(targetID, focusTerminal: true)) 576 + } else { 577 + // Enter canvas if there are any open worktrees. 578 + guard !state.orderedWorktreeRows().isEmpty else { return .none } 579 + return .send(.selectCanvas) 2043 580 } 2044 - .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true) 2045 - } 2046 581 2047 - case .dismissToast: 2048 - state.statusToast = nil 2049 - return .none 2050 - 2051 - case .delayedPullRequestRefresh(let worktreeID): 2052 - guard let worktree = state.worktree(for: worktreeID), 2053 - let repositoryID = state.repositoryID(containing: worktreeID), 2054 - let repository = state.repositories[id: repositoryID] 2055 - else { 582 + case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 583 + let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) 584 + var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) 585 + if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { 586 + nextWorktreeIDs.insert(selectedWorktreeID) 587 + } 588 + state.sidebarSelectedWorktreeIDs = nextWorktreeIDs 2056 589 return .none 2057 - } 2058 - let repositoryRootURL = worktree.repositoryRootURL 2059 - let worktreeIDs = repository.worktrees.map(\.id) 2060 - return .run { send in 2061 - try? await ContinuousClock().sleep(for: .seconds(2)) 2062 - await send( 2063 - .worktreeInfoEvent( 2064 - .repositoryPullRequestRefresh( 2065 - repositoryRootURL: repositoryRootURL, 2066 - worktreeIDs: worktreeIDs 2067 - ) 2068 - ) 2069 - ) 2070 - } 2071 - .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true) 2072 590 2073 - case .worktreeNotificationReceived(let worktreeID): 2074 - guard let repositoryID = state.repositoryID(containing: worktreeID), 2075 - let repository = state.repositories[id: repositoryID], 2076 - let worktree = repository.worktrees[id: worktreeID] 2077 - else { 2078 - return .none 2079 - } 2080 - if state.isWorktreeArchived(worktree.id) { 2081 - return .none 2082 - } 591 + case .selectRepository(let repositoryID): 592 + guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } 593 + state.selection = .repository(repositoryID) 594 + state.sidebarSelectedWorktreeIDs = [] 595 + return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) 2083 596 2084 - var effects: [Effect<Action>] = [] 2085 - 2086 - if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 2087 - let reordered = reorderedUnpinnedWorktreeIDs( 2088 - for: worktreeID, 2089 - in: repository, 2090 - state: state 2091 - ) 2092 - if state.worktreeOrderByRepository[repositoryID] != reordered { 2093 - withAnimation(.snappy(duration: 0.2)) { 2094 - state.worktreeOrderByRepository[repositoryID] = reordered 2095 - } 2096 - let worktreeOrderByRepository = state.worktreeOrderByRepository 2097 - effects.append( 2098 - .run { _ in 2099 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 2100 - } 2101 - ) 597 + case .selectWorktree(let worktreeID, let focusTerminal): 598 + setSingleWorktreeSelection(worktreeID, state: &state) 599 + if focusTerminal, let worktreeID { 600 + state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) 2102 601 } 2103 - } 602 + let selectedWorktree = state.worktree(for: worktreeID) 603 + return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) 2104 604 2105 - if effects.isEmpty { 2106 - return .none 2107 - } 2108 - return .merge(effects) 605 + case .selectNextWorktree: 606 + guard let id = state.worktreeID(byOffset: 1) else { return .none } 607 + return .send(.selectWorktree(id)) 2109 608 2110 - case .worktreeInfoEvent(let event): 2111 - switch event { 2112 - case .branchChanged(let worktreeID): 2113 - guard let worktree = state.worktree(for: worktreeID) else { 609 + case .selectPreviousWorktree: 610 + guard let id = state.worktreeID(byOffset: -1) else { return .none } 611 + return .send(.selectWorktree(id)) 612 + 613 + case .requestRenameBranch(let worktreeID, let branchName): 614 + guard let worktree = state.worktree(for: worktreeID) else { return .none } 615 + let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) 616 + guard !trimmed.isEmpty else { 617 + state.alert = messageAlert( 618 + title: "Branch name required", 619 + message: "Enter a branch name to rename." 620 + ) 2114 621 return .none 2115 622 } 2116 - let worktreeURL = worktree.workingDirectory 2117 - let gitClient = gitClient 2118 - return .run { send in 2119 - if let name = await gitClient.branchName(worktreeURL) { 2120 - await send(.worktreeBranchNameLoaded(worktreeID: worktreeID, name: name)) 2121 - } 623 + guard !trimmed.contains(where: \.isWhitespace) else { 624 + state.alert = messageAlert( 625 + title: "Branch name invalid", 626 + message: "Branch names can't contain spaces." 627 + ) 628 + return .none 2122 629 } 2123 - case .filesChanged(let worktreeID): 2124 - guard let worktree = state.worktree(for: worktreeID) else { 630 + if trimmed == worktree.name { 2125 631 return .none 2126 632 } 2127 - let worktreeURL = worktree.workingDirectory 2128 - let gitClient = gitClient 633 + analyticsClient.capture("branch_renamed", nil) 2129 634 return .run { send in 2130 - if let changes = await gitClient.lineChanges(worktreeURL) { 635 + do { 636 + try await gitClient.renameBranch(worktree.workingDirectory, trimmed) 637 + await send(.reloadRepositories(animated: true)) 638 + } catch { 2131 639 await send( 2132 - .worktreeLineChangesLoaded( 2133 - worktreeID: worktreeID, 2134 - added: changes.added, 2135 - removed: changes.removed 640 + .presentAlert( 641 + title: "Unable to rename branch", 642 + message: error.localizedDescription 2136 643 ) 2137 644 ) 2138 645 } 2139 646 } 2140 - case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): 2141 - let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) } 2142 - guard let firstWorktree = worktrees.first, 2143 - let repositoryID = state.repositoryID(containing: firstWorktree.id) 2144 - else { 2145 - return .none 2146 - } 2147 - var seen = Set<String>() 2148 - let branches = 2149 - worktrees 2150 - .map(\.name) 2151 - .filter { !$0.isEmpty && seen.insert($0).inserted } 2152 - guard !branches.isEmpty else { 2153 - return .none 2154 - } 2155 - switch state.githubIntegrationAvailability { 2156 - case .available: 2157 - if state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) { 2158 - queuePullRequestRefresh( 647 + 648 + case .worktreeCreationPrompt(.presented(.delegate(.cancel))): 649 + return .send(.worktreeCreation(.promptCanceled)) 650 + 651 + case .worktreeCreationPrompt( 652 + .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) 653 + ): 654 + return .send( 655 + .worktreeCreation( 656 + .startPromptedWorktreeCreation( 2159 657 repositoryID: repositoryID, 2160 - repositoryRootURL: repositoryRootURL, 2161 - worktreeIDs: worktreeIDs, 2162 - refreshesByRepositoryID: &state.queuedPullRequestRefreshByRepositoryID 658 + branchName: branchName, 659 + baseRef: baseRef 2163 660 ) 2164 - return .none 2165 - } 2166 - state.inFlightPullRequestRefreshRepositoryIDs.insert(repositoryID) 2167 - return refreshRepositoryPullRequests( 2168 - repositoryID: repositoryID, 2169 - repositoryRootURL: repositoryRootURL, 2170 - worktrees: worktrees, 2171 - branches: branches 2172 661 ) 2173 - case .unknown: 2174 - queuePullRequestRefresh( 2175 - repositoryID: repositoryID, 2176 - repositoryRootURL: repositoryRootURL, 2177 - worktreeIDs: worktreeIDs, 2178 - refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 2179 - ) 2180 - return .send(.refreshGithubIntegrationAvailability) 2181 - case .checking: 2182 - queuePullRequestRefresh( 2183 - repositoryID: repositoryID, 2184 - repositoryRootURL: repositoryRootURL, 2185 - worktreeIDs: worktreeIDs, 2186 - refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 2187 - ) 2188 - return .none 2189 - case .unavailable: 2190 - queuePullRequestRefresh( 2191 - repositoryID: repositoryID, 2192 - repositoryRootURL: repositoryRootURL, 2193 - worktreeIDs: worktreeIDs, 2194 - refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 2195 - ) 2196 - return .none 2197 - case .disabled: 2198 - return .none 2199 - } 2200 - } 662 + ) 2201 663 2202 - case .refreshGithubIntegrationAvailability: 2203 - guard state.githubIntegrationAvailability != .checking, 2204 - state.githubIntegrationAvailability != .disabled 2205 - else { 2206 - return .none 2207 - } 2208 - state.githubIntegrationAvailability = .checking 2209 - let githubIntegration = githubIntegration 2210 - return .run { send in 2211 - let isAvailable = await githubIntegration.isAvailable() 2212 - await send(.githubIntegrationAvailabilityUpdated(isAvailable)) 2213 - } 2214 - .cancellable(id: CancelID.githubIntegrationAvailability, cancelInFlight: true) 664 + case .worktreeCreationPrompt(.dismiss): 665 + return .send(.worktreeCreation(.promptDismissed)) 2215 666 2216 - case .githubIntegrationAvailabilityUpdated(let isAvailable): 2217 - guard state.githubIntegrationAvailability != .disabled else { 667 + case .worktreeCreationPrompt: 2218 668 return .none 2219 - } 2220 - state.githubIntegrationAvailability = isAvailable ? .available : .unavailable 2221 - guard isAvailable else { 2222 - for (repositoryID, queued) in state.queuedPullRequestRefreshByRepositoryID { 2223 - queuePullRequestRefresh( 2224 - repositoryID: repositoryID, 2225 - repositoryRootURL: queued.repositoryRootURL, 2226 - worktreeIDs: queued.worktreeIDs, 2227 - refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID 2228 - ) 2229 - } 2230 - state.queuedPullRequestRefreshByRepositoryID.removeAll() 2231 - state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 2232 - return .run { send in 2233 - while !Task.isCancelled { 2234 - try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) 2235 - guard !Task.isCancelled else { 2236 - return 2237 - } 2238 - await send(.refreshGithubIntegrationAvailability) 2239 - } 2240 - } 2241 - .cancellable(id: CancelID.githubIntegrationRecovery, cancelInFlight: true) 2242 - } 2243 - let pendingRefreshes = state.pendingPullRequestRefreshByRepositoryID.values.sorted { 2244 - $0.repositoryRootURL.path(percentEncoded: false) 2245 - < $1.repositoryRootURL.path(percentEncoded: false) 2246 - } 2247 - state.pendingPullRequestRefreshByRepositoryID.removeAll() 2248 - return .merge( 2249 - .cancel(id: CancelID.githubIntegrationRecovery), 2250 - .merge( 2251 - pendingRefreshes.map { pending in 2252 - .send( 2253 - .worktreeInfoEvent( 2254 - .repositoryPullRequestRefresh( 2255 - repositoryRootURL: pending.repositoryRootURL, 2256 - worktreeIDs: pending.worktreeIDs 2257 - ) 2258 - ) 2259 - ) 2260 - } 2261 - ) 2262 - ) 2263 669 2264 - case .repositoryPullRequestRefreshCompleted(let repositoryID): 2265 - state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) 2266 - guard state.githubIntegrationAvailability == .available, 2267 - let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( 2268 - forKey: repositoryID 2269 - ) 2270 - else { 2271 - return .none 2272 - } 2273 - return .send( 2274 - .worktreeInfoEvent( 2275 - .repositoryPullRequestRefresh( 2276 - repositoryRootURL: pending.repositoryRootURL, 2277 - worktreeIDs: pending.worktreeIDs 2278 - ) 2279 - ) 2280 - ) 670 + case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): 671 + return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) 2281 672 2282 - case .worktreeBranchNameLoaded(let worktreeID, let name): 2283 - updateWorktreeName(worktreeID, name: name, state: &state) 2284 - return .none 2285 - 2286 - case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): 2287 - updateWorktreeLineChanges( 2288 - worktreeID: worktreeID, 2289 - added: added, 2290 - removed: removed, 2291 - state: &state 2292 - ) 2293 - return .none 2294 - 2295 - case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): 2296 - guard let repository = state.repositories[id: repositoryID] else { 2297 - return .none 2298 - } 2299 - var archiveWorktreeIDs: [Worktree.ID] = [] 2300 - for worktreeID in pullRequestsByWorktreeID.keys.sorted() { 2301 - guard let worktree = repository.worktrees[id: worktreeID] else { 2302 - continue 2303 - } 2304 - let pullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil 2305 - let previousPullRequest = state.worktreeInfoByID[worktreeID]?.pullRequest 2306 - guard previousPullRequest != pullRequest else { 2307 - continue 2308 - } 2309 - let previousMerged = previousPullRequest?.state == "MERGED" 2310 - let nextMerged = pullRequest?.state == "MERGED" 2311 - updateWorktreePullRequest( 2312 - worktreeID: worktreeID, 2313 - pullRequest: pullRequest, 2314 - state: &state 673 + case .alert(.presented(.confirmArchiveWorktrees(let targets))): 674 + return .merge( 675 + targets.map { target in 676 + .send(.worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) 677 + } 2315 678 ) 2316 - if state.automaticallyArchiveMergedWorktrees, 2317 - !previousMerged, 2318 - nextMerged, 2319 - !state.isMainWorktree(worktree), 2320 - !state.isWorktreeArchived(worktreeID), 2321 - !state.deletingWorktreeIDs.contains(worktreeID) 2322 - { 2323 - archiveWorktreeIDs.append(worktreeID) 2324 - } 2325 - } 2326 - guard !archiveWorktreeIDs.isEmpty else { 2327 - return .none 2328 - } 2329 - return .merge( 2330 - archiveWorktreeIDs.map { worktreeID in 2331 - .send(.archiveWorktreeConfirmed(worktreeID, repositoryID)) 2332 - } 2333 - ) 2334 679 2335 - case .pullRequestAction(let worktreeID, let action): 2336 - guard let worktree = state.worktree(for: worktreeID), 2337 - let repositoryID = state.repositoryID(containing: worktreeID), 2338 - let repository = state.repositories[id: repositoryID], 2339 - let pullRequest = state.worktreeInfo(for: worktreeID)?.pullRequest 2340 - else { 2341 - return .send( 2342 - .presentAlert( 2343 - title: "Pull request not available", 2344 - message: "Prowl could not find a pull request for this worktree." 2345 - ) 2346 - ) 2347 - } 2348 - let repoRoot = worktree.repositoryRootURL 2349 - let worktreeRoot = worktree.workingDirectory 2350 - let pullRequestRefresh = WorktreeInfoWatcherClient.Event.repositoryPullRequestRefresh( 2351 - repositoryRootURL: repoRoot, 2352 - worktreeIDs: repository.worktrees.map(\.id) 2353 - ) 2354 - let branchName = pullRequest.headRefName ?? worktree.name 2355 - let failingCheckDetailsURL = (pullRequest.statusCheckRollup?.checks ?? []).first { 2356 - $0.checkState == .failure && $0.detailsUrl != nil 2357 - }?.detailsUrl 2358 - switch action { 2359 - case .openOnGithub: 2360 - guard let url = URL(string: pullRequest.url) else { 2361 - return .send( 2362 - .presentAlert( 2363 - title: "Invalid pull request URL", 2364 - message: "Prowl could not open the pull request URL." 2365 - ) 2366 - ) 2367 - } 2368 - return .run { @MainActor _ in 2369 - NSWorkspace.shared.open(url) 2370 - } 680 + case .alert(.presented(.confirmDeleteWorktree(let worktreeID, let repositoryID))): 681 + return .send(.worktreeLifecycle(.deleteWorktreeConfirmed(worktreeID, repositoryID))) 2371 682 2372 - case .copyFailingJobURL: 2373 - guard let failingCheckDetailsURL, !failingCheckDetailsURL.isEmpty else { 2374 - return .send( 2375 - .presentAlert( 2376 - title: "Failing check not found", 2377 - message: "Prowl could not find a failing check URL." 2378 - ) 2379 - ) 2380 - } 2381 - return .run { send in 2382 - await MainActor.run { 2383 - NSPasteboard.general.clearContents() 2384 - NSPasteboard.general.setString(failingCheckDetailsURL, forType: .string) 683 + case .alert(.presented(.confirmDeleteWorktrees(let targets))): 684 + return .merge( 685 + targets.map { target in 686 + .send(.worktreeLifecycle(.deleteWorktreeConfirmed(target.worktreeID, target.repositoryID))) 2385 687 } 2386 - await send(.showToast(.success("Failing job URL copied"))) 2387 - } 688 + ) 2388 689 2389 - case .openFailingCheckDetails: 2390 - guard let failingCheckDetailsURL, let url = URL(string: failingCheckDetailsURL) else { 2391 - return .send( 2392 - .presentAlert( 2393 - title: "Failing check not found", 2394 - message: "Prowl could not find a failing check with details." 2395 - ) 2396 - ) 690 + case .alert(.presented(.confirmRemoveRepository(let repositoryID))): 691 + guard let repository = state.repositories[id: repositoryID] else { 692 + return .none 2397 693 } 2398 - return .run { @MainActor _ in 2399 - NSWorkspace.shared.open(url) 694 + if state.removingRepositoryIDs.contains(repository.id) { 695 + return .none 2400 696 } 697 + state.alert = nil 698 + state.removingRepositoryIDs.insert(repository.id) 699 + let selectionWasRemoved = 700 + state.selectedWorktreeID.map { id in 701 + repository.worktrees.contains(where: { $0.id == id }) 702 + } ?? false 703 + return .send( 704 + .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) 2401 705 2402 - case .markReadyForReview: 2403 - let githubCLI = githubCLI 2404 - let githubIntegration = githubIntegration 2405 - return .run { send in 2406 - guard await githubIntegration.isAvailable() else { 2407 - await send( 2408 - .presentAlert( 2409 - title: "GitHub integration unavailable", 2410 - message: "Enable GitHub integration to mark a pull request as ready." 2411 - ) 2412 - ) 2413 - return 2414 - } 2415 - await send(.showToast(.inProgress("Marking PR ready…"))) 2416 - do { 2417 - try await githubCLI.markPullRequestReady(worktreeRoot, pullRequest.number) 2418 - await send(.showToast(.success("Pull request marked ready"))) 2419 - await send(.delayedPullRequestRefresh(worktreeID)) 2420 - } catch { 2421 - await send(.dismissToast) 2422 - await send( 2423 - .presentAlert( 2424 - title: "Failed to mark pull request ready", 2425 - message: error.localizedDescription 2426 - ) 2427 - ) 2428 - } 2429 - } 706 + case .presentAlert(let title, let message): 707 + state.alert = messageAlert(title: title, message: message) 708 + return .none 2430 709 2431 - case .merge: 2432 - let githubCLI = githubCLI 2433 - let githubIntegration = githubIntegration 2434 - return .run { send in 2435 - guard await githubIntegration.isAvailable() else { 2436 - await send( 2437 - .presentAlert( 2438 - title: "GitHub integration unavailable", 2439 - message: "Enable GitHub integration to merge a pull request." 2440 - ) 2441 - ) 2442 - return 2443 - } 2444 - @Shared(.repositorySettings(repoRoot)) var repositorySettings 2445 - let strategy = repositorySettings.pullRequestMergeStrategy 2446 - await send(.showToast(.inProgress("Merging pull request…"))) 2447 - do { 2448 - try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy) 2449 - await send(.showToast(.success("Pull request merged"))) 2450 - await send(.worktreeInfoEvent(pullRequestRefresh)) 2451 - await send(.delayedPullRequestRefresh(worktreeID)) 2452 - } catch { 710 + case .showToast(let toast): 711 + state.statusToast = toast 712 + switch toast { 713 + case .inProgress: 714 + return .cancel(id: CancelID.toastAutoDismiss) 715 + case .success, .warning: 716 + return .run { send in 717 + try? await ContinuousClock().sleep(for: .seconds(2.5)) 2453 718 await send(.dismissToast) 2454 - await send( 2455 - .presentAlert( 2456 - title: "Failed to merge pull request", 2457 - message: error.localizedDescription 2458 - ) 2459 - ) 2460 719 } 720 + .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true) 2461 721 } 2462 722 2463 - case .close: 2464 - let githubCLI = githubCLI 2465 - let githubIntegration = githubIntegration 2466 - return .run { send in 2467 - guard await githubIntegration.isAvailable() else { 2468 - await send( 2469 - .presentAlert( 2470 - title: "GitHub integration unavailable", 2471 - message: "Enable GitHub integration to close a pull request." 2472 - ) 2473 - ) 2474 - return 2475 - } 2476 - await send(.showToast(.inProgress("Closing pull request…"))) 2477 - do { 2478 - try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) 2479 - await send(.showToast(.success("Pull request closed"))) 2480 - await send(.worktreeInfoEvent(pullRequestRefresh)) 2481 - await send(.delayedPullRequestRefresh(worktreeID)) 2482 - } catch { 2483 - await send(.dismissToast) 2484 - await send( 2485 - .presentAlert( 2486 - title: "Failed to close pull request", 2487 - message: error.localizedDescription 2488 - ) 2489 - ) 2490 - } 2491 - } 723 + case .dismissToast: 724 + state.statusToast = nil 725 + return .none 2492 726 2493 - case .copyCiFailureLogs: 2494 - let githubCLI = githubCLI 2495 - let githubIntegration = githubIntegration 2496 - return .run { send in 2497 - guard await githubIntegration.isAvailable() else { 2498 - await send( 2499 - .presentAlert( 2500 - title: "GitHub integration unavailable", 2501 - message: "Enable GitHub integration to copy CI failure logs." 2502 - ) 2503 - ) 2504 - return 727 + case .worktreeInfoEvent(let event): 728 + switch event { 729 + case .branchChanged(let worktreeID): 730 + guard let worktree = state.worktree(for: worktreeID) else { 731 + return .none 2505 732 } 2506 - guard !branchName.isEmpty else { 2507 - await send( 2508 - .presentAlert( 2509 - title: "Branch name unavailable", 2510 - message: "Prowl could not determine the pull request branch." 2511 - ) 2512 - ) 2513 - return 2514 - } 2515 - await send(.showToast(.inProgress("Fetching CI logs…"))) 2516 - do { 2517 - guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { 2518 - await send(.dismissToast) 2519 - await send( 2520 - .presentAlert( 2521 - title: "No workflow runs found", 2522 - message: "Prowl could not find any workflow runs for this branch." 2523 - ) 2524 - ) 2525 - return 2526 - } 2527 - guard run.conclusion?.lowercased() == "failure" else { 2528 - await send(.dismissToast) 2529 - await send( 2530 - .presentAlert( 2531 - title: "No failing workflow run", 2532 - message: "Prowl could not find a failing workflow run to copy logs from." 2533 - ) 2534 - ) 2535 - return 2536 - } 2537 - let failedLogs = try await githubCLI.failedRunLogs(worktreeRoot, run.databaseId) 2538 - let logs = 2539 - if failedLogs.isEmpty { 2540 - try await githubCLI.runLogs(worktreeRoot, run.databaseId) 2541 - } else { 2542 - failedLogs 2543 - } 2544 - guard !logs.isEmpty else { 2545 - await send(.dismissToast) 2546 - await send( 2547 - .presentAlert( 2548 - title: "No CI logs available", 2549 - message: "The workflow run failed but produced no logs." 2550 - ) 2551 - ) 2552 - return 733 + let worktreeURL = worktree.workingDirectory 734 + let gitClient = gitClient 735 + return .run { send in 736 + if let name = await gitClient.branchName(worktreeURL) { 737 + await send(.worktreeBranchNameLoaded(worktreeID: worktreeID, name: name)) 2553 738 } 2554 - await MainActor.run { 2555 - NSPasteboard.general.clearContents() 2556 - NSPasteboard.general.setString(logs, forType: .string) 2557 - } 2558 - await send(.showToast(.success("CI failure logs copied"))) 2559 - } catch { 2560 - await send(.dismissToast) 2561 - await send( 2562 - .presentAlert( 2563 - title: "Failed to copy CI failure logs", 2564 - message: error.localizedDescription 2565 - ) 2566 - ) 2567 739 } 2568 - } 2569 - 2570 - case .rerunFailedJobs: 2571 - let githubCLI = githubCLI 2572 - let githubIntegration = githubIntegration 2573 - return .run { send in 2574 - guard await githubIntegration.isAvailable() else { 2575 - await send( 2576 - .presentAlert( 2577 - title: "GitHub integration unavailable", 2578 - message: "Enable GitHub integration to re-run failed jobs." 2579 - ) 2580 - ) 2581 - return 2582 - } 2583 - guard !branchName.isEmpty else { 2584 - await send( 2585 - .presentAlert( 2586 - title: "Branch name unavailable", 2587 - message: "Prowl could not determine the pull request branch." 2588 - ) 2589 - ) 2590 - return 740 + case .filesChanged(let worktreeID): 741 + guard let worktree = state.worktree(for: worktreeID) else { 742 + return .none 2591 743 } 2592 - await send(.showToast(.inProgress("Re-running failed jobs…"))) 2593 - do { 2594 - guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { 2595 - await send(.dismissToast) 744 + let worktreeURL = worktree.workingDirectory 745 + let gitClient = gitClient 746 + return .run { send in 747 + if let changes = await gitClient.lineChanges(worktreeURL) { 2596 748 await send( 2597 - .presentAlert( 2598 - title: "No workflow runs found", 2599 - message: "Prowl could not find any workflow runs for this branch." 749 + .worktreeLineChangesLoaded( 750 + worktreeID: worktreeID, 751 + added: changes.added, 752 + removed: changes.removed 2600 753 ) 2601 754 ) 2602 - return 2603 755 } 2604 - guard run.conclusion?.lowercased() == "failure" else { 2605 - await send(.dismissToast) 2606 - await send( 2607 - .presentAlert( 2608 - title: "No failing workflow run", 2609 - message: "Prowl could not find a failing workflow run to re-run." 2610 - ) 2611 - ) 2612 - return 2613 - } 2614 - try await githubCLI.rerunFailedJobs(worktreeRoot, run.databaseId) 2615 - await send(.showToast(.success("Failed jobs re-run started"))) 2616 - await send(.delayedPullRequestRefresh(worktreeID)) 2617 - } catch { 2618 - await send(.dismissToast) 2619 - await send( 2620 - .presentAlert( 2621 - title: "Failed to re-run failed jobs", 2622 - message: error.localizedDescription 756 + } 757 + case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): 758 + return .send( 759 + .githubIntegration( 760 + .repositoryPullRequestRefreshRequested( 761 + repositoryRootURL: repositoryRootURL, 762 + worktreeIDs: worktreeIDs 2623 763 ) 2624 764 ) 2625 - } 765 + ) 2626 766 } 2627 - } 2628 767 2629 - case .setGithubIntegrationEnabled(let isEnabled): 2630 - if isEnabled { 2631 - state.githubIntegrationAvailability = .unknown 2632 - state.pendingPullRequestRefreshByRepositoryID.removeAll() 2633 - state.queuedPullRequestRefreshByRepositoryID.removeAll() 2634 - state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 2635 - return .merge( 2636 - .cancel(id: CancelID.githubIntegrationRecovery), 2637 - .send(.refreshGithubIntegrationAvailability) 2638 - ) 2639 - } 2640 - state.githubIntegrationAvailability = .disabled 2641 - state.pendingPullRequestRefreshByRepositoryID.removeAll() 2642 - state.queuedPullRequestRefreshByRepositoryID.removeAll() 2643 - state.inFlightPullRequestRefreshRepositoryIDs.removeAll() 2644 - let worktreeIDs = Array(state.worktreeInfoByID.keys) 2645 - for worktreeID in worktreeIDs { 2646 - updateWorktreePullRequest( 768 + case .worktreeBranchNameLoaded(let worktreeID, let name): 769 + updateWorktreeName(worktreeID, name: name, state: &state) 770 + return .none 771 + 772 + case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): 773 + updateWorktreeLineChanges( 2647 774 worktreeID: worktreeID, 2648 - pullRequest: nil, 775 + added: added, 776 + removed: removed, 2649 777 state: &state 2650 778 ) 2651 - } 2652 - return .merge( 2653 - .cancel(id: CancelID.githubIntegrationAvailability), 2654 - .cancel(id: CancelID.githubIntegrationRecovery) 2655 - ) 779 + return .none 2656 780 2657 - case .setAutomaticallyArchiveMergedWorktrees(let isEnabled): 2658 - state.automaticallyArchiveMergedWorktrees = isEnabled 2659 - return .none 2660 - 2661 - case .setMoveNotifiedWorktreeToTop(let isEnabled): 2662 - state.moveNotifiedWorktreeToTop = isEnabled 2663 - return .none 781 + case .alert(.dismiss): 782 + state.alert = nil 783 + return .none 2664 784 2665 - case .openRepositorySettings(let repositoryID): 2666 - return .send(.delegate(.openRepositorySettings(repositoryID))) 785 + case .alert: 786 + return .none 2667 787 2668 - case .alert(.dismiss): 2669 - state.alert = nil 2670 - return .none 788 + case .delegate: 789 + return .none 790 + } 791 + } 2671 792 2672 - case .alert: 2673 - return .none 2674 - 2675 - case .delegate: 2676 - return .none 2677 - } 793 + worktreeCreationReducer 794 + worktreeLifecycleReducer 795 + worktreeOrderingReducer 796 + githubIntegrationReducer 797 + repositoryManagementReducer 2678 798 } 2679 799 .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { 2680 800 WorktreeCreationPromptFeature() 2681 801 } 2682 802 } 2683 803 2684 - private func refreshRepositoryPullRequests( 804 + func refreshRepositoryPullRequests( 2685 805 repositoryID: Repository.ID, 2686 806 repositoryRootURL: URL, 2687 807 worktrees: [Worktree], ··· 2691 811 let githubCLI = githubCLI 2692 812 return .run { send in 2693 813 guard let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) else { 2694 - await send(.repositoryPullRequestRefreshCompleted(repositoryID)) 814 + await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) 2695 815 return 2696 816 } 2697 817 do { ··· 2706 826 pullRequestsByWorktreeID[worktree.id] = prsByBranch[worktree.name] 2707 827 } 2708 828 await send( 2709 - .repositoryPullRequestsLoaded( 2710 - repositoryID: repositoryID, 2711 - pullRequestsByWorktreeID: pullRequestsByWorktreeID 829 + .githubIntegration( 830 + .repositoryPullRequestsLoaded( 831 + repositoryID: repositoryID, 832 + pullRequestsByWorktreeID: pullRequestsByWorktreeID 833 + ) 2712 834 ) 2713 835 ) 2714 836 } catch { 2715 - await send(.repositoryPullRequestRefreshCompleted(repositoryID)) 837 + await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) 2716 838 return 2717 839 } 2718 - await send(.repositoryPullRequestRefreshCompleted(repositoryID)) 840 + await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) 2719 841 } 2720 842 } 2721 843 2722 - private func loadPersistedRepositoryEntries( 844 + func loadPersistedRepositoryEntries( 2723 845 fallbackRoots: [URL] = [] 2724 846 ) async -> [PersistedRepositoryEntry] { 2725 847 let entries = await repositoryPersistence.loadRepositoryEntries() ··· 2741 863 return await upgradedRepositoryEntriesIfNeeded(resolvedEntries) 2742 864 } 2743 865 2744 - private func upgradedRepositoryEntriesIfNeeded( 866 + func upgradedRepositoryEntriesIfNeeded( 2745 867 _ entries: [PersistedRepositoryEntry] 2746 868 ) async -> [PersistedRepositoryEntry] { 2747 869 let upgradedEntries = await withTaskGroup(of: (Int, PersistedRepositoryEntry).self) { group in ··· 2792 914 return normalizedEntries 2793 915 } 2794 916 2795 - private nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { 917 + nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { 2796 918 guard case GitClientError.commandFailed(_, let message) = error else { 2797 919 return false 2798 920 } 2799 921 return message.localizedCaseInsensitiveContains("not a git repository") 2800 922 } 2801 923 2802 - private nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { 924 + nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { 2803 925 let detail: String 2804 926 if case GitClientError.commandFailed(_, let message) = error, 2805 927 !message.isEmpty ··· 2811 933 return "\(path): \(detail)" 2812 934 } 2813 935 2814 - private func loadRepositories( 936 + func loadRepositories( 2815 937 fallbackRoots: [URL] = [], 2816 938 animated: Bool = false 2817 939 ) -> Effect<Action> { ··· 2841 963 let errorMessage: String? 2842 964 } 2843 965 2844 - private func loadRepositoriesData(_ entries: [PersistedRepositoryEntry]) async -> ([Repository], [LoadFailure]) { 966 + func loadRepositoriesData(_ entries: [PersistedRepositoryEntry]) async -> ([Repository], [LoadFailure]) { 2845 967 let fetchResults = await withTaskGroup(of: WorktreesFetchResult.self) { group in 2846 968 for entry in entries { 2847 969 let gitClient = self.gitClient ··· 2913 1035 return (loaded, failures) 2914 1036 } 2915 1037 2916 - private func applyRepositories( 1038 + func applyRepositories( 2917 1039 _ repositories: [Repository], 2918 1040 roots: [URL], 2919 1041 shouldPruneArchivedWorktreeIDs: Bool, ··· 3011 1133 ) 3012 1134 } 3013 1135 3014 - private func messageAlert(title: String, message: String) -> AlertState<Alert> { 1136 + func messageAlert(title: String, message: String) -> AlertState<Alert> { 3015 1137 AlertState { 3016 1138 TextState(title) 3017 1139 } actions: { ··· 3023 1145 } 3024 1146 } 3025 1147 3026 - private func confirmationAlertForRepositoryRemoval( 1148 + func confirmationAlertForRepositoryRemoval( 3027 1149 repositoryID: Repository.ID, 3028 1150 state: State 3029 1151 ) -> AlertState<Alert>? { ··· 3047 1169 } 3048 1170 } 3049 1171 3050 - private func selectionDidChange( 1172 + func selectionDidChange( 3051 1173 previousSelectionID: Worktree.ID?, 3052 1174 previousSelectedWorktree: Worktree?, 3053 1175 selectedWorktreeID: Worktree.ID?, ··· 3066 1188 } 3067 1189 } 3068 1190 1191 + // Sub-reducers are in separate files: 1192 + // - RepositoriesFeature+WorktreeCreation.swift 1193 + // - RepositoriesFeature+WorktreeLifecycle.swift 1194 + // - RepositoriesFeature+WorktreeOrdering.swift 1195 + // - RepositoriesFeature+GithubIntegration.swift 1196 + // - RepositoriesFeature+RepositoryManagement.swift 1197 + 3069 1198 extension RepositoriesFeature.State { 3070 1199 var selectedWorktreeID: Worktree.ID? { 3071 1200 selection?.worktreeID ··· 3512 1641 } 3513 1642 } 3514 1643 3515 - private struct FailedWorktreeCleanup { 1644 + struct FailedWorktreeCleanup { 3516 1645 let didRemoveWorktree: Bool 3517 1646 let didUpdatePinned: Bool 3518 1647 let didUpdateOrder: Bool 3519 1648 let worktree: Worktree? 3520 1649 } 3521 1650 3522 - private func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) { 1651 + func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) { 3523 1652 state.pendingWorktrees.removeAll { $0.id == id } 3524 1653 } 3525 1654 3526 - private func updatePendingWorktreeProgress( 1655 + func updatePendingWorktreeProgress( 3527 1656 _ id: String, 3528 1657 progress: WorktreeCreationProgress, 3529 1658 state: inout RepositoriesFeature.State ··· 3534 1663 state.pendingWorktrees[index].progress = progress 3535 1664 } 3536 1665 3537 - private func insertWorktree( 1666 + func insertWorktree( 3538 1667 _ worktree: Worktree, 3539 1668 repositoryID: Repository.ID, 3540 1669 state: inout RepositoriesFeature.State ··· 3555 1684 } 3556 1685 3557 1686 @discardableResult 3558 - private func removeWorktree( 1687 + func removeWorktree( 3559 1688 _ worktreeID: Worktree.ID, 3560 1689 repositoryID: Repository.ID, 3561 1690 state: inout RepositoriesFeature.State ··· 3574 1703 return true 3575 1704 } 3576 1705 3577 - private func cleanupFailedWorktree( 1706 + func cleanupFailedWorktree( 3578 1707 repositoryID: Repository.ID, 3579 1708 name: String?, 3580 1709 baseDirectory: URL, ··· 3625 1754 ) 3626 1755 } 3627 1756 3628 - private func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { 1757 + func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { 3629 1758 PathPolicy.contains(path, in: baseDirectory) 3630 1759 } 3631 1760 3632 - private struct WorktreeCleanupStateResult { 1761 + struct WorktreeCleanupStateResult { 3633 1762 let didRemoveWorktree: Bool 3634 1763 let didUpdatePinned: Bool 3635 1764 let didUpdateOrder: Bool 3636 1765 } 3637 1766 3638 - private func cleanupWorktreeState( 1767 + func cleanupWorktreeState( 3639 1768 _ worktreeID: Worktree.ID, 3640 1769 repositoryID: Repository.ID, 3641 1770 state: inout RepositoriesFeature.State ··· 3672 1801 ) 3673 1802 } 3674 1803 3675 - private nonisolated func archiveScriptCommand(_ script: String) -> String { 1804 + nonisolated func archiveScriptCommand(_ script: String) -> String { 3676 1805 let normalized = script.replacing("\n", with: "\\n") 3677 1806 return "bash -lc \(shellQuote(normalized))" 3678 1807 } 3679 1808 3680 - private nonisolated func worktreeCreateCommand( 1809 + nonisolated func worktreeCreateCommand( 3681 1810 baseDirectoryURL: URL, 3682 1811 name: String, 3683 1812 copyIgnored: Bool, ··· 3703 1832 return parts.map(shellQuote).joined(separator: " ") 3704 1833 } 3705 1834 3706 - private nonisolated func shellQuote(_ value: String) -> String { 1835 + nonisolated func shellQuote(_ value: String) -> String { 3707 1836 let needsQuoting = value.contains { character in 3708 1837 character.isWhitespace || character == "\"" || character == "'" || character == "\\" 3709 1838 } ··· 3768 1897 } 3769 1898 } 3770 1899 3771 - private func updateWorktreePullRequest( 1900 + func updateWorktreePullRequest( 3772 1901 worktreeID: Worktree.ID, 3773 1902 pullRequest: GithubPullRequest?, 3774 1903 state: inout RepositoriesFeature.State ··· 3782 1911 } 3783 1912 } 3784 1913 3785 - private func queuePullRequestRefresh( 1914 + func queuePullRequestRefresh( 3786 1915 repositoryID: Repository.ID, 3787 1916 repositoryRootURL: URL, 3788 1917 worktreeIDs: [Worktree.ID], ··· 3802 1931 } 3803 1932 } 3804 1933 3805 - private func reorderedUnpinnedWorktreeIDs( 1934 + func reorderedUnpinnedWorktreeIDs( 3806 1935 for worktreeID: Worktree.ID, 3807 1936 in repository: Repository, 3808 1937 state: RepositoriesFeature.State ··· 3816 1945 return ordered 3817 1946 } 3818 1947 3819 - private func restoreSelection( 1948 + func restoreSelection( 3820 1949 _ id: Worktree.ID?, 3821 1950 pendingID: Worktree.ID, 3822 1951 state: inout RepositoriesFeature.State ··· 3828 1957 ) 3829 1958 } 3830 1959 3831 - private func isSelectionValid( 1960 + func isSelectionValid( 3832 1961 _ id: Worktree.ID?, 3833 1962 state: RepositoriesFeature.State 3834 1963 ) -> Bool { ··· 3851 1980 } 3852 1981 } 3853 1982 3854 - private func setSingleWorktreeSelection( 1983 + func setSingleWorktreeSelection( 3855 1984 _ worktreeID: Worktree.ID?, 3856 1985 state: inout RepositoriesFeature.State 3857 1986 ) { ··· 3863 1992 } 3864 1993 } 3865 1994 3866 - private func repositoryForWorktreeCreation( 1995 + func repositoryForWorktreeCreation( 3867 1996 _ state: RepositoriesFeature.State 3868 1997 ) -> Repository? { 3869 1998 if let selectedRepository = state.selectedRepository, ··· 3984 2113 return false 3985 2114 } 3986 2115 3987 - private func firstAvailableWorktreeID( 2116 + func firstAvailableWorktreeID( 3988 2117 from repositories: [Repository], 3989 2118 state: RepositoriesFeature.State 3990 2119 ) -> Worktree.ID? { ··· 3996 2125 return nil 3997 2126 } 3998 2127 3999 - private func firstAvailableWorktreeID( 2128 + func firstAvailableWorktreeID( 4000 2129 in repositoryID: Repository.ID, 4001 2130 state: RepositoriesFeature.State 4002 2131 ) -> Worktree.ID? { ··· 4006 2135 return state.orderedWorktrees(in: repository).first?.id 4007 2136 } 4008 2137 4009 - private func nextWorktreeID( 2138 + func nextWorktreeID( 4010 2139 afterRemoving worktree: Worktree, 4011 2140 in repository: Repository, 4012 2141 state: RepositoriesFeature.State
+3 -3
supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift
··· 29 29 let deleteWorktreeAction: (() -> Void)? = { 30 30 guard !selectedTargets.isEmpty else { return nil } 31 31 return { 32 - store.send(.requestDeleteWorktrees(selectedTargets)) 32 + store.send(.worktreeLifecycle(.requestDeleteWorktrees(selectedTargets))) 33 33 } 34 34 }() 35 35 let confirmWorktreeAction: (() -> Void)? = { ··· 54 54 worktree: worktree, 55 55 info: store.state.worktreeInfo(for: worktree.id), 56 56 onUnarchive: { 57 - store.send(.unarchiveWorktree(worktree.id)) 57 + store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) 58 58 }, 59 59 onDelete: { 60 - store.send(.requestDeleteWorktree(worktree.id, group.repository.id)) 60 + store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, group.repository.id))) 61 61 } 62 62 ) 63 63 .tag(worktree.id)
+4 -4
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 23 23 repository.kind == .plain 24 24 && state.selectedRepositoryID == repository.id 25 25 let openRepoSettings = { 26 - _ = store.send(.openRepositorySettings(repository.id)) 26 + _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) 27 27 } 28 28 let toggleExpanded = { 29 29 guard !isRemovingRepository else { return } ··· 78 78 } 79 79 .help("Repo Settings ") 80 80 Button("Remove Repository") { 81 - store.send(.requestRemoveRepository(repository.id)) 81 + store.send(.repositoryManagement(.requestRemoveRepository(repository.id))) 82 82 } 83 83 .help("Remove repository ") 84 84 .disabled(isRemovingRepository) ··· 104 104 .disabled(isRemovingRepository) 105 105 if repository.capabilities.supportsWorktrees { 106 106 Button { 107 - store.send(.createRandomWorktreeInRepository(repository.id)) 107 + store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 108 108 } label: { 109 109 Label("New Worktree", systemImage: "plus") 110 110 .labelStyle(.iconOnly) ··· 193 193 } 194 194 .help("Repo Settings ") 195 195 Button("Remove Repository") { 196 - store.send(.requestRemoveRepository(repository.id)) 196 + store.send(.repositoryManagement(.requestRemoveRepository(repository.id))) 197 197 } 198 198 .help("Remove repository ") 199 199 .disabled(isRemovingRepository)
+3 -3
supacode/Features/Repositories/Views/SidebarListView.swift
··· 126 126 store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 127 127 }, 128 128 removeRepository: { 129 - store.send(.removeFailedRepository(repositoryID)) 129 + store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) 130 130 } 131 131 ) 132 132 .padding(.horizontal, 12) ··· 155 155 } 156 156 } 157 157 .onMove { offsets, destination in 158 - store.send(.repositoriesMoved(offsets, destination)) 158 + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 159 159 } 160 160 } 161 161 } ··· 196 196 .dropDestination(for: URL.self) { urls, _ in 197 197 let fileURLs = urls.filter(\.isFileURL) 198 198 guard !fileURLs.isEmpty else { return false } 199 - store.send(.openRepositories(fileURLs)) 199 + store.send(.repositoryManagement(.openRepositories(fileURLs))) 200 200 return true 201 201 } 202 202 .onKeyPress { keyPress in
+4 -4
supacode/Features/Repositories/Views/SidebarView.swift
··· 97 97 guard !targets.isEmpty else { return nil } 98 98 return { 99 99 if targets.count == 1, let target = targets.first { 100 - store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 100 + store.send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) 101 101 } else { 102 - store.send(.requestArchiveWorktrees(targets)) 102 + store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) 103 103 } 104 104 } 105 105 } ··· 119 119 guard !targets.isEmpty else { return nil } 120 120 return { 121 121 if targets.count == 1, let target = targets.first { 122 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 122 + store.send(.worktreeLifecycle(.requestDeleteWorktree(target.worktreeID, target.repositoryID))) 123 123 } else { 124 - store.send(.requestDeleteWorktrees(targets)) 124 + store.send(.worktreeLifecycle(.requestDeleteWorktrees(targets))) 125 125 } 126 126 } 127 127 }
+1 -1
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 221 221 .frame(maxWidth: .infinity, maxHeight: .infinity) 222 222 .onAppear { 223 223 if shouldFocusTerminal { 224 - store.send(.repositories(.consumeTerminalFocus(selectedTerminalWorktree.id))) 224 + store.send(.repositories(.worktreeCreation(.consumeTerminalFocus(selectedTerminalWorktree.id)))) 225 225 } 226 226 } 227 227 } else if let selectedRepository = repositories.selectedRepository {
+9 -9
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 64 64 ) 65 65 } 66 66 .onMove { offsets, destination in 67 - store.send(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 67 + store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 68 68 } 69 69 ForEach(sections.pending) { row in 70 70 rowView( ··· 83 83 ) 84 84 } 85 85 .onMove { offsets, destination in 86 - store.send(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 86 + store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 87 87 } 88 88 } 89 89 ··· 295 295 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) { 296 296 _ = withAnimation(.easeOut(duration: 0.2)) { 297 297 if isPinned { 298 - store.send(.unpinWorktree(worktreeID)) 298 + store.send(.worktreeOrdering(.unpinWorktree(worktreeID))) 299 299 } else { 300 - store.send(.pinWorktree(worktreeID)) 300 + store.send(.worktreeOrdering(.pinWorktree(worktreeID))) 301 301 } 302 302 } 303 303 } 304 304 305 305 private func archiveWorktree(_ worktreeID: Worktree.ID) { 306 - store.send(.requestArchiveWorktree(worktreeID, repository.id)) 306 + store.send(.worktreeLifecycle(.requestArchiveWorktree(worktreeID, repository.id))) 307 307 } 308 308 309 309 private func contextActionRows(for row: WorktreeRowModel) -> [WorktreeRowModel] { ··· 317 317 private func archiveWorktrees(_ targets: [RepositoriesFeature.ArchiveWorktreeTarget]) { 318 318 guard !targets.isEmpty else { return } 319 319 if targets.count == 1, let target = targets.first { 320 - store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 320 + store.send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) 321 321 } else { 322 - store.send(.requestArchiveWorktrees(targets)) 322 + store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) 323 323 } 324 324 } 325 325 326 326 private func deleteWorktrees(_ targets: [RepositoriesFeature.DeleteWorktreeTarget]) { 327 327 guard !targets.isEmpty else { return } 328 328 if targets.count == 1, let target = targets.first { 329 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 329 + store.send(.worktreeLifecycle(.requestDeleteWorktree(target.worktreeID, target.repositoryID))) 330 330 } else { 331 - store.send(.requestDeleteWorktrees(targets)) 331 + store.send(.worktreeLifecycle(.requestDeleteWorktrees(targets))) 332 332 } 333 333 } 334 334
+9 -7
supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift
··· 42 42 guard !worktrees.isEmpty, worktrees.count <= Self.maxWorktrees else { 43 43 return false 44 44 } 45 - guard worktrees.allSatisfy({ 46 - $0.isValid( 47 - maxTabsPerWorktree: Self.maxTabsPerWorktree, 48 - maxSplitNodesPerTab: Self.maxSplitNodesPerTab, 49 - maxSplitDepth: Self.maxSplitDepth 50 - ) 51 - }) else { 45 + guard 46 + worktrees.allSatisfy({ 47 + $0.isValid( 48 + maxTabsPerWorktree: Self.maxTabsPerWorktree, 49 + maxSplitNodesPerTab: Self.maxSplitNodesPerTab, 50 + maxSplitDepth: Self.maxSplitDepth 51 + ) 52 + }) 53 + else { 52 54 return false 53 55 } 54 56 if let selectedWorktreeID {
+6 -4
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 1518 1518 script: String, 1519 1519 environmentExportPrefix: String 1520 1520 ) -> String? { 1521 - guard let input = makeCommandInput( 1522 - script: script, 1523 - environmentExportPrefix: environmentExportPrefix 1524 - ) else { 1521 + guard 1522 + let input = makeCommandInput( 1523 + script: script, 1524 + environmentExportPrefix: environmentExportPrefix 1525 + ) 1526 + else { 1525 1527 return nil 1526 1528 } 1527 1529 return input + "exit\n"
+4 -4
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 44 44 } 45 45 46 46 await store.send(.commandPalette(.delegate(.newWorktree))) 47 - await store.receive(\.repositories.createRandomWorktree) { 47 + await store.receive(\.repositories.worktreeCreation.createRandomWorktree) { 48 48 $0.repositories.alert = expectedAlert 49 49 } 50 50 } ··· 120 120 store.exhaustivity = .off 121 121 122 122 await store.send(.commandPalette(.delegate(.closePullRequest("/tmp/repo/wt-close")))) 123 - await store.receive(\.repositories.pullRequestAction) 123 + await store.receive(\.repositories.githubIntegration.pullRequestAction) 124 124 } 125 125 126 126 @Test(.dependencies) func removeWorktreeDispatchesRequest() async { ··· 155 155 } 156 156 157 157 await store.send(.commandPalette(.delegate(.removeWorktree(worktree.id, repository.id)))) 158 - await store.receive(\.repositories.requestDeleteWorktree) { 158 + await store.receive(\.repositories.worktreeLifecycle.requestDeleteWorktree) { 159 159 $0.repositories.alert = expectedAlert 160 160 } 161 161 } ··· 192 192 } 193 193 194 194 await store.send(.commandPalette(.delegate(.archiveWorktree(worktree.id, repository.id)))) 195 - await store.receive(\.repositories.requestArchiveWorktree) { 195 + await store.receive(\.repositories.worktreeLifecycle.requestArchiveWorktree) { 196 196 $0.repositories.alert = expectedAlert 197 197 } 198 198 }
+2 -1
supacodeTests/AppFeatureCustomCommandTests.swift
··· 196 196 $0.selectedCustomCommands = settings.customCommands 197 197 $0.resolvedKeybindings = KeybindingResolver.resolve( 198 198 schema: .appResolverSchema(customCommands: settings.customCommands), 199 - migratedOverrides: LegacyCustomCommandShortcutMigration 199 + migratedOverrides: 200 + LegacyCustomCommandShortcutMigration 200 201 .migrate(commands: settings.customCommands) 201 202 .overrides 202 203 )
+8 -8
supacodeTests/AppFeatureSettingsChangedTests.swift
··· 17 17 } 18 18 19 19 await store.send(.settings(.delegate(.settingsChanged(settings)))) 20 - await store.receive(\.repositories.setGithubIntegrationEnabled) { 20 + await store.receive(\.repositories.githubIntegration.setGithubIntegrationEnabled) { 21 21 $0.repositories.githubIntegrationAvailability = .disabled 22 22 } 23 - await store.receive(\.repositories.setAutomaticallyArchiveMergedWorktrees) { 23 + await store.receive(\.repositories.githubIntegration.setAutomaticallyArchiveMergedWorktrees) { 24 24 $0.repositories.automaticallyArchiveMergedWorktrees = true 25 25 } 26 - await store.receive(\.repositories.setMoveNotifiedWorktreeToTop) { 26 + await store.receive(\.repositories.worktreeOrdering.setMoveNotifiedWorktreeToTop) { 27 27 $0.repositories.moveNotifiedWorktreeToTop = false 28 28 } 29 29 await store.receive(\.updates.applySettings) { ··· 80 80 $0.settings.keybindingUserOverrides = settings.keybindingUserOverrides 81 81 $0.resolvedKeybindings = expectedResolved 82 82 } 83 - await store.receive(\.repositories.setGithubIntegrationEnabled) 84 - await store.receive(\.repositories.setAutomaticallyArchiveMergedWorktrees) 85 - await store.receive(\.repositories.setMoveNotifiedWorktreeToTop) 83 + await store.receive(\.repositories.githubIntegration.setGithubIntegrationEnabled) 84 + await store.receive(\.repositories.githubIntegration.setAutomaticallyArchiveMergedWorktrees) 85 + await store.receive(\.repositories.worktreeOrdering.setMoveNotifiedWorktreeToTop) 86 86 await store.receive(\.updates.applySettings) { 87 87 $0.updates.didConfigureUpdates = true 88 88 } 89 - await store.receive(\.repositories.refreshGithubIntegrationAvailability) { 89 + await store.receive(\.repositories.githubIntegration.refreshGithubIntegrationAvailability) { 90 90 $0.repositories.githubIntegrationAvailability = .checking 91 91 } 92 - await store.receive(\.repositories.githubIntegrationAvailabilityUpdated) { 92 + await store.receive(\.repositories.githubIntegration.githubIntegrationAvailabilityUpdated) { 93 93 $0.repositories.githubIntegrationAvailability = .available 94 94 $0.repositories.queuedPullRequestRefreshByRepositoryID = [:] 95 95 $0.repositories.inFlightPullRequestRefreshRepositoryIDs = []
+2 -2
supacodeTests/AppFeatureTerminalSetupScriptTests.swift
··· 31 31 32 32 await store.send(.newTerminal) 33 33 await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) 34 - await store.receive(\.repositories.consumeSetupScript) { 34 + await store.receive(\.repositories.worktreeCreation.consumeSetupScript) { 35 35 $0.repositories.pendingSetupScriptWorktreeIDs.remove(worktree.id) 36 36 } 37 37 await store.finish() ··· 102 102 } 103 103 104 104 await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) 105 - await store.receive(\.repositories.consumeSetupScript) { 105 + await store.receive(\.repositories.worktreeCreation.consumeSetupScript) { 106 106 $0.repositories.pendingSetupScriptWorktreeIDs.remove(worktree.id) 107 107 } 108 108 await store.finish()
+12 -10
supacodeTests/CLICommandEnvelopeTests.swift
··· 59 59 @Test func envelopeSendWithSelectorRoundTrips() throws { 60 60 let envelope = CommandEnvelope( 61 61 output: .json, 62 - command: .send(SendInput( 63 - selector: .pane("abc-123"), 64 - text: "hello world", 65 - trailingEnter: false 66 - )) 62 + command: .send( 63 + SendInput( 64 + selector: .pane("abc-123"), 65 + text: "hello world", 66 + trailingEnter: false 67 + )) 67 68 ) 68 69 let data = try JSONEncoder().encode(envelope) 69 70 let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) ··· 79 80 @Test func envelopeKeyWithRepeatRoundTrips() throws { 80 81 let envelope = CommandEnvelope( 81 82 output: .text, 82 - command: .key(KeyInput( 83 - selector: .tab("tab-1"), 84 - token: "enter", 85 - repeatCount: 5 86 - )) 83 + command: .key( 84 + KeyInput( 85 + selector: .tab("tab-1"), 86 + token: "enter", 87 + repeatCount: 5 88 + )) 87 89 ) 88 90 let data = try JSONEncoder().encode(envelope) 89 91 let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data)
+4 -1
supacodeTests/GitAutomaticBaseRefIntegrationTests.swift
··· 51 51 private func runGit(_ arguments: [String]) throws -> String { 52 52 let process = Process() 53 53 process.executableURL = URL(fileURLWithPath: "/usr/bin/git") 54 - process.arguments = ["-c", "core.hooksPath=/dev/null"] + arguments 54 + process.arguments = arguments 55 + var environment = ProcessInfo.processInfo.environment 56 + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" 57 + process.environment = environment 55 58 let pipe = Pipe() 56 59 process.standardOutput = pipe 57 60 process.standardError = pipe
+187 -159
supacodeTests/RepositoriesFeatureTests.swift
··· 448 448 } 449 449 450 450 await store.send( 451 - .openRepositories([ 452 - URL(fileURLWithPath: repoSelection), 453 - URL(fileURLWithPath: plainRoot), 454 - ]) 451 + .repositoryManagement( 452 + .openRepositories([ 453 + URL(fileURLWithPath: repoSelection), 454 + URL(fileURLWithPath: plainRoot), 455 + ])) 455 456 ) 456 - await store.receive(\.openRepositoriesFinished) { 457 + await store.receive(\.repositoryManagement.openRepositoriesFinished) { 457 458 $0.repositories = [gitRepository, plainRepository] 458 459 $0.repositoryRoots = [repoRoot, plainRoot].map { URL(fileURLWithPath: $0) } 459 460 $0.isInitialLoadComplete = true ··· 515 516 } 516 517 517 518 await store.send( 518 - .openRepositories([ 519 - URL(fileURLWithPath: repoSelection), 520 - URL(fileURLWithPath: blockedRoot), 521 - ]) 519 + .repositoryManagement( 520 + .openRepositories([ 521 + URL(fileURLWithPath: repoSelection), 522 + URL(fileURLWithPath: blockedRoot), 523 + ])) 522 524 ) 523 - await store.receive(\.openRepositoriesFinished) { 525 + await store.receive(\.repositoryManagement.openRepositoriesFinished) { 524 526 $0.repositories = [repository] 525 527 $0.repositoryRoots = [repoRoot].map { URL(fileURLWithPath: $0) } 526 528 $0.isInitialLoadComplete = true ··· 826 828 TextState("Open a repository to create a worktree.") 827 829 } 828 830 829 - await store.send(.createRandomWorktree) { 831 + await store.send(.worktreeCreation(.createRandomWorktree)) { 830 832 $0.alert = expectedAlert 831 833 } 832 834 } ··· 857 859 TextState("This folder doesn't support worktrees.") 858 860 } 859 861 860 - await store.send(.createRandomWorktree) { 862 + await store.send(.worktreeCreation(.createRandomWorktree)) { 861 863 $0.alert = expectedAlert 862 864 } 863 865 } ··· 873 875 $0.gitClient.branchRefs = { _ in ["origin/main", "origin/dev"] } 874 876 } 875 877 876 - await store.send(.createRandomWorktreeInRepository(repository.id)) 877 - await store.receive(\.promptedWorktreeCreationDataLoaded) { 878 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 879 + await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { 878 880 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 879 881 repositoryID: repository.id, 880 882 repositoryName: repository.name, ··· 905 907 RepositoriesFeature() 906 908 } 907 909 908 - await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) { 910 + await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) 911 + await store.receive(\.worktreeCreation.promptCanceled) { 909 912 $0.worktreeCreationPrompt = nil 910 913 } 911 914 } ··· 931 934 } 932 935 933 936 await store.send( 934 - .startPromptedWorktreeCreation( 935 - repositoryID: repository.id, 936 - branchName: "feature/existing", 937 - baseRef: nil 938 - ) 937 + .worktreeCreation( 938 + .startPromptedWorktreeCreation( 939 + repositoryID: repository.id, 940 + branchName: "feature/existing", 941 + baseRef: nil 942 + )) 939 943 ) { 940 944 $0.worktreeCreationPrompt?.validationMessage = nil 941 945 $0.worktreeCreationPrompt?.isValidating = true 942 946 } 943 - await store.receive(\.promptedWorktreeCreationChecked) { 947 + await store.receive(\.worktreeCreation.promptedWorktreeCreationChecked) { 944 948 $0.worktreeCreationPrompt?.validationMessage = "Branch name already exists." 945 949 $0.worktreeCreationPrompt?.isValidating = false 946 950 } ··· 991 995 $0.gitClient.branchRefs = { _ in ["origin/main"] } 992 996 } 993 997 994 - await store.send(.createRandomWorktreeInRepository(repoA.id)) 998 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repoA.id))) 995 999 await promptLoadGate.waitUntilArmed() 996 - await store.send(.createRandomWorktreeInRepository(repoB.id)) 1000 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repoB.id))) 997 1001 await promptLoadGate.resume() 998 - await store.receive(\.promptedWorktreeCreationDataLoaded) { 1002 + await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { 999 1003 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 1000 1004 repositoryID: repoB.id, 1001 1005 repositoryName: repoB.name, ··· 1034 1038 } 1035 1039 1036 1040 await store.send( 1037 - .startPromptedWorktreeCreation( 1038 - repositoryID: repository.id, 1039 - branchName: "feature/new-branch", 1040 - baseRef: nil 1041 - ) 1041 + .worktreeCreation( 1042 + .startPromptedWorktreeCreation( 1043 + repositoryID: repository.id, 1044 + branchName: "feature/new-branch", 1045 + baseRef: nil 1046 + )) 1042 1047 ) { 1043 1048 $0.worktreeCreationPrompt?.validationMessage = nil 1044 1049 $0.worktreeCreationPrompt?.isValidating = true 1045 1050 } 1046 - await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) { 1051 + await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) 1052 + await store.receive(\.worktreeCreation.promptCanceled) { 1047 1053 $0.worktreeCreationPrompt = nil 1048 1054 } 1049 1055 await validationClock.advance(by: .seconds(1)) ··· 1074 1080 } 1075 1081 1076 1082 await store.send( 1077 - .createWorktreeInRepository( 1078 - repositoryID: repository.id, 1079 - nameSource: .explicit("../../Desktop"), 1080 - baseRefSource: .repositorySetting 1081 - ) 1083 + .worktreeCreation( 1084 + .createWorktreeInRepository( 1085 + repositoryID: repository.id, 1086 + nameSource: .explicit("../../Desktop"), 1087 + baseRefSource: .repositorySetting 1088 + )) 1082 1089 ) 1083 - await store.receive(\.createRandomWorktreeFailed) { 1090 + await store.receive(\.worktreeCreation.createRandomWorktreeFailed) { 1084 1091 $0.alert = expectedAlert 1085 1092 } 1086 1093 #expect(store.state.pendingWorktrees.isEmpty) ··· 1114 1121 } 1115 1122 1116 1123 await store.send( 1117 - .createRandomWorktreeFailed( 1118 - title: "Unable to create worktree", 1119 - message: "boom", 1120 - pendingID: "pending:1", 1121 - previousSelection: nil, 1122 - repositoryID: repository.id, 1123 - name: "../../Desktop", 1124 - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 1125 - ) 1124 + .worktreeCreation( 1125 + .createRandomWorktreeFailed( 1126 + title: "Unable to create worktree", 1127 + message: "boom", 1128 + pendingID: "pending:1", 1129 + previousSelection: nil, 1130 + repositoryID: repository.id, 1131 + name: "../../Desktop", 1132 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 1133 + )) 1126 1134 ) { 1127 1135 $0.alert = expectedAlert 1128 1136 } ··· 1162 1170 } 1163 1171 store.exhaustivity = .off 1164 1172 1165 - await store.send(.createRandomWorktreeInRepository(repository.id)) 1166 - await store.receive(\.createRandomWorktreeSucceeded) 1173 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1174 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1167 1175 await store.finish() 1168 1176 1169 1177 #expect(store.state.pendingWorktrees.isEmpty) ··· 1214 1222 } 1215 1223 store.exhaustivity = .off 1216 1224 1217 - await store.send(.createRandomWorktreeInRepository(repository.id)) 1218 - await store.receive(\.createRandomWorktreeSucceeded) 1225 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1226 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1219 1227 await store.finish() 1220 1228 1221 1229 let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( ··· 1265 1273 } 1266 1274 store.exhaustivity = .off 1267 1275 1268 - await store.send(.createRandomWorktreeInRepository(repository.id)) 1269 - await store.receive(\.createRandomWorktreeSucceeded) 1276 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1277 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1270 1278 await store.finish() 1271 1279 1272 1280 let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( ··· 1301 1309 } 1302 1310 store.exhaustivity = .off 1303 1311 1304 - await store.send(.createRandomWorktreeInRepository(repository.id)) 1305 - await store.receive(\.createRandomWorktreeFailed) 1312 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1313 + await store.receive(\.worktreeCreation.createRandomWorktreeFailed) 1306 1314 await store.finish() 1307 1315 1308 1316 let expectedAlert = AlertState<RepositoriesFeature.Alert> { ··· 1363 1371 } 1364 1372 1365 1373 await store.send( 1366 - .createRandomWorktreeFailed( 1367 - title: "Unable to create worktree", 1368 - message: "boom", 1369 - pendingID: "pending:test", 1370 - previousSelection: nil, 1371 - repositoryID: repository.id, 1372 - name: "new-branch", 1373 - baseDirectory: createTimeBaseDirectory 1374 - ) 1374 + .worktreeCreation( 1375 + .createRandomWorktreeFailed( 1376 + title: "Unable to create worktree", 1377 + message: "boom", 1378 + pendingID: "pending:test", 1379 + previousSelection: nil, 1380 + repositoryID: repository.id, 1381 + name: "new-branch", 1382 + baseDirectory: createTimeBaseDirectory 1383 + )) 1375 1384 ) { 1376 1385 $0.alert = expectedAlert 1377 1386 } ··· 1421 1430 copyUntracked: true 1422 1431 ) 1423 1432 await store.send( 1424 - .pendingWorktreeProgressUpdated( 1425 - id: pendingID, 1426 - progress: nextProgress 1427 - ) 1433 + .worktreeCreation( 1434 + .pendingWorktreeProgressUpdated( 1435 + id: pendingID, 1436 + progress: nextProgress 1437 + )) 1428 1438 ) { 1429 1439 $0.pendingWorktrees[0].progress = nextProgress 1430 1440 } ··· 1461 1471 } 1462 1472 1463 1473 await store.send( 1464 - .createRandomWorktreeFailed( 1465 - title: "Unable to create worktree", 1466 - message: "boom", 1467 - pendingID: pendingID, 1468 - previousSelection: nil, 1469 - repositoryID: repository.id, 1470 - name: nil, 1471 - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 1472 - ) 1474 + .worktreeCreation( 1475 + .createRandomWorktreeFailed( 1476 + title: "Unable to create worktree", 1477 + message: "boom", 1478 + pendingID: pendingID, 1479 + previousSelection: nil, 1480 + repositoryID: repository.id, 1481 + name: nil, 1482 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 1483 + )) 1473 1484 ) { 1474 1485 $0.pendingWorktrees = [] 1475 1486 $0.selection = nil ··· 1477 1488 } 1478 1489 1479 1490 await store.send( 1480 - .pendingWorktreeProgressUpdated( 1481 - id: pendingID, 1482 - progress: WorktreeCreationProgress(stage: .creatingWorktree) 1483 - ) 1491 + .worktreeCreation( 1492 + .pendingWorktreeProgressUpdated( 1493 + id: pendingID, 1494 + progress: WorktreeCreationProgress(stage: .creatingWorktree) 1495 + )) 1484 1496 ) 1485 1497 #expect(store.state.pendingWorktrees.isEmpty) 1486 1498 } ··· 1505 1517 TextState("Delete \(worktree.name)? This deletes the worktree directory and its local branch.") 1506 1518 } 1507 1519 1508 - await store.send(.requestDeleteWorktree(worktree.id, repository.id)) { 1520 + await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { 1509 1521 $0.alert = expectedAlert 1510 1522 } 1511 1523 } ··· 1528 1540 TextState("Deleting the main worktree is not allowed.") 1529 1541 } 1530 1542 1531 - await store.send(.requestDeleteWorktree(mainWorktree.id, repository.id)) { 1543 + await store.send(.worktreeLifecycle(.requestDeleteWorktree(mainWorktree.id, repository.id))) { 1532 1544 $0.alert = expectedAlert 1533 1545 } 1534 1546 } ··· 1557 1569 TextState("Delete 2 worktrees? This deletes the worktree directories and their local branches.") 1558 1570 } 1559 1571 1560 - await store.send(.requestDeleteWorktrees(targets)) { 1572 + await store.send(.worktreeLifecycle(.requestDeleteWorktrees(targets))) { 1561 1573 $0.alert = expectedAlert 1562 1574 } 1563 1575 } ··· 1582 1594 TextState("Archive \(worktree.name)?") 1583 1595 } 1584 1596 1585 - await store.send(.requestArchiveWorktree(worktree.id, repository.id)) { 1597 + await store.send(.worktreeLifecycle(.requestArchiveWorktree(worktree.id, repository.id))) { 1586 1598 $0.alert = expectedAlert 1587 1599 } 1588 1600 } ··· 1612 1624 TextState("Archive 2 worktrees?") 1613 1625 } 1614 1626 1615 - await store.send(.requestArchiveWorktrees(targets)) { 1627 + await store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) { 1616 1628 $0.alert = expectedAlert 1617 1629 } 1618 1630 } ··· 1641 1653 RepositoriesFeature() 1642 1654 } 1643 1655 1644 - await store.send(.requestArchiveWorktree(featureWorktree.id, repository.id)) 1645 - await store.receive(\.archiveWorktreeConfirmed) 1646 - await store.receive(\.archiveWorktreeApply) { 1656 + await store.send(.worktreeLifecycle(.requestArchiveWorktree(featureWorktree.id, repository.id))) 1657 + await store.receive(\.worktreeLifecycle.archiveWorktreeConfirmed) 1658 + await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { 1647 1659 $0.archivedWorktreeIDs = [featureWorktree.id] 1648 1660 $0.pinnedWorktreeIDs = [] 1649 1661 $0.worktreeOrderByRepository = [:] ··· 1681 1693 } 1682 1694 } 1683 1695 1684 - await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 1696 + await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { 1685 1697 $0.archivingWorktreeIDs = [featureWorktree.id] 1686 1698 $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1687 1699 titleText: "Running archive script", ··· 1689 1701 commandText: "bash -lc 'echo syncing\\necho done'" 1690 1702 ) 1691 1703 } 1692 - await store.receive(\.archiveScriptProgressUpdated) { 1704 + await store.receive(\.worktreeLifecycle.archiveScriptProgressUpdated) { 1693 1705 $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1694 1706 titleText: "Running archive script", 1695 1707 detailText: "syncing", ··· 1697 1709 outputLines: ["syncing"] 1698 1710 ) 1699 1711 } 1700 - await store.receive(\.archiveScriptProgressUpdated) { 1712 + await store.receive(\.worktreeLifecycle.archiveScriptProgressUpdated) { 1701 1713 $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1702 1714 titleText: "Running archive script", 1703 1715 detailText: "done", ··· 1705 1717 outputLines: ["syncing", "done"] 1706 1718 ) 1707 1719 } 1708 - await store.receive(\.archiveScriptSucceeded) { 1720 + await store.receive(\.worktreeLifecycle.archiveScriptSucceeded) { 1709 1721 $0.archivingWorktreeIDs = [] 1710 1722 $0.archiveScriptProgressByWorktreeID = [:] 1711 1723 } 1712 - await store.receive(\.archiveWorktreeApply) { 1724 + await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { 1713 1725 $0.archivedWorktreeIDs = [featureWorktree.id] 1714 1726 } 1715 1727 await store.receive(\.delegate.repositoriesChanged) ··· 1755 1767 TextState("Command failed: bash -lc exit 7\nstderr:\nfail") 1756 1768 } 1757 1769 1758 - await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 1770 + await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { 1759 1771 $0.archivingWorktreeIDs = [featureWorktree.id] 1760 1772 $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1761 1773 titleText: "Running archive script", ··· 1763 1775 commandText: "bash -lc 'exit 7'" 1764 1776 ) 1765 1777 } 1766 - await store.receive(\.archiveScriptFailed) { 1778 + await store.receive(\.worktreeLifecycle.archiveScriptFailed) { 1767 1779 $0.archivingWorktreeIDs = [] 1768 1780 $0.archiveScriptProgressByWorktreeID = [:] 1769 1781 $0.alert = expectedAlert ··· 1784 1796 RepositoriesFeature() 1785 1797 } 1786 1798 1787 - await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1799 + await store.send( 1800 + .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1801 + ) 1788 1802 #expect(store.state.archivedWorktreeIDs.isEmpty) 1789 1803 } 1790 1804 ··· 1801 1815 RepositoriesFeature() 1802 1816 } 1803 1817 1804 - await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure")) 1818 + await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) 1805 1819 #expect(store.state.alert == nil) 1806 1820 #expect(store.state.archivedWorktreeIDs.isEmpty) 1807 1821 } ··· 1838 1852 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1839 1853 #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1840 1854 1841 - await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1855 + await store.send( 1856 + .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1857 + ) 1842 1858 #expect(store.state.archivingWorktreeIDs.isEmpty) 1843 1859 #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1844 1860 } ··· 1875 1891 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1876 1892 #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1877 1893 1878 - await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed")) 1894 + await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) 1879 1895 #expect(store.state.archivingWorktreeIDs.isEmpty) 1880 1896 #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1881 1897 #expect(store.state.alert != nil) ··· 1936 1952 RepositoriesFeature() 1937 1953 } 1938 1954 1939 - await store.send(.worktreeNotificationReceived(featureWorktree.id)) 1955 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureWorktree.id))) 1940 1956 #expect(store.state.statusToast == nil) 1941 1957 } 1942 1958 ··· 1952 1968 RepositoriesFeature() 1953 1969 } 1954 1970 1955 - await store.send(.worktreeNotificationReceived(featureB.id)) { 1971 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { 1956 1972 $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureA.id] 1957 1973 } 1958 1974 #expect(store.state.statusToast == nil) ··· 1971 1987 RepositoriesFeature() 1972 1988 } 1973 1989 1974 - await store.send(.worktreeNotificationReceived(featureB.id)) 1990 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) 1975 1991 #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) 1976 1992 #expect(store.state.statusToast == nil) 1977 1993 } ··· 1983 1999 RepositoriesFeature() 1984 2000 } 1985 2001 1986 - await store.send(.setMoveNotifiedWorktreeToTop(false)) { 2002 + await store.send(.worktreeOrdering(.setMoveNotifiedWorktreeToTop(false))) { 1987 2003 $0.moveNotifiedWorktreeToTop = false 1988 2004 } 1989 2005 } ··· 2140 2156 RepositoriesFeature() 2141 2157 } 2142 2158 2143 - await store.send(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3)) { 2159 + await store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3))) { 2144 2160 $0.worktreeOrderByRepository[repoRoot] = [worktree2.id, worktree3.id, worktree1.id] 2145 2161 } 2146 2162 } ··· 2159 2175 RepositoriesFeature() 2160 2176 } 2161 2177 2162 - await store.send(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0)) { 2178 + await store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0))) { 2163 2179 $0.pinnedWorktreeIDs = [worktreeA2.id, worktreeB1.id, worktreeA1.id] 2164 2180 } 2165 2181 } ··· 2332 2348 } 2333 2349 2334 2350 await store.send( 2335 - .worktreeDeleted( 2336 - removedWorktree.id, 2337 - repositoryID: repository.id, 2338 - selectionWasRemoved: false, 2339 - nextSelection: nil 2340 - ) 2351 + .worktreeLifecycle( 2352 + .worktreeDeleted( 2353 + removedWorktree.id, 2354 + repositoryID: repository.id, 2355 + selectionWasRemoved: false, 2356 + nextSelection: nil 2357 + )) 2341 2358 ) { 2342 2359 $0.deletingWorktreeIDs = [] 2343 2360 $0.pendingSetupScriptWorktreeIDs = [] ··· 2371 2388 } 2372 2389 2373 2390 await store.send( 2374 - .worktreeDeleted( 2375 - removedWorktree.id, 2376 - repositoryID: repository.id, 2377 - selectionWasRemoved: false, 2378 - nextSelection: nil 2379 - ) 2391 + .worktreeLifecycle( 2392 + .worktreeDeleted( 2393 + removedWorktree.id, 2394 + repositoryID: repository.id, 2395 + selectionWasRemoved: false, 2396 + nextSelection: nil 2397 + )) 2380 2398 ) { 2381 2399 $0.deletingWorktreeIDs = [] 2382 2400 $0.repositories = [updatedRepository] ··· 2415 2433 } 2416 2434 2417 2435 await store.send( 2418 - .createRandomWorktreeSucceeded( 2419 - newWorktree, 2420 - repositoryID: repository.id, 2421 - pendingID: pendingID 2422 - ) 2436 + .worktreeCreation( 2437 + .createRandomWorktreeSucceeded( 2438 + newWorktree, 2439 + repositoryID: repository.id, 2440 + pendingID: pendingID 2441 + )) 2423 2442 ) { 2424 2443 $0.pendingSetupScriptWorktreeIDs.insert(newWorktree.id) 2425 2444 $0.pendingTerminalFocusWorktreeIDs.insert(newWorktree.id) ··· 2456 2475 let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name) 2457 2476 2458 2477 await store.send( 2459 - .repositoryPullRequestsLoaded( 2460 - repositoryID: repository.id, 2461 - pullRequestsByWorktreeID: [featureWorktree.id: mergedPullRequest] 2462 - ) 2478 + .githubIntegration( 2479 + .repositoryPullRequestsLoaded( 2480 + repositoryID: repository.id, 2481 + pullRequestsByWorktreeID: [featureWorktree.id: mergedPullRequest] 2482 + )) 2463 2483 ) { 2464 2484 $0.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 2465 2485 addedLines: nil, ··· 2467 2487 pullRequest: mergedPullRequest 2468 2488 ) 2469 2489 } 2470 - await store.receive(\.archiveWorktreeConfirmed) 2471 - await store.receive(\.archiveWorktreeApply) { 2490 + await store.receive(\.worktreeLifecycle.archiveWorktreeConfirmed) 2491 + await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { 2472 2492 $0.archivedWorktreeIDs = [featureWorktree.id] 2473 2493 } 2474 2494 await store.receive(\.delegate.repositoriesChanged) ··· 2486 2506 let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: mainWorktree.name) 2487 2507 2488 2508 await store.send( 2489 - .repositoryPullRequestsLoaded( 2490 - repositoryID: repository.id, 2491 - pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] 2492 - ) 2509 + .githubIntegration( 2510 + .repositoryPullRequestsLoaded( 2511 + repositoryID: repository.id, 2512 + pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] 2513 + )) 2493 2514 ) { 2494 2515 $0.worktreeInfoByID[mainWorktree.id] = WorktreeInfoEntry( 2495 2516 addedLines: nil, ··· 2529 2550 } 2530 2551 store.exhaustivity = .off 2531 2552 2532 - await store.send(.pullRequestAction(featureWorktree.id, .merge)) 2553 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) 2533 2554 await store.receive(\.showToast) { 2534 2555 $0.statusToast = .inProgress("Merging pull request…") 2535 2556 } ··· 2571 2592 } 2572 2593 store.exhaustivity = .off 2573 2594 2574 - await store.send(.pullRequestAction(featureWorktree.id, .close)) 2595 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .close))) 2575 2596 await store.receive(\.showToast) { 2576 2597 $0.statusToast = .inProgress("Closing pull request…") 2577 2598 } ··· 2611 2632 worktreeIDs: [mainWorktree.id, featureWorktree.id] 2612 2633 ) 2613 2634 ) 2614 - ) { 2635 + ) 2636 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { 2615 2637 $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] 2616 2638 } 2617 - await store.receive(\.repositoryPullRequestRefreshCompleted) { 2639 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { 2618 2640 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2619 2641 } 2620 2642 await store.finish() ··· 2650 2672 worktreeIDs: [mainWorktree.id, featureWorktree.id] 2651 2673 ) 2652 2674 ) 2653 - ) { 2675 + ) 2676 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { 2654 2677 $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( 2655 2678 repositoryRootURL: URL(fileURLWithPath: repoRoot), 2656 2679 worktreeIDs: [mainWorktree.id, featureWorktree.id] 2657 2680 ) 2658 2681 } 2659 - await store.receive(\.refreshGithubIntegrationAvailability) { 2682 + await store.receive(\.githubIntegration.refreshGithubIntegrationAvailability) { 2660 2683 $0.githubIntegrationAvailability = .checking 2661 2684 } 2662 - await store.receive(\.githubIntegrationAvailabilityUpdated) { 2685 + await store.receive(\.githubIntegration.githubIntegrationAvailabilityUpdated) { 2663 2686 $0.githubIntegrationAvailability = .unavailable 2664 2687 $0.queuedPullRequestRefreshByRepositoryID = [:] 2665 2688 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2666 2689 } 2667 - await store.send(.setGithubIntegrationEnabled(false)) { 2690 + await store.send(.githubIntegration(.setGithubIntegrationEnabled(false))) { 2668 2691 $0.githubIntegrationAvailability = .disabled 2669 2692 $0.pendingPullRequestRefreshByRepositoryID = [:] 2670 2693 $0.queuedPullRequestRefreshByRepositoryID = [:] ··· 2695 2718 worktreeIDs: [mainWorktree.id, featureWorktree.id] 2696 2719 ) 2697 2720 ) 2698 - ) { 2721 + ) 2722 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { 2699 2723 $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( 2700 2724 repositoryRootURL: URL(fileURLWithPath: repoRoot), 2701 2725 worktreeIDs: [mainWorktree.id, featureWorktree.id] ··· 2729 2753 } 2730 2754 } 2731 2755 2732 - await store.send(.githubIntegrationAvailabilityUpdated(true)) { 2756 + await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(true))) { 2733 2757 $0.githubIntegrationAvailability = .available 2734 2758 $0.pendingPullRequestRefreshByRepositoryID = [:] 2735 2759 } 2736 - await store.receive(\.worktreeInfoEvent) { 2760 + await store.receive(\.worktreeInfoEvent) 2761 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { 2737 2762 $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] 2738 2763 } 2739 - await store.receive(\.repositoryPullRequestRefreshCompleted) { 2764 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { 2740 2765 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2741 2766 } 2742 2767 await store.finish() ··· 2762 2787 RepositoriesFeature() 2763 2788 } 2764 2789 2765 - await store.send(.githubIntegrationAvailabilityUpdated(false)) { 2790 + await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) { 2766 2791 $0.githubIntegrationAvailability = .unavailable 2767 2792 $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( 2768 2793 repositoryRootURL: URL(fileURLWithPath: repoRoot), ··· 2771 2796 $0.queuedPullRequestRefreshByRepositoryID = [:] 2772 2797 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2773 2798 } 2774 - await store.send(.setGithubIntegrationEnabled(false)) { 2799 + await store.send(.githubIntegration(.setGithubIntegrationEnabled(false))) { 2775 2800 $0.githubIntegrationAvailability = .disabled 2776 2801 $0.pendingPullRequestRefreshByRepositoryID = [:] 2777 2802 $0.queuedPullRequestRefreshByRepositoryID = [:] ··· 2792 2817 RepositoriesFeature() 2793 2818 } 2794 2819 2795 - await store.send(.githubIntegrationAvailabilityUpdated(false)) 2796 - await store.send(.githubIntegrationAvailabilityUpdated(true)) 2820 + await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) 2821 + await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(true))) 2797 2822 #expect(store.state == expectedState) 2798 2823 await store.finish() 2799 2824 } ··· 2827 2852 } 2828 2853 2829 2854 await store.send( 2830 - .repositoryPullRequestRefreshCompleted(repository.id) 2855 + .githubIntegration(.repositoryPullRequestRefreshCompleted(repository.id)) 2831 2856 ) { 2832 2857 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2833 2858 $0.queuedPullRequestRefreshByRepositoryID = [:] 2834 2859 } 2835 - await store.receive(\.worktreeInfoEvent) { 2860 + await store.receive(\.worktreeInfoEvent) 2861 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { 2836 2862 $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] 2837 2863 } 2838 - await store.receive(\.repositoryPullRequestRefreshCompleted) { 2864 + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { 2839 2865 $0.inFlightPullRequestRefreshRepositoryIDs = [] 2840 2866 } 2841 2867 await store.finish() ··· 2862 2888 } 2863 2889 2864 2890 await store.send( 2865 - .repositoryPullRequestsLoaded( 2866 - repositoryID: repository.id, 2867 - pullRequestsByWorktreeID: [featureWorktree.id: pullRequest] 2868 - ) 2891 + .githubIntegration( 2892 + .repositoryPullRequestsLoaded( 2893 + repositoryID: repository.id, 2894 + pullRequestsByWorktreeID: [featureWorktree.id: pullRequest] 2895 + )) 2869 2896 ) 2870 2897 await store.finish() 2871 2898 } ··· 2891 2918 let pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [featureWorktree.id: nil] 2892 2919 2893 2920 await store.send( 2894 - .repositoryPullRequestsLoaded( 2895 - repositoryID: repository.id, 2896 - pullRequestsByWorktreeID: pullRequestsByWorktreeID 2897 - ) 2921 + .githubIntegration( 2922 + .repositoryPullRequestsLoaded( 2923 + repositoryID: repository.id, 2924 + pullRequestsByWorktreeID: pullRequestsByWorktreeID 2925 + )) 2898 2926 ) { 2899 2927 $0.worktreeInfoByID.removeValue(forKey: featureWorktree.id) 2900 2928 } ··· 2907 2935 RepositoriesFeature() 2908 2936 } 2909 2937 2910 - await store.send(.unarchiveWorktree(worktree.id)) 2938 + await store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) 2911 2939 expectNoDifference(store.state.archivedWorktreeIDs, []) 2912 2940 } 2913 2941
+3 -3
supacodeTests/WorktreeEnvironmentTests.swift
··· 73 73 74 74 let input = makeBlockingScriptInput( 75 75 script: """ 76 - docker compose down 77 - codex exec "test" 78 - """, 76 + docker compose down 77 + codex exec "test" 78 + """, 79 79 environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 80 80 ) 81 81