diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 7591da2a9d50..17df1981a9dc 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import os import UserNotifications @@ -739,6 +740,16 @@ struct TerminalNotification: Identifiable, Hashable { } } +struct TerminalNotificationBadgeInvalidation: Equatable { + enum Scope: Equatable { + case panelIds(Set) + case workspaceManualUnread + } + + let tabId: UUID + let scope: Scope +} + @MainActor final class TerminalNotificationStore: ObservableObject { private struct TabSurfaceKey: Hashable { @@ -768,6 +779,7 @@ final class TerminalNotificationStore: ObservableObject { didSet { indexes = Self.buildIndexes(for: notifications) refreshUnreadPresentation() + publishNotificationBadgeInvalidations(oldValue: oldValue, newValue: notifications) if !suppressNotificationDiffPublishing { CmuxEventBus.shared.publishNotificationChanges(oldValue: oldValue, newValue: notifications) } } } @@ -775,7 +787,10 @@ final class TerminalNotificationStore: ObservableObject { // Workspace-level unread drives sidebar workspace badges; pane-level manual // unread remains owned by Workspace.manualUnreadPanelIds. @Published private(set) var manualUnreadWorkspaceIds: Set = [] { - didSet { refreshUnreadPresentation() } + didSet { + refreshUnreadPresentation() + publishWorkspaceManualUnreadInvalidations(oldValue: oldValue, newValue: manualUnreadWorkspaceIds) + } } @Published private(set) var panelDerivedUnreadWorkspaceIds: Set = [] { didSet { refreshUnreadPresentation() } @@ -783,7 +798,11 @@ final class TerminalNotificationStore: ObservableObject { @Published private(set) var restoredUnreadWorkspaceIds: Set = [] { didSet { refreshUnreadPresentation() } } - @Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:] + @Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:] { + didSet { + publishFocusedReadIndicatorInvalidations(oldValue: oldValue, newValue: focusedReadIndicatorByTabId) + } + } @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private var suppressNotificationDiffPublishing = false @@ -831,6 +850,7 @@ final class TerminalNotificationStore: ObservableObject { private var lastNotificationDateByCooldownKey: [String: Date] = [:] private var lastNotificationHookFailureDateByKey: [NotificationHookFailureThrottleKey: Date] = [:] private var indexes = NotificationIndexes() + private let badgeInvalidationSubject = PassthroughSubject() private init() { indexes = Self.buildIndexes(for: notifications) @@ -1277,6 +1297,12 @@ final class TerminalNotificationStore: ObservableObject { ) } + func badgeInvalidations(forTabId tabId: UUID) -> AnyPublisher { + badgeInvalidationSubject + .filter { $0.tabId == tabId } + .eraseToAnyPublisher() + } + private func commitCooldownReservation( _ reservation: NotificationCooldownReservation?, at date: Date @@ -2149,6 +2175,71 @@ final class TerminalNotificationStore: ObservableObject { return indexes } + private func publishNotificationBadgeInvalidations( + oldValue: [TerminalNotification], + newValue: [TerminalNotification] + ) { + let oldPanelIdsByTabId = Self.unreadBadgePanelIdsByTabId(for: oldValue) + let newPanelIdsByTabId = Self.unreadBadgePanelIdsByTabId(for: newValue) + let tabIds = Set(oldPanelIdsByTabId.keys).union(newPanelIdsByTabId.keys) + + for tabId in tabIds { + let changedPanelIds = (oldPanelIdsByTabId[tabId] ?? []) + .symmetricDifference(newPanelIdsByTabId[tabId] ?? []) + guard !changedPanelIds.isEmpty else { continue } + badgeInvalidationSubject.send( + TerminalNotificationBadgeInvalidation(tabId: tabId, scope: .panelIds(changedPanelIds)) + ) + } + } + + private func publishWorkspaceManualUnreadInvalidations( + oldValue: Set, + newValue: Set + ) { + for tabId in oldValue.symmetricDifference(newValue) { + badgeInvalidationSubject.send( + TerminalNotificationBadgeInvalidation(tabId: tabId, scope: .workspaceManualUnread) + ) + } + } + + private func publishFocusedReadIndicatorInvalidations( + oldValue: [UUID: UUID], + newValue: [UUID: UUID] + ) { + let tabIds = Set(oldValue.keys).union(newValue.keys) + for tabId in tabIds { + guard oldValue[tabId] != newValue[tabId] else { continue } + var changedPanelIds = Set() + if let oldPanelId = oldValue[tabId] { + changedPanelIds.insert(oldPanelId) + } + if let newPanelId = newValue[tabId] { + changedPanelIds.insert(newPanelId) + } + guard !changedPanelIds.isEmpty else { continue } + badgeInvalidationSubject.send( + TerminalNotificationBadgeInvalidation(tabId: tabId, scope: .panelIds(changedPanelIds)) + ) + } + } + + private static func unreadBadgePanelIdsByTabId( + for notifications: [TerminalNotification] + ) -> [UUID: Set] { + var panelIdsByTabId: [UUID: Set] = [:] + for notification in notifications where !notification.isRead { + if let surfaceId = notification.surfaceId { + panelIdsByTabId[notification.tabId, default: []].insert(surfaceId) + } + if let panelId = notification.panelId { + panelIdsByTabId[notification.tabId, default: []].insert(panelId) + } + } + return panelIdsByTabId + } + private static func notificationSortPrecedes(_ lhs: TerminalNotification, _ rhs: TerminalNotification) -> Bool { if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2a42a9402972..24b60408ad58 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10502,6 +10502,63 @@ final class Workspace: Identifiable, ObservableObject { } } + func syncBonsplitTabPresentationState(forPanelIds panelIds: Set) { + guard !panelIds.isEmpty else { return } + let notificationStore = AppDelegate.shared?.notificationStore + let isWorkspaceManuallyUnread = notificationStore?.hasManualUnread(forTabId: id) ?? false + let workspaceManualUnreadPanelId = isWorkspaceManuallyUnread + ? representativePanelIdForWorkspaceManualUnread() + : nil + + for panelId in panelIds { + syncBonsplitTabPresentationState( + forPanelId: panelId, + notificationStore: notificationStore, + isWorkspaceManuallyUnread: isWorkspaceManuallyUnread, + workspaceManualUnreadPanelId: workspaceManualUnreadPanelId + ) + } + } + + func syncBonsplitTabPresentationStateForAllPanels() { + syncBonsplitTabPresentationState(forPanelIds: Set(panels.keys)) + } + + private func syncBonsplitTabPresentationState( + forPanelId panelId: UUID, + notificationStore: TerminalNotificationStore?, + isWorkspaceManuallyUnread: Bool, + workspaceManualUnreadPanelId: UUID? + ) { + guard let tabId = surfaceIdFromPanelId(panelId) else { return } + let expectedKind = panelKind(panelId: panelId) + let expectedPinned = isPanelPinned(panelId) + let shouldShowUnread = Self.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore?.hasVisibleNotificationIndicator( + forTabId: id, + surfaceId: panelId + ) ?? false, + hasPanelUnreadIndicator: manualUnreadPanelIds.contains(panelId) || restoredUnreadPanelIds.contains(panelId), + isWorkspaceManuallyUnread: isWorkspaceManuallyUnread, + isWorkspaceManualUnreadRepresentative: workspaceManualUnreadPanelId == panelId + ) + let kindUpdate: String?? = .some(expectedKind) + + guard let tab = bonsplitController.tab(tabId) else { return } + guard tab.showsNotificationBadge != shouldShowUnread || + tab.isPinned != expectedPinned || + tab.kind != expectedKind else { + return + } + + bonsplitController.updateTab( + tabId, + kind: kindUpdate, + showsNotificationBadge: shouldShowUnread, + isPinned: expectedPinned + ) + } + private func hasVisibleNotificationIndicator(panelId: UUID) -> Bool { AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false } @@ -10536,24 +10593,11 @@ final class Workspace: Identifiable, ObservableObject { } private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { - guard let tabId = surfaceIdFromPanelId(panelId) else { return } - let notificationStore = AppDelegate.shared?.notificationStore - let shouldShowUnread = Self.shouldShowUnreadIndicator( - hasUnreadNotification: hasVisibleNotificationIndicator(panelId: panelId), - hasPanelUnreadIndicator: manualUnreadPanelIds.contains(panelId) || restoredUnreadPanelIds.contains(panelId), - isWorkspaceManuallyUnread: notificationStore?.hasManualUnread(forTabId: id) ?? false, - isWorkspaceManualUnreadRepresentative: representativePanelIdForWorkspaceManualUnread() == panelId - ) - if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { - return - } - bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) + syncBonsplitTabPresentationState(forPanelIds: [panelId]) } private func syncUnreadBadgeStateForAllPanels() { - for panelId in panels.keys { - syncUnreadBadgeStateForPanel(panelId) - } + syncBonsplitTabPresentationStateForAllPanels() } func syncPanelDerivedWorkspaceUnread() { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 128777f7a43e..2ada2afc422b 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -2,6 +2,7 @@ import SwiftUI import Foundation import AppKit import Bonsplit +import Combine enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable { case surface @@ -171,10 +172,16 @@ struct WorkspaceContentView: View { @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @State private var lastAppliedUsesHostLayerBackground = GhosttyApp.shared.usesHostLayerBackground @State private var deferredThemeRefresh: DeferredThemeRefresh? + @State private var lastWorkspaceManualUnreadPanelIdForBadgeSync: UUID? + @State private var lastWorkspaceManualUnreadForBadgeSync = false + @State private var unreadBadgeRevision: UInt64 = 0 @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var notificationStore: TerminalNotificationStore + + private var notificationStore: TerminalNotificationStore { + AppDelegate.shared?.notificationStore ?? TerminalNotificationStore.shared + } private var isMinimalMode: Bool { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal @@ -196,6 +203,7 @@ struct WorkspaceContentView: View { let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || workspace.panels.count > 1 let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay + let _ = unreadBadgeRevision let isWorkspaceManuallyUnread = notificationStore.hasManualUnread(forTabId: workspace.id) let workspaceManualUnreadPanelId = workspace.representativePanelIdForWorkspaceManualUnread() @@ -301,7 +309,8 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { updateAgentHibernationPresentationVisibility() - syncBonsplitNotificationBadges() + syncBonsplitNotificationBadgesForAllPanels() + resetWorkspaceManualUnreadBadgeSyncState() refreshGhosttyAppearanceConfig(reason: "onAppear") } .onChange(of: isWorkspaceVisible) { _, isVisible in @@ -315,20 +324,17 @@ struct WorkspaceContentView: View { .onDisappear { workspace.setAgentHibernationAutoResumePresentationVisible(false) } - .onChange(of: notificationStore.notifications) { _, _ in - syncBonsplitNotificationBadges() - } - .onChange(of: workspace.manualUnreadPanelIds) { _, _ in - syncBonsplitNotificationBadges() + .onReceive(notificationStore.badgeInvalidations(forTabId: workspace.id)) { invalidation in + applyNotificationBadgeInvalidation(invalidation) } - .onChange(of: workspace.restoredUnreadPanelIds) { _, _ in - syncBonsplitNotificationBadges() + .onChange(of: workspace.manualUnreadPanelIds) { oldValue, newValue in + syncBonsplitNotificationBadges(forPanelIds: oldValue.symmetricDifference(newValue)) } - .onChange(of: isWorkspaceManuallyUnread) { _, _ in - syncBonsplitNotificationBadges() + .onChange(of: workspace.restoredUnreadPanelIds) { oldValue, newValue in + syncBonsplitNotificationBadges(forPanelIds: oldValue.symmetricDifference(newValue)) } - .onChange(of: workspaceManualUnreadPanelId) { _, _ in - syncBonsplitNotificationBadges() + .onChange(of: workspaceManualUnreadPanelId) { oldValue, newValue in + syncWorkspaceManualUnreadRepresentativeChange(oldPanelId: oldValue, newPanelId: newValue) } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload") @@ -360,42 +366,57 @@ struct WorkspaceContentView: View { .ignoresSafeArea(.container, edges: (isMinimalMode && !isFullScreen) ? .top : []) } - private func syncBonsplitNotificationBadges() { - let manualUnread = workspace.manualUnreadPanelIds - let restoredUnread = workspace.restoredUnreadPanelIds - let isWorkspaceManuallyUnread = notificationStore.hasManualUnread(forTabId: workspace.id) - let workspaceManualUnreadPanelId = workspace.representativePanelIdForWorkspaceManualUnread() + private func applyNotificationBadgeInvalidation(_ invalidation: TerminalNotificationBadgeInvalidation) { + switch invalidation.scope { + case .panelIds(let panelIds): + syncBonsplitNotificationBadges(forPanelIds: panelIds) + case .workspaceManualUnread: + syncWorkspaceManualUnreadBadgeState() + } + } - for paneId in workspace.bonsplitController.allPaneIds { - for tab in workspace.bonsplitController.tabs(inPane: paneId) { - let panelId = workspace.panelIdFromSurfaceId(tab.id) - let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } - let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false - let shouldShow = panelId.map { - Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( - forTabId: workspace.id, - surfaceId: $0 - ), - hasPanelUnreadIndicator: manualUnread.contains($0) || restoredUnread.contains($0), - isWorkspaceManuallyUnread: isWorkspaceManuallyUnread, - isWorkspaceManualUnreadRepresentative: workspaceManualUnreadPanelId == $0 - ) - } ?? false - let kindUpdate: String?? = expectedKind.map { .some($0) } - - if tab.showsNotificationBadge != shouldShow || - tab.isPinned != expectedPinned || - (expectedKind != nil && tab.kind != expectedKind) { - workspace.bonsplitController.updateTab( - tab.id, - kind: kindUpdate, - showsNotificationBadge: shouldShow, - isPinned: expectedPinned - ) - } - } + private func syncBonsplitNotificationBadgesForAllPanels() { + workspace.syncBonsplitTabPresentationStateForAllPanels() + bumpUnreadBadgeRevision() + } + + private func syncBonsplitNotificationBadges(forPanelIds panelIds: Set) { + guard !panelIds.isEmpty else { return } + workspace.syncBonsplitTabPresentationState(forPanelIds: panelIds) + bumpUnreadBadgeRevision() + } + + private func resetWorkspaceManualUnreadBadgeSyncState() { + lastWorkspaceManualUnreadForBadgeSync = notificationStore.hasManualUnread(forTabId: workspace.id) + lastWorkspaceManualUnreadPanelIdForBadgeSync = workspace.representativePanelIdForWorkspaceManualUnread() + } + + private func syncWorkspaceManualUnreadBadgeState() { + let wasUnread = lastWorkspaceManualUnreadForBadgeSync + let oldPanelId = lastWorkspaceManualUnreadPanelIdForBadgeSync + let isUnread = notificationStore.hasManualUnread(forTabId: workspace.id) + let newPanelId = workspace.representativePanelIdForWorkspaceManualUnread() + lastWorkspaceManualUnreadForBadgeSync = isUnread + lastWorkspaceManualUnreadPanelIdForBadgeSync = newPanelId + + var affectedPanelIds = Set() + if wasUnread, let oldPanelId { + affectedPanelIds.insert(oldPanelId) } + if isUnread, let newPanelId { + affectedPanelIds.insert(newPanelId) + } + syncBonsplitNotificationBadges(forPanelIds: affectedPanelIds) + } + + private func syncWorkspaceManualUnreadRepresentativeChange(oldPanelId: UUID?, newPanelId: UUID?) { + lastWorkspaceManualUnreadPanelIdForBadgeSync = newPanelId + guard notificationStore.hasManualUnread(forTabId: workspace.id) else { return } + syncBonsplitNotificationBadges(forPanelIds: Set([oldPanelId, newPanelId].compactMap { $0 })) + } + + private func bumpUnreadBadgeRevision() { + unreadBadgeRevision &+= 1 } private var splitZoomRenderIdentity: String { diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 29e956e54c42..e5e9ff3abc9d 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import Combine #if canImport(cmux_DEV) @testable import cmux_DEV @@ -2199,6 +2200,114 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertFalse(workspace.bonsplitController.tab(initialTabId)?.showsNotificationBadge ?? true) XCTAssertTrue(workspace.bonsplitController.tab(splitTabId)?.showsNotificationBadge ?? false) } + + func testNotificationBadgeInvalidationPublisherEmitsOnlyChangedWorkspacePanel() { + let store = TerminalNotificationStore.shared + let workspaceId = UUID() + let siblingWorkspaceId = UUID() + let panelId = UUID() + let siblingPanelId = UUID() + var workspaceInvalidations: [TerminalNotificationBadgeInvalidation] = [] + var siblingInvalidations: [TerminalNotificationBadgeInvalidation] = [] + var cancellables = Set() + + store.replaceNotificationsForTesting([]) + store.badgeInvalidations(forTabId: workspaceId) + .sink { workspaceInvalidations.append($0) } + .store(in: &cancellables) + store.badgeInvalidations(forTabId: siblingWorkspaceId) + .sink { siblingInvalidations.append($0) } + .store(in: &cancellables) + + let workspaceNotification = TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: panelId, + title: "workspace", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let siblingNotification = TerminalNotification( + id: UUID(), + tabId: siblingWorkspaceId, + surfaceId: siblingPanelId, + title: "sibling", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + + store.replaceNotificationsForTesting([workspaceNotification]) + + XCTAssertEqual( + workspaceInvalidations, + [TerminalNotificationBadgeInvalidation(tabId: workspaceId, scope: .panelIds([panelId]))] + ) + XCTAssertTrue(siblingInvalidations.isEmpty) + + workspaceInvalidations.removeAll() + siblingInvalidations.removeAll() + + store.replaceNotificationsForTesting([workspaceNotification, siblingNotification]) + + XCTAssertTrue(workspaceInvalidations.isEmpty) + XCTAssertEqual( + siblingInvalidations, + [TerminalNotificationBadgeInvalidation(tabId: siblingWorkspaceId, scope: .panelIds([siblingPanelId]))] + ) + } + + func testBonsplitTabPresentationSyncUpdatesOnlyRequestedPanel() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let store = TerminalNotificationStore.shared + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let workspace = Workspace() + guard let initialPanelId = workspace.focusedPanelId, + let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal, focus: false), + let initialTabId = workspace.surfaceIdFromPanelId(initialPanelId), + let splitTabId = workspace.surfaceIdFromPanelId(splitPanel.id) else { + XCTFail("Expected workspace with a split panel") + return + } + let notification = TerminalNotification( + id: UUID(), + tabId: workspace.id, + surfaceId: initialPanelId, + title: "target", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + store.replaceNotificationsForTesting([notification]) + + workspace.bonsplitController.updateTab(initialTabId, showsNotificationBadge: false) + workspace.bonsplitController.updateTab(splitTabId, showsNotificationBadge: true) + + workspace.syncBonsplitTabPresentationState(forPanelIds: [initialPanelId]) + + XCTAssertTrue(workspace.bonsplitController.tab(initialTabId)?.showsNotificationBadge ?? false) + XCTAssertTrue( + workspace.bonsplitController.tab(splitTabId)?.showsNotificationBadge ?? false, + "Targeted sync should not walk through and repair an unrelated tab" + ) + + workspace.syncBonsplitTabPresentationStateForAllPanels() + + XCTAssertFalse(workspace.bonsplitController.tab(splitTabId)?.showsNotificationBadge ?? true) + } } final class CommandPaletteFuzzyMatcherTests: XCTestCase {