native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
20 kB · 505 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506import ComposableArchitectureimport Foundation
@Reducerstruct RepositorySettingsFeature { private enum CancelID { static let symbolSuggestions = "repositorySettings.symbolSuggestions" }
/// Lifecycle of the "Suggested for this repository" section inside /// the symbol picker sheet. Successful results also live in the /// session cache owned by `RepositorySymbolSuggestionClient`, so /// `.idle` on a fresh State still resolves instantly when a cached /// run exists. enum SymbolSuggestionsPhase: Equatable { case idle case loading case loaded(RepositorySymbolSuggestions) case failed(String) }
@ObservableState struct State: Equatable { var rootURL: URL /// Persistence key for `@Shared(.repositoryAppearances)`. Defaults /// to empty so legacy test fixtures that only construct the four /// originally-required fields keep compiling — production /// callers (AppFeature) always pass the canonical `repository.id`. var repositoryID: Repository.ID = "" var repositoryKind: Repository.Kind var workspace: ProjectWorkspace? var settings: RepositorySettings var userSettings: UserRepositorySettings var appearance: RepositoryAppearance = .empty var globalDefaultWorktreeBaseDirectoryPath: String? var globalCopyIgnoredOnWorktreeCreate: Bool = false var globalCopyUntrackedOnWorktreeCreate: Bool = false var globalPullRequestMergeStrategy: PullRequestMergeStrategy = .merge var isBareRepository = false var branchOptions: [String] = [] var defaultWorktreeBaseRef = "origin/main" var isBranchDataLoaded = false var keybindingUserOverrides: KeybindingUserOverrideStore = .empty var appearanceImportError: String? var isSymbolPickerPresented = false var symbolSuggestions: SymbolSuggestionsPhase = .idle var workflows = WorkflowsSettingsFeature.State()
var capabilities: Repository.Capabilities { switch repositoryKind { case .git: .git case .plain: .plain } }
var showsWorktreeSettings: Bool { capabilities.supportsWorktrees }
var showsDiffSettings: Bool { capabilities.supportsDiff }
var showsPullRequestSettings: Bool { capabilities.supportsPullRequests }
var showsDiffsAndPullRequestSettings: Bool { showsDiffSettings || showsPullRequestSettings }
var showsSetupScriptSettings: Bool { capabilities.supportsWorktrees }
var showsArchiveScriptSettings: Bool { capabilities.supportsWorktrees }
var showsRunScriptSettings: Bool { capabilities.supportsRunnableFolderActions }
var showsCustomCommandsSettings: Bool { capabilities.supportsRunnableFolderActions }
var exampleWorktreePath: String { SupacodePaths.exampleWorktreePath( for: rootURL, globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, repositoryOverridePath: settings.worktreeBaseDirectoryPath ) } }
enum Action: BindableAction { case task case workflowsAppeared case settingsLoaded( RepositorySettings, UserRepositorySettings, isBareRepository: Bool, globalDefaultWorktreeBaseDirectoryPath: String?, globalCopyIgnoredOnWorktreeCreate: Bool, globalCopyUntrackedOnWorktreeCreate: Bool, globalPullRequestMergeStrategy: PullRequestMergeStrategy, keybindingUserOverrides: KeybindingUserOverrideStore ) case appearanceLoaded(RepositoryAppearance) case setAppearanceColor(RepositoryColorChoice?) case setAppearanceIcon(RepositoryIconSource?) case chooseSymbolTapped case suggestIconTapped case regenerateSuggestionsTapped case symbolPickerDismissed case symbolSuggestionsLoaded(RepositorySymbolSuggestions) case symbolSuggestionsFailed(String) case importUserImage(URL) case userImageImported(filename: String) case userImageImportFailed(String) case dismissAppearanceImportError case resetAppearance case setGlobalCommandEnabled(UserCustomCommand.ID, Bool) case setDefaultAgentProfileID(AgentProfile.ID?) case branchDataLoaded([String], defaultBaseRef: String) case workflows(WorkflowsSettingsFeature.Action) case delegate(Delegate) case binding(BindingAction<State>) }
@CasePathable enum Delegate: Equatable { case settingsChanged(URL) }
@Dependency(GitClientDependency.self) private var gitClient @Dependency(\.repositoryIconAssetStore) private var repositoryIconAssetStore @Dependency(\.repositorySymbolSuggestionClient) private var symbolSuggestionClient
var body: some Reducer<State, Action> { BindingReducer() Reduce { state, action in switch action { case .task: let rootURL = state.rootURL guard state.capabilities.supportsRepositoryGitSettings else { return .run { send in @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings @Shared(.settingsFile) var settingsFile let global = settingsFile.global await send( .settingsLoaded( repositorySettings, userRepositorySettings, isBareRepository: false, globalDefaultWorktreeBaseDirectoryPath: global.defaultWorktreeBaseDirectoryPath, globalCopyIgnoredOnWorktreeCreate: global.copyIgnoredOnWorktreeCreate, globalCopyUntrackedOnWorktreeCreate: global.copyUntrackedOnWorktreeCreate, globalPullRequestMergeStrategy: global.pullRequestMergeStrategy, keybindingUserOverrides: global.keybindingUserOverrides ) ) } } let gitClient = gitClient return .run { send in let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings @Shared(.settingsFile) var settingsFile let global = settingsFile.global await send( .settingsLoaded( repositorySettings, userRepositorySettings, isBareRepository: isBareRepository, globalDefaultWorktreeBaseDirectoryPath: global.defaultWorktreeBaseDirectoryPath, globalCopyIgnoredOnWorktreeCreate: global.copyIgnoredOnWorktreeCreate, globalCopyUntrackedOnWorktreeCreate: global.copyUntrackedOnWorktreeCreate, globalPullRequestMergeStrategy: global.pullRequestMergeStrategy, keybindingUserOverrides: global.keybindingUserOverrides ) ) let branches: [String] do { branches = try await gitClient.branchRefs(rootURL) } catch { let rootPath = rootURL.path(percentEncoded: false) SupaLogger("Settings").warning( "Branch refs failed for \(rootPath): \(error.localizedDescription)" ) branches = [] } let defaultBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" await send(.branchDataLoaded(branches, defaultBaseRef: defaultBaseRef)) }
case .workflowsAppeared: state.workflows.settingsScope = .repository( WorkflowSettingsRepositoryContext( repositoryID: state.repositoryID, name: state.settings.customTitle ?? state.rootURL.lastPathComponent, rootURL: state.rootURL)) return .none
case .settingsLoaded( let settings, let userSettings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath, let globalCopyIgnoredOnWorktreeCreate, let globalCopyUntrackedOnWorktreeCreate, let globalPullRequestMergeStrategy, let keybindingUserOverrides ): var updatedSettings = settings updatedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( updatedSettings.worktreeBaseDirectoryPath, repositoryRootURL: state.rootURL ) if isBareRepository { updatedSettings.copyIgnoredOnWorktreeCreate = nil updatedSettings.copyUntrackedOnWorktreeCreate = nil } state.settings = updatedSettings state.userSettings = userSettings.normalized() state.globalDefaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) state.globalCopyIgnoredOnWorktreeCreate = globalCopyIgnoredOnWorktreeCreate state.globalCopyUntrackedOnWorktreeCreate = globalCopyUntrackedOnWorktreeCreate state.globalPullRequestMergeStrategy = globalPullRequestMergeStrategy state.isBareRepository = isBareRepository state.keybindingUserOverrides = keybindingUserOverrides guard updatedSettings != settings else { return .none } let rootURL = state.rootURL @Shared(.repositorySettings(rootURL)) var repositorySettings $repositorySettings.withLock { $0 = updatedSettings } return .send(.delegate(.settingsChanged(rootURL)))
case .setGlobalCommandEnabled(let commandID, let isEnabled): guard state.userSettings.isGlobalCommandEnabled(commandID) != isEnabled else { return .none } state.userSettings.setGlobalCommandEnabled(isEnabled, id: commandID) let rootURL = state.rootURL // Targeted write: `state.userSettings` is a load-time snapshot, and // `lastLaunchedAgentProfileID` is written externally by profile // launches — a whole-struct writeback would clobber it. @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings $userRepositorySettings.withLock { $0.setGlobalCommandEnabled(isEnabled, id: commandID) } return .send(.delegate(.settingsChanged(rootURL)))
case .setDefaultAgentProfileID(let profileID): guard state.userSettings.defaultAgentProfileID != profileID else { return .none } state.userSettings.defaultAgentProfileID = profileID let rootURL = state.rootURL @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings $userRepositorySettings.withLock { $0.defaultAgentProfileID = profileID } return .send(.delegate(.settingsChanged(rootURL)))
case .appearanceLoaded(let appearance): state.appearance = appearance return .none
case .setAppearanceColor(let color): guard state.appearance.color != color else { return .none } state.appearance.color = color return persistAppearance(state.appearance, repositoryID: state.repositoryID)
case .setAppearanceIcon(let newIcon): let previousIcon = state.appearance.icon guard previousIcon != newIcon else { return .none } state.appearance.icon = newIcon if newIcon == nil { // An explicit clear also suppresses automatic detection so an // in-flight detector can't restore the icon the user removed. state.appearance.iconDetectionSuppressed = true } let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) let cleanup = removeAbandonedUserImage( previous: previousIcon, new: newIcon, rootURL: state.rootURL ) return .merge(persist, cleanup)
case .importUserImage(let sourceURL): let rootURL = state.rootURL let store = repositoryIconAssetStore return .run { send in do { let filename = try store.importImage(sourceURL, rootURL) await send(.userImageImported(filename: filename)) } catch { await send(.userImageImportFailed(error.localizedDescription)) } }
case .userImageImported(let filename): return .send(.setAppearanceIcon(.userImage(filename: filename)))
case .userImageImportFailed(let message): state.appearanceImportError = message return .none
case .dismissAppearanceImportError: state.appearanceImportError = nil return .none
case .chooseSymbolTapped: state.isSymbolPickerPresented = true guard case .idle = state.symbolSuggestions else { return .none } // Surface a cached run if one exists; otherwise the section // stays idle with an explicit Suggest button. let repositoryID = state.repositoryID let client = symbolSuggestionClient return .run { send in if let cached = await client.cachedSuggestions(repositoryID) { await send(.symbolSuggestionsLoaded(cached)) } }
case .suggestIconTapped: state.isSymbolPickerPresented = true switch state.symbolSuggestions { case .loading, .loaded: return .none case .idle, .failed: state.symbolSuggestions = .loading return suggestionsEffect(state: state, forceRegenerate: false) }
case .regenerateSuggestionsTapped: state.symbolSuggestions = .loading return suggestionsEffect(state: state, forceRegenerate: true)
case .symbolSuggestionsLoaded(let suggestions): state.symbolSuggestions = .loaded(suggestions) return .none
case .symbolSuggestionsFailed(let message): state.symbolSuggestions = .failed(message) return .none
case .resetAppearance: let previousIcon = state.appearance.icon guard !state.appearance.isEmpty else { return .none } // Removing an icon via reset counts as an explicit clear for // detection purposes; a reset that only dropped a color does not. state.appearance = RepositoryAppearance(iconDetectionSuppressed: previousIcon != nil) let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) let cleanup = removeAbandonedUserImage( previous: previousIcon, new: nil, rootURL: state.rootURL ) return .merge(persist, cleanup)
case .branchDataLoaded(let branches, let defaultBaseRef): state.defaultWorktreeBaseRef = defaultBaseRef var options = branches if !options.contains(defaultBaseRef) { options.append(defaultBaseRef) } if let selected = state.settings.worktreeBaseRef, !options.contains(selected) { options.append(selected) } state.branchOptions = options state.isBranchDataLoaded = true return .none
case .symbolPickerDismissed: state.isSymbolPickerPresented = false return reduceSymbolPickerDismissal(state: &state)
case .binding(\.isSymbolPickerPresented): guard !state.isSymbolPickerPresented else { return .none } return reduceSymbolPickerDismissal(state: &state)
case .binding: if state.isBareRepository { state.settings.copyIgnoredOnWorktreeCreate = nil state.settings.copyUntrackedOnWorktreeCreate = nil } state.userSettings = state.userSettings.normalized() let rootURL = state.rootURL var normalizedSettings = state.settings normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( normalizedSettings.worktreeBaseDirectoryPath, repositoryRootURL: rootURL ) let trimmedCustomTitle = normalizedSettings.customTitle? .trimmingCharacters(in: .whitespacesAndNewlines) normalizedSettings.customTitle = (trimmedCustomTitle?.isEmpty ?? true) ? nil : trimmedCustomTitle normalizedSettings.githubAccountOverride = normalizedSettings.githubAccountOverride?.normalized @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings $repositorySettings.withLock { $0 = normalizedSettings } // `state.userSettings` is a load-time snapshot; preserve the // externally-written launch memory instead of clobbering it with the // stale copy (profile launches update it while Settings stays open). let lastLaunched = userRepositorySettings.lastLaunchedAgentProfileID state.userSettings.lastLaunchedAgentProfileID = lastLaunched $userRepositorySettings.withLock { $0 = state.userSettings } return .send(.delegate(.settingsChanged(rootURL)))
case .workflows: return .none
case .delegate: return .none } } Scope(state: \.workflows, action: \.workflows) { WorkflowsSettingsFeature() } }
/// Sheet dismissed: cancel an in-flight generation and reset a /// transient loading state so reopening starts cleanly (a finished /// run stays visible — it's also session-cached). private func reduceSymbolPickerDismissal(state: inout State) -> Effect<Action> { if case .loading = state.symbolSuggestions { state.symbolSuggestions = .idle } return .cancel(id: CancelID.symbolSuggestions) }
/// Kicks off (or resolves from cache) one suggestion run. The model /// call is cooperatively cancellable; closing the sheet cancels it /// via `CancelID.symbolSuggestions`. private func suggestionsEffect(state: State, forceRegenerate: Bool) -> Effect<Action> { let repositoryID = state.repositoryID let rootURL = state.rootURL let displayName: String = if let custom = state.settings.customTitle, !custom.isEmpty { custom } else { state.rootURL.lastPathComponent } let client = symbolSuggestionClient return .run { send in if !forceRegenerate, let cached = await client.cachedSuggestions(repositoryID) { await send(.symbolSuggestionsLoaded(cached)) return } do { let suggestions = try await client.generateSuggestions(repositoryID, rootURL, displayName) await send(.symbolSuggestionsLoaded(suggestions)) } catch is CancellationError { // Sheet closed mid-generation — nothing to report. } catch { await send(.symbolSuggestionsFailed(error.localizedDescription)) } } .cancellable(id: CancelID.symbolSuggestions, cancelInFlight: true) }
/// Writes the appearance back to the global `@Shared` dict, dropping /// the entry when it's been cleared so the on-disk file stays tight. private func persistAppearance( _ appearance: RepositoryAppearance, repositoryID: Repository.ID ) -> Effect<Action> { .run { _ in @Shared(.repositoryAppearances) var appearances $appearances.withLock { if appearance.isEmpty { $0.removeValue(forKey: repositoryID) } else { $0[repositoryID] = appearance } } } }
/// When the icon transitions away from a file-backed source (user /// import or automatic detection), the old asset on disk is no longer /// referenced and should be cleaned up. No-op when the previous icon /// wasn't file-backed or when the new icon keeps the same file. private func removeAbandonedUserImage( previous: RepositoryIconSource?, new: RepositoryIconSource?, rootURL: URL ) -> Effect<Action> { guard let oldFilename = previous?.storedImageFilename else { return .none } if new?.storedImageFilename == oldFilename { return .none } let store = repositoryIconAssetStore return .run { _ in try? store.remove(oldFilename, rootURL) } }
}