diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 27dc1736a..9b5b36f34 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -78,6 +78,7 @@ public struct SettingsFeature { public var confirmQuitMode: ConfirmQuitMode public var terminateSessionsOnQuit: Bool public var remoteSessionPersistenceEnabled: Bool + public var appVisibility: AppVisibility public var cliInstallState = CLIInstallState.checking /// Installed editors in menu order, resolved once off the picker's body. public var installedOpenActions: [OpenWorktreeAction] @@ -130,6 +131,7 @@ public struct SettingsFeature { confirmQuitMode = settings.confirmQuitMode terminateSessionsOnQuit = settings.terminateSessionsOnQuit remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled + appVisibility = settings.appVisibility defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" } @@ -170,7 +172,8 @@ public struct SettingsFeature { autoUpdateAgentIntegrationsEnabled: autoUpdateAgentIntegrationsEnabled, confirmQuitMode: confirmQuitMode, terminateSessionsOnQuit: terminateSessionsOnQuit, - remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled + remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, + appVisibility: appVisibility ) } } @@ -181,6 +184,7 @@ public struct SettingsFeature { case repositoriesChanged([SettingsRepositorySummary]) case setSelection(SettingsSection?) case setSystemNotificationsEnabled(Bool) + case setAppVisibility(AppVisibility) case setAutomatedActionPolicy(AutomatedActionPolicy) case showNotificationPermissionAlert(errorMessage: String?) case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) @@ -305,6 +309,7 @@ public struct SettingsFeature { state.confirmQuitMode = normalizedSettings.confirmQuitMode state.terminateSessionsOnQuit = normalizedSettings.terminateSessionsOnQuit state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled + state.appVisibility = normalizedSettings.appVisibility state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.syncGlobalDefaults(from: normalizedSettings) synchronizeRepositorySelection(for: &state) @@ -331,6 +336,14 @@ public struct SettingsFeature { state.syncGlobalDefaults(from: state.globalSettings) return persist(state) + case .setAppVisibility(let visibility): + // MenuBarExtra echoes the current value on every scene evaluation; + // persisting each echo would loop scene -> persist -> scene. + guard state.appVisibility != visibility else { return .none } + state.appVisibility = visibility + state.syncGlobalDefaults(from: state.globalSettings) + return persist(state) + case .setAutomatedActionPolicy(let policy): state.automatedActionPolicy = policy state.syncGlobalDefaults(from: state.globalSettings) diff --git a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift new file mode 100644 index 000000000..d5a3d9bbc --- /dev/null +++ b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift @@ -0,0 +1,35 @@ +import SupacodeSettingsShared +import SwiftUI + +/// One card in the Dock/menu-bar visibility picker, mirroring `AppearanceOptionCardView`. +struct AppVisibilityOptionCardView: View { + let visibility: AppVisibility + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(visibility.imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(.rect(cornerRadius: 8)) + .accessibilityLabel(visibility.title) + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isSelected ? Color.accentColor : .clear, + lineWidth: 2 + ) + } + Text(visibility.title) + .font(.callout) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(isSelected ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .help(visibility.help) + } +} diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index 1e9a627ca..e2c0ccd08 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -59,6 +59,20 @@ public struct AppearanceSettingsView: View { ) } } + Section { + LabeledContent("Visibility") { + HStack(spacing: 12) { + ForEach(AppVisibility.allCases) { visibility in + AppVisibilityOptionCardView( + visibility: visibility, + isSelected: visibility == store.appVisibility + ) { + store.send(.setAppVisibility(visibility)) + } + } + } + } + } Section("Editor") { // The stored id deliberately keeps naming an uninstalled editor, so the choice // survives a reinstall. No row is tagged with it though, and an untagged diff --git a/SupacodeSettingsShared/Models/AppVisibility.swift b/SupacodeSettingsShared/Models/AppVisibility.swift new file mode 100644 index 000000000..519372ce9 --- /dev/null +++ b/SupacodeSettingsShared/Models/AppVisibility.swift @@ -0,0 +1,47 @@ +/// Where Supacode shows up on the system: the Dock, the menu bar, or both. +/// Every case keeps at least one surface enabled (there is no "hidden +/// everywhere" case), so the app is never unreachable. +public enum AppVisibility: String, CaseIterable, Identifiable, Codable, Sendable { + case dock + case menuBar + case dockAndMenuBar + + public var id: String { rawValue } + + public var title: String { + switch self { + case .dock: "Dock" + case .menuBar: "Menu Bar" + case .dockAndMenuBar: "Both" + } + } + + public var help: String { + switch self { + case .dock: "Show Supacode in the Dock only." + case .menuBar: "Show Supacode in the menu bar only, with no Dock icon." + case .dockAndMenuBar: "Show Supacode in both the Dock and the menu bar." + } + } + + public var imageName: String { + switch self { + case .dock: "VisibilityDock" + case .menuBar: "VisibilityMenuBar" + case .dockAndMenuBar: "VisibilityBoth" + } + } + + public var showsMenuBarIcon: Bool { + self == .dockAndMenuBar || self == .menuBar + } + + public var showsDockIcon: Bool { + self != .menuBar + } + + /// Hiding the Dock icon means running as an accessory app. + public var hidesDockIcon: Bool { + !showsDockIcon + } +} diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 43271b538..41bcc21a4 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -70,6 +70,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// When true, remote surfaces wrap their session in zmx on the host when /// the host has it installed, so the session survives disconnects. public var remoteSessionPersistenceEnabled: Bool + /// Where Supacode appears: Dock, menu bar, or both. + public var appVisibility: AppVisibility public static let `default` = GlobalSettings( appearanceMode: .dark, @@ -104,7 +106,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoUpdateAgentIntegrationsEnabled: true, confirmQuitMode: .auto, terminateSessionsOnQuit: false, - remoteSessionPersistenceEnabled: true + remoteSessionPersistenceEnabled: true, + appVisibility: .dock ) public init( @@ -140,7 +143,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoUpdateAgentIntegrationsEnabled: Bool = true, confirmQuitMode: ConfirmQuitMode = .auto, terminateSessionsOnQuit: Bool = false, - remoteSessionPersistenceEnabled: Bool = true + remoteSessionPersistenceEnabled: Bool = true, + appVisibility: AppVisibility = .dock ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -175,6 +179,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.confirmQuitMode = confirmQuitMode self.terminateSessionsOnQuit = terminateSessionsOnQuit self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled + self.appVisibility = appVisibility } /// Keys for reading renamed settings fields that no longer @@ -337,5 +342,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { remoteSessionPersistenceEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteSessionPersistenceEnabled) ?? Self.default.remoteSessionPersistenceEnabled + // Reject unrecognized values (and a mistyped key) from corrupted or + // hand-edited settings files: a throw here resets the whole file to defaults. + appVisibility = + ((try? container.decodeIfPresent(String.self, forKey: .appVisibility)) ?? nil) + .flatMap(AppVisibility.init(rawValue:)) + ?? Self.default.appVisibility } } diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index a076d7977..b2b94a35e 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -1,6 +1,33 @@ import AppKit +import SupacodeSettingsShared + +private let visibilityLogger = SupaLogger("AppVisibility") extension NSApplication { + /// Applies the Dock/menu-bar visibility mode: `.menuBar` runs as an accessory + /// (no Dock icon), every other mode as a regular app. Returns false when + /// AppKit refuses the switch. + @MainActor + @discardableResult + func applyActivationPolicy(for visibility: AppVisibility) -> Bool { + let policy: NSApplication.ActivationPolicy = visibility.hidesDockIcon ? .accessory : .regular + guard activationPolicy() != policy else { return true } + if setActivationPolicy(policy) { return true } + // AppKit refuses `.accessory` -> `.regular` while the app is inactive, so + // retry once activated. Only that direction: force-activating to *hide* the + // Dock icon would pop the app to the front for a quieting action. + guard policy == .regular else { + visibilityLogger.error("setActivationPolicy(.accessory) refused; app stays regular.") + return false + } + activate() + guard setActivationPolicy(policy) else { + visibilityLogger.error("setActivationPolicy(.regular) refused; app stays accessory.") + return false + } + return true + } + /// Brings the main window forward, deminiaturizing if needed. /// /// Falls back to any non-`NSPanel`, non-settings window so a stale @@ -25,10 +52,20 @@ extension NSApplication { if let window = windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { return window } - let candidates = windows.filter { !($0 is NSPanel) } + let candidates = windows.filter(\.isSurfaceableAppWindow) if let window = candidates.first(where: { $0.identifier?.rawValue != WindowID.settings }) { return window } return candidates.first } } + +extension NSWindow { + /// A real app window the user can be sent to. Excludes `NSPanel` (the shared + /// color / font panels) and anything that can't take main status, notably the + /// status item's window, which would otherwise pass for a visible main window + /// once the menu bar extra is inserted. + var isSurfaceableAppWindow: Bool { + canBecomeMain && !(self is NSPanel) + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 6a6415a3f..bfd425a52 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -76,17 +76,20 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { // the main window. Opt the singleton out per-process so a panel // left open from a previous session can't survive the relaunch. NSColorPanel.shared.isRestorable = false - appStore?.send(.appLaunched) + guard let appStore else { + SupaLogger("App").error("applicationDidFinishLaunching with no store; launch setup skipped.") + return + } + // Apply the saved Dock/menu-bar visibility before the first window shows. + NSApplication.shared.applyActivationPolicy(for: appStore.state.settings.appVisibility) + appStore.send(.appLaunched) } func applicationDidBecomeActive(_ notification: Notification) { appStore?.send(.applicationDidBecomeActive) let app = NSApplication.shared - // Filter `NSPanel` out of the visibility check — the system - // color / font panels (and any sheet-attached child panels) are - // not "main windows" that should suppress surfacing. let hasVisibleMainWindow = app.windows.contains { window in - window.isVisible && !(window is NSPanel) + window.isVisible && window.isSurfaceableAppWindow } guard !hasVisibleMainWindow else { return } app.surfaceMainWindow() @@ -270,6 +273,9 @@ struct SupacodeApp: App { markNotificationRead: { worktreeID, notificationID in terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) }, + markAllNotificationsRead: { + terminalManager.markAllNotificationsRead() + }, hasInflightBlockingScripts: { terminalManager.hasInflightBlockingScripts }, @@ -578,5 +584,26 @@ struct SupacodeApp: App { .windowToolbarStyle(.unified) .defaultSize(width: 720, height: 640) .restorationBehavior(.disabled) + MenuBarExtra(isInserted: menuBarInserted) { + MenuBarNotificationsMenu(store: store) + } label: { + MenuBarNotificationsLabel(store: store) + } + // `.window`, not `.menu`: a native menu item can't host the sidebar row's + // dots, agent badges, and diff stats. The panel is styled to read like a menu. + .menuBarExtraStyle(.window) + } + + /// Dragging the status item out of the menu bar falls back to `.dock`, so at + /// least one surface stays enabled. + private var menuBarInserted: Binding { + Binding( + get: { store.settings.appVisibility.showsMenuBarIcon }, + set: { newValue in + // Ignore MenuBarExtra's scene-evaluation echo; only a real flip should persist. + guard newValue != store.settings.appVisibility.showsMenuBarIcon else { return } + store.send(.settings(.setAppVisibility(newValue ? .dockAndMenuBar : .dock))) + } + ) } } diff --git a/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json b/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json new file mode 100644 index 000000000..f97c95bd3 --- /dev/null +++ b/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MenuBarSC.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg b/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg new file mode 100644 index 000000000..9db046846 --- /dev/null +++ b/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json b/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json new file mode 100644 index 000000000..98592421e --- /dev/null +++ b/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "VisibilityBoth.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VisibilityBoth@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png new file mode 100644 index 000000000..95bdacd73 Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png differ diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png new file mode 100644 index 000000000..eba485323 Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png differ diff --git a/supacode/Assets.xcassets/VisibilityDock.imageset/Contents.json b/supacode/Assets.xcassets/VisibilityDock.imageset/Contents.json new file mode 100644 index 000000000..853c5e6e4 --- /dev/null +++ b/supacode/Assets.xcassets/VisibilityDock.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "VisibilityDock.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VisibilityDock@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock.png b/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock.png new file mode 100644 index 000000000..37c11899c Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock.png differ diff --git a/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png b/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png new file mode 100644 index 000000000..72bba572f Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png differ diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json new file mode 100644 index 000000000..765f88202 --- /dev/null +++ b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "VisibilityMenuBar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VisibilityMenuBar@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png new file mode 100644 index 000000000..e67608e0e Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png differ diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png new file mode 100644 index 000000000..e8e1a104a Binary files /dev/null and b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png differ diff --git a/supacode/Clients/AppLifecycle/AppLifecycleClient.swift b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift index 6550dccea..b5e52e2e7 100644 --- a/supacode/Clients/AppLifecycle/AppLifecycleClient.swift +++ b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift @@ -1,17 +1,27 @@ import AppKit import ComposableArchitecture +import SupacodeSettingsShared struct AppLifecycleClient { var terminate: @MainActor @Sendable () -> Void + /// Applies the Dock/menu-bar visibility mode. Returns false when AppKit + /// refuses the switch, which would otherwise leave the app with no surface. + var applyVisibility: @MainActor @Sendable (AppVisibility) -> Bool + /// Brings the main window forward. Returns false when there is no window to surface. + var surfaceMainWindow: @MainActor @Sendable () -> Bool } extension AppLifecycleClient: DependencyKey { static let liveValue = AppLifecycleClient( - terminate: { NSApplication.shared.terminate(nil) } + terminate: { NSApplication.shared.terminate(nil) }, + applyVisibility: { NSApplication.shared.applyActivationPolicy(for: $0) }, + surfaceMainWindow: { NSApplication.shared.surfaceMainWindow() } ) static let testValue = AppLifecycleClient( - terminate: unimplemented("AppLifecycleClient.terminate") + terminate: unimplemented("AppLifecycleClient.terminate"), + applyVisibility: unimplemented("AppLifecycleClient.applyVisibility", placeholder: true), + surfaceMainWindow: unimplemented("AppLifecycleClient.surfaceMainWindow", placeholder: true) ) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index cc942862b..83f744825 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -17,6 +17,8 @@ struct TerminalClient { var selectedSurfaceID: @MainActor @Sendable (Worktree.ID) -> UUID? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void + /// Marks every notification in every worktree read (menu bar "Mark All as Read"). + var markAllNotificationsRead: @MainActor @Sendable () -> Void /// Blocking scripts (setup / archive / delete / run) bypass zmx and die /// with the app, so the auto-mode quit confirmation needs to know. var hasInflightBlockingScripts: @MainActor @Sendable () -> Bool @@ -135,6 +137,7 @@ extension TerminalClient: DependencyKey { selectedSurfaceID: { _ in fatalError("TerminalClient.selectedSurfaceID not configured") }, latestUnreadNotification: { fatalError("TerminalClient.latestUnreadNotification not configured") }, markNotificationRead: { _, _ in fatalError("TerminalClient.markNotificationRead not configured") }, + markAllNotificationsRead: { fatalError("TerminalClient.markAllNotificationsRead not configured") }, hasInflightBlockingScripts: { fatalError("TerminalClient.hasInflightBlockingScripts not configured") }, terminateAllSessions: { fatalError("TerminalClient.terminateAllSessions not configured") }, reapOrphanSessions: { _ in fatalError("TerminalClient.reapOrphanSessions not configured") }, @@ -153,6 +156,7 @@ extension TerminalClient: DependencyKey { selectedSurfaceID: unimplemented("TerminalClient.selectedSurfaceID", placeholder: nil), latestUnreadNotification: unimplemented("TerminalClient.latestUnreadNotification", placeholder: nil), markNotificationRead: unimplemented("TerminalClient.markNotificationRead"), + markAllNotificationsRead: unimplemented("TerminalClient.markAllNotificationsRead"), hasInflightBlockingScripts: unimplemented("TerminalClient.hasInflightBlockingScripts", placeholder: false), terminateAllSessions: unimplemented("TerminalClient.terminateAllSessions"), reapOrphanSessions: unimplemented("TerminalClient.reapOrphanSessions"), diff --git a/supacode/Features/App/Models/MenuBarNotificationList.swift b/supacode/Features/App/Models/MenuBarNotificationList.swift new file mode 100644 index 000000000..3bbf01d63 --- /dev/null +++ b/supacode/Features/App/Models/MenuBarNotificationList.swift @@ -0,0 +1,124 @@ +import Foundation +import IdentifiedCollections +import OrderedCollections +import SupacodeSettingsShared + +/// The menu bar's sections, mirroring the sidebar: the Pinned and Active rows +/// it hoists, then an Unread section for anything unread that neither one +/// already shows. Only row IDs are cached; each row observes its own leaf, +/// exactly like the sidebar does. +struct MenuBarSections: Equatable { + var pinned: [Worktree.ID] = [] + var active: [Worktree.ID] = [] + var unread: [Worktree.ID] = [] + /// Repo tags for the `repo · worktree` subtitle. `sidebarStructure` only + /// builds these for repos contributing a highlight row, so unread-only repos + /// need their own. + var repositoryTagByID: [Repository.ID: SidebarHighlightRepoTag] = [:] + /// Any unread row at all, hoisted or not. Drives the status item's dot and + /// the "Mark All as Read" gate, which must not go dark just because the only + /// unread row is also Active. + var hasUnread = false + + var isEmpty: Bool { pinned.isEmpty && active.isEmpty && unread.isEmpty } + + /// Headers and rows flattened into the order the menu renders them. + var entries: [MenuBarEntry] { + var entries: [MenuBarEntry] = [] + appendSection(&entries, title: "Pinned", rowIDs: pinned, dotColor: .pinned) + appendSection(&entries, title: "Active", rowIDs: active, dotColor: .active) + appendSection(&entries, title: "Unread", rowIDs: unread, dotColor: nil) + return entries + } + + private func appendSection( + _ entries: inout [MenuBarEntry], + title: String, + rowIDs: [Worktree.ID], + dotColor: SidebarStructure.HighlightKind? + ) { + guard !rowIDs.isEmpty else { return } + entries.append(MenuBarEntry(id: .header(title), content: .header(title, dotColor))) + entries.append(contentsOf: rowIDs.map { MenuBarEntry(id: .row($0), content: .worktree($0)) }) + } +} + +/// One line of the menu: a section header or a worktree row. +struct MenuBarEntry: Identifiable, Equatable { + enum Key: Hashable { + case header(String) + case row(Worktree.ID) + } + + enum Content: Equatable { + case header(String, SidebarStructure.HighlightKind?) + case worktree(Worktree.ID) + } + + let id: Key + let content: Content +} + +extension RepositoriesFeature.State { + /// Cached on `menuBarSectionsCache`; the menu bar scene reads the cache + /// rather than walking `sidebarItems` from its body. + func computeMenuBarSections() -> MenuBarSections { + var sections = MenuBarSections(repositoryTagByID: sidebarStructure.repositoryHighlightByID) + for section in sidebarStructure.sections { + guard case .highlight(let kind, let rowIDs) = section else { continue } + switch kind { + case .pinned: sections.pinned = rowIDs + case .active: sections.active = rowIDs + } + } + let unread = unreadRowIDs() + sections.hasUnread = !unread.isEmpty + // The highlight sections already show their rows; listing them again under + // Unread would double them up. + sections.unread = unread.filter { !sidebarStructure.hoistedRowIDs.contains($0) } + for rowID in sections.unread { + guard let repositoryID = sidebarItems[id: rowID]?.repositoryID, + sections.repositoryTagByID[repositoryID] == nil, + let repository = repositories[id: repositoryID] + else { continue } + let section = sidebar.sections[repositoryID] + sections.repositoryTagByID[repositoryID] = SidebarHighlightRepoTag( + repoName: Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name), + repoColor: section?.color, + hostInfo: repository.host?.displayAuthority + ) + } + return sections + } + + /// Rows carrying unread notifications, in sidebar order. Folders included: + /// their notifications land on the synthetic row standing in for the folder. + private func unreadRowIDs() -> [Worktree.ID] { + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + var orderedIDs = orderedRepositoryIDs() + let coveredIDs = Set(orderedIDs) + for repository in repositories where repository.host != nil && !coveredIDs.contains(repository.id) { + orderedIDs.append(repository.id) + } + + let archived = archivedWorktreeIDSet + var rowIDs: [Worktree.ID] = [] + for repositoryID in orderedIDs { + guard let repository = repositoriesByID[repositoryID] else { continue } + guard repository.isGitRepository else { + let folderID = Repository.folderWorktreeID(for: repository.rootURL) + if sidebarItems[id: folderID]?.hasUnseenNotifications == true { + rowIDs.append(folderID) + } + continue + } + for worktree in orderedWorktrees(in: repository) + where !archived.contains(worktree.id) + && sidebarItems[id: worktree.id]?.hasUnseenNotifications == true + { + rowIDs.append(worktree.id) + } + } + return rowIDs + } +} diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index ac80a6222..f74eab52a 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -124,7 +124,8 @@ extension AppFeature.Action { .worktreeSettingsLoaded, .openSelectedWorktree, .revealInFinder, .openWorktree, .openWorktreeFailed, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, - .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, .runScript, .runNamedScript, + .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, + .menuBarWorktreeSelected, .markAllNotificationsRead, .runScript, .runNamedScript, .manageRepositoryScripts, .stopScript, .stopRunScripts, .closeTab, .closeSurface, .startSearch, .searchSelection, .navigateSearchNext, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 4d7d76985..dac5c1ab4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -159,6 +159,7 @@ struct AppFeature { var hasAnyTerminalSurface: Bool = false var lastKnownSystemNotificationsEnabled: Bool var lastKnownAgentPresenceBadgesEnabled: Bool + var lastKnownAppVisibility: AppVisibility var pendingDeeplinks: [Deeplink] = [] var isDeeplinkReferenceRequested = false /// Cached projection of every primitive the menu-bar `WorktreeCommands` @@ -197,6 +198,7 @@ struct AppFeature { self.settings = settings lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled + lastKnownAppVisibility = settings.appVisibility // Seed from settings so `state.allScripts` doesn't start empty before the // first `settingsChanged` delegate fires. Globals aren't worktree-scoped, // so deselection (line below in `selectedWorktreeChanged(nil)`) @@ -304,6 +306,8 @@ struct AppFeature { case selectTerminalTabAtIndex(Int) case splitTerminal(TerminalSplitMenuDirection) case jumpToLatestUnread + case menuBarWorktreeSelected(worktreeID: Worktree.ID) + case markAllNotificationsRead case runScript case runNamedScript(ScriptDefinition) case manageRepositoryScripts @@ -634,6 +638,13 @@ struct AppFeature { let agentBadgesFlipped = settings.agentPresenceBadgesEnabled != state.lastKnownAgentPresenceBadgesEnabled state.lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled + let visibilityChanged = settings.appVisibility != state.lastKnownAppVisibility + let previousVisibility = state.lastKnownAppVisibility + // Surface the main window when the Dock icon comes back, so leaving + // menu-bar-only mode never strands the user without a window. + let dockIconReappeared = + state.lastKnownAppVisibility.hidesDockIcon && !settings.appVisibility.hidesDockIcon + state.lastKnownAppVisibility = settings.appVisibility // Compare IDs as a set: name/command edits and pure reorders should not re-prune recency. let globalScriptIDsChanged = Set(state.globalScripts.map(\.id)) != Set(settings.globalScripts.map(\.id)) state.globalScripts = settings.globalScripts @@ -698,6 +709,23 @@ struct AppFeature { } }, ] + if visibilityChanged { + effects.append( + .run { @MainActor send in + // The status item is already gone by now (the `MenuBarExtra` + // binding reads the new value on the same scene pass), so a + // refused policy switch would leave no surface at all. Fall back + // to the previous mode, which puts one of them back. + guard appLifecycleClient.applyVisibility(settings.appVisibility) else { + await send(.settings(.setAppVisibility(previousVisibility))) + return + } + if dockIconReappeared { + _ = appLifecycleClient.surfaceMainWindow() + } + } + ) + } if globalScriptIDsChanged { effects.append(pruneScriptRecencyEffect(state: state)) } @@ -882,6 +910,27 @@ struct AppFeature { } ) + case .menuBarWorktreeSelected(let worktreeID): + // The menu snapshots its rows when it opens, so the worktree can be + // archived or deleted before the click lands. Surface the app anyway: + // in menu bar mode a dead click is indistinguishable from a hang. + guard state.repositories.worktree(for: worktreeID) != nil else { + jumpLogger.warning( + "menuBarWorktreeSelected: worktree \(worktreeID) vanished between menu render and click." + ) + analyticsClient.capture("menu_bar_worktree_selected_stale", nil) + return .run { @MainActor _ in _ = appLifecycleClient.surfaceMainWindow() } + } + analyticsClient.capture("menu_bar_worktree_selected", nil) + return .merge( + .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), + .run { @MainActor _ in _ = appLifecycleClient.surfaceMainWindow() } + ) + + case .markAllNotificationsRead: + analyticsClient.capture("notifications_mark_all_read", nil) + return .run { _ in await terminalClient.markAllNotificationsRead() } + case .runScript: // An empty `repoScripts` means "this repository configures no run script" only // once its settings have landed, and they land a disk read after the selection diff --git a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift new file mode 100644 index 000000000..5add284f7 --- /dev/null +++ b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift @@ -0,0 +1,335 @@ +import AppKit +import ComposableArchitecture +import SupacodeSettingsFeature +import SupacodeSettingsShared +import SwiftUI + +private let menuBarLogger = SupaLogger("MenuBar") + +/// Contents of the menu bar extra: the sidebar's Pinned, Active, and Unread +/// rows, then quick actions. Rendered as a window rather than a native menu so +/// the rows can be the real `SidebarItemView`, with the same icon, title, +/// subtitle, notification and script dots, agent badges, and diff stats. +struct MenuBarNotificationsMenu: View { + @Environment(\.openWindow) private var openWindow + let store: StoreOf + + var body: some View { + let sections = store.repositories.menuBarSectionsCache + let repositories = store.scope(state: \.repositories, action: \.repositories) + // The session list scrolls once it outgrows the screen so the action rows + // below stay reachable; the action rows never scroll, exactly like a menu. + VStack(alignment: .leading, spacing: 0) { + MenuBarSessionList { + if sections.isEmpty { + Text("No Sessions Need Attention") + .foregroundStyle(.secondary) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 5) + } else { + ForEach(sections.entries) { entry in + MenuBarEntryRow( + entry: entry, + repositories: repositories, + sections: sections, + onOpen: openWorktree + ) + } + } + } + MenuBarDivider() + MenuBarActionRow(title: "Mark All as Read", isEnabled: sections.hasUnread) { + dismissMenuBarExtra() + store.send(.markAllNotificationsRead) + } + MenuBarActionRow(title: "Show Main Window") { + showMainWindow() + } + MenuBarActionRow(title: "Settings...") { + dismissMenuBarExtra() + store.send(.settings(.setSelection(.general))) + openWindow(id: WindowID.settings) + NSApplication.shared.activate() + } + MenuBarDivider() + MenuBarActionRow(title: "Quit Supacode") { + // The quit confirmation is an alert hosted by the main window, so it + // needs one on screen before it can be answered. + showMainWindow() + store.send(.requestQuit) + } + } + .padding(.vertical, MenuBarMetrics.panelPadding) + .frame(width: MenuBarMetrics.width) + // Gives the rows' `ConcentricRectangle` highlight the panel's corners to + // stay concentric with; nothing else supplies a container shape here. + .containerShape(.rect(cornerRadius: MenuBarMetrics.panelCornerRadius)) + } + + private func openWorktree(_ worktreeID: Worktree.ID) { + showMainWindow() + store.send(.menuBarWorktreeSelected(worktreeID: worktreeID)) + } + + /// `openWindow` re-creates the scene the user closed, which `surfaceMainWindow()` + /// cannot do (and it reports success after raising any other window, so it + /// can't gate this either). Surfacing afterwards deminiaturizes it. + private func showMainWindow() { + dismissMenuBarExtra() + openWindow(id: WindowID.main) + NSApplication.shared.surfaceMainWindow() + } + + /// Selecting anything must dismiss the whole menu bar extra, not just its + /// panel: closing the panel window directly leaves the status item stuck + /// highlighted, so the next click only deselects it. Clicking the status + /// button toggles the extra off through the system, which closes the panel + /// and clears the highlight together. + private func dismissMenuBarExtra() { + guard let button = Self.statusBarButton() else { + // Defense in depth: if a future OS moves the status item out of reach the + // panel would silently stay open on every selection, so make it loud. + menuBarLogger.warning("Status item button not found; panel stays open on selection.") + return + } + button.performClick(nil) + } + + /// The menu bar extra's `NSStatusBarButton`, found by walking the app's + /// windows. SwiftUI's `MenuBarExtra` never exposes its status item. + private static func statusBarButton() -> NSStatusBarButton? { + for window in NSApp.windows { + if let button = statusBarButton(in: window.contentView) { return button } + } + return nil + } + + private static func statusBarButton(in view: NSView?) -> NSStatusBarButton? { + guard let view else { return nil } + if let button = view as? NSStatusBarButton { return button } + for subview in view.subviews { + if let button = statusBarButton(in: subview) { return button } + } + return nil + } +} + +enum MenuBarMetrics { + static let width: CGFloat = 320 + static let panelPadding: CGFloat = 5 + static let sectionSpacing: CGFloat = 6 + /// Inset of a row's content, matching where a menu starts its titles. + static let rowPadding: CGFloat = 14 + /// Inset of a highlighted row from the panel edge, so its corners sit + /// concentric with the panel's. + static let highlightInset: CGFloat = 5 + /// The menu bar extra's window corner radius, which the system rounds the + /// panel to but never exposes. + static let panelCornerRadius: CGFloat = 14 + static let highlightCornerRadius = panelCornerRadius - highlightInset + /// Height the session list caps at before it scrolls, leaving the action + /// rows and a margin below the menu bar on screen. + static var sessionListMaxHeight: CGFloat { + let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800 + return max(160, visibleHeight - 220) + } +} + +/// The session area. It hugs its content until that would run past the screen, +/// then caps and scrolls, so the pinned action rows below stay on screen no +/// matter how many worktrees are listed. `ViewThatFits` picks the plain stack +/// while it fits the cap and the scrolling one only once it doesn't, so short +/// lists never reserve empty height. +private struct MenuBarSessionList: View { + let content: Content + // The menu bar window proposes no height, so the list must carry an explicit + // one: the rows' natural height until it hits the cap, then scroll. `nil` + // (before the first measurement) falls back to the cap so rows always render + // rather than collapsing to zero. + @State private var contentHeight: CGFloat? + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { contentHeight = $0 } + } + .frame(height: min(contentHeight ?? .infinity, MenuBarMetrics.sessionListMaxHeight)) + // No rubber-banding while everything fits. + .scrollBounceBehavior(.basedOnSize) + } +} + +/// Full-bleed separator with the vertical breathing room a menu gives it. +private struct MenuBarDivider: View { + var body: some View { + Divider() + .padding(.vertical, MenuBarMetrics.panelPadding) + } +} + +private struct MenuBarEntryRow: View { + let entry: MenuBarEntry + let repositories: StoreOf + let sections: MenuBarSections + let onOpen: (Worktree.ID) -> Void + + var body: some View { + switch entry.content { + case .header(let title, let kind): + MenuBarSectionHeader(title: title, dotColor: kind?.indicatorColor) + case .worktree(let rowID): + MenuBarWorktreeRowView( + rowID: rowID, + repositories: repositories, + sections: sections, + onOpen: onOpen + ) + } + } +} + +/// Mirrors the sidebar's highlight header: the title plus the section's dot. +private struct MenuBarSectionHeader: View { + let title: String + let dotColor: Color? + + var body: some View { + HStack(spacing: 4) { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + if let dotColor { + SidebarHighlightHeaderDot(color: dotColor) + } + } + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.top, MenuBarMetrics.sectionSpacing) + .padding(.bottom, 2) + } +} + +/// Scopes to the single leaf and renders the real sidebar row, so per-row +/// notification and agent churn invalidates only this row. +private struct MenuBarWorktreeRowView: View { + let rowID: Worktree.ID + let repositories: StoreOf + let sections: MenuBarSections + let onOpen: (Worktree.ID) -> Void + @State private var isHovering = false + @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true + + var body: some View { + if let itemStore = repositories.scope( + state: \.sidebarItems[id: rowID], + action: \.sidebarItems[id: rowID] + ) { + Button { + onOpen(rowID) + } label: { + SidebarItemView( + store: itemStore, + hideSubtitle: false, + hideSubtitleOnMatch: hideSubtitleOnMatch, + showsPullRequestInfo: true, + shortcutHint: nil, + highlightSubtitle: sections.repositoryTagByID[itemStore.repositoryID] + ) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .menuBarRowHighlight(isHovering) + .background(MenuBarRowHighlight(isHighlighted: isHovering)) + .onHover { isHovering = $0 } + } + } +} + +/// Menu-style action row, sharing the worktree rows' highlight so the whole +/// panel reads as one menu. +private struct MenuBarActionRow: View { + let title: String + var isEnabled: Bool = true + let action: () -> Void + @State private var isHovering = false + + var body: some View { + let isHighlighted = isHovering && isEnabled + Button(action: action) { + Text(title) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .foregroundStyle(isEnabled ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary)) + .menuBarRowHighlight(isHighlighted) + .background(MenuBarRowHighlight(isHighlighted: isHighlighted)) + .onHover { isHovering = $0 } + } +} + +/// The menu's selection fill: accent-colored, inset from the panel edge, its +/// corners concentric with the panel's. +private struct MenuBarRowHighlight: View { + let isHighlighted: Bool + + var body: some View { + // A row in the middle of the panel is far from its corners, so concentricity + // alone would round it to nothing: the floor is what actually shows. + ConcentricRectangle( + corners: .concentric(minimum: .fixed(MenuBarMetrics.highlightCornerRadius)), + isUniform: true + ) + .fill(isHighlighted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear)) + .padding(.horizontal, MenuBarMetrics.highlightInset) + } +} + +extension View { + /// Flips the row's contents to their emphasized palette, the same way the + /// sidebar recolors a selected row. + fileprivate func menuBarRowHighlight(_ isHighlighted: Bool) -> some View { + environment(\.backgroundProminence, isHighlighted ? .increased : .standard) + } +} + +/// Status item label: the app icon's "SC" monogram with a red dot while +/// anything is unread. +struct MenuBarNotificationsLabel: View { + let store: StoreOf + + var body: some View { + let hasUnread = store.repositories.menuBarSectionsCache.hasUnread + HStack { + // The glyph is lifted from the app icon so the monogram matches its + // typeface; template rendering tints its white fill to the menu bar's + // label color instead of leaving it white-on-white. The asset bakes in + // SF-Symbol-like margins, so it fills the menu bar height as-is. + Image("MenuBarSC") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: .infinity) + if hasUnread { + Circle() + .fill(.orange) + .frame(width: 5, height: 5) + .fixedSize() + } + } + .accessibilityLabel(hasUnread ? "Supacode, unread notifications" : "Supacode") + } +} diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index e62e2cd14..227d3d74f 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -360,6 +360,16 @@ extension RepositoriesFeature.State { toolbarNotificationGroupsCache = new } } + + /// Equatable-diffs the menu bar sections against the cache so the status menu + /// only rebuilds when the rows it renders actually change. Runs after + /// `recomputeSidebarStructureIfChanged()`, whose highlight sections it reads. + mutating func recomputeMenuBarSectionsIfChanged() { + let new = computeMenuBarSections() + if new != menuBarSectionsCache { + menuBarSectionsCache = new + } + } } /// Per-cache invalidation flag set returned by every reducer action. Exhaustive @@ -614,6 +624,11 @@ extension RepositoriesFeature.State { if invalidations.contains(.toolbarNotificationGroups) { recomputeToolbarNotificationGroupsIfChanged() } + // The menu bar rows key off notifications *and* agent activity, and agent + // snapshots only invalidate `.sidebarStructure`, so they need both flags. + if !invalidations.isDisjoint(with: [.sidebarStructure, .toolbarNotificationGroups]) { + recomputeMenuBarSectionsIfChanged() + } if invalidations.contains(.openActionResolution) { pruneOpenActionsForRemovedRepositoriesIfChanged() } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 031e29ff4..0f214b3ae 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -218,6 +218,11 @@ struct RepositoriesFeature { /// mutation across all worktrees). Recomputed via /// `recomputeToolbarNotificationGroupsIfChanged()`. var toolbarNotificationGroupsCache: [ToolbarNotificationRepositoryGroup] = [] + /// Cached menu bar sections. The `MenuBarExtra` scene reads this instead of + /// `sidebarItems`, which would subscribe the status menu to every per-row + /// notification and agent tick. Recomputed via + /// `recomputeMenuBarSectionsIfChanged()`. + var menuBarSectionsCache = MenuBarSections() @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var repositoryCustomization: RepositoryCustomizationFeature.State? @Presents var worktreeCustomization: WorktreeCustomizationFeature.State? diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 188e80148..b677039b1 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1090,6 +1090,17 @@ final class WorktreeTerminalManager { emitProjection(for: worktreeID) } + /// Indicator and projection updates propagate via each state's notification + /// callbacks. Every state is swept, not just the unread ones, so a surface + /// whose unseen mirror drifted out of sync with its notifications is repaired. + func markAllNotificationsRead() { + let unread = states.values.count(where: \.hasUnseenNotification) + terminalLogger.info("markAllNotificationsRead: clearing unread in \(unread) worktree(s).") + for state in states.values { + state.markAllNotificationsRead() + } + } + /// Embed `agentsBySurface` in each surface so badges survive relaunch. func saveAllLayoutSnapshots( agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] diff --git a/supacodeTests/AppFeatureMenuBarNotificationsTests.swift b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift new file mode 100644 index 000000000..f09b85daf --- /dev/null +++ b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift @@ -0,0 +1,248 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import SupacodeSettingsFeature +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +struct AppFeatureMenuBarNotificationsTests { + @Test(.dependencies) func menuBarWorktreeSelectedSelectsWorktreeAndSurfacesTheWindow() async { + let worktree = makeWorktree() + let surfaced = LockIsolated(0) + let store = makeStore(worktree: worktree) { + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } + + await store.send(.menuBarWorktreeSelected(worktreeID: worktree.id)) + await store.receive(\.repositories.selectWorktree) + await store.finish() + + #expect(store.state.repositories.selectedWorktreeID == worktree.id) + #expect(surfaced.value == 1) + } + + @Test(.dependencies) func menuBarWorktreeSelectedStillSurfacesTheWindowWhenTheWorktreeIsGone() async { + let worktree = makeWorktree() + let surfaced = LockIsolated(0) + let store = makeStore(worktree: worktree) { + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } + + // A stale click must never be a no-op: in menu bar mode that is + // indistinguishable from a hung app. + await store.send(.menuBarWorktreeSelected(worktreeID: Worktree.ID("/tmp/repo/vanished"))) + await store.finish() + + #expect(surfaced.value == 1) + } + + @Test(.dependencies) func markAllNotificationsReadForwardsToTerminalClient() async { + let worktree = makeWorktree() + let calls = LockIsolated(0) + let store = makeStore(worktree: worktree) { + $0.terminalClient.markAllNotificationsRead = { + calls.withValue { $0 += 1 } + } + } + + await store.send(.markAllNotificationsRead) + await store.finish() + + #expect(calls.value == 1) + } + + // MARK: - Activation policy. + + @Test(.dependencies) func switchingToMenuBarOnlyAppliesTheAccessoryPolicy() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } + + var settings = GlobalSettings.default + settings.appVisibility = .menuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.menuBar]) + // Nothing to surface: the Dock icon is going away, not coming back. + #expect(surfaced.value == 0) + } + + @Test(.dependencies) func leavingMenuBarOnlyRestoresTheDockIconAndSurfacesTheWindow() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } + + var settings = GlobalSettings.default + settings.appVisibility = .dock + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.dock]) + // Without this the user leaves menu bar mode with a Dock icon and no window. + #expect(surfaced.value == 1) + } + + @Test(.dependencies) func leavingMenuBarOnlyForBothAlsoSurfacesTheWindow() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } + + var settings = GlobalSettings.default + settings.appVisibility = .dockAndMenuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.dockAndMenuBar]) + // The Dock icon comes back here too, so the window must follow. + #expect(surfaced.value == 1) + } + + @Test(.dependencies) func aRefusedPolicySwitchFallsBackToTheModeThatStillHasASurface() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + // AppKit refuses `.accessory` -> `.regular`: without a fallback the status + // item is already gone and no Dock icon arrives, leaving no surface at all. + $0.appLifecycleClient.applyVisibility = { _ in false } + $0.appLifecycleClient.surfaceMainWindow = { true } + } + + var settings = GlobalSettings.default + settings.appVisibility = .dock + await store.send(.settings(.delegate(.settingsChanged(settings)))) + // The fallback must carry `.menuBar` specifically: a wrong direction here + // (e.g. re-sending `.dock`) would strand the user with no surface. + await store.receive(\.settings.setAppVisibility, .menuBar) + await store.finish() + + #expect(store.state.settings.appVisibility == .menuBar) + } + + @Test(.dependencies) func repeatedSettingsChangesDoNotReapplyTheSameVisibility() async { + let applied = LockIsolated<[AppVisibility]>([]) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { true } + } + + var settings = GlobalSettings.default + settings.appVisibility = .menuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) + // A second, identical delegate must not re-apply the policy: `activate()` on + // the retry path would steal focus on every unrelated settings edit. + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.menuBar]) + #expect(store.state.lastKnownAppVisibility == .menuBar) + } + + @Test(.dependencies) func unchangedVisibilityTouchesTheActivationPolicyNotAtAll() async { + let applied = LockIsolated<[AppVisibility]>([]) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + } + + var settings = GlobalSettings.default + settings.appVisibility = .dock + settings.agentPresenceBadgesEnabled.toggle() + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value.isEmpty) + } + + // MARK: - Helpers. + + private func makeWorktree( + id: String = "/tmp/repo/wt-1", + name: String = "wt-1" + ) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + ) + } + + private func makeStore( + worktree: Worktree, + settings: GlobalSettings = .default, + withAdditionalDependencies: (inout DependencyValues) -> Void + ) -> TestStoreOf { + var repositoriesState = RepositoriesFeature.State() + let repository = Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: [worktree], + ) + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + repositoriesState.isInitialLoadComplete = true + + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State(settings: settings) + ) + ) { + AppFeature() + } withDependencies: { values in + values.terminalClient.tabExists = { _, _ in true } + values.terminalClient.surfaceExists = { _, _, _ in true } + withAdditionalDependencies(&values) + } + store.exhaustivity = .off + return store + } +} diff --git a/supacodeTests/AppVisibilityTests.swift b/supacodeTests/AppVisibilityTests.swift new file mode 100644 index 000000000..a87acc7a1 --- /dev/null +++ b/supacodeTests/AppVisibilityTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import SupacodeSettingsShared + +struct AppVisibilityTests { + /// The whole point of the three-case model: no mode may hide the app from + /// both surfaces at once, which would leave it unreachable. + @Test func everyModeKeepsAtLeastOneSurfaceEnabled() { + for visibility in AppVisibility.allCases { + #expect(visibility.showsDockIcon || visibility.showsMenuBarIcon) + } + } + + @Test func onlyMenuBarModeRunsAsAnAccessory() { + #expect(AppVisibility.menuBar.hidesDockIcon) + #expect(!AppVisibility.dock.hidesDockIcon) + #expect(!AppVisibility.dockAndMenuBar.hidesDockIcon) + } + + @Test func menuBarIconIsInsertedForBothMenuBarModes() { + #expect(AppVisibility.menuBar.showsMenuBarIcon) + #expect(AppVisibility.dockAndMenuBar.showsMenuBarIcon) + #expect(!AppVisibility.dock.showsMenuBarIcon) + } + + /// Raw values are persisted, so reordering or renaming a case must not + /// silently repoint an existing settings file at a different mode. + @Test func rawValuesAreStable() { + #expect(AppVisibility(rawValue: "dock") == .dock) + #expect(AppVisibility(rawValue: "menuBar") == .menuBar) + #expect(AppVisibility(rawValue: "dockAndMenuBar") == .dockAndMenuBar) + } + + @Test func cardsReadDockThenMenuBarThenBoth() { + #expect(AppVisibility.allCases.map(\.title) == ["Dock", "Menu Bar", "Both"]) + } + + @Test func everyCardHasArtwork() { + #expect( + AppVisibility.allCases.map(\.imageName) + == ["VisibilityDock", "VisibilityMenuBar", "VisibilityBoth"] + ) + } +} diff --git a/supacodeTests/MenuBarNotificationListTests.swift b/supacodeTests/MenuBarNotificationListTests.swift new file mode 100644 index 000000000..e482394a9 --- /dev/null +++ b/supacodeTests/MenuBarNotificationListTests.swift @@ -0,0 +1,240 @@ +import Foundation +import IdentifiedCollections +import Sharing +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +struct MenuBarNotificationListTests { + @Test func listsUnreadWorktreesWithTheirCountAndRepoSubtitle() { + let noisy = makeWorktree(id: "/tmp/repo/noisy", name: "noisy") + let quiet = makeWorktree(id: "/tmp/repo/quiet", name: "quiet") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [noisy, quiet])] + ) + setRowNotifications( + &state, id: noisy.id, + notifications: [ + makeNotification(isRead: false), + makeNotification(isRead: false), + makeNotification(isRead: true), + ] + ) + + let sections = state.computeMenuBarSections() + + #expect(sections.unread == [noisy.id]) + #expect(sections.repositoryTagByID[Repository.ID("/tmp/repo/")]?.repoName == "repo") + #expect(sections.hasUnread) + } + + @Test func isEmptyWhenEveryNotificationIsRead() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] + ) + setRowNotifications(&state, id: worktree.id, notifications: [makeNotification(isRead: true)]) + + let sections = state.computeMenuBarSections() + + #expect(sections.unread.isEmpty) + #expect(!sections.hasUnread) + } + + @Test func mirrorsTheSidebarActiveSection() { + let working = makeWorktree(id: "/tmp/repo/working", name: "working") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [working])] + ) + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: working.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() + + // The sidebar hoists a tracked agent into Active, so the menu must too. + #expect(state.sidebarStructure.hoistedRowIDs.contains(working.id)) + #expect(sections.active == [working.id]) + #expect(sections.unread.isEmpty) + } + + @Test func unreadSectionExcludesRowsTheHighlightSectionsAlreadyShow() { + let hoisted = makeWorktree(id: "/tmp/repo/hoisted", name: "hoisted") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [hoisted])] + ) + // Unread plus a tracked agent classifies as Active, so the row is hoisted. + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: hoisted.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + setRowNotifications(&state, id: hoisted.id, notifications: [makeNotification(isRead: false)]) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() + + #expect(sections.active == [hoisted.id]) + #expect(sections.unread.isEmpty) + // The dot and "Mark All as Read" must still see the unread row. + #expect(sections.hasUnread) + } + + @Test func highlightSectionsAreTakenStraightFromTheSidebarStructure() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] + ) + state.reconcileSidebarForTesting() + + // Nothing hoisted: the menu must not invent rows the sidebar doesn't show. + #expect(state.sidebarStructure.hoistedRowIDs.isEmpty) + let sections = state.computeMenuBarSections() + #expect(sections.pinned.isEmpty) + #expect(sections.active.isEmpty) + #expect(sections.isEmpty) + } + + @Test func entriesRenderInPinnedThenActiveThenUnreadOrder() { + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned") + let working = makeWorktree(id: "/tmp/repo/working", name: "working") + let noisy = makeWorktree(id: "/tmp/repo/noisy", name: "noisy") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [pinned, working, noisy])] + ) + state.$sidebar.withLock { sidebar in + sidebar.insert(worktree: pinned.id, in: Repository.ID("/tmp/repo/"), bucket: .pinned) + } + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: working.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + setRowNotifications(&state, id: noisy.id, notifications: [makeNotification(isRead: false)]) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() + #expect(sections.pinned == [pinned.id]) + #expect(sections.active == [working.id]) + #expect(sections.unread == [noisy.id]) + + // The user-visible section order is the whole point of the flattening. + #expect( + sections.entries.map(\.id) == [ + .header("Pinned"), .row(pinned.id), + .header("Active"), .row(working.id), + .header("Unread"), .row(noisy.id), + ] + ) + } + + @Test func listsUnreadFolderRepositoriesByTheirSyntheticRow() { + let folderURL = URL(fileURLWithPath: "/tmp/folder") + let folderID = Repository.folderWorktreeID(for: folderURL) + var state = RepositoriesFeature.State( + reconciledRepositories: [ + Repository( + id: RepositoryID(folderURL.path(percentEncoded: false) + "/"), + rootURL: folderURL, + name: "folder", + worktrees: [ + Worktree( + id: folderID, + name: "folder", + detail: "", + workingDirectory: folderURL, + repositoryRootURL: folderURL + ) + ], + isGitRepository: false + ) + ] + ) + setRowNotifications(&state, id: folderID, notifications: [makeNotification(isRead: false)]) + + // A folder's notifications land on its synthetic folder row, which the + // non-git branch must still surface. + #expect(state.computeMenuBarSections().unread == [folderID]) + } + + @Test func excludesArchivedWorktreesFromUnread() { + let archived = makeWorktree(id: "/tmp/repo/archived", name: "archived") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [archived])] + ) + setRowNotifications(&state, id: archived.id, notifications: [makeNotification(isRead: false)]) + state.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: archived.id, + in: Repository.ID("/tmp/repo/"), + bucket: .archived, + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) + ) + } + state.reconcileSidebarForTesting() + + // An archived row has nothing actionable behind it, so it must not light + // the status item or appear in the menu. + #expect(state.computeMenuBarSections().unread.isEmpty) + } + + @Test func postReduceHookRefreshesTheCache() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] + ) + #expect(state.menuBarSectionsCache.unread.isEmpty) + + setRowNotifications(&state, id: worktree.id, notifications: [makeNotification(isRead: false)]) + state.applyPostReduceCacheRecomputes(.toolbarNotificationGroups) + + #expect(state.menuBarSectionsCache.unread == [worktree.id]) + + // Agent activity raises only `.sidebarStructure`, so the cache must key off + // that flag too, or a row the agent hoists to Active would never reach the + // menu. The row moves from Unread to Active once the agent lands. + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: worktree.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + state.applyPostReduceCacheRecomputes(.sidebarStructure) + + #expect(state.menuBarSectionsCache.unread.isEmpty) + #expect(state.menuBarSectionsCache.active == [worktree.id]) + } + + // MARK: - Helpers. + + private func makeNotification(isRead: Bool) -> WorktreeTerminalNotification { + WorktreeTerminalNotification( + surfaceID: UUID(), + title: "claude", + body: "needs your permission", + createdAt: .distantPast, + isRead: isRead + ) + } + + private func setRowNotifications( + _ state: inout RepositoriesFeature.State, + id: SidebarItemID, + notifications: [WorktreeTerminalNotification] + ) { + state.sidebarItems[id: id]?.notifications = IdentifiedArrayOf(uniqueElements: notifications) + state.sidebarItems[id: id]?.hasUnseenNotifications = notifications.contains { !$0.isRead } + } + + private func makeWorktree(id: String, name: String) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeRepository(worktrees: [Worktree]) -> Repository { + Repository( + // `Repository.id` keeps its trailing slash; the worktree IDs don't. + id: "/tmp/repo/", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 57cd4a204..98a2d6cdf 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -130,6 +130,43 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func settingAppVisibilityPersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .dockAndMenuBar + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.setAppVisibility(.menuBar)) { + $0.appVisibility = .menuBar + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.appVisibility == .menuBar) + } + + @Test(.dependencies) func setAppVisibilityPersistsOnlyRealFlips() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .dockAndMenuBar + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + // Echo of the current value (MenuBarExtra scene evaluation) is a no-op. + await store.send(.setAppVisibility(.dockAndMenuBar)) + + await store.send(.setAppVisibility(.dock)) { + $0.appVisibility = .dock + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.appVisibility == .dock) + } + @Test(.dependencies) func selectingNotificationSoundPlaysPreview() async { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index ef4cf4d7e..3d52bc504 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -409,6 +409,97 @@ struct SettingsFilePersistenceTests { #expect(settings.global.remoteSessionPersistenceEnabled == true) } + @Test(.dependencies) func decodesMissingAppVisibilityAsDock() throws { + // A file predating the menu bar feature was Dock-only, and the menu bar + // stays opt-in. + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dock) + } + + @Test(.dependencies) func decodesUnrecognizedAppVisibilityAsDockWithoutDiscardingTheFile() throws { + // A throw here would reset the whole file to defaults and write it back, so + // a hand-edited or newer-than-us value must fall through to the default. + let file = SettingsFileWithRawAppVisibility( + global: GlobalSettingsWithRawAppVisibility( + appearanceMode: .light, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: false, + appVisibility: "menubar" + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(file) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dock) + // Both differ from `GlobalSettings.default`, so a reset-to-defaults would fail here. + #expect(settings.global.appearanceMode == .light) + #expect(settings.global.updatesAutomaticallyCheckForUpdates == false) + } + + @Test(.dependencies) func decodesMistypedAppVisibilityAsDockWithoutDiscardingTheFile() throws { + // A hand-edit can produce the wrong JSON type, not just an unknown string. + let json = """ + {"global":{"appearanceMode":"light","updatesAutomaticallyCheckForUpdates":false,\ + "updatesAutomaticallyDownloadUpdates":false,"appVisibility":3},"repositories":{}} + """ + let storage = MutableTestStorage(initialData: Data(json.utf8)) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dock) + #expect(settings.global.appearanceMode == .light) + } + + @Test(.dependencies) func roundTripsExplicitAppVisibility() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.appVisibility = .menuBar } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + #expect(reloaded.global.appVisibility == .menuBar) + } + @Test(.dependencies) func roundTripsExplicitRemoteSessionPersistenceDisabled() throws { let storage = SettingsTestStorage() @@ -508,3 +599,15 @@ private struct LegacyGlobalSettingsWithQuitToggle: Codable { var updatesAutomaticallyDownloadUpdates: Bool var confirmBeforeQuit: Bool } + +private struct SettingsFileWithRawAppVisibility: Codable { + var global: GlobalSettingsWithRawAppVisibility + var repositories: [String: RepositorySettings] +} + +private struct GlobalSettingsWithRawAppVisibility: Codable { + var appearanceMode: AppearanceMode + var updatesAutomaticallyCheckForUpdates: Bool + var updatesAutomaticallyDownloadUpdates: Bool + var appVisibility: String +}