native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge remote-tracking branch 'upstream/main'

onevcat 46e97bb4 cccd36d3

+972 -72
+2 -2
README.md
··· 1 1 # Supacode 2 2 3 - A macOS app for running multiple coding agents in isolated worktrees. 3 + Native terminal coding agents command center. 4 4 5 - <img width="3600" height="2260" alt="image" src="https://github.com/user-attachments/assets/31eb062c-f2d6-406d-8c60-d2f1664a0c21" /> 5 + ![screenshot](https://www.supacode.sh/screenshot.png) 6 6 7 7 ## Technical Stack 8 8
+9
supacode.json
··· 1 + { 2 + "archiveScript" : "", 3 + "copyIgnoredOnWorktreeCreate" : true, 4 + "copyUntrackedOnWorktreeCreate" : true, 5 + "openActionID" : "xcode", 6 + "pullRequestMergeStrategy" : "merge", 7 + "runScript" : "make run-app", 8 + "setupScript" : "git submodule update -f --depth=1 --init --no-fetch -j 8 --progress\ncodex" 9 + }
+1
supacode.xcodeproj/project.pbxproj
··· 251 251 /* Begin PBXShellScriptBuildPhase section */ 252 252 D67F9B122F33000100A1B2C3 /* Verify git-wt script */ = { 253 253 isa = PBXShellScriptBuildPhase; 254 + alwaysOutOfDate = 1; 254 255 buildActionMask = 2147483647; 255 256 files = ( 256 257 );
+33
supacode/Clients/Notifications/NotificationSoundClient.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + 5 + private let appNotificationSound: NSSound? = { 6 + guard let url = Bundle.main.url(forResource: "notification", withExtension: "wav") else { 7 + return nil 8 + } 9 + return NSSound(contentsOf: url, byReference: true) 10 + }() 11 + 12 + struct NotificationSoundClient { 13 + var play: @MainActor @Sendable () -> Void 14 + } 15 + 16 + extension NotificationSoundClient: DependencyKey { 17 + static let liveValue = NotificationSoundClient( 18 + play: { 19 + _ = appNotificationSound?.play() 20 + } 21 + ) 22 + 23 + static let testValue = NotificationSoundClient( 24 + play: {} 25 + ) 26 + } 27 + 28 + extension DependencyValues { 29 + var notificationSoundClient: NotificationSoundClient { 30 + get { self[NotificationSoundClient.self] } 31 + set { self[NotificationSoundClient.self] = newValue } 32 + } 33 + }
+109
supacode/Clients/Notifications/SystemNotificationClient.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + import UserNotifications 5 + 6 + private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { 7 + func userNotificationCenter( 8 + _ center: UNUserNotificationCenter, 9 + willPresent notification: UNNotification 10 + ) async -> UNNotificationPresentationOptions { 11 + [.badge, .sound, .banner] 12 + } 13 + } 14 + 15 + @MainActor 16 + private let foregroundSystemNotificationDelegate = ForegroundSystemNotificationDelegate() 17 + 18 + @MainActor 19 + private func configuredNotificationCenter() -> UNUserNotificationCenter { 20 + let center = UNUserNotificationCenter.current() 21 + if center.delegate !== foregroundSystemNotificationDelegate { 22 + center.delegate = foregroundSystemNotificationDelegate 23 + } 24 + return center 25 + } 26 + 27 + struct SystemNotificationClient { 28 + struct AuthorizationRequestResult: Equatable { 29 + let granted: Bool 30 + let errorMessage: String? 31 + } 32 + 33 + enum AuthorizationStatus: Equatable { 34 + case authorized 35 + case denied 36 + case notDetermined 37 + } 38 + 39 + var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus 40 + var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult 41 + var send: @MainActor @Sendable (_ title: String, _ body: String) async -> Void 42 + var openSettings: @MainActor @Sendable () async -> Void 43 + } 44 + 45 + extension SystemNotificationClient: DependencyKey { 46 + static let liveValue = SystemNotificationClient( 47 + authorizationStatus: { 48 + let center = configuredNotificationCenter() 49 + let settings = await center.notificationSettings() 50 + switch settings.authorizationStatus { 51 + case .authorized, .provisional: 52 + return .authorized 53 + case .denied: 54 + return .denied 55 + case .notDetermined: 56 + return .notDetermined 57 + @unknown default: 58 + return .denied 59 + } 60 + }, 61 + requestAuthorization: { 62 + let center = configuredNotificationCenter() 63 + do { 64 + let granted = try await center.requestAuthorization( 65 + options: [.alert, .badge, .sound] 66 + ) 67 + return AuthorizationRequestResult(granted: granted, errorMessage: nil) 68 + } catch { 69 + return AuthorizationRequestResult( 70 + granted: false, 71 + errorMessage: error.localizedDescription 72 + ) 73 + } 74 + }, 75 + send: { title, body in 76 + let center = configuredNotificationCenter() 77 + let content = UNMutableNotificationContent() 78 + content.title = title 79 + content.body = body 80 + content.sound = .default 81 + let request = UNNotificationRequest( 82 + identifier: UUID().uuidString, 83 + content: content, 84 + trigger: nil 85 + ) 86 + try? await center.add(request) 87 + }, 88 + openSettings: { 89 + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { 90 + return 91 + } 92 + _ = NSWorkspace.shared.open(url) 93 + } 94 + ) 95 + 96 + static let testValue = SystemNotificationClient( 97 + authorizationStatus: { .notDetermined }, 98 + requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, 99 + send: { _, _ in }, 100 + openSettings: {} 101 + ) 102 + } 103 + 104 + extension DependencyValues { 105 + var systemNotificationClient: SystemNotificationClient { 106 + get { self[SystemNotificationClient.self] } 107 + set { self[SystemNotificationClient.self] = newValue } 108 + } 109 + }
+65 -10
supacode/Features/App/Reducer/AppFeature.swift
··· 4 4 import PostHog 5 5 import SwiftUI 6 6 7 - private let notificationSound: NSSound? = { 8 - guard let url = Bundle.main.url(forResource: "notification", withExtension: "wav") else { 9 - return nil 10 - } 11 - return NSSound(contentsOf: url, byReference: true) 12 - }() 13 - 14 7 private enum CancelID { 15 8 static let periodicRefresh = "app.periodicRefresh" 16 9 } ··· 29 22 var isRunScriptPromptPresented = false 30 23 var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] 31 24 var notificationIndicatorCount: Int = 0 25 + var lastKnownSystemNotificationsEnabled: Bool 32 26 @Presents var alert: AlertState<Alert>? 33 27 var commandPaletteItems: [CommandPaletteItem] { 34 28 CommandPaletteFeature.commandPaletteItems(from: repositories) ··· 40 34 ) { 41 35 self.repositories = repositories 42 36 self.settings = settings 37 + lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 43 38 } 44 39 } 45 40 ··· 69 64 case navigateSearchNext 70 65 case navigateSearchPrevious 71 66 case endSearch 67 + case systemNotificationsPermissionFailed(errorMessage: String?) 72 68 case alert(PresentationAction<Alert>) 73 69 case terminalEvent(TerminalClient.Event) 74 70 } ··· 76 72 enum Alert: Equatable { 77 73 case dismiss 78 74 case confirmQuit 75 + case openSystemNotificationSettings 79 76 } 80 77 81 78 @Dependency(AnalyticsClient.self) private var analyticsClient 82 79 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 83 80 @Dependency(WorkspaceClient.self) private var workspaceClient 84 81 @Dependency(SettingsWindowClient.self) private var settingsWindowClient 82 + @Dependency(NotificationSoundClient.self) private var notificationSoundClient 83 + @Dependency(SystemNotificationClient.self) private var systemNotificationClient 85 84 @Dependency(TerminalClient.self) private var terminalClient 86 85 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 87 86 ··· 248 247 return .none 249 248 250 249 case .settings(.delegate(.settingsChanged(let settings))): 250 + let shouldCheckSystemNotificationPermission = 251 + settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled 252 + state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 251 253 if let selectedWorktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) { 252 254 let rootURL = selectedWorktree.repositoryRootURL 253 255 @Shared(.repositorySettings(rootURL)) var repositorySettings ··· 288 290 await worktreeInfoWatcher.send( 289 291 .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) 290 292 ) 293 + }, 294 + .run { send in 295 + guard shouldCheckSystemNotificationPermission else { return } 296 + let status = await systemNotificationClient.authorizationStatus() 297 + switch status { 298 + case .authorized: 299 + return 300 + case .notDetermined: 301 + let result = await systemNotificationClient.requestAuthorization() 302 + if !result.granted { 303 + await send( 304 + .systemNotificationsPermissionFailed(errorMessage: result.errorMessage) 305 + ) 306 + } 307 + case .denied: 308 + await send(.systemNotificationsPermissionFailed(errorMessage: "Authorization status is denied.")) 309 + } 291 310 } 292 311 ) 293 312 ··· 520 539 state.selectedRunScript = settings.runScript 521 540 return .none 522 541 542 + case .systemNotificationsPermissionFailed(let errorMessage): 543 + let message: String 544 + if let errorMessage, !errorMessage.isEmpty { 545 + message = 546 + "Supacode cannot send system notifications.\n\n" 547 + + "Error: \(errorMessage)" 548 + } else { 549 + message = "Supacode cannot send system notifications while permission is denied." 550 + } 551 + state.alert = AlertState { 552 + TextState("Enable Notifications in System Settings") 553 + } actions: { 554 + ButtonState(action: .openSystemNotificationSettings) { 555 + TextState("Open System Settings") 556 + } 557 + ButtonState(role: .cancel, action: .dismiss) { 558 + TextState("Cancel") 559 + } 560 + } message: { 561 + TextState(message) 562 + } 563 + return .send(.settings(.setSystemNotificationsEnabled(false))) 564 + 523 565 case .alert(.dismiss): 524 566 state.alert = nil 525 567 return .none 568 + 569 + case .alert(.presented(.openSystemNotificationSettings)): 570 + state.alert = nil 571 + return .run { _ in 572 + await systemNotificationClient.openSettings() 573 + } 526 574 527 575 case .alert(.presented(.confirmQuit)): 528 576 analyticsClient.capture("app_quit", nil) ··· 604 652 case .commandPalette: 605 653 return .none 606 654 607 - case .terminalEvent(.notificationReceived(let worktreeID, _, _)): 655 + case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 608 656 var effects: [Effect<Action>] = [ 609 657 .send(.repositories(.worktreeNotificationReceived(worktreeID))) 610 658 ] 611 - if state.settings.notificationSoundEnabled { 659 + if state.settings.systemNotificationsEnabled { 660 + effects.append( 661 + .run { _ in 662 + await systemNotificationClient.send(title, body) 663 + } 664 + ) 665 + } 666 + if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { 612 667 effects.append( 613 668 .run { _ in 614 - await MainActor.run { _ = notificationSound?.play() } 669 + await notificationSoundClient.play() 615 670 } 616 671 ) 617 672 }
+64
supacode/Features/Settings/BusinessLogic/RepositoryLocalSettingsPersistence.swift
··· 1 + import Dependencies 2 + import Foundation 3 + 4 + nonisolated struct RepositoryLocalSettingsStorage: Sendable { 5 + var load: @Sendable (URL) throws -> Data 6 + var save: @Sendable (Data, URL) throws -> Void 7 + } 8 + 9 + nonisolated enum RepositoryLocalSettingsStorageKey: DependencyKey { 10 + static var liveValue: RepositoryLocalSettingsStorage { 11 + RepositoryLocalSettingsStorage( 12 + load: { try Data(contentsOf: $0) }, 13 + save: { data, url in 14 + let directory = url.deletingLastPathComponent() 15 + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 16 + try data.write(to: url, options: [.atomic]) 17 + } 18 + ) 19 + } 20 + 21 + static var previewValue: RepositoryLocalSettingsStorage { .inMemory() } 22 + static var testValue: RepositoryLocalSettingsStorage { .inMemory() } 23 + } 24 + 25 + extension DependencyValues { 26 + nonisolated var repositoryLocalSettingsStorage: RepositoryLocalSettingsStorage { 27 + get { self[RepositoryLocalSettingsStorageKey.self] } 28 + set { self[RepositoryLocalSettingsStorageKey.self] = newValue } 29 + } 30 + } 31 + 32 + extension RepositoryLocalSettingsStorage { 33 + nonisolated static func inMemory() -> RepositoryLocalSettingsStorage { 34 + let storage = InMemoryRepositoryLocalSettingsStorage() 35 + return RepositoryLocalSettingsStorage( 36 + load: { try storage.load($0) }, 37 + save: { try storage.save($0, $1) } 38 + ) 39 + } 40 + } 41 + 42 + nonisolated enum RepositoryLocalSettingsStorageError: Error { 43 + case missing 44 + } 45 + 46 + nonisolated final class InMemoryRepositoryLocalSettingsStorage: @unchecked Sendable { 47 + private let lock = NSLock() 48 + private var dataByURL: [URL: Data] = [:] 49 + 50 + func load(_ url: URL) throws -> Data { 51 + lock.lock() 52 + defer { lock.unlock() } 53 + guard let data = dataByURL[url] else { 54 + throw RepositoryLocalSettingsStorageError.missing 55 + } 56 + return data 57 + } 58 + 59 + func save(_ data: Data, _ url: URL) throws { 60 + lock.lock() 61 + defer { lock.unlock() } 62 + dataByURL[url] = data 63 + } 64 + }
+42 -13
supacode/Features/Settings/BusinessLogic/RepositorySettingsKey.swift
··· 1 + import Dependencies 1 2 import Foundation 2 3 import Sharing 3 4 ··· 7 8 8 9 nonisolated struct RepositorySettingsKey: SharedKey { 9 10 let repositoryID: String 11 + let rootURL: URL 10 12 11 13 init(rootURL: URL) { 12 - repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 14 + self.rootURL = rootURL.standardizedFileURL 15 + repositoryID = self.rootURL.path(percentEncoded: false) 13 16 } 14 17 15 18 var id: RepositorySettingsKeyID { ··· 20 23 context: LoadContext<RepositorySettings>, 21 24 continuation: LoadContinuation<RepositorySettings> 22 25 ) { 26 + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 27 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 28 + if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { 29 + let decoder = JSONDecoder() 30 + if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { 31 + continuation.resume(returning: settings) 32 + return 33 + } 34 + let path = repositorySettingsURL.path(percentEncoded: false) 35 + SupaLogger("Settings").warning( 36 + "Unable to decode repository settings at \(path); migrating from global settings." 37 + ) 38 + } 39 + 23 40 @Shared(.settingsFile) var settingsFile: SettingsFile 24 - let settings = $settingsFile.withLock { settings in 25 - if let existing = settings.repositories[repositoryID] { 26 - return existing 27 - } 28 - let defaults = context.initialValue ?? .default 29 - settings.repositories[repositoryID] = defaults 30 - return defaults 41 + let migratedSettings = $settingsFile.withLock { settings in 42 + settings.repositories[repositoryID] ?? (context.initialValue ?? .default) 43 + } 44 + do { 45 + let encoder = JSONEncoder() 46 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 47 + let data = try encoder.encode(migratedSettings) 48 + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) 49 + } catch { 50 + let path = repositorySettingsURL.path(percentEncoded: false) 51 + SupaLogger("Settings").warning( 52 + "Unable to write migrated repository settings to \(path): \(error.localizedDescription)" 53 + ) 31 54 } 32 - continuation.resume(returning: settings) 55 + continuation.resume(returning: migratedSettings) 33 56 } 34 57 35 58 func subscribe( ··· 44 67 context _: SaveContext, 45 68 continuation: SaveContinuation 46 69 ) { 47 - @Shared(.settingsFile) var settingsFile: SettingsFile 48 - $settingsFile.withLock { 49 - $0.repositories[repositoryID] = value 70 + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 71 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 72 + do { 73 + let encoder = JSONEncoder() 74 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 75 + let data = try encoder.encode(value) 76 + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) 77 + continuation.resume() 78 + } catch { 79 + continuation.resume(throwing: error) 50 80 } 51 - continuation.resume() 52 81 } 53 82 } 54 83
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 7 7 var updatesAutomaticallyDownloadUpdates: Bool 8 8 var inAppNotificationsEnabled: Bool 9 9 var notificationSoundEnabled: Bool 10 + var systemNotificationsEnabled: Bool 10 11 var moveNotifiedWorktreeToTop: Bool 11 12 var analyticsEnabled: Bool 12 13 var crashReportsEnabled: Bool ··· 24 25 updatesAutomaticallyDownloadUpdates: false, 25 26 inAppNotificationsEnabled: true, 26 27 notificationSoundEnabled: true, 28 + systemNotificationsEnabled: false, 27 29 moveNotifiedWorktreeToTop: true, 28 30 analyticsEnabled: true, 29 31 crashReportsEnabled: true, ··· 42 44 updatesAutomaticallyDownloadUpdates: Bool, 43 45 inAppNotificationsEnabled: Bool, 44 46 notificationSoundEnabled: Bool, 47 + systemNotificationsEnabled: Bool = false, 45 48 moveNotifiedWorktreeToTop: Bool, 46 49 analyticsEnabled: Bool, 47 50 crashReportsEnabled: Bool, ··· 58 61 self.updatesAutomaticallyDownloadUpdates = updatesAutomaticallyDownloadUpdates 59 62 self.inAppNotificationsEnabled = inAppNotificationsEnabled 60 63 self.notificationSoundEnabled = notificationSoundEnabled 64 + self.systemNotificationsEnabled = systemNotificationsEnabled 61 65 self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop 62 66 self.analyticsEnabled = analyticsEnabled 63 67 self.crashReportsEnabled = crashReportsEnabled ··· 87 91 notificationSoundEnabled = 88 92 try container.decodeIfPresent(Bool.self, forKey: .notificationSoundEnabled) 89 93 ?? Self.default.notificationSoundEnabled 94 + systemNotificationsEnabled = 95 + try container.decodeIfPresent(Bool.self, forKey: .systemNotificationsEnabled) 96 + ?? Self.default.systemNotificationsEnabled 90 97 moveNotifiedWorktreeToTop = 91 98 try container.decodeIfPresent(Bool.self, forKey: .moveNotifiedWorktreeToTop) 92 99 ?? Self.default.moveNotifiedWorktreeToTop
+20 -7
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 13 13 var updatesAutomaticallyDownloadUpdates: Bool 14 14 var inAppNotificationsEnabled: Bool 15 15 var notificationSoundEnabled: Bool 16 + var systemNotificationsEnabled: Bool 16 17 var moveNotifiedWorktreeToTop: Bool 17 18 var analyticsEnabled: Bool 18 19 var crashReportsEnabled: Bool ··· 33 34 updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates 34 35 inAppNotificationsEnabled = settings.inAppNotificationsEnabled 35 36 notificationSoundEnabled = settings.notificationSoundEnabled 37 + systemNotificationsEnabled = settings.systemNotificationsEnabled 36 38 moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop 37 39 analyticsEnabled = settings.analyticsEnabled 38 40 crashReportsEnabled = settings.crashReportsEnabled ··· 52 54 updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates, 53 55 inAppNotificationsEnabled: inAppNotificationsEnabled, 54 56 notificationSoundEnabled: notificationSoundEnabled, 57 + systemNotificationsEnabled: systemNotificationsEnabled, 55 58 moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, 56 59 analyticsEnabled: analyticsEnabled, 57 60 crashReportsEnabled: crashReportsEnabled, ··· 67 70 case task 68 71 case settingsLoaded(GlobalSettings) 69 72 case setSelection(SettingsSection?) 73 + case setSystemNotificationsEnabled(Bool) 70 74 case repositorySettings(RepositorySettingsFeature.Action) 71 75 case delegate(Delegate) 72 76 case binding(BindingAction<State>) ··· 107 111 state.updatesAutomaticallyDownloadUpdates = normalizedSettings.updatesAutomaticallyDownloadUpdates 108 112 state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled 109 113 state.notificationSoundEnabled = normalizedSettings.notificationSoundEnabled 114 + state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled 110 115 state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop 111 116 state.analyticsEnabled = normalizedSettings.analyticsEnabled 112 117 state.crashReportsEnabled = normalizedSettings.crashReportsEnabled ··· 117 122 return .send(.delegate(.settingsChanged(normalizedSettings))) 118 123 119 124 case .binding: 120 - let settings = state.globalSettings 121 - @Shared(.settingsFile) var settingsFile 122 - $settingsFile.withLock { $0.global = settings } 123 - if settings.analyticsEnabled { 124 - analyticsClient.capture("settings_changed", nil) 125 - } 126 - return .send(.delegate(.settingsChanged(settings))) 125 + return persist(state) 126 + 127 + case .setSystemNotificationsEnabled(let isEnabled): 128 + state.systemNotificationsEnabled = isEnabled 129 + return persist(state) 127 130 128 131 case .setSelection(let selection): 129 132 state.selection = selection ?? .general ··· 139 142 .ifLet(\.repositorySettings, action: \.repositorySettings) { 140 143 RepositorySettingsFeature() 141 144 } 145 + } 146 + 147 + private func persist(_ state: State) -> Effect<Action> { 148 + let settings = state.globalSettings 149 + @Shared(.settingsFile) var settingsFile 150 + $settingsFile.withLock { $0.global = settings } 151 + if settings.analyticsEnabled { 152 + analyticsClient.capture("settings_changed", nil) 153 + } 154 + return .send(.delegate(.settingsChanged(settings))) 142 155 } 143 156 }
+5
supacode/Features/Settings/Views/NotificationsSettingsView.swift
··· 19 19 ) 20 20 .help("Play a sound when a notification is received") 21 21 Toggle( 22 + "System notifications", 23 + isOn: $store.systemNotificationsEnabled 24 + ) 25 + .help("Show macOS system notifications") 26 + Toggle( 22 27 "Move notified worktree to top", 23 28 isOn: $store.moveNotifiedWorktreeToTop 24 29 )
+1
supacode/Features/Settings/Views/SettingsView.swift
··· 121 121 } 122 122 } 123 123 .navigationSplitViewStyle(.balanced) 124 + .alert(store: store.scope(state: \.$alert, action: \.alert)) 124 125 .frame(minWidth: 750, minHeight: 500) 125 126 .background { 126 127 WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme)
+14 -13
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 698 698 } 699 699 700 700 private func appendNotification(title: String, body: String, surfaceId: UUID) { 701 - guard notificationsEnabled else { return } 702 701 let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) 703 702 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) 704 703 guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } 705 - let previousHasUnseen = hasUnseenNotification 706 - let isRead = isSelected() && isFocusedSurface(surfaceId) 707 - notifications.insert( 708 - WorktreeTerminalNotification( 709 - surfaceId: surfaceId, 710 - title: trimmedTitle, 711 - body: trimmedBody, 712 - isRead: isRead 713 - ), 714 - at: 0 715 - ) 716 - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 704 + if notificationsEnabled { 705 + let previousHasUnseen = hasUnseenNotification 706 + let isRead = isSelected() && isFocusedSurface(surfaceId) 707 + notifications.insert( 708 + WorktreeTerminalNotification( 709 + surfaceId: surfaceId, 710 + title: trimmedTitle, 711 + body: trimmedBody, 712 + isRead: isRead 713 + ), 714 + at: 0 715 + ) 716 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 717 + } 717 718 onNotificationReceived?(trimmedTitle, trimmedBody) 718 719 } 719 720
+11 -1
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 91 91 .URL, 92 92 ] 93 93 94 + static func normalizedWorkingDirectoryPath(_ path: String) -> String { 95 + var normalized = path 96 + while normalized.count > 1 && normalized.hasSuffix("/") { 97 + normalized.removeLast() 98 + } 99 + return normalized 100 + } 101 + 94 102 override var acceptsFirstResponder: Bool { true } 95 103 96 104 init( ··· 105 113 self.fontSize = fontSize ?? 0 106 114 self.context = context 107 115 if let workingDirectory { 108 - let path = workingDirectory.path(percentEncoded: false) 116 + let path = Self.normalizedWorkingDirectoryPath( 117 + workingDirectory.path(percentEncoded: false) 118 + ) 109 119 workingDirectoryCString = path.withCString { strdup($0) } 110 120 } else { 111 121 workingDirectoryCString = nil
+4
supacode/Support/SupacodePaths.swift
··· 19 19 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 20 20 } 21 21 22 + static func repositorySettingsURL(for rootURL: URL) -> URL { 23 + rootURL.standardizedFileURL.appending(path: "supacode.json", directoryHint: .notDirectory) 24 + } 25 + 22 26 private static func repositoryDirectoryName(for rootURL: URL) -> String { 23 27 let repoName = rootURL.lastPathComponent 24 28 if repoName.isEmpty || repoName == ".bare" || repoName == ".git" {
+54
supacodeTests/AppFeatureDefaultEditorTests.swift
··· 39 39 await store.finish() 40 40 } 41 41 42 + @Test(.dependencies) func repositoryLocalSettingsOverrideGlobalRepositorySettings() async throws { 43 + let worktree = makeWorktree() 44 + let repositoriesState = makeRepositoriesState(worktree: worktree) 45 + let settingsStorage = SettingsTestStorage() 46 + let localStorage = RepositoryLocalSettingsTestStorage() 47 + let settingsFileURL = URL( 48 + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" 49 + ) 50 + let repositoryID = worktree.repositoryRootURL.standardizedFileURL.path(percentEncoded: false) 51 + var globalRepositorySettings = RepositorySettings.default 52 + globalRepositorySettings.openActionID = OpenWorktreeAction.finder.settingsID 53 + var localRepositorySettings = RepositorySettings.default 54 + localRepositorySettings.openActionID = OpenWorktreeAction.terminal.settingsID 55 + localRepositorySettings.runScript = "pnpm dev" 56 + 57 + withDependencies { 58 + $0.settingsFileStorage = settingsStorage.storage 59 + $0.settingsFileURL = settingsFileURL 60 + $0.repositoryLocalSettingsStorage = localStorage.storage 61 + } operation: { 62 + @Shared(.settingsFile) var settingsFile 63 + $settingsFile.withLock { 64 + $0.repositories[repositoryID] = globalRepositorySettings 65 + } 66 + } 67 + 68 + let encoder = JSONEncoder() 69 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 70 + try localStorage.save( 71 + encoder.encode(localRepositorySettings), 72 + at: SupacodePaths.repositorySettingsURL(for: worktree.repositoryRootURL) 73 + ) 74 + 75 + let store = TestStore( 76 + initialState: AppFeature.State( 77 + repositories: repositoriesState, 78 + settings: SettingsFeature.State() 79 + ) 80 + ) { 81 + AppFeature() 82 + } withDependencies: { 83 + $0.settingsFileStorage = settingsStorage.storage 84 + $0.settingsFileURL = settingsFileURL 85 + $0.repositoryLocalSettingsStorage = localStorage.storage 86 + } 87 + 88 + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 89 + await store.receive(\.worktreeSettingsLoaded) { 90 + $0.openActionSelection = .terminal 91 + $0.selectedRunScript = "pnpm dev" 92 + } 93 + await store.finish() 94 + } 95 + 42 96 @Test(.dependencies) func selectedWorktreeChangedOnlyUpdatesWatcherSelection() async { 43 97 let worktree = makeWorktree() 44 98 let repositoriesState = makeRepositoriesState(worktree: worktree)
+215
supacodeTests/AppFeatureSystemNotificationTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct AppFeatureSystemNotificationTests { 10 + @Test(.dependencies) func firstTimeDeniedTurnsSystemNotificationsBackOffWithAlert() async { 11 + let storage = SettingsTestStorage() 12 + let authorizationRequests = LockIsolated(0) 13 + let store = withDependencies { 14 + $0.settingsFileStorage = storage.storage 15 + } operation: { 16 + TestStore(initialState: AppFeature.State()) { 17 + AppFeature() 18 + } withDependencies: { 19 + $0.systemNotificationClient.authorizationStatus = { .notDetermined } 20 + $0.systemNotificationClient.requestAuthorization = { 21 + authorizationRequests.withValue { $0 += 1 } 22 + return SystemNotificationClient.AuthorizationRequestResult( 23 + granted: false, 24 + errorMessage: "Mock request error" 25 + ) 26 + } 27 + } 28 + } 29 + store.exhaustivity = .off 30 + 31 + await store.send(.settings(.binding(.set(\.systemNotificationsEnabled, true)))) { 32 + $0.settings.systemNotificationsEnabled = true 33 + } 34 + await store.receive(\.systemNotificationsPermissionFailed) 35 + await store.receive(\.settings.setSystemNotificationsEnabled) { 36 + $0.settings.systemNotificationsEnabled = false 37 + } 38 + 39 + let expectedAlert = AlertState<AppFeature.Alert> { 40 + TextState("Enable Notifications in System Settings") 41 + } actions: { 42 + ButtonState(action: .openSystemNotificationSettings) { 43 + TextState("Open System Settings") 44 + } 45 + ButtonState(role: .cancel, action: .dismiss) { 46 + TextState("Cancel") 47 + } 48 + } message: { 49 + TextState("Supacode cannot send system notifications.\n\nError: Mock request error") 50 + } 51 + 52 + #expect(authorizationRequests.value == 1) 53 + #expect(store.state.settings.systemNotificationsEnabled == false) 54 + #expect(store.state.alert == expectedAlert) 55 + } 56 + 57 + @Test(.dependencies) func deniedStatusShowsAlertAndOpensSystemSettings() async { 58 + let storage = SettingsTestStorage() 59 + let authorizationRequests = LockIsolated(0) 60 + let openedSettings = LockIsolated(0) 61 + let store = withDependencies { 62 + $0.settingsFileStorage = storage.storage 63 + } operation: { 64 + TestStore(initialState: AppFeature.State()) { 65 + AppFeature() 66 + } withDependencies: { 67 + $0.systemNotificationClient.authorizationStatus = { .denied } 68 + $0.systemNotificationClient.requestAuthorization = { 69 + authorizationRequests.withValue { $0 += 1 } 70 + return SystemNotificationClient.AuthorizationRequestResult( 71 + granted: false, 72 + errorMessage: "Mock request error" 73 + ) 74 + } 75 + $0.systemNotificationClient.openSettings = { 76 + openedSettings.withValue { $0 += 1 } 77 + } 78 + } 79 + } 80 + store.exhaustivity = .off 81 + 82 + await store.send(.settings(.binding(.set(\.systemNotificationsEnabled, true)))) { 83 + $0.settings.systemNotificationsEnabled = true 84 + } 85 + await store.receive(\.systemNotificationsPermissionFailed) 86 + await store.receive(\.settings.setSystemNotificationsEnabled) { 87 + $0.settings.systemNotificationsEnabled = false 88 + } 89 + 90 + let expectedAlert = AlertState<AppFeature.Alert> { 91 + TextState("Enable Notifications in System Settings") 92 + } actions: { 93 + ButtonState(action: .openSystemNotificationSettings) { 94 + TextState("Open System Settings") 95 + } 96 + ButtonState(role: .cancel, action: .dismiss) { 97 + TextState("Cancel") 98 + } 99 + } message: { 100 + TextState("Supacode cannot send system notifications.\n\nError: Authorization status is denied.") 101 + } 102 + 103 + #expect(authorizationRequests.value == 0) 104 + #expect(store.state.settings.systemNotificationsEnabled == false) 105 + #expect(store.state.alert == expectedAlert) 106 + 107 + await store.send(.alert(.presented(.openSystemNotificationSettings))) { 108 + $0.alert = nil 109 + } 110 + await store.finish() 111 + #expect(openedSettings.value == 1) 112 + } 113 + 114 + @Test(.dependencies) func notificationReceivedSendsSystemNotificationWhenEnabled() async { 115 + var globalSettings = GlobalSettings.default 116 + globalSettings.systemNotificationsEnabled = true 117 + let sends = LockIsolated<[(String, String)]>([]) 118 + let store = TestStore( 119 + initialState: AppFeature.State( 120 + settings: SettingsFeature.State(settings: globalSettings) 121 + ) 122 + ) { 123 + AppFeature() 124 + } withDependencies: { 125 + $0.systemNotificationClient.send = { title, body in 126 + sends.withValue { $0.append((title, body)) } 127 + } 128 + } 129 + store.exhaustivity = .off 130 + 131 + await store.send( 132 + .terminalEvent( 133 + .notificationReceived( 134 + worktreeID: "/tmp/repo/wt-1", 135 + title: "Done", 136 + body: "Build succeeded" 137 + ) 138 + ) 139 + ) 140 + await store.finish() 141 + 142 + #expect(sends.value.count == 1) 143 + #expect(sends.value.first?.0 == "Done") 144 + #expect(sends.value.first?.1 == "Build succeeded") 145 + } 146 + 147 + @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenSystemNotificationsEnabled() async { 148 + var globalSettings = GlobalSettings.default 149 + globalSettings.systemNotificationsEnabled = true 150 + globalSettings.notificationSoundEnabled = true 151 + let plays = LockIsolated(0) 152 + let store = TestStore( 153 + initialState: AppFeature.State( 154 + settings: SettingsFeature.State(settings: globalSettings) 155 + ) 156 + ) { 157 + AppFeature() 158 + } withDependencies: { 159 + $0.notificationSoundClient.play = { 160 + plays.withValue { $0 += 1 } 161 + } 162 + } 163 + store.exhaustivity = .off 164 + 165 + await store.send( 166 + .terminalEvent( 167 + .notificationReceived( 168 + worktreeID: "/tmp/repo/wt-1", 169 + title: "Done", 170 + body: "Build succeeded" 171 + ) 172 + ) 173 + ) 174 + await store.finish() 175 + 176 + #expect(plays.value == 0) 177 + } 178 + 179 + @Test(.dependencies) func notificationReceivedPlaysLocalSoundWhenSystemNotificationsDisabled() async { 180 + var globalSettings = GlobalSettings.default 181 + globalSettings.systemNotificationsEnabled = false 182 + globalSettings.notificationSoundEnabled = true 183 + let plays = LockIsolated(0) 184 + let sends = LockIsolated(0) 185 + let store = TestStore( 186 + initialState: AppFeature.State( 187 + settings: SettingsFeature.State(settings: globalSettings) 188 + ) 189 + ) { 190 + AppFeature() 191 + } withDependencies: { 192 + $0.notificationSoundClient.play = { 193 + plays.withValue { $0 += 1 } 194 + } 195 + $0.systemNotificationClient.send = { _, _ in 196 + sends.withValue { $0 += 1 } 197 + } 198 + } 199 + store.exhaustivity = .off 200 + 201 + await store.send( 202 + .terminalEvent( 203 + .notificationReceived( 204 + worktreeID: "/tmp/repo/wt-1", 205 + title: "Done", 206 + body: "Build succeeded" 207 + ) 208 + ) 209 + ) 210 + await store.finish() 211 + 212 + #expect(plays.value == 1) 213 + #expect(sends.value == 0) 214 + } 215 + }
+21
supacodeTests/GhosttySurfaceViewTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + @MainActor 6 + struct GhosttySurfaceViewTests { 7 + @Test func normalizedWorkingDirectoryPathRemovesTrailingSlashForNonRootPath() { 8 + #expect( 9 + GhosttySurfaceView.normalizedWorkingDirectoryPath("/Users/onevcat/Sync/github/supacode/") 10 + == "/Users/onevcat/Sync/github/supacode" 11 + ) 12 + #expect( 13 + GhosttySurfaceView.normalizedWorkingDirectoryPath("/Users/onevcat/Sync/github/supacode///") 14 + == "/Users/onevcat/Sync/github/supacode" 15 + ) 16 + } 17 + 18 + @Test func normalizedWorkingDirectoryPathKeepsRootPath() { 19 + #expect(GhosttySurfaceView.normalizedWorkingDirectoryPath("/") == "/") 20 + } 21 + }
+271 -26
supacodeTests/RepositorySettingsKeyTests.swift
··· 14 14 #expect(!json.contains("worktreeBaseRef")) 15 15 } 16 16 17 - @Test(.dependencies) func loadCreatesDefaultAndPersists() throws { 18 - let storage = SettingsTestStorage() 17 + @Test(.dependencies) func loadCreatesDefaultAndMigratesToLocal() throws { 18 + let globalStorage = SettingsTestStorage() 19 + let localStorage = RepositoryLocalSettingsTestStorage() 19 20 let rootURL = URL(fileURLWithPath: "/tmp/repo") 21 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 22 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 23 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 20 24 21 - let settings = withDependencies { 22 - $0.settingsFileStorage = storage.storage 25 + let loaded = withDependencies { 26 + $0.settingsFileStorage = globalStorage.storage 27 + $0.settingsFileURL = settingsFileURL 28 + $0.repositoryLocalSettingsStorage = localStorage.storage 23 29 } operation: { 24 30 @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 25 31 return repositorySettings 26 32 } 27 33 28 - #expect(settings == RepositorySettings.default) 34 + #expect(loaded == .default) 29 35 30 - let saved: SettingsFile = withDependencies { 31 - $0.settingsFileStorage = storage.storage 36 + let localData = try #require(localStorage.data(at: localURL)) 37 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 38 + #expect(localDecoded == .default) 39 + 40 + let globalSaved: SettingsFile = withDependencies { 41 + $0.settingsFileStorage = globalStorage.storage 42 + $0.settingsFileURL = settingsFileURL 43 + $0.repositoryLocalSettingsStorage = localStorage.storage 32 44 } operation: { 33 - @Shared(.settingsFile) var settings: SettingsFile 34 - return settings 45 + @Shared(.settingsFile) var settingsFile: SettingsFile 46 + return settingsFile 35 47 } 36 48 37 - #expect( 38 - saved.repositories[rootURL.path(percentEncoded: false)] == RepositorySettings.default 39 - ) 49 + #expect(globalSaved.repositories[repositoryID] == nil) 40 50 } 41 51 42 - @Test(.dependencies) func saveOverwritesExistingSettings() throws { 43 - let storage = SettingsTestStorage() 52 + @Test(.dependencies) func saveOverwritesExistingSettingsInLocalFile() throws { 53 + let globalStorage = SettingsTestStorage() 54 + let localStorage = RepositoryLocalSettingsTestStorage() 44 55 let rootURL = URL(fileURLWithPath: "/tmp/repo") 56 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 57 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 45 58 46 - var settings = RepositorySettings.default 47 - settings.runScript = "echo updated" 59 + try localStorage.save(encode(.default), at: localURL) 60 + 61 + var updated = RepositorySettings.default 62 + updated.runScript = "echo updated" 63 + 48 64 withDependencies { 49 - $0.settingsFileStorage = storage.storage 65 + $0.settingsFileStorage = globalStorage.storage 66 + $0.settingsFileURL = settingsFileURL 67 + $0.repositoryLocalSettingsStorage = localStorage.storage 50 68 } operation: { 51 69 @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 52 70 $repositorySettings.withLock { 53 - $0 = settings 71 + $0 = updated 54 72 } 55 73 } 56 74 57 - let reloaded: SettingsFile = withDependencies { 58 - $0.settingsFileStorage = storage.storage 59 - } operation: { 60 - @Shared(.settingsFile) var settings: SettingsFile 61 - return settings 62 - } 63 - 64 - #expect(reloaded.repositories[rootURL.path(percentEncoded: false)] == settings) 75 + let localData = try #require(localStorage.data(at: localURL)) 76 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 77 + #expect(localDecoded == updated) 65 78 } 66 79 67 80 @Test func decodeMissingArchiveScriptDefaultsToEmpty() throws { ··· 77 90 let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 78 91 79 92 #expect(settings.archiveScript.isEmpty) 93 + } 94 + 95 + @Test(.dependencies) func loadPrefersLocalSupacodeJSONOverGlobalEntry() throws { 96 + let globalStorage = SettingsTestStorage() 97 + let localStorage = RepositoryLocalSettingsTestStorage() 98 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 99 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 100 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 101 + var globalSettings = RepositorySettings.default 102 + globalSettings.runScript = "echo global" 103 + var localSettings = RepositorySettings.default 104 + localSettings.runScript = "echo local" 105 + 106 + withDependencies { 107 + $0.settingsFileStorage = globalStorage.storage 108 + $0.settingsFileURL = settingsFileURL 109 + $0.repositoryLocalSettingsStorage = localStorage.storage 110 + } operation: { 111 + @Shared(.settingsFile) var settingsFile: SettingsFile 112 + $settingsFile.withLock { 113 + $0.repositories[repositoryID] = globalSettings 114 + } 115 + } 116 + 117 + try localStorage.save( 118 + encode(localSettings), 119 + at: SupacodePaths.repositorySettingsURL(for: rootURL) 120 + ) 121 + 122 + let loaded = withDependencies { 123 + $0.settingsFileStorage = globalStorage.storage 124 + $0.settingsFileURL = settingsFileURL 125 + $0.repositoryLocalSettingsStorage = localStorage.storage 126 + } operation: { 127 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 128 + return repositorySettings 129 + } 130 + 131 + #expect(loaded == localSettings) 132 + } 133 + 134 + @Test(.dependencies) func loadMigratesGlobalWhenLocalMissing() throws { 135 + let globalStorage = SettingsTestStorage() 136 + let localStorage = RepositoryLocalSettingsTestStorage() 137 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 138 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 139 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 140 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 141 + var globalSettings = RepositorySettings.default 142 + globalSettings.runScript = "echo global" 143 + 144 + withDependencies { 145 + $0.settingsFileStorage = globalStorage.storage 146 + $0.settingsFileURL = settingsFileURL 147 + $0.repositoryLocalSettingsStorage = localStorage.storage 148 + } operation: { 149 + @Shared(.settingsFile) var settingsFile: SettingsFile 150 + $settingsFile.withLock { 151 + $0.repositories[repositoryID] = globalSettings 152 + } 153 + } 154 + 155 + let loaded = withDependencies { 156 + $0.settingsFileStorage = globalStorage.storage 157 + $0.settingsFileURL = settingsFileURL 158 + $0.repositoryLocalSettingsStorage = localStorage.storage 159 + } operation: { 160 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 161 + return repositorySettings 162 + } 163 + 164 + #expect(loaded == globalSettings) 165 + 166 + let localData = try #require(localStorage.data(at: localURL)) 167 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 168 + #expect(localDecoded == globalSettings) 169 + } 170 + 171 + @Test(.dependencies) func loadMigratesGlobalWhenLocalInvalid() throws { 172 + let globalStorage = SettingsTestStorage() 173 + let localStorage = RepositoryLocalSettingsTestStorage() 174 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 175 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 176 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 177 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 178 + var globalSettings = RepositorySettings.default 179 + globalSettings.runScript = "echo global" 180 + 181 + withDependencies { 182 + $0.settingsFileStorage = globalStorage.storage 183 + $0.settingsFileURL = settingsFileURL 184 + $0.repositoryLocalSettingsStorage = localStorage.storage 185 + } operation: { 186 + @Shared(.settingsFile) var settingsFile: SettingsFile 187 + $settingsFile.withLock { 188 + $0.repositories[repositoryID] = globalSettings 189 + } 190 + } 191 + 192 + try localStorage.save(Data("{".utf8), at: localURL) 193 + 194 + let loaded = withDependencies { 195 + $0.settingsFileStorage = globalStorage.storage 196 + $0.settingsFileURL = settingsFileURL 197 + $0.repositoryLocalSettingsStorage = localStorage.storage 198 + } operation: { 199 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 200 + return repositorySettings 201 + } 202 + 203 + #expect(loaded == globalSettings) 204 + 205 + let localData = try #require(localStorage.data(at: localURL)) 206 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 207 + #expect(localDecoded == globalSettings) 208 + } 209 + 210 + @Test(.dependencies) func saveWritesLocalWhenLocalFileExists() throws { 211 + let globalStorage = SettingsTestStorage() 212 + let localStorage = RepositoryLocalSettingsTestStorage() 213 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 214 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 215 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 216 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 217 + 218 + try localStorage.save(encode(.default), at: localURL) 219 + 220 + var updated = RepositorySettings.default 221 + updated.runScript = "echo local" 222 + 223 + withDependencies { 224 + $0.settingsFileStorage = globalStorage.storage 225 + $0.settingsFileURL = settingsFileURL 226 + $0.repositoryLocalSettingsStorage = localStorage.storage 227 + } operation: { 228 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 229 + $repositorySettings.withLock { 230 + $0 = updated 231 + } 232 + } 233 + 234 + let localData = try #require(localStorage.data(at: localURL)) 235 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 236 + #expect(localDecoded == updated) 237 + 238 + let globalSaved: SettingsFile = withDependencies { 239 + $0.settingsFileStorage = globalStorage.storage 240 + $0.settingsFileURL = settingsFileURL 241 + $0.repositoryLocalSettingsStorage = localStorage.storage 242 + } operation: { 243 + @Shared(.settingsFile) var settingsFile: SettingsFile 244 + return settingsFile 245 + } 246 + 247 + #expect(globalSaved.repositories[repositoryID] == nil) 248 + } 249 + 250 + @Test(.dependencies) func saveWritesLocalWhenLocalFileMissing() throws { 251 + let globalStorage = SettingsTestStorage() 252 + let localStorage = RepositoryLocalSettingsTestStorage() 253 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 254 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 255 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 256 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 257 + 258 + var updated = RepositorySettings.default 259 + updated.runScript = "echo local" 260 + 261 + withDependencies { 262 + $0.settingsFileStorage = globalStorage.storage 263 + $0.settingsFileURL = settingsFileURL 264 + $0.repositoryLocalSettingsStorage = localStorage.storage 265 + } operation: { 266 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 267 + $repositorySettings.withLock { 268 + $0 = updated 269 + } 270 + } 271 + 272 + let localData = try #require(localStorage.data(at: localURL)) 273 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 274 + #expect(localDecoded == updated) 275 + 276 + let globalSaved: SettingsFile = withDependencies { 277 + $0.settingsFileStorage = globalStorage.storage 278 + $0.settingsFileURL = settingsFileURL 279 + $0.repositoryLocalSettingsStorage = localStorage.storage 280 + } operation: { 281 + @Shared(.settingsFile) var settingsFile: SettingsFile 282 + return settingsFile 283 + } 284 + 285 + #expect(globalSaved.repositories[repositoryID] == nil) 286 + } 287 + 288 + private func encode(_ settings: RepositorySettings) throws -> Data { 289 + let encoder = JSONEncoder() 290 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 291 + return try encoder.encode(settings) 292 + } 293 + } 294 + 295 + nonisolated final class RepositoryLocalSettingsTestStorage: @unchecked Sendable { 296 + private let lock = NSLock() 297 + private var dataByURL: [URL: Data] = [:] 298 + 299 + var storage: RepositoryLocalSettingsStorage { 300 + RepositoryLocalSettingsStorage( 301 + load: { try self.load($0) }, 302 + save: { try self.save($0, at: $1) } 303 + ) 304 + } 305 + 306 + func data(at url: URL) -> Data? { 307 + lock.lock() 308 + defer { lock.unlock() } 309 + return dataByURL[url] 310 + } 311 + 312 + func save(_ data: Data, at url: URL) throws { 313 + lock.lock() 314 + defer { lock.unlock() } 315 + dataByURL[url] = data 316 + } 317 + 318 + private func load(_ url: URL) throws -> Data { 319 + lock.lock() 320 + defer { lock.unlock() } 321 + guard let data = dataByURL[url] else { 322 + throw RepositoryLocalSettingsStorageError.missing 323 + } 324 + return data 80 325 } 81 326 }
+23
supacodeTests/SettingsFeatureTests.swift
··· 19 19 updatesAutomaticallyDownloadUpdates: true, 20 20 inAppNotificationsEnabled: false, 21 21 notificationSoundEnabled: true, 22 + systemNotificationsEnabled: true, 22 23 moveNotifiedWorktreeToTop: false, 23 24 analyticsEnabled: false, 24 25 crashReportsEnabled: true, ··· 45 46 $0.inAppNotificationsEnabled = false 46 47 $0.notificationSoundEnabled = true 47 48 $0.moveNotifiedWorktreeToTop = false 49 + $0.systemNotificationsEnabled = true 48 50 $0.analyticsEnabled = false 49 51 $0.crashReportsEnabled = true 50 52 $0.githubIntegrationEnabled = true ··· 65 67 updatesAutomaticallyDownloadUpdates: false, 66 68 inAppNotificationsEnabled: false, 67 69 notificationSoundEnabled: false, 70 + systemNotificationsEnabled: false, 68 71 moveNotifiedWorktreeToTop: true, 69 72 analyticsEnabled: true, 70 73 crashReportsEnabled: false, ··· 92 95 updatesAutomaticallyDownloadUpdates: initialSettings.updatesAutomaticallyDownloadUpdates, 93 96 inAppNotificationsEnabled: initialSettings.inAppNotificationsEnabled, 94 97 notificationSoundEnabled: initialSettings.notificationSoundEnabled, 98 + systemNotificationsEnabled: initialSettings.systemNotificationsEnabled, 95 99 moveNotifiedWorktreeToTop: initialSettings.moveNotifiedWorktreeToTop, 96 100 analyticsEnabled: initialSettings.analyticsEnabled, 97 101 crashReportsEnabled: initialSettings.crashReportsEnabled, ··· 105 109 expectNoDifference(settingsFile.global, expectedSettings) 106 110 } 107 111 112 + @Test(.dependencies) func setSystemNotificationsEnabledPersistsChanges() async { 113 + var initialSettings = GlobalSettings.default 114 + initialSettings.systemNotificationsEnabled = false 115 + @Shared(.settingsFile) var settingsFile 116 + $settingsFile.withLock { $0.global = initialSettings } 117 + 118 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 119 + SettingsFeature() 120 + } 121 + 122 + await store.send(.setSystemNotificationsEnabled(true)) { 123 + $0.systemNotificationsEnabled = true 124 + } 125 + await store.receive(\.delegate.settingsChanged) 126 + #expect(settingsFile.global.systemNotificationsEnabled == true) 127 + } 128 + 108 129 @Test(.dependencies) func selectionDoesNotMutateRepositorySettings() async { 109 130 let selection = SettingsSection.repository("repo-id") 110 131 let store = TestStore(initialState: SettingsFeature.State()) { ··· 142 163 updatesAutomaticallyDownloadUpdates: true, 143 164 inAppNotificationsEnabled: false, 144 165 notificationSoundEnabled: false, 166 + systemNotificationsEnabled: true, 145 167 moveNotifiedWorktreeToTop: true, 146 168 analyticsEnabled: true, 147 169 crashReportsEnabled: false, ··· 161 183 $0.inAppNotificationsEnabled = false 162 184 $0.notificationSoundEnabled = false 163 185 $0.moveNotifiedWorktreeToTop = true 186 + $0.systemNotificationsEnabled = true 164 187 $0.analyticsEnabled = true 165 188 $0.crashReportsEnabled = false 166 189 $0.githubIntegrationEnabled = true
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 102 102 #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) 103 103 #expect(settings.global.inAppNotificationsEnabled == true) 104 104 #expect(settings.global.notificationSoundEnabled == true) 105 + #expect(settings.global.systemNotificationsEnabled == false) 105 106 #expect(settings.global.moveNotifiedWorktreeToTop == true) 106 107 #expect(settings.global.analyticsEnabled == true) 107 108 #expect(settings.global.crashReportsEnabled == true)