diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 8d65aab69..2d1e31c16 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -58,6 +58,11 @@ public struct SettingsFeature { public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool public var notificationRetentionLimit: NotificationRetentionLimit + // Inspector-owned notification view prefs; the Settings window doesn't edit + // them, it only carries them through so a settings write can't reset them. + public var notificationScope: NotificationScope + public var notificationsGroupedByWorktree: Bool + public var notificationsUnreadOnly: Bool public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool @@ -150,6 +155,9 @@ public struct SettingsFeature { muteNotificationsForActiveSurface = settings.muteNotificationsForActiveSurface moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop notificationRetentionLimit = settings.notificationRetentionLimit + notificationScope = settings.notificationScope + notificationsGroupedByWorktree = settings.notificationsGroupedByWorktree + notificationsUnreadOnly = settings.notificationsUnreadOnly analyticsEnabled = settings.analyticsEnabled crashReportsEnabled = settings.crashReportsEnabled githubIntegrationEnabled = settings.githubIntegrationEnabled @@ -198,6 +206,9 @@ public struct SettingsFeature { muteNotificationsForActiveSurface: muteNotificationsForActiveSurface, moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, notificationRetentionLimit: notificationRetentionLimit, + notificationScope: notificationScope, + notificationsGroupedByWorktree: notificationsGroupedByWorktree, + notificationsUnreadOnly: notificationsUnreadOnly, analyticsEnabled: analyticsEnabled, crashReportsEnabled: crashReportsEnabled, githubIntegrationEnabled: githubIntegrationEnabled, @@ -364,6 +375,9 @@ public struct SettingsFeature { state.muteNotificationsForActiveSurface = normalizedSettings.muteNotificationsForActiveSurface state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop state.notificationRetentionLimit = normalizedSettings.notificationRetentionLimit + state.notificationScope = normalizedSettings.notificationScope + state.notificationsGroupedByWorktree = normalizedSettings.notificationsGroupedByWorktree + state.notificationsUnreadOnly = normalizedSettings.notificationsUnreadOnly state.analyticsEnabled = normalizedSettings.analyticsEnabled state.crashReportsEnabled = normalizedSettings.crashReportsEnabled state.githubIntegrationEnabled = normalizedSettings.githubIntegrationEnabled diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 3f39162f8..c7fd1541f 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -57,6 +57,14 @@ public nonisolated enum NotificationRetentionLimit: Int, Codable, CaseIterable, } } +/// Which worktrees the notification inspector lists. Persisted across sessions. +public nonisolated enum NotificationScope: String, Codable, CaseIterable, Sendable { + case all + case currentWorktree + + public static let defaultValue: NotificationScope = .all +} + /// How Supacode combines the user's own Ghostty config with the optional /// Supacode-specific config at `~/.supacode/ghostty.config`. public nonisolated enum GhosttyUserConfigMode: String, Codable, CaseIterable, Sendable { @@ -111,6 +119,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool public var notificationRetentionLimit: NotificationRetentionLimit + public var notificationScope: NotificationScope + /// Whether the notification inspector groups its list into worktree sections. + public var notificationsGroupedByWorktree: Bool + /// Whether the notification inspector hides read notifications. + public var notificationsUnreadOnly: Bool public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool @@ -182,6 +195,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { muteNotificationsForActiveSurface: true, moveNotifiedWorktreeToTop: false, notificationRetentionLimit: .defaultValue, + notificationScope: .defaultValue, + notificationsGroupedByWorktree: false, + notificationsUnreadOnly: false, analyticsEnabled: true, crashReportsEnabled: true, githubIntegrationEnabled: true, @@ -224,6 +240,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { muteNotificationsForActiveSurface: Bool = true, moveNotifiedWorktreeToTop: Bool, notificationRetentionLimit: NotificationRetentionLimit = .defaultValue, + notificationScope: NotificationScope = .defaultValue, + notificationsGroupedByWorktree: Bool = false, + notificationsUnreadOnly: Bool = false, analyticsEnabled: Bool, crashReportsEnabled: Bool, githubIntegrationEnabled: Bool, @@ -268,6 +287,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.muteNotificationsForActiveSurface = muteNotificationsForActiveSurface self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop self.notificationRetentionLimit = notificationRetentionLimit + self.notificationScope = notificationScope + self.notificationsGroupedByWorktree = notificationsGroupedByWorktree + self.notificationsUnreadOnly = notificationsUnreadOnly self.analyticsEnabled = analyticsEnabled self.crashReportsEnabled = crashReportsEnabled self.githubIntegrationEnabled = githubIntegrationEnabled @@ -353,6 +375,17 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { (try container.decodeIfPresent(Int.self, forKey: .notificationRetentionLimit)) .flatMap(NotificationRetentionLimit.init(rawValue:)) ?? Self.default.notificationRetentionLimit + // Fall back instead of throwing, which would reset the whole file. + notificationScope = + ((try? container.decodeIfPresent(String.self, forKey: .notificationScope)) ?? nil) + .flatMap(NotificationScope.init(rawValue:)) + ?? Self.default.notificationScope + notificationsGroupedByWorktree = + try container.decodeIfPresent(Bool.self, forKey: .notificationsGroupedByWorktree) + ?? Self.default.notificationsGroupedByWorktree + notificationsUnreadOnly = + try container.decodeIfPresent(Bool.self, forKey: .notificationsUnreadOnly) + ?? Self.default.notificationsUnreadOnly analyticsEnabled = try container.decodeIfPresent(Bool.self, forKey: .analyticsEnabled) ?? Self.default.analyticsEnabled diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index dac8c7b37..d66dc0caa 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -364,9 +364,10 @@ extension RepositoriesFeature.State { /// projection actually changes. mutating func recomputeToolbarNotificationGroupsIfChanged() { let new = computeToolbarNotificationGroups() - if new != toolbarNotificationGroupsCache { - toolbarNotificationGroupsCache = new - } + guard new != toolbarNotificationGroupsCache else { return } + toolbarNotificationGroupsCache = new + // Pure function of the groups, so rebuild it in the same guarded step. + toolbarNotificationItemsCache = NotificationInspectorList.flatten(new) } /// Equatable-diffs the menu bar sections against the cache so the status menu diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index a0718f0e7..e09f05686 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -37,12 +37,165 @@ struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { var unseenNotificationCount: Int { unseenSurfaces.reduce(0) { $0 + $1.count } } +} + +/// A notification flattened out of the groups, carrying its source for the +/// inspector's single reverse-chronological list. +struct FlatNotificationItem: Identifiable, Equatable, Sendable { + // A notification is never surfaced under two worktrees, so its id is unique here. + var id: UUID { notification.id } + let notification: WorktreeTerminalNotification + let worktreeID: Worktree.ID + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + /// A folder's synthetic worktree repeats the repo name; drives dropping the suffix. + let isFolder: Bool +} + +/// A worktree's notifications, for the inspector's optional grouped layout. +struct GroupedNotifications: Identifiable, Equatable { + var id: Worktree.ID { worktreeID } + let worktreeID: Worktree.ID + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + let items: [WorktreeTerminalNotification] +} + +/// Pure derivations for the flat notification inspector. +enum NotificationInspectorList { + /// Flattens the grouped cache into one list, newest first, stable on id. + static func flatten(_ groups: [ToolbarNotificationRepositoryGroup]) -> [FlatNotificationItem] { + var items: [FlatNotificationItem] = [] + for repository in groups { + for worktree in repository.worktrees { + for notification in worktree.notifications { + items.append( + FlatNotificationItem( + notification: notification, + worktreeID: worktree.id, + repositoryName: repository.name, + repositoryColor: repository.color, + worktreeName: worktree.name, + isFolder: repository.isFolder + ) + ) + } + } + } + items.sort { lhs, rhs in + guard lhs.notification.createdAt == rhs.notification.createdAt else { + return lhs.notification.createdAt > rhs.notification.createdAt + } + return lhs.id.uuidString > rhs.id.uuidString + } + return items + } + + /// Filters the flat list to the active scope and read state. + /// `.currentWorktree` with no selection lists nothing, not everything. + static func visibleItems( + _ items: [FlatNotificationItem], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID?, + unreadOnly: Bool + ) -> [FlatNotificationItem] { + var filtered: [FlatNotificationItem] = [] + for item in items { + guard worktreeInScope(item.worktreeID, scope: scope, selectedWorktreeID: selectedWorktreeID) else { + continue + } + guard !unreadOnly || !item.notification.isRead else { continue } + filtered.append(item) + } + return filtered + } - /// Surfaces whose unread notifications were all pruned from the visible log; - /// the inspector renders one "go to the surface" row per entry. - var prunedUnseenSurfaces: [WorktreeUnseenSurface] { - let visibleSurfaceIDs = Set(notifications.map(\.surfaceID)) - return unseenSurfaces.filter { !visibleSurfaceIDs.contains($0.id) } + /// Worktree sections for the grouped layout, read from the already-grouped + /// cache in sidebar order and filtered to the active scope and read state. + static func visibleGroups( + _ groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID?, + unreadOnly: Bool + ) -> [GroupedNotifications] { + var result: [GroupedNotifications] = [] + for repository in groups { + for worktree in repository.worktrees + where worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) { + var items: [WorktreeTerminalNotification] = [] + for notification in worktree.notifications where !unreadOnly || !notification.isRead { + items.append(notification) + } + guard !items.isEmpty else { continue } + result.append( + GroupedNotifications( + worktreeID: worktree.id, + repositoryName: repository.name, + repositoryColor: repository.color, + worktreeName: worktree.name, + isFolder: repository.isFolder, + items: items + ) + ) + } + } + return result + } + + /// Unread the retention cap evicted, clamped per surface so a drifted counter can't borrow slack from a sibling. + static func prunedUnreadCount( + groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> Int { + var total = 0 + for repository in groups { + for worktree in repository.worktrees { + guard worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) else { + continue + } + var visibleUnreadBySurface: [UUID: Int] = [:] + for notification in worktree.notifications where !notification.isRead { + visibleUnreadBySurface[notification.surfaceID, default: 0] += 1 + } + for surface in worktree.unseenSurfaces { + total += max(0, surface.count - (visibleUnreadBySurface[surface.id] ?? 0)) + } + } + } + return total + } + + /// Worktrees a bulk action targets, from the groups so a pruned-only worktree counts. + static func actionableWorktreeIDs( + groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> [Worktree.ID] { + var ids: [Worktree.ID] = [] + for repository in groups { + for worktree in repository.worktrees + where worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) { + ids.append(worktree.id) + } + } + return ids + } + + private static func worktreeInScope( + _ worktreeID: Worktree.ID, + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> Bool { + switch scope { + case .all: + return true + case .currentWorktree: + return worktreeID == selectedWorktreeID + } } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7085af43a..6350e5471 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -280,6 +280,9 @@ struct RepositoriesFeature { /// mutation across all worktrees). Recomputed via /// `recomputeToolbarNotificationGroupsIfChanged()`. var toolbarNotificationGroupsCache: [ToolbarNotificationRepositoryGroup] = [] + /// Notifications flattened into one reverse-chron list, rebuilt with the + /// groups cache. The scope filter stays a view predicate, never cached. + var toolbarNotificationItemsCache: [FlatNotificationItem] = [] /// 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 diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index db3ff0bcd..0c3357b9c 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -122,7 +122,6 @@ struct WorktreeDetailView: View { fileOpenActions: state.installedOpenActions.filter(\.canOpenFiles), resolvedOpenAction: resolvedSelection, onSelectNotification: selectToolbarNotification, - onSelectSurface: selectToolbarSurface, onPullRequestAction: { sendPullRequestAction($0, worktree: selectedWorktree) }, onOpenFile: { store.send(.openFile($0, with: $1)) }, onActivateFile: { store.send(.openFileFromExplorer($0)) } @@ -412,21 +411,17 @@ struct WorktreeDetailView: View { } } + /// Selects the worktree and focuses the notification's surface, which marks it read. private func selectToolbarNotification( _ worktreeID: Worktree.ID, _ notification: WorktreeTerminalNotification ) { - selectToolbarSurface(worktreeID, notification.surfaceID) - } - - /// Focuses a surface directly, used by the inspector's pruned-unread row where - /// no notification object survives to carry the surface ID. - private func selectToolbarSurface(_ worktreeID: Worktree.ID, _ surfaceID: UUID) { store.send(.repositories(.selectWorktree(worktreeID))) if let host = terminalManager.hostIfExists(for: worktreeID), - !host.focusSurface(id: surfaceID) + !host.focusSurface(id: notification.surfaceID) { - SupaLogger("Terminal").warning("Failed to focus surface \(surfaceID) for worktree \(worktreeID).") + SupaLogger("Terminal").warning( + "Failed to focus surface \(notification.surfaceID) for worktree \(worktreeID).") } } diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index 8d3efa9c5..8d74d610d 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -15,7 +15,6 @@ struct WorktreeStatusInspectorContainer: View { let fileOpenActions: [OpenWorktreeAction] let resolvedOpenAction: OpenWorktreeAction? let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void let onPullRequestAction: (RepositoriesFeature.PullRequestAction) -> Void let onOpenFile: (URL, OpenWorktreeAction?) -> Void let onActivateFile: (URL) -> Void @@ -45,8 +44,7 @@ struct WorktreeStatusInspectorContainer: View { WorktreeNotificationsInspectorView( repositoriesStore: repositoriesStore, terminalManager: terminalManager, - onSelectNotification: onSelectNotification, - onSelectSurface: onSelectSurface + onSelectNotification: onSelectNotification ) } } @@ -476,26 +474,75 @@ private struct PullRequestMergeQueueRow: View { // MARK: - Notifications pane -/// Inspector pane for worktree notifications. Reads the notification cache in -/// its own body so notification churn invalidates only this pane, mirroring the -/// toolbar bell host. +/// Inspector pane for worktree notifications. Reads the caches in its own body +/// so churn invalidates only this pane; applies the scope and read filter here. struct WorktreeNotificationsInspectorView: View { let repositoriesStore: StoreOf let terminalManager: WorktreeTerminalManager let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void + + @Shared(.settingsFile) private var settingsFile var body: some View { + let scope = settingsFile.global.notificationScope + let isGrouped = settingsFile.global.notificationsGroupedByWorktree + let unreadOnly = settingsFile.global.notificationsUnreadOnly + let selectedWorktreeID = repositoriesStore.selectedWorktreeID let groups = repositoriesStore.toolbarNotificationGroupsCache + let allItems = repositoriesStore.toolbarNotificationItemsCache + let items = NotificationInspectorList.visibleItems( + allItems, scope: scope, selectedWorktreeID: selectedWorktreeID, unreadOnly: unreadOnly + ) + let groupedItems = + isGrouped + ? NotificationInspectorList.visibleGroups( + groups, scope: scope, selectedWorktreeID: selectedWorktreeID, unreadOnly: unreadOnly) + : [] + let prunedCount = NotificationInspectorList.prunedUnreadCount( + groups: groups, scope: scope, selectedWorktreeID: selectedWorktreeID + ) + let hasNotificationsAnywhere = + !allItems.isEmpty + || NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) > 0 + let actionableWorktreeIDs = NotificationInspectorList.actionableWorktreeIDs( + groups: groups, scope: scope, selectedWorktreeID: selectedWorktreeID + ) + NotificationsInspectorContent( - groups: groups, + items: items, + groupedItems: groupedItems, + isGrouped: isGrouped, + scope: scope, + unreadOnly: unreadOnly, + selectedWorktreeID: selectedWorktreeID, + prunedCount: prunedCount, + hasNotificationsAnywhere: hasNotificationsAnywhere, + onClearFilters: { + $settingsFile.withLock { + $0.global.notificationScope = .all + $0.global.notificationsUnreadOnly = false + } + }, onSelectNotification: onSelectNotification, - onSelectSurface: onSelectSurface, + onMarkRead: { worktreeID, notificationID in + terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + }, + onDismiss: { worktreeID, notificationID in + terminalManager.dismissNotification(worktreeID: worktreeID, notificationID: notificationID) + }, + onMarkAllRead: { + for worktreeID in actionableWorktreeIDs { + terminalManager.hostIfExists(for: worktreeID)?.markAllNotificationsRead() + } + }, onDismissAll: { - for repositoryGroup in groups { - for worktreeGroup in repositoryGroup.worktrees { - terminalManager.hostIfExists(for: worktreeGroup.id)? - .dismissAllNotifications() + for worktreeID in actionableWorktreeIDs { + let host = terminalManager.hostIfExists(for: worktreeID) + // Unread-only view dismisses only what it shows; read entries stay. + if unreadOnly { + host?.dismissUnreadNotifications() + } else { + host?.dismissAllNotifications() } } } @@ -504,40 +551,60 @@ struct WorktreeNotificationsInspectorView: View { } private struct NotificationsInspectorContent: View { - let groups: [ToolbarNotificationRepositoryGroup] + let items: [FlatNotificationItem] + let groupedItems: [GroupedNotifications] + let isGrouped: Bool + let scope: NotificationScope + let unreadOnly: Bool + let selectedWorktreeID: Worktree.ID? + let prunedCount: Int + let hasNotificationsAnywhere: Bool + let onClearFilters: () -> Void let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void + let onMarkRead: (Worktree.ID, UUID) -> Void + let onDismiss: (Worktree.ID, UUID) -> Void + let onMarkAllRead: () -> Void let onDismissAll: () -> Void + @State private var confirmingDismissAll = false + var body: some View { - let count = groups.reduce(0) { $0 + $1.notificationCount } - let unseenCount = groups.flatMap(\.worktrees).reduce(0) { $0 + $1.unseenNotificationCount } + let isEmpty = items.isEmpty && prunedCount == 0 // `List` virtualizes rows (NSTableView), so a large backlog builds only the // on-screen rows on open, never the whole log or its markdown bodies. List { - ForEach(groups) { repository in - ForEach(repository.worktrees) { worktree in + if isGrouped { + ForEach(groupedItems) { group in Section { - ForEach(worktree.notifications) { notification in + ForEach(group.items) { notification in NotificationRow( notification: notification, - worktreeID: worktree.id, - onSelect: onSelectNotification - ) - } - // A surface whose unread notifications were all pruned by the cap - // still needs a way back, so synthesize one row per orphaned surface. - ForEach(worktree.prunedUnseenSurfaces) { surface in - PrunedNotificationRow( - surface: surface, - worktreeID: worktree.id, - onSelect: onSelectSurface + worktreeID: group.worktreeID, + source: nil, + onSelect: onSelectNotification, + onMarkRead: onMarkRead, + onDismiss: onDismiss ) } } header: { - NotificationWorktreeHeader(repository: repository, worktree: worktree) + NotificationGroupHeader(group: group) } } + } else { + ForEach(items) { item in + NotificationRow( + notification: item.notification, + worktreeID: item.worktreeID, + source: scope == .all ? Self.rowSource(item, selectedWorktreeID: selectedWorktreeID) : nil, + onSelect: onSelectNotification, + onMarkRead: onMarkRead, + onDismiss: onDismiss + ) + } + } + // One aggregate for evicted unread; timeless, so it sits at the bottom. + if prunedCount > 0 { + PrunedNotificationSummaryRow(count: prunedCount) } } .listStyle(.inset) @@ -547,107 +614,262 @@ private struct NotificationsInspectorContent: View { // The header bar sits only over the list, so it scrolls under it for the // native top blur; the empty state reserves no bar. .safeAreaBar(edge: .top) { - if !groups.isEmpty { - HStack { - Text("Notifications") - .appFont(.headline) - Spacer() - Button("Dismiss All", action: onDismissAll) - .buttonStyle(.borderless) - .disabled(count == 0 && unseenCount == 0) - .help("Dismiss all notifications.") - } - .padding(.horizontal) - .padding(.vertical) + if hasNotificationsAnywhere { + NotificationsInspectorHeader( + canAct: !isEmpty, + onMarkAllRead: onMarkAllRead, + onRequestDismissAll: { confirmingDismissAll = true } + ) } } - // Empty state as a background so it fills the whole pane (past the safe area) - // instead of being offset by the reserved bar. - .background { - if groups.isEmpty { - ContentUnavailableView( - "No Notifications", - systemImage: "bell.slash", - description: Text("Agent and terminal notifications appear here.") + // Overlay, not background, so the Show All button stays hittable above the list. + .overlay { + if isEmpty { + NotificationsEmptyState( + scope: scope, + unreadOnly: unreadOnly, + hasNotificationsElsewhere: hasNotificationsAnywhere, + onClearFilters: onClearFilters ) } } + .confirmationDialog( + // Dismiss All also clears pruned unread, so the count includes it. + Self.dismissAllTitle(scope: scope, unreadOnly: unreadOnly, count: items.count + prunedCount), + isPresented: $confirmingDismissAll, + titleVisibility: .visible + ) { + Button("Dismiss All", role: .destructive, action: onDismissAll) + Button("Cancel", role: .cancel) {} + } + } + + private static func dismissAllTitle(scope: NotificationScope, unreadOnly: Bool, count: Int) -> String { + let suffix = scope == .currentWorktree ? " in this worktree" : "" + let adjective = unreadOnly ? "unread " : "" + guard count > 0 else { return "Dismiss all \(adjective)notifications\(suffix)?" } + let noun = count == 1 ? "notification" : "notifications" + return "Dismiss all \(count) \(adjective)\(noun)\(suffix)?" + } + + private static func rowSource( + _ item: FlatNotificationItem, selectedWorktreeID: Worktree.ID? + ) -> NotificationRowSource { + NotificationRowSource( + repositoryName: item.repositoryName, + repositoryColor: item.repositoryColor, + worktreeName: item.worktreeName, + isFolder: item.isFolder, + isSelectedWorktree: item.worktreeID == selectedWorktreeID + ) } } -/// Section header mirroring the sidebar's row identity: colored repo name, -/// folder glyph for folder repos, and the worktree's sidebar title. -private struct NotificationWorktreeHeader: View { - let repository: ToolbarNotificationRepositoryGroup - let worktree: ToolbarNotificationWorktreeGroup +private struct NotificationsInspectorHeader: View { + let canAct: Bool + let onMarkAllRead: () -> Void + let onRequestDismissAll: () -> Void var body: some View { - HStack(spacing: 5) { - if repository.isFolder { - Image(systemName: "folder") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } else { - Image(worktree.pullRequestIcon.assetName) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundStyle(worktree.pullRequestIcon.color) - .help(worktree.pullRequestIcon.statusDescription) - .accessibilityLabel(worktree.pullRequestIcon.statusDescription) + HStack { + NotificationFilterMenu() + Spacer() + NotificationOverflowMenu( + onMarkAllRead: onMarkAllRead, + onRequestDismissAll: onRequestDismissAll + ) + .disabled(!canAct) + } + .padding(.horizontal) + .padding(.vertical) + } +} + +/// Scope filter (its label doubles as the pane title) plus the unread-only and +/// grouping toggles. Binds settings directly for stable bindings, not closures. +private struct NotificationFilterMenu: View { + @Shared(.settingsFile) private var settingsFile + + var body: some View { + let scope = settingsFile.global.notificationScope + let unreadOnly = settingsFile.global.notificationsUnreadOnly + Menu { + Picker("Show Notifications From", selection: Binding($settingsFile.global.notificationScope)) { + ForEach(NotificationScope.allCases, id: \.self) { option in + Text(option.inspectorTitle).tag(option) + } } - // Repo keeps layout priority so the colored tag doesn't truncate first, - // mirroring the sidebar highlight subtitle. - Text(repository.name) - .foregroundStyle(repositoryStyle) - .layoutPriority(1) - // A folder's synthetic worktree repeats the repo name; skip the trail. - if !repository.isFolder { - Text(verbatim: "·") - .foregroundStyle(.tertiary) - Text(worktree.name) + .pickerStyle(.inline) + .labelsHidden() + Divider() + Toggle("Unread Only", isOn: Binding($settingsFile.global.notificationsUnreadOnly)) + Toggle("Group into Worktrees", isOn: Binding($settingsFile.global.notificationsGroupedByWorktree)) + } label: { + HStack(spacing: 3) { + Text(scope.inspectorTitle) + .appFont(.headline) + .foregroundStyle(.primary) + // Unread-only is easy to forget once set; flag it with the row dot. + if unreadOnly { + Circle() + .fill(.orange) + .frame(width: 6, height: 6) + .padding(.trailing, 4) + .accessibilityLabel("Unread only") + } + // A plain button menu draws no indicator, so supply the chevron here. + Image(systemName: "chevron.down") + .appFont(.caption2) .foregroundStyle(.secondary) + .accessibilityHidden(true) } } + // Plain button menu renders the label faithfully; borderless overrides its weight. + .menuStyle(.button) + .buttonStyle(.plain) + .fixedSize() + .help("Filter which worktrees' notifications are shown.") + } +} + +/// Section header for the grouped layout. +private struct NotificationGroupHeader: View { + let group: GroupedNotifications + + var body: some View { + NotificationSourceTag( + repositoryName: group.repositoryName, + repositoryColor: group.repositoryColor, + worktreeName: group.worktreeName, + isFolder: group.isFolder + ) .appFont(.subheadline, weight: .medium) - .lineLimit(1) .textCase(nil) } +} - private var repositoryStyle: AnyShapeStyle { - repository.color.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) +/// Overflow menu; Dismiss All is destructive so the owner routes it through a +/// confirmation. +private struct NotificationOverflowMenu: View { + let onMarkAllRead: () -> Void + let onRequestDismissAll: () -> Void + + var body: some View { + Menu { + Button(action: onMarkAllRead) { + Label("Mark All as Read", systemImage: "checkmark.circle") + } + Button(role: .destructive, action: onRequestDismissAll) { + Label("Dismiss All", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .appFont(.headline) + .contentShape(.rect) + .accessibilityLabel("More notification actions") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("More notification actions.") } } +/// Distinguishes "nothing anywhere" from "nothing matches the active filters", +/// which offers a one-tap reset back to every notification. +private struct NotificationsEmptyState: View { + let scope: NotificationScope + let unreadOnly: Bool + let hasNotificationsElsewhere: Bool + let onClearFilters: () -> Void + + var body: some View { + if filtersActive, hasNotificationsElsewhere { + ContentUnavailableView { + Label(title, systemImage: "bell.slash") + } description: { + Text(message) + } actions: { + Button("Show All Notifications", action: onClearFilters) + } + } else { + ContentUnavailableView( + "No Notifications", + systemImage: "bell.slash", + description: Text("Agent and terminal notifications appear here.") + ) + } + } + + private var filtersActive: Bool { + scope == .currentWorktree || unreadOnly + } + + private var title: String { + switch (unreadOnly, scope) { + case (true, .currentWorktree): "No Unread in This Worktree" + case (true, .all): "No Unread Notifications" + case (false, _): "None in This Worktree" + } + } + + private var message: String { + switch (unreadOnly, scope) { + case (true, .currentWorktree): "Read notifications and other worktrees are hidden." + case (true, .all): "Read notifications are hidden." + case (false, _): "Notifications from other worktrees are hidden." + } + } +} + +/// The repo/worktree source shown inside a flat-list row; nil in grouped mode +/// where the section header carries it instead. +private struct NotificationRowSource { + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + let isSelectedWorktree: Bool +} + private struct NotificationRow: View { let notification: WorktreeTerminalNotification let worktreeID: Worktree.ID + let source: NotificationRowSource? let onSelect: (Worktree.ID, WorktreeTerminalNotification) -> Void + let onMarkRead: (Worktree.ID, UUID) -> Void + let onDismiss: (Worktree.ID, UUID) -> Void var body: some View { - // Notification titles are the agent slug ("claude", "codex", …) when the - // notification came from an agent; show its mark and display name instead. + // Agent notifications carry the agent slug as the title; show its mark and name. let agent = SkillAgent(rawValue: notification.title.lowercased()) let title = agent?.displayName ?? (notification.title.isEmpty ? "Terminal" : notification.title) + // The whole row navigates to the source; the chevron is a passive affordance. Button { onSelect(worktreeID, notification) } label: { HStack(alignment: .top, spacing: 10) { NotificationSourceIcon(agent: agent) .padding(.top, 1) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { + // Dimmed for other worktrees so the selected one stands out. + if let source { + NotificationSourceTag( + repositoryName: source.repositoryName, + repositoryColor: source.repositoryColor, + worktreeName: source.worktreeName, + isFolder: source.isFolder + ) + .appFont(.caption) + .opacity(source.isSelectedWorktree ? 1 : 0.5) + } HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) .appFont(.subheadline, weight: .semibold) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .lineLimit(1) Spacer(minLength: 6) - // Self-updating relative time; no shared clock needed, so a row's - // markdown body is never re-parsed just to advance the timestamp. + // Self-updating relative time, so the markdown body isn't re-parsed to tick. Text(notification.createdAt, style: .relative) .appFont(.caption) .foregroundStyle(.tertiary) @@ -660,19 +882,94 @@ private struct NotificationRow: View { .accessibilityHidden(true) } if !notification.body.isEmpty { - Text(Self.markdown(notification.body)) - .appFont(.callout) - .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + NotificationBodyText(text: notification.body, isRead: notification.isRead) } } + Image(systemName: "chevron.forward") + .appFont(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 1) + .accessibilityHidden(true) } .padding(.vertical, 4) .contentShape(.rect) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) + .contextMenu { + Button { + Self.copyToPasteboard(title: title, body: notification.body) + } label: { + Label("Copy Notification", systemImage: "doc.on.doc") + } + Section { + Button { + onMarkRead(worktreeID, notification.id) + } label: { + Label("Mark as Read", systemImage: "checkmark.circle") + } + .disabled(notification.isRead) + Button(role: .destructive) { + onDismiss(worktreeID, notification.id) + } label: { + Label("Dismiss", systemImage: "trash") + } + } + } + } + + private static func copyToPasteboard(title: String, body: String) { + var parts: [String] = [] + for part in [title, body] where !part.isEmpty { + parts.append(part) + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(parts.joined(separator: "\n\n"), forType: .string) + } +} + +/// Colored repository name, then "· worktree" for non-folder repos. Shared by +/// the flat row source line and the grouped section header (which set the font). +private struct NotificationSourceTag: View { + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + + var body: some View { + HStack(spacing: 4) { + // Repo keeps layout priority so the colored tag doesn't truncate first. + Text(repositoryName) + .foregroundStyle(repositoryStyle) + .layoutPriority(1) + // A folder's synthetic worktree repeats the repo name, so skip the trail. + if !isFolder { + Text(verbatim: "·") + .foregroundStyle(.tertiary) + Text(worktreeName) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + } + + private var repositoryStyle: AnyShapeStyle { + repositoryColor.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) + } +} + +/// Notification body, capped at three lines. Truncated, not expandable: the row +/// click navigates to the source instead. +private struct NotificationBodyText: View { + let text: String + let isRead: Bool + + var body: some View { + Text(Self.markdown(text)) + .appFont(.callout) + .foregroundStyle(isRead ? Color.secondary : Color.primary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) } private static func markdown(_ string: String) -> AttributedString { @@ -683,48 +980,42 @@ private struct NotificationRow: View { } } -/// Synthesized row for a surface whose unread notifications the cap already -/// pruned from the log. Keeps the unread reachable: tapping focuses the surface. -private struct PrunedNotificationRow: View { - let surface: WorktreeUnseenSurface - let worktreeID: Worktree.ID - let onSelect: (Worktree.ID, UUID) -> Void +/// Non-interactive aggregate reconciling the bell badge with the visible rows. +private struct PrunedNotificationSummaryRow: View { + let count: Int var body: some View { - Button { - onSelect(worktreeID, surface.id) - } label: { - HStack(alignment: .top, spacing: 10) { - NotificationSourceIcon(agent: nil) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(title) - .appFont(.subheadline, weight: .semibold) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer(minLength: 6) - Circle() - .fill(.orange) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - } - Text("Cleared per your Notification settings.") - .appFont(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } + HStack(alignment: .top, spacing: 10) { + NotificationSourceIcon(agent: nil) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .appFont(.subheadline, weight: .semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("Older unread cleared per your Notification settings.") + .appFont(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.vertical, 4) - .contentShape(.rect) - .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .padding(.vertical, 4) + .contentShape(.rect) + .frame(maxWidth: .infinity, alignment: .leading) } private var title: String { - surface.count == 1 ? "1 unread notification" : "\(surface.count) unread notifications" + count == 1 ? "1 more unread beyond retention" : "\(count) more unread beyond retention" + } +} + +extension NotificationScope { + fileprivate var inspectorTitle: String { + switch self { + case .all: "All Notifications" + case .currentWorktree: "Current Worktree" + } } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift index f5044cc47..574d2d136 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift @@ -375,6 +375,14 @@ final class WorktreeContentHost { emitNotificationStateChanged() } + /// Drops unread entries, visible and pruned; read entries stay. Backs the + /// inspector's "Dismiss All" while the unread-only filter is active. + func dismissUnreadNotifications() { + notifications.removeAll { !$0.isRead } + clearAllUnseenCounters() + emitNotificationStateChanged() + } + func enforceNotificationRetentionLimit() { guard trimNotificationsToRetentionLimit() else { return } emitNotificationStateChanged() diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index d24891c31..3692b46d8 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1954,6 +1954,11 @@ final class WorktreeTerminalManager { emitProjection(for: worktreeID) } + func dismissNotification(worktreeID: Worktree.ID, notificationID: UUID) { + hosts[worktreeID]?.dismissNotification(notificationID) + 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. diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 4c06f4898..7e78f3524 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -183,6 +183,29 @@ struct SettingsFeatureTests { #expect(settingsFile.global.chromeTextSize == .large) } + @Test(.dependencies) func unrelatedSettingsChangeKeepsNotificationInspectorPrefs() async { + // The inspector owns scope / grouping / unread-only; the Settings window only + // carries them through, so an unrelated change here must not reset them. + var initialSettings = GlobalSettings.default + initialSettings.notificationScope = .currentWorktree + initialSettings.notificationsGroupedByWorktree = true + initialSettings.notificationsUnreadOnly = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.terminalHibernationEnabled, false))) { + $0.terminalHibernationEnabled = false + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.notificationScope == .currentWorktree) + #expect(settingsFile.global.notificationsGroupedByWorktree) + #expect(settingsFile.global.notificationsUnreadOnly) + } + @Test(.dependencies) func togglingAutomaticRepositoryRefreshPersistsChanges() async { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } diff --git a/supacodeTests/ToolbarNotificationGroupingTests.swift b/supacodeTests/ToolbarNotificationGroupingTests.swift index 4da0729ca..984801a26 100644 --- a/supacodeTests/ToolbarNotificationGroupingTests.swift +++ b/supacodeTests/ToolbarNotificationGroupingTests.swift @@ -367,7 +367,7 @@ struct ToolbarNotificationGroupingTests { ) } - @Test func prunedUnreadSurfacesFormAGroupWithSyntheticRows() { + @Test func unseenCountCountsSurfacesWhoseNotificationsWereAllPruned() { let repoPath = "/tmp/repo" let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) @@ -383,10 +383,9 @@ struct ToolbarNotificationGroupingTests { let worktree = state.computeToolbarNotificationGroups().first?.worktrees.first #expect(worktree?.unseenNotificationCount == 3) - #expect(worktree?.prunedUnseenSurfaces.map(\.id) == [surfaceID]) } - @Test func surfaceWithVisibleNotificationIsNotSynthesized() { + @Test func unseenCountReflectsSurfaceWithVisibleNotification() { let repoPath = "/tmp/repo" let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) @@ -394,7 +393,6 @@ struct ToolbarNotificationGroupingTests { var state = RepositoriesFeature.State(reconciledRepositories: [repo]) state.repositoryRoots = [repo.rootURL] - // The surface still has a visible notification, so no synthetic row is added. let surfaceID = UUID() setRowNotifications( &state, id: feature.id, @@ -407,7 +405,6 @@ struct ToolbarNotificationGroupingTests { let worktree = state.computeToolbarNotificationGroups().first?.worktrees.first #expect(worktree?.unseenNotificationCount == 1) - #expect(worktree?.prunedUnseenSurfaces.isEmpty == true) } private func setRowNotifications( @@ -478,3 +475,561 @@ struct ScriptMenuIdentityTests { )) } } + +@MainActor +struct NotificationInspectorListTests { + @Test func flattenSortsNewestFirstAndDenormalizesSource() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "old", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "new", body: "x", createdAt: at(300)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "mid", body: "x", createdAt: at(200)) + ]) + // The group name resolves from the sidebar title, so set a custom one to + // assert flatten denormalizes it onto the row deterministically. + state.sidebarItems[id: featureA.id]?.customTitle = "Feature A" + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + #expect(items.map(\.notification.title) == ["new", "mid", "old"]) + #expect(items.first?.worktreeID == featureA.id) + #expect(items.first?.repositoryName == "Repo") + #expect(items.first?.worktreeName == "Feature A") + } + + @Test func visibleItemsAppliesScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + #expect( + NotificationInspectorList.visibleItems( + items, scope: .all, selectedWorktreeID: featureA.id, unreadOnly: false + ).count == 3) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: false + ).map(\.notification.title) == ["a1", "a2"]) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: false + ).map(\.notification.title) == ["b1"]) + // No selection under the current-worktree scope lists nothing, not everything. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: nil, unreadOnly: false + ).isEmpty) + } + + @Test func visibleItemsUnreadOnlyHidesReadAcrossScopes() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "b2", body: "x", createdAt: at(50), isRead: true), + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + // Under `.all`, unread from every worktree survives (selection is ignored) and + // all read entries drop, in global time order. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .all, selectedWorktreeID: featureA.id, unreadOnly: true + ).map(\.notification.title) == ["a1", "b1"]) + // Current-worktree scope narrows the same filter to just the selected worktree's unread. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: true + ).map(\.notification.title) == ["a1"]) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: true + ).map(\.notification.title) == ["b1"]) + } + + @Test func prunedUnreadCountAndActionableWorktreesRespectScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Both worktrees have only pruned unread (no visible notifications). + for (worktree, count) in [(featureA, 3), (featureB, 2)] { + state.sidebarItems[id: worktree.id]?.notifications = [] + state.sidebarItems[id: worktree.id]?.hasUnseenNotifications = true + state.sidebarItems[id: worktree.id]?.unseenSurfaces = [WorktreeUnseenSurface(id: UUID(), count: count)] + } + + let groups = state.computeToolbarNotificationGroups() + + #expect( + NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 5) + #expect( + NotificationInspectorList.prunedUnreadCount( + groups: groups, scope: .currentWorktree, selectedWorktreeID: featureA.id) == 3) + // Pruned-only worktrees still count as actionable so bulk actions clear them. + #expect( + Set(NotificationInspectorList.actionableWorktreeIDs(groups: groups, scope: .all, selectedWorktreeID: nil)) + == Set([featureA.id, featureB.id])) + #expect( + NotificationInspectorList.actionableWorktreeIDs( + groups: groups, scope: .currentWorktree, selectedWorktreeID: featureB.id) == [featureB.id]) + } + + @Test func flatCacheRebuildsWithTheGroupsCache() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "n1", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "n2", body: "x", createdAt: at(200)), + ]) + + #expect(state.toolbarNotificationItemsCache.isEmpty) + state.recomputeToolbarNotificationGroupsIfChanged() + + #expect( + state.toolbarNotificationItemsCache + == NotificationInspectorList.flatten(state.toolbarNotificationGroupsCache)) + #expect(state.toolbarNotificationItemsCache.map(\.notification.title) == ["n2", "n1"]) + } + + @Test func prunedUnreadCountFoldsPartiallyPrunedSurfaces() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Surface X keeps 1 visible unread but has 3 outstanding (2 evicted); surface + // Y is fully pruned (2). Pruned = (3 - 1) + 2 = 4, not the 2 a surface-level + // count would report. + let surfaceX = UUID() + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X", body: "x", createdAt: at(100)) + ]) + state.sidebarItems[id: feature.id]?.unseenSurfaces = [ + WorktreeUnseenSurface(id: surfaceX, count: 3), + WorktreeUnseenSurface(id: UUID(), count: 2), + ] + + let groups = state.computeToolbarNotificationGroups() + #expect(NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 4) + } + + @Test func prunedUnreadCountClampsPerSurfaceUnderCounterDrift() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Surface X's counter (1) has drifted below its 2 visible unread rows; surface + // Y is fully pruned (5). Per-surface clamping keeps X at 0, so pruned = 5, not + // the 4 a pooled worktree-level subtraction would report. + let surfaceX = UUID() + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X1", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X2", body: "x", createdAt: at(110)), + ]) + state.sidebarItems[id: feature.id]?.unseenSurfaces = [ + WorktreeUnseenSurface(id: surfaceX, count: 1), + WorktreeUnseenSurface(id: UUID(), count: 5), + ] + + let groups = state.computeToolbarNotificationGroups() + #expect(NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 5) + } + + @Test func flattenTieBreaksEqualTimestampsByDescendingID() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let low = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let high = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(id: low, surfaceID: UUID(), title: "low", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(id: high, surfaceID: UUID(), title: "high", body: "x", createdAt: at(100)), + ]) + + // Equal timestamps fall back to descending uuidString for a stable order. + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + #expect(items.map(\.notification.title) == ["high", "low"]) + } + + @Test func flattenSortsAcrossRepositoriesAndCarriesEachSource() { + let repoAPath = "/tmp/repo-a" + let repoBPath = "/tmp/repo-b" + let featureA = makeWorktree(id: "\(repoAPath)/a", name: "a", repoRoot: repoAPath) + let featureB = makeWorktree(id: "\(repoBPath)/b", name: "b", repoRoot: repoBPath) + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [featureA]) + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] + state.$sidebar.withLock { sidebar in + sidebar.sections[repoA.id] = .init(title: "Repo A", color: .teal) + sidebar.sections[repoB.id] = .init(title: "Repo B", color: .purple) + } + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-old", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-new", body: "x", createdAt: at(300)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b-mid", body: "x", createdAt: at(200)) + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + // Global time sort ignores repository grouping. + #expect(items.map(\.notification.title) == ["a-new", "b-mid", "a-old"]) + let byTitle = Dictionary(uniqueKeysWithValues: items.map { ($0.notification.title, $0) }) + #expect(byTitle["a-new"]?.repositoryName == "Repo A") + #expect(byTitle["a-new"]?.repositoryColor == .teal) + #expect(byTitle["b-mid"]?.repositoryName == "Repo B") + #expect(byTitle["b-mid"]?.repositoryColor == .purple) + } + + @Test func visibleGroupsReadsGroupedCacheScoped() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(100)) + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let all = NotificationInspectorList.visibleGroups(groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + #expect(Set(all.map(\.worktreeID)) == Set([featureA.id, featureB.id])) + #expect(all.allSatisfy { !$0.items.isEmpty }) + + let current = NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: false) + #expect(current.map(\.worktreeID) == [featureB.id]) + #expect(current.first?.items.map(\.title) == ["b1"]) + } + + @Test func visibleGroupsUnreadOnlyDropsReadAndEmptiedSections() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200), isRead: true) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: true) + + // featureB is all-read, so its section drops entirely; featureA keeps only unread. + #expect(sections.map(\.worktreeID) == [featureA.id]) + #expect(sections.first?.items.map(\.title) == ["a1"]) + } + + @Test func visibleGroupsUnreadOnlyCombinesWithCurrentWorktreeScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: true) + + // Scope keeps only the selected worktree; unread-only drops its read entry. + #expect(sections.map(\.worktreeID) == [featureA.id]) + #expect(sections.first?.items.map(\.title) == ["a1"]) + } + + @Test func visibleGroupsExcludesPrunedOnlyWorktree() { + let repoPath = "/tmp/repo" + let pruned = makeWorktree(id: "\(repoPath)/pruned", name: "pruned", repoRoot: repoPath) + let live = makeWorktree(id: "\(repoPath)/live", name: "live", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [pruned, live]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // `pruned` has outstanding unread but no visible notifications (cap-evicted). + state.sidebarItems[id: pruned.id]?.notifications = [] + state.sidebarItems[id: pruned.id]?.hasUnseenNotifications = true + state.sidebarItems[id: pruned.id]?.unseenSurfaces = [WorktreeUnseenSurface(id: UUID(), count: 2)] + setRowNotifications( + &state, id: live.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "l1", body: "x", createdAt: at(100)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + // The pruned-only worktree renders no section but stays actionable for bulk actions. + #expect(sections.map(\.worktreeID) == [live.id]) + #expect( + Set(NotificationInspectorList.actionableWorktreeIDs(groups: groups, scope: .all, selectedWorktreeID: nil)) + == Set([pruned.id, live.id])) + } + + @Test func visibleGroupsAcrossRepositoriesCarriesSourceAndOrder() { + let repoAPath = "/tmp/repo-a" + let repoBPath = "/tmp/repo-b" + let featureA = makeWorktree(id: "\(repoAPath)/a", name: "a", repoRoot: repoAPath) + let featureB = makeWorktree(id: "\(repoBPath)/b", name: "b", repoRoot: repoBPath) + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [featureA]) + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] + state.$sidebar.withLock { sidebar in + sidebar.sections[repoA.id] = .init(title: "Repo A", color: .teal) + sidebar.sections[repoB.id] = .init(title: "Repo B", color: .purple) + } + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-new", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-old", body: "x", createdAt: at(100)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + #expect(sections.map(\.worktreeID) == [featureA.id, featureB.id]) + let sectionA = sections.first { $0.worktreeID == featureA.id } + #expect(sectionA?.repositoryName == "Repo A") + #expect(sectionA?.repositoryColor == .teal) + // Grouped items keep the stored order (not the flat list's global sort). + #expect(sectionA?.items.map(\.title) == ["a-new", "a-old"]) + #expect(sections.first { $0.worktreeID == featureB.id }?.repositoryColor == .purple) + } + + @Test func visibleGroupsCurrentWorktreeWithNilSelectionIsEmpty() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/a", name: "a", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(100)) + ]) + + let groups = state.computeToolbarNotificationGroups() + #expect( + NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: nil, unreadOnly: false + ).isEmpty) + } + + private func at(_ seconds: TimeInterval) -> Date { + Date(timeIntervalSince1970: seconds) + } + + private func setRowNotifications( + _ state: inout RepositoriesFeature.State, + id: SidebarItemID, + notifications: [WorktreeTerminalNotification] + ) { + let hasUnseen = notifications.contains(where: { !$0.isRead }) + state.sidebarItems[id: id]?.notifications = IdentifiedArrayOf(uniqueElements: notifications) + state.sidebarItems[id: id]?.hasUnseenNotifications = hasUnseen + } + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository(id: String, name: String, worktrees: [Worktree]) -> Repository { + Repository( + id: RepositoryID(id), + rootURL: URL(fileURLWithPath: id), + name: name, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +} + +struct NotificationScopeSettingsTests { + @Test func defaultsToAll() { + #expect(GlobalSettings.default.notificationScope == .all) + } + + @Test func absentKeyDecodesToAll() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationScope") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationScope == .all) + } + + @Test func roundTripsCurrentWorktree() throws { + var settings = GlobalSettings.default + settings.notificationScope = .currentWorktree + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationScope == .currentWorktree) + } + + @Test func presentButUnknownScopeDecodesToAllWithoutThrowing() throws { + // A scope written by a newer build (or corruption) must fall back, not throw + // and reset the whole settings file. + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object["notificationScope"] = "nonsense" + let mangled = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: mangled) + #expect(decoded.notificationScope == .all) + } + + @Test func groupingDefaultsToOff() { + #expect(GlobalSettings.default.notificationsGroupedByWorktree == false) + } + + @Test func groupingRoundTrips() throws { + var settings = GlobalSettings.default + settings.notificationsGroupedByWorktree = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationsGroupedByWorktree) + } + + @Test func groupingAbsentKeyDecodesToOff() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationsGroupedByWorktree") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationsGroupedByWorktree == false) + } + + @Test func unreadOnlyDefaultsToOff() { + #expect(GlobalSettings.default.notificationsUnreadOnly == false) + } + + @Test func unreadOnlyRoundTrips() throws { + var settings = GlobalSettings.default + settings.notificationsUnreadOnly = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationsUnreadOnly) + } + + @Test func unreadOnlyAbsentKeyDecodesToOff() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationsUnreadOnly") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationsUnreadOnly == false) + } +}