native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #46 from supabitapp/dolphin

Add in-app OSC notifications

authored by

khoi and committed by
GitHub
7b9ce21e 7e707713

+356 -23
+16
bins/osc777-notify.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + title=${1:-"Title"} 5 + body=${2:-"Body"} 6 + count=${3:-"3"} 7 + sleep_seconds=${4:-"0.1"} 8 + 9 + for i in $(seq 1 "$count"); do 10 + if [ "$count" -gt 1 ]; then 11 + printf '\033]777;notify;%s %s/%s;%s\a' "$title" "$i" "$count" "$body" 12 + else 13 + printf '\033]777;notify;%s;%s\a' "$title" "$body" 14 + fi 15 + sleep "$sleep_seconds" 16 + done
+11
bins/osc9-4-progress.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + sleep_seconds=${1:-"0.05"} 5 + 6 + for progress in $(seq 1 100); do 7 + printf '\033]9;4;1;%s\a' "$progress" 8 + sleep "$sleep_seconds" 9 + done 10 + 11 + printf '\033]9;4;0\a'
+15
bins/osc9-notify.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + body=${1:-"Hello from OSC 9"} 5 + count=${2:-"3"} 6 + sleep_seconds=${3:-"0.1"} 7 + 8 + for i in $(seq 1 "$count"); do 9 + if [ "$count" -gt 1 ]; then 10 + printf '\033]9;%s %s/%s\a' "$body" "$i" "$count" 11 + else 12 + printf '\033]9;%s\a' "$body" 13 + fi 14 + sleep "$sleep_seconds" 15 + done
+5 -2
supacode/App/WorktreeSplitView.swift
··· 10 10 if isInfoVisible { 11 11 HSplitView { 12 12 WorktreeDetailView(store: store, terminalManager: terminalManager) 13 - WorktreeInfoView(store: store.scope(state: \.worktreeInfo, action: \.worktreeInfo)) 14 - .frame(minWidth: 260, idealWidth: 320, maxWidth: 400) 13 + WorktreeInfoView( 14 + store: store.scope(state: \.worktreeInfo, action: \.worktreeInfo), 15 + terminalManager: terminalManager 16 + ) 17 + .frame(minWidth: 260, idealWidth: 320, maxWidth: 400) 15 18 } 16 19 } else { 17 20 WorktreeDetailView(store: store, terminalManager: terminalManager)
+6
supacode/App/supacodeApp.swift
··· 76 76 }, 77 77 prune: { ids in 78 78 terminalManager.prune(keeping: ids) 79 + }, 80 + setNotificationsEnabled: { enabled in 81 + terminalManager.setNotificationsEnabled(enabled) 82 + }, 83 + clearNotificationIndicator: { worktree in 84 + terminalManager.clearNotificationIndicator(for: worktree) 79 85 } 80 86 ) 81 87 }
+11 -1
supacode/Clients/Terminal/TerminalClient.swift
··· 5 5 var closeFocusedTab: @MainActor @Sendable (Worktree) -> Bool 6 6 var closeFocusedSurface: @MainActor @Sendable (Worktree) -> Bool 7 7 var prune: @MainActor @Sendable (Set<Worktree.ID>) -> Void 8 + var setNotificationsEnabled: @MainActor @Sendable (Bool) -> Void 9 + var clearNotificationIndicator: @MainActor @Sendable (Worktree) -> Void 8 10 } 9 11 10 12 extension TerminalClient: DependencyKey { ··· 18 20 }, 19 21 prune: { _ in 20 22 fatalError("TerminalClient.prune not configured") 23 + }, 24 + setNotificationsEnabled: { _ in 25 + fatalError("TerminalClient.setNotificationsEnabled not configured") 26 + }, 27 + clearNotificationIndicator: { _ in 28 + fatalError("TerminalClient.clearNotificationIndicator not configured") 21 29 } 22 30 ) 23 31 ··· 25 33 createTab: { _ in }, 26 34 closeFocusedTab: { _ in false }, 27 35 closeFocusedSurface: { _ in false }, 28 - prune: { _ in } 36 + prune: { _ in }, 37 + setNotificationsEnabled: { _ in }, 38 + clearNotificationIndicator: { _ in } 29 39 ) 30 40 } 31 41
+17 -7
supacode/Features/App/Reducer/AppFeature.swift
··· 74 74 } 75 75 let settings = repositorySettingsClient.load(worktree.repositoryRootURL) 76 76 state.openActionSelection = OpenWorktreeAction.fromSettingsID(settings.openActionID) 77 - return .send(.worktreeInfo(.worktreeChanged(worktree))) 77 + return .merge( 78 + .send(.worktreeInfo(.worktreeChanged(worktree))), 79 + .run { _ in 80 + await terminalClient.clearNotificationIndicator(worktree) 81 + } 82 + ) 78 83 79 84 case .repositories(.delegate(.repositoriesChanged(let repositories))): 80 85 let ids = Set(repositories.flatMap { $0.worktrees.map(\.id) }) ··· 83 88 } 84 89 85 90 case .settings(.delegate(.settingsChanged(let settings))): 86 - return .send( 87 - .updates( 88 - .applySettings( 89 - automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates, 90 - automaticallyDownloads: settings.updatesAutomaticallyDownloadUpdates 91 + return .merge( 92 + .send( 93 + .updates( 94 + .applySettings( 95 + automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates, 96 + automaticallyDownloads: settings.updatesAutomaticallyDownloadUpdates 97 + ) 91 98 ) 92 - ) 99 + ), 100 + .run { _ in 101 + await terminalClient.setNotificationsEnabled(settings.inAppNotificationsEnabled) 102 + } 93 103 ) 94 104 95 105 case .openActionSelectionChanged(let action):
+8
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 6 6 let isMainWorktree: Bool 7 7 let isLoading: Bool 8 8 let taskStatus: WorktreeTaskStatus? 9 + let showsNotificationIndicator: Bool 9 10 let shortcutHint: String? 10 11 11 12 var body: some View { ··· 25 26 } 26 27 Text(name) 27 28 Spacer(minLength: 8) 29 + if showsNotificationIndicator { 30 + Image(systemName: "bell.fill") 31 + .font(.caption) 32 + .foregroundStyle(.orange) 33 + .help("Unread notifications (no shortcut)") 34 + .accessibilityLabel("Unread notifications") 35 + } 28 36 if let shortcutHint { 29 37 ShortcutHintView(text: shortcutHint, color: .secondary) 30 38 }
+4
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 37 37 shortcutHint: String? 38 38 ) -> some View { 39 39 let taskStatus = terminalManager.focusedTaskStatus(for: row.id) 40 + let isSelected = row.id == store.state.selectedWorktreeID 41 + let showsNotificationIndicator = !isSelected && terminalManager.hasUnseenNotifications(for: row.id) 40 42 let displayName = row.isDeleting ? "\(row.name) (removing...)" : row.name 41 43 if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 42 44 WorktreeRow( ··· 45 47 isMainWorktree: row.isMainWorktree, 46 48 isLoading: row.isPending || row.isDeleting, 47 49 taskStatus: taskStatus, 50 + showsNotificationIndicator: showsNotificationIndicator, 48 51 shortcutHint: shortcutHint 49 52 ) 50 53 .tag(SidebarSelection.worktree(row.id)) ··· 75 78 isMainWorktree: row.isMainWorktree, 76 79 isLoading: row.isPending || row.isDeleting, 77 80 taskStatus: taskStatus, 81 + showsNotificationIndicator: showsNotificationIndicator, 78 82 shortcutHint: shortcutHint 79 83 ) 80 84 .tag(SidebarSelection.worktree(row.id))
+23 -1
supacode/Features/Settings/Models/GlobalSettings.swift
··· 2 2 var appearanceMode: AppearanceMode 3 3 var updatesAutomaticallyCheckForUpdates: Bool 4 4 var updatesAutomaticallyDownloadUpdates: Bool 5 + var inAppNotificationsEnabled: Bool 5 6 6 7 static let `default` = GlobalSettings( 7 8 appearanceMode: .system, 8 9 updatesAutomaticallyCheckForUpdates: true, 9 - updatesAutomaticallyDownloadUpdates: false 10 + updatesAutomaticallyDownloadUpdates: false, 11 + inAppNotificationsEnabled: true 10 12 ) 13 + 14 + init( 15 + appearanceMode: AppearanceMode, 16 + updatesAutomaticallyCheckForUpdates: Bool, 17 + updatesAutomaticallyDownloadUpdates: Bool, 18 + inAppNotificationsEnabled: Bool 19 + ) { 20 + self.appearanceMode = appearanceMode 21 + self.updatesAutomaticallyCheckForUpdates = updatesAutomaticallyCheckForUpdates 22 + self.updatesAutomaticallyDownloadUpdates = updatesAutomaticallyDownloadUpdates 23 + self.inAppNotificationsEnabled = inAppNotificationsEnabled 24 + } 25 + 26 + init(from decoder: any Decoder) throws { 27 + let container = try decoder.container(keyedBy: CodingKeys.self) 28 + appearanceMode = try container.decode(AppearanceMode.self, forKey: .appearanceMode) 29 + updatesAutomaticallyCheckForUpdates = try container.decode(Bool.self, forKey: .updatesAutomaticallyCheckForUpdates) 30 + updatesAutomaticallyDownloadUpdates = try container.decode(Bool.self, forKey: .updatesAutomaticallyDownloadUpdates) 31 + inAppNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .inAppNotificationsEnabled) ?? Self.default.inAppNotificationsEnabled 32 + } 11 33 }
+11 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 8 8 var appearanceMode: AppearanceMode 9 9 var updatesAutomaticallyCheckForUpdates: Bool 10 10 var updatesAutomaticallyDownloadUpdates: Bool 11 + var inAppNotificationsEnabled: Bool 11 12 12 13 init(settings: GlobalSettings = .default) { 13 14 appearanceMode = settings.appearanceMode 14 15 updatesAutomaticallyCheckForUpdates = settings.updatesAutomaticallyCheckForUpdates 15 16 updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates 17 + inAppNotificationsEnabled = settings.inAppNotificationsEnabled 16 18 } 17 19 18 20 var globalSettings: GlobalSettings { 19 21 GlobalSettings( 20 22 appearanceMode: appearanceMode, 21 23 updatesAutomaticallyCheckForUpdates: updatesAutomaticallyCheckForUpdates, 22 - updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates 24 + updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates, 25 + inAppNotificationsEnabled: inAppNotificationsEnabled 23 26 ) 24 27 } 25 28 } ··· 29 32 case setAppearanceMode(AppearanceMode) 30 33 case setUpdatesAutomaticallyCheckForUpdates(Bool) 31 34 case setUpdatesAutomaticallyDownloadUpdates(Bool) 35 + case setInAppNotificationsEnabled(Bool) 32 36 case delegate(Delegate) 33 37 } 34 38 ··· 60 64 61 65 case .setUpdatesAutomaticallyDownloadUpdates(let value): 62 66 state.updatesAutomaticallyDownloadUpdates = value 67 + let settings = state.globalSettings 68 + settingsClient.save(settings) 69 + return .send(.delegate(.settingsChanged(settings))) 70 + 71 + case .setInAppNotificationsEnabled(let value): 72 + state.inAppNotificationsEnabled = value 63 73 let settings = state.globalSettings 64 74 settingsClient.save(settings) 65 75 return .send(.delegate(.settingsChanged(settings)))
+25
supacode/Features/Settings/Views/NotificationsSettingsView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct NotificationsSettingsView: View { 5 + @Bindable var store: StoreOf<SettingsFeature> 6 + 7 + var body: some View { 8 + VStack(alignment: .leading) { 9 + Form { 10 + Section("Notifications") { 11 + Toggle( 12 + "Show bell icon next to worktree", 13 + isOn: Binding( 14 + get: { store.inAppNotificationsEnabled }, 15 + set: { store.send(.setInAppNotificationsEnabled($0)) } 16 + ) 17 + ) 18 + .help("Show bell icon next to worktree (no shortcut)") 19 + } 20 + } 21 + .formStyle(.grouped) 22 + } 23 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 24 + } 25 + }
+1
supacode/Features/Settings/Views/SettingsSection.swift
··· 4 4 case agents 5 5 case chat 6 6 case appearance 7 + case notifications 7 8 case updates 8 9 case repository(Repository.ID) 9 10 }
+8
supacode/Features/Settings/Views/SettingsView.swift
··· 30 30 .tag(SettingsSection.chat) 31 31 Label("Appearance", systemImage: "paintpalette") 32 32 .tag(SettingsSection.appearance) 33 + Label("Notifications", systemImage: "bell") 34 + .tag(SettingsSection.notifications) 33 35 Label("Updates", systemImage: "arrow.down.circle") 34 36 .tag(SettingsSection.updates) 35 37 ··· 64 66 AppearanceSettingsView(store: settingsStore) 65 67 .navigationTitle("Appearance") 66 68 .navigationSubtitle("Theme and visuals") 69 + } 70 + case .notifications: 71 + SettingsDetailView { 72 + NotificationsSettingsView(store: settingsStore) 73 + .navigationTitle("Notifications") 74 + .navigationSubtitle("In-app alerts and delivery") 67 75 } 68 76 case .updates: 69 77 SettingsDetailView {
+21
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 5 5 final class WorktreeTerminalManager { 6 6 private let runtime: GhosttyRuntime 7 7 private var states: [Worktree.ID: WorktreeTerminalState] = [:] 8 + private var notificationsEnabled = true 8 9 9 10 init(runtime: GhosttyRuntime) { 10 11 self.runtime = runtime ··· 23 24 worktree: worktree, 24 25 runSetupScript: runSetupScript 25 26 ) 27 + state.setNotificationsEnabled(notificationsEnabled) 26 28 states[worktree.id] = state 27 29 return state 28 30 } ··· 55 57 states = states.filter { worktreeIDs.contains($0.key) } 56 58 } 57 59 60 + func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? { 61 + states[worktreeID] 62 + } 63 + 58 64 func focusedTaskStatus(for worktreeID: Worktree.ID) -> WorktreeTaskStatus? { 59 65 states[worktreeID]?.focusedTaskStatus 66 + } 67 + 68 + func setNotificationsEnabled(_ enabled: Bool) { 69 + notificationsEnabled = enabled 70 + for state in states.values { 71 + state.setNotificationsEnabled(enabled) 72 + } 73 + } 74 + 75 + func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { 76 + states[worktreeID]?.hasUnseenNotification == true 77 + } 78 + 79 + func clearNotificationIndicator(for worktree: Worktree) { 80 + states[worktree.id]?.clearNotificationIndicator() 60 81 } 61 82 }
+19
supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift
··· 1 + import Foundation 2 + 3 + struct WorktreeTerminalNotification: Identifiable, Equatable, Sendable { 4 + let id: UUID 5 + let surfaceId: UUID 6 + let title: String 7 + let body: String 8 + 9 + init(id: UUID = UUID(), surfaceId: UUID, title: String, body: String) { 10 + self.id = id 11 + self.surfaceId = surfaceId 12 + self.title = title 13 + self.body = body 14 + } 15 + 16 + var content: String { 17 + [title, body].filter { !$0.isEmpty }.joined(separator: " - ") 18 + } 19 + }
+43
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 15 15 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 16 16 private var tabIsRunningById: [TerminalTabID: Bool] = [:] 17 17 private var pendingSetupScript: Bool 18 + var notifications: [WorktreeTerminalNotification] = [] 19 + var notificationsEnabled = true 20 + var hasUnseenNotification = false 18 21 19 22 init(runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false) { 20 23 self.runtime = runtime ··· 61 64 func focusSelectedTab() { 62 65 guard let tabId = tabManager.selectedTabId else { return } 63 66 focusSurface(in: tabId) 67 + } 68 + 69 + @discardableResult 70 + func focusSurface(id: UUID) -> Bool { 71 + guard let tabId = tabId(containing: id), 72 + let surface = surfaces[id] 73 + else { 74 + return false 75 + } 76 + tabManager.selectTab(tabId) 77 + focusSurface(surface, in: tabId) 78 + return true 64 79 } 65 80 66 81 @discardableResult ··· 242 257 tabManager.closeAll() 243 258 } 244 259 260 + func setNotificationsEnabled(_ enabled: Bool) { 261 + notificationsEnabled = enabled 262 + if !enabled { 263 + hasUnseenNotification = false 264 + } 265 + } 266 + 267 + func clearNotificationIndicator() { 268 + hasUnseenNotification = false 269 + } 270 + 245 271 private func setupScriptInput(shouldRun: Bool) -> String? { 246 272 guard shouldRun else { return nil } 247 273 let settings = settingsStorage.load(for: worktree.repositoryRootURL) ··· 289 315 guard let self else { return } 290 316 self.updateRunningState(for: tabId) 291 317 } 318 + view.bridge.onDesktopNotification = { [weak self, weak view] title, body in 319 + guard let self, let view else { return } 320 + self.appendNotification(title: title, body: body, surfaceId: view.id) 321 + } 292 322 view.bridge.onCloseRequest = { [weak self, weak view] processAlive in 293 323 guard let self, let view else { return } 294 324 self.handleCloseRequest(for: view, processAlive: processAlive) ··· 326 356 focusedSurfaceIdByTab[tabId] = surface.id 327 357 surface.requestFocus() 328 358 updateTabTitle(for: tabId) 359 + } 360 + 361 + private func appendNotification(title: String, body: String, surfaceId: UUID) { 362 + guard notificationsEnabled else { return } 363 + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) 364 + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) 365 + guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } 366 + notifications.append(WorktreeTerminalNotification( 367 + surfaceId: surfaceId, 368 + title: trimmedTitle, 369 + body: trimmedBody 370 + )) 371 + hasUnseenNotification = true 329 372 } 330 373 331 374 private func removeTree(for tabId: TerminalTabID) {
+26
supacode/Features/Terminal/Views/WorktreeNotificationsListView.swift
··· 1 + import SwiftUI 2 + 3 + struct WorktreeNotificationsListView: View { 4 + @Bindable var state: WorktreeTerminalState 5 + let worktreeName: String 6 + 7 + var body: some View { 8 + if state.notificationsEnabled, !state.notifications.isEmpty { 9 + VStack(alignment: .leading) { 10 + Text("Notifications") 11 + .font(.headline) 12 + ForEach(state.notifications) { notification in 13 + Button { 14 + _ = state.focusSurface(id: notification.surfaceId) 15 + } label: { 16 + Text("\(worktreeName) - \(notification.content)") 17 + .frame(maxWidth: .infinity, alignment: .leading) 18 + } 19 + .buttonStyle(.plain) 20 + .help("Focus pane (no shortcut)") 21 + } 22 + } 23 + .frame(maxWidth: .infinity, alignment: .leading) 24 + } 25 + } 26 + }
+7 -1
supacode/Features/WorktreeInfo/Views/WorktreeInfoView.swift
··· 3 3 4 4 struct WorktreeInfoView: View { 5 5 @Bindable var store: StoreOf<WorktreeInfoFeature> 6 + let terminalManager: WorktreeTerminalManager 6 7 7 8 var body: some View { 8 9 let state = store.state ··· 23 24 } 24 25 } 25 26 .frame(maxWidth: .infinity, maxHeight: .infinity) 26 - } else if let snapshot = state.snapshot { 27 + } else if let snapshot = state.snapshot, let worktree = state.worktree { 28 + let terminalState = terminalManager.stateIfExists(for: worktree.id) 27 29 ScrollView { 28 30 VStack(alignment: .leading) { 29 31 if case .failed(let message) = state.status { ··· 126 128 Text(nextRefresh, style: .relative) 127 129 } 128 130 .foregroundStyle(.secondary) 131 + } 132 + 133 + if let terminalState { 134 + WorktreeNotificationsListView(state: terminalState, worktreeName: worktree.name) 129 135 } 130 136 } 131 137 .frame(maxWidth: .infinity, alignment: .leading)
+5 -2
supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
··· 13 13 var onCloseTab: ((ghostty_action_close_tab_mode_e) -> Bool)? 14 14 var onGotoTab: ((ghostty_action_goto_tab_e) -> Bool)? 15 15 var onProgressReport: ((ghostty_action_progress_report_state_e) -> Void)? 16 + var onDesktopNotification: ((String, String) -> Void)? 16 17 private var progressResetTask: Task<Void, Never>? 17 18 18 19 deinit { ··· 163 164 164 165 case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: 165 166 let note = action.action.desktop_notification 166 - state.desktopNotificationTitle = string(from: note.title) 167 - state.desktopNotificationBody = string(from: note.body) 167 + let title = string(from: note.title) ?? "" 168 + let body = string(from: note.body) ?? "" 169 + guard !(title.isEmpty && body.isEmpty) else { return true } 170 + onDesktopNotification?(title, body) 168 171 return true 169 172 170 173 default:
-2
supacode/Infrastructure/Ghostty/GhosttySurfaceState.swift
··· 46 46 var openConfigCount: Int = 0 47 47 var presentTerminalCount: Int = 0 48 48 var resetWindowSizeCount: Int = 0 49 - var desktopNotificationTitle: String? 50 - var desktopNotificationBody: String? 51 49 var quitTimer: ghostty_action_quit_timer_e? 52 50 }
+32
supacodeTests/GhosttySurfaceBridgeTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + import GhosttyKit 5 + 6 + @MainActor 7 + struct GhosttySurfaceBridgeTests { 8 + @Test func desktopNotificationEmitsCallback() { 9 + let bridge = GhosttySurfaceBridge() 10 + var received: (String, String)? 11 + bridge.onDesktopNotification = { title, body in 12 + received = (title, body) 13 + } 14 + 15 + var action = ghostty_action_s() 16 + action.tag = GHOSTTY_ACTION_DESKTOP_NOTIFICATION 17 + let target = ghostty_target_s() 18 + 19 + "Title".withCString { titlePtr in 20 + "Body".withCString { bodyPtr in 21 + action.action.desktop_notification = ghostty_action_desktop_notification_s( 22 + title: titlePtr, 23 + body: bodyPtr 24 + ) 25 + _ = bridge.handleAction(target: target, action: action) 26 + } 27 + } 28 + 29 + #expect(received?.0 == "Title") 30 + #expect(received?.1 == "Body") 31 + } 32 + }
+7 -3
supacodeTests/SettingsFeatureTests.swift
··· 9 9 let loaded = GlobalSettings( 10 10 appearanceMode: .dark, 11 11 updatesAutomaticallyCheckForUpdates: false, 12 - updatesAutomaticallyDownloadUpdates: true 12 + updatesAutomaticallyDownloadUpdates: true, 13 + inAppNotificationsEnabled: false 13 14 ) 14 15 let store = TestStore(initialState: SettingsFeature.State()) { 15 16 SettingsFeature() ··· 21 22 $0.appearanceMode = .dark 22 23 $0.updatesAutomaticallyCheckForUpdates = false 23 24 $0.updatesAutomaticallyDownloadUpdates = true 25 + $0.inAppNotificationsEnabled = false 24 26 } 25 27 await store.receive(.delegate(.settingsChanged(loaded))) 26 28 } ··· 41 43 await store.receive(.delegate(.settingsChanged(GlobalSettings( 42 44 appearanceMode: .dark, 43 45 updatesAutomaticallyCheckForUpdates: true, 44 - updatesAutomaticallyDownloadUpdates: false 46 + updatesAutomaticallyDownloadUpdates: false, 47 + inAppNotificationsEnabled: true 45 48 )))) 46 49 47 50 #expect(saved.value == GlobalSettings( 48 51 appearanceMode: .dark, 49 52 updatesAutomaticallyCheckForUpdates: true, 50 - updatesAutomaticallyDownloadUpdates: false 53 + updatesAutomaticallyDownloadUpdates: false, 54 + inAppNotificationsEnabled: true 51 55 )) 52 56 } 53 57 }
+27
supacodeTests/SettingsStorageTests.swift
··· 51 51 #expect(decoded == .default) 52 52 } 53 53 54 + @Test func migratesOldSettingsWithoutInAppNotificationsEnabled() throws { 55 + let root = try makeTempDirectory() 56 + defer { try? FileManager.default.removeItem(at: root) } 57 + let settingsURL = root.appending(path: "settings.json") 58 + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) 59 + 60 + let oldSettings = """ 61 + { 62 + "global": { 63 + "appearanceMode": "dark", 64 + "updatesAutomaticallyCheckForUpdates": false, 65 + "updatesAutomaticallyDownloadUpdates": true 66 + }, 67 + "repositories": {} 68 + } 69 + """ 70 + try Data(oldSettings.utf8).write(to: settingsURL) 71 + 72 + let storage = SettingsStorage(settingsURL: settingsURL) 73 + let settings = storage.load() 74 + 75 + #expect(settings.global.appearanceMode == .dark) 76 + #expect(settings.global.updatesAutomaticallyCheckForUpdates == false) 77 + #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) 78 + #expect(settings.global.inAppNotificationsEnabled == true) 79 + } 80 + 54 81 private func makeTempDirectory() throws -> URL { 55 82 let url = FileManager.default.temporaryDirectory 56 83 .appending(path: UUID().uuidString, directoryHint: .isDirectory)
+8 -3
supacodeTests/supacodeTests.swift
··· 16 16 } 17 17 18 18 @Test func worktreeNameGeneratorReturnsRemainingName() { 19 + let adjectives = WorktreeNameGenerator.adjectives 19 20 let animals = WorktreeNameGenerator.animals 20 - let expected = animals.last 21 - let excluded = Set(animals.dropLast()) 21 + let allNames = adjectives.flatMap { adj in animals.map { "\(adj)-\($0)" } } 22 + let expected = allNames.last! 23 + let excluded = Set(allNames.dropLast()) 22 24 let name = WorktreeNameGenerator.nextName(excluding: excluded) 23 25 #expect(name == expected) 24 26 } 25 27 26 28 @Test func worktreeNameGeneratorReturnsNilWhenExhausted() { 27 - let excluded = Set(WorktreeNameGenerator.animals) 29 + let adjectives = WorktreeNameGenerator.adjectives 30 + let animals = WorktreeNameGenerator.animals 31 + let allNames = adjectives.flatMap { adj in animals.map { "\(adj)-\($0)" } } 32 + let excluded = Set(allNames) 28 33 let name = WorktreeNameGenerator.nextName(excluding: excluded) 29 34 #expect(name == nil) 30 35 }