native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
24 kB · 644 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645import ComposableArchitectureimport Foundationimport SwiftUI
extension RepositoriesFeature { // swiftlint:disable:next cyclomatic_complexity function_body_length func reduceWorktreeLifecycle( state: inout State, action: WorktreeLifecycleAction ) -> Effect<Action> { switch action { case .requestArchiveWorktree(let worktreeID, let repositoryID): if state.removingRepositoryIDs.contains(repositoryID) { return .none } guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { return .none } if state.isMainWorktree(worktree) { return .none } if state.deletingWorktreeIDs.contains(worktree.id) { return .none } if state.archivingWorktreeIDs.contains(worktree.id) { return .none } if state.isWorktreeArchived(worktree.id) { return .none } if state.isWorktreeMerged(worktree) { return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktree.id, repository.id))) } state.alert = AlertState { TextState(String(localized: "Archive worktree?")) } actions: { ButtonState(role: .destructive, action: .confirmArchiveWorktree(worktree.id, repository.id)) { TextState(String(localized: "Archive (⌘↩)")) } ButtonState(role: .cancel) { TextState(String(localized: "Cancel")) } } message: { TextState(archiveWorktreeAlertMessage(for: worktree.name)) } return .none
case .requestArchiveWorktrees(let targets): var validTargets: [ArchiveWorktreeTarget] = [] var seenWorktreeIDs: Set<Worktree.ID> = [] for target in targets { guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } if state.removingRepositoryIDs.contains(target.repositoryID) { continue } guard let repository = state.repositories[id: target.repositoryID], let worktree = repository.worktrees[id: target.worktreeID] else { continue } if state.isMainWorktree(worktree) || state.deletingWorktreeIDs.contains(worktree.id) || state.archivingWorktreeIDs.contains(worktree.id) || state.isWorktreeArchived(worktree.id) { continue } validTargets.append(target) } guard !validTargets.isEmpty else { return .none } if validTargets.count == 1, let target = validTargets.first { return .send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) } let count = validTargets.count state.alert = AlertState { TextState(String(localized: "Archive \(count) worktrees?")) } actions: { ButtonState(role: .destructive, action: .confirmArchiveWorktrees(validTargets)) { TextState(String(localized: "Archive \(count) (⌘↩)")) } ButtonState(role: .cancel) { TextState(String(localized: "Cancel")) } } message: { TextState(archiveWorktreesAlertMessage()) } return .none
case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { return .none } if state.isWorktreeArchived(worktreeID) || state.archivingWorktreeIDs.contains(worktreeID) { state.alert = nil return .none } state.alert = nil @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings let script = repositorySettings.archiveScript let commandText = archiveScriptCommand(script) let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return .send(.worktreeLifecycle(.archiveWorktreeApply(worktreeID, repositoryID))) } state.archivingWorktreeIDs.insert(worktreeID) state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( titleText: String(localized: "Running archive script"), detailText: String(localized: "Preparing archive script"), commandText: commandText ) let shellClient = self.shellClient let scriptWithEnv = worktree.scriptEnvironmentExportPrefix + script return .run { send in let envURL = URL(fileURLWithPath: "/usr/bin/env") var progress = ArchiveScriptProgress( titleText: String(localized: "Running archive script"), detailText: String(localized: "Running archive script"), commandText: commandText ) do { for try await event in shellClient.runLoginStream( envURL, ["bash", "-lc", scriptWithEnv], worktree.workingDirectory, log: false ) { switch event { case .line(let line): let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { continue } progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) await send(.worktreeLifecycle(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress))) case .finished: await send( .worktreeLifecycle( .archiveScriptSucceeded( worktreeID: worktreeID, repositoryID: repositoryID ) ) ) } } } catch { await send( .worktreeLifecycle( .archiveScriptFailed( worktreeID: worktreeID, message: error.localizedDescription ) ) ) } } .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true)
case .archiveScriptProgressUpdated(let worktreeID, let progress): guard state.archivingWorktreeIDs.contains(worktreeID) else { return .none } state.archiveScriptProgressByWorktreeID[worktreeID] = progress return .none
case .archiveScriptSucceeded(let worktreeID, let repositoryID): guard state.archivingWorktreeIDs.contains(worktreeID) else { return .none } state.archivingWorktreeIDs.remove(worktreeID) state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) return .send(.worktreeLifecycle(.archiveWorktreeApply(worktreeID, repositoryID)))
case .archiveScriptFailed(let worktreeID, let message): guard state.archivingWorktreeIDs.contains(worktreeID) else { return .none } state.archivingWorktreeIDs.remove(worktreeID) state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) state.alert = messageAlert(title: String(localized: "Archive script failed"), message: message) return .none
case .archiveWorktreeApply(let worktreeID, let repositoryID): guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { return .none } if state.isWorktreeArchived(worktreeID) { state.alert = nil return .none } let previousSelection = state.selectedWorktreeID let previousSelectedWorktree = state.worktree(for: previousSelection) let selectionWasRemoved = state.selectedWorktreeID == worktree.id let nextSelection = selectionWasRemoved ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) : nil var didUpdateWorktreeOrder = false let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) withAnimation { state.alert = nil state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } if var order = state.worktreeOrderByRepository[repositoryID] { order.removeAll { $0 == worktreeID } if order.isEmpty { state.worktreeOrderByRepository.removeValue(forKey: repositoryID) } else { state.worktreeOrderByRepository[repositoryID] = order } didUpdateWorktreeOrder = true } state.archivedWorktrees.append(ArchivedWorktree(id: worktreeID, archivedAt: now)) if selectionWasRemoved { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) state.selectedWorkspaceChildID = nil } } let archivedWorktrees = state.archivedWorktrees let repositories = state.repositories let selectedWorktree = state.worktree(for: state.selectedWorktreeID) let selectionChanged = selectionDidChange( previousSelectionID: previousSelection, previousSelectedWorktree: previousSelectedWorktree, selectedWorktreeID: state.selectedWorktreeID, selectedWorktree: selectedWorktree ) var effects: [Effect<Action>] = [ .send(.delegate(.repositoriesChanged(repositories))), .run { _ in await repositoryPersistence.saveArchivedWorktrees(archivedWorktrees) }, ] if wasPinned { let pinnedWorktreeIDs = state.pinnedWorktreeIDs effects.append( .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) } ) } if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository effects.append( .run { _ in await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) } ) } if selectionChanged { effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) } return .merge(effects)
case .unarchiveWorktree(let worktreeID): if !state.isWorktreeArchived(worktreeID) { return .none } withAnimation { state.archivedWorktrees.removeAll { $0.id == worktreeID } } let archivedWorktrees = state.archivedWorktrees let repositories = state.repositories return .merge( .send(.delegate(.repositoriesChanged(repositories))), .run { _ in await repositoryPersistence.saveArchivedWorktrees(archivedWorktrees) } )
case .requestDeleteWorktree(let worktreeID, let repositoryID): if state.removingRepositoryIDs.contains(repositoryID) { return .none } guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { return .none } if state.isMainWorktree(worktree) { state.alert = messageAlert( title: String(localized: "Delete not allowed"), message: String(localized: "Deleting the main worktree is not allowed.") ) return .none } if state.archivingWorktreeIDs.contains(worktree.id) { return .none } if state.deletingWorktreeIDs.contains(worktree.id) { return .none } state.deleteWorktreeConfirmation = makeDeleteWorktreeConfirmation( id: state.nextDeleteWorktreeConfirmationID, targets: [DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], state: state ) state.nextDeleteWorktreeConfirmationID += 1 return .none
case .requestDeleteWorktrees(let targets): var validTargets: [DeleteWorktreeTarget] = [] var seenWorktreeIDs: Set<Worktree.ID> = [] for target in targets { guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } if state.removingRepositoryIDs.contains(target.repositoryID) { continue } guard let repository = state.repositories[id: target.repositoryID], let worktree = repository.worktrees[id: target.worktreeID] else { continue } if state.isMainWorktree(worktree) || state.deletingWorktreeIDs.contains(worktree.id) || state.archivingWorktreeIDs.contains(worktree.id) { continue } validTargets.append(target) } guard !validTargets.isEmpty else { return .none } state.deleteWorktreeConfirmation = makeDeleteWorktreeConfirmation( id: state.nextDeleteWorktreeConfirmationID, targets: validTargets, state: state ) state.nextDeleteWorktreeConfirmationID += 1 return .none
case .deleteWorktreePromptDeleteBranchChanged(let deleteBranch): state.deleteWorktreeConfirmation?.deleteBranch = deleteBranch return .none
case .deleteWorktreePromptDismissed: state.deleteWorktreeConfirmation = nil return .none
case .deleteWorktreePromptConfirmed: guard let confirmation = state.deleteWorktreeConfirmation else { return .none } state.deleteWorktreeConfirmation = nil state.$deleteBranchOnManualWorktreeDelete.withLock { $0 = confirmation.deleteBranch } return .merge( confirmation.targets.map { target in .send( .worktreeLifecycle( .deleteWorktreeConfirmed( target.worktreeID, target.repositoryID, deleteBranch: confirmation.deleteBranch )) ) } )
case .deleteWorktreeConfirmed(let worktreeID, let repositoryID, let deleteBranch): guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { return .none } if state.archivingWorktreeIDs.contains(worktree.id) { return .none } if state.deletingWorktreeIDs.contains(worktree.id) { return .none } state.alert = nil state.deletingWorktreeIDs.insert(worktree.id) let selectionWasRemoved = state.selectedWorktreeID == worktree.id let nextSelection = selectionWasRemoved ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) : nil return .run { send in do { _ = try await gitClient.removeWorktree( worktree, false ) let forceDeleteBranchRequest: ForceDeleteBranchRequest? if deleteBranch { do { _ = try await gitClient.deleteLocalBranch(worktree.name, worktree.repositoryRootURL, false) forceDeleteBranchRequest = nil } catch { forceDeleteBranchRequest = ForceDeleteBranchRequest( branchName: worktree.name, repositoryRootURL: worktree.repositoryRootURL, errorMessage: error.localizedDescription ) } } else { forceDeleteBranchRequest = nil } await send( .worktreeLifecycle( .worktreeDeleted( worktree.id, repositoryID: repository.id, selectionWasRemoved: selectionWasRemoved, nextSelection: nextSelection, forceDeleteBranchRequest: forceDeleteBranchRequest ) ) ) } catch { await send(.worktreeLifecycle(.deleteWorktreeFailed(error.localizedDescription, worktreeID: worktree.id))) } }
case .worktreeDeleted( let worktreeID, let repositoryID, _, let nextSelection, let forceDeleteBranchRequest ): analyticsClient.capture("worktree_deleted", [String: Any]?.none) let previousSelection = state.selectedWorktreeID let previousSelectedWorktree = state.worktree(for: previousSelection) let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) var didUpdateWorktreeOrder = false let wasArchived = state.isWorktreeArchived(worktreeID) withAnimation(.easeOut(duration: 0.2)) { state.deletingWorktreeIDs.remove(worktreeID) state.archivingWorktreeIDs.remove(worktreeID) state.pendingWorktrees.removeAll { $0.id == worktreeID } state.pendingSetupScriptWorktreeIDs.remove(worktreeID) state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) state.worktreeInfoByID.removeValue(forKey: worktreeID) state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } state.archivedWorktrees.removeAll { $0.id == worktreeID } state.$prowlCreatedWorktreeIDs.withLock { $0.removeAll { $0 == worktreeID } } if var order = state.worktreeOrderByRepository[repositoryID] { order.removeAll { $0 == worktreeID } if order.isEmpty { state.worktreeOrderByRepository.removeValue(forKey: repositoryID) } else { state.worktreeOrderByRepository[repositoryID] = order } didUpdateWorktreeOrder = true } _ = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) let selectionNeedsUpdate = state.selection == .worktree(worktreeID) if selectionNeedsUpdate { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) state.selectedWorkspaceChildID = nil } } let roots = state.repositories.map(\.rootURL) let repositories = state.repositories let selectedWorktree = state.worktree(for: state.selectedWorktreeID) let selectionChanged = selectionDidChange( previousSelectionID: previousSelection, previousSelectedWorktree: previousSelectedWorktree, selectedWorktreeID: state.selectedWorktreeID, selectedWorktree: selectedWorktree ) var immediateEffects: [Effect<Action>] = [ .send(.delegate(.repositoriesChanged(repositories))) ] if selectionChanged { immediateEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) } var followupEffects: [Effect<Action>] = [ roots.isEmpty ? .none : .send(.reloadRepositories(animated: true)) ] if wasPinned { let pinnedWorktreeIDs = state.pinnedWorktreeIDs followupEffects.append( .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) } ) } if wasArchived { let archivedWorktrees = state.archivedWorktrees followupEffects.append( .run { _ in await repositoryPersistence.saveArchivedWorktrees(archivedWorktrees) } ) } if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository followupEffects.append( .run { _ in await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) } ) } if let forceDeleteBranchRequest { state.pendingForceDeleteBranchRequests.append(forceDeleteBranchRequest) presentNextForceDeleteBranchAlert(state: &state) } return .concatenate( .merge(immediateEffects), .merge(followupEffects) )
case .deleteWorktreeFailed(let message, let worktreeID): state.deletingWorktreeIDs.remove(worktreeID) state.alert = messageAlert(title: String(localized: "Unable to delete worktree"), message: message) return .none
case .forceDeleteBranchConfirmed(let request): state.alert = nil removePendingForceDeleteBranchRequest(request, state: &state) presentNextForceDeleteBranchAlert(state: &state) return .run { send in do { _ = try await gitClient.deleteLocalBranch(request.branchName, request.repositoryRootURL, true) } catch { await send(.worktreeLifecycle(.forceDeleteBranchFailed(error.localizedDescription))) } }
case .forceDeleteBranchFailed(let message): state.alert = messageAlert(title: String(localized: "Unable to delete branch"), message: message) return .none } }
var worktreeLifecycleReducer: some ReducerOf<Self> { Reduce { state, action in guard case .worktreeLifecycle(let action) = action else { return .none } return reduceWorktreeLifecycle(state: &state, action: action) } }}
private func archiveWorktreeAlertMessage(for name: String) -> String { let shortcut = AppShortcuts.archivedWorktrees.display return String(localized: "Find \(name) later in Menu Bar > Worktrees > Archived Worktrees (\(shortcut)).")}
private func archiveWorktreesAlertMessage() -> String { let shortcut = AppShortcuts.archivedWorktrees.display return String(localized: "Find them later in Menu Bar > Worktrees > Archived Worktrees (\(shortcut)).")}
private func makeDeleteWorktreeConfirmation( id: Int, targets: [RepositoriesFeature.DeleteWorktreeTarget], state: RepositoriesFeature.State) -> DeleteWorktreeConfirmation { let count = targets.count let defaultDeleteBranch = state.deleteBranchOnManualWorktreeDelete if count == 1, let target = targets.first, let worktree = state.repositories[id: target.repositoryID]?.worktrees[id: target.worktreeID] { return DeleteWorktreeConfirmation( id: id, title: String(localized: "Delete worktree?"), message: String(localized: "Delete \(worktree.name)? The worktree directory will be removed."), targets: targets, deleteBranch: defaultDeleteBranch ) } return DeleteWorktreeConfirmation( id: id, title: String(localized: "Delete \(count) worktrees?"), message: String(localized: "Delete \(count) worktrees? Their worktree directories will be removed."), targets: targets, deleteBranch: defaultDeleteBranch )}
private func forceDeleteBranchAlert(_ request: ForceDeleteBranchRequest) -> AlertState<RepositoriesFeature.Alert> { AlertState { TextState(String(localized: "Force delete branch?")) } actions: { ButtonState(role: .destructive, action: .confirmForceDeleteBranch(request)) { TextState(String(localized: "Force Delete")) } ButtonState(role: .cancel) { TextState(String(localized: "Keep Branch")) } } message: { TextState( String( localized: """ The worktree was deleted, but \(request.branchName) could not be deleted safely.
\(request.errorMessage) """ ) ) }}
func presentNextForceDeleteBranchAlert(state: inout RepositoriesFeature.State) { guard state.alert == nil, let request = state.pendingForceDeleteBranchRequests.first else { return } state.alert = forceDeleteBranchAlert(request)}
func dismissCurrentForceDeleteBranchRequest(state: inout RepositoriesFeature.State) { guard let request = currentForceDeleteBranchRequest(from: state.alert) else { state.alert = nil return } state.alert = nil removePendingForceDeleteBranchRequest(request, state: &state) presentNextForceDeleteBranchAlert(state: &state)}
private func removePendingForceDeleteBranchRequest( _ request: ForceDeleteBranchRequest, state: inout RepositoriesFeature.State) { state.pendingForceDeleteBranchRequests.removeAll { $0 == request }}
private func currentForceDeleteBranchRequest( from alert: AlertState<RepositoriesFeature.Alert>?) -> ForceDeleteBranchRequest? { guard let alert else { return nil } for button in alert.buttons { if case .confirmForceDeleteBranch(let request)? = button.action.action { return request } } return nil}