Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Foundation
import os
import UserNotifications
Expand Down Expand Up @@ -739,6 +740,16 @@ struct TerminalNotification: Identifiable, Hashable {
}
}

struct TerminalNotificationBadgeInvalidation: Equatable {
enum Scope: Equatable {
case panelIds(Set<UUID>)
case workspaceManualUnread
}

let tabId: UUID
let scope: Scope
}

@MainActor
final class TerminalNotificationStore: ObservableObject {
private struct TabSurfaceKey: Hashable {
Expand Down Expand Up @@ -768,22 +779,30 @@ 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) }
}
}
@Published private(set) var notificationMenuSnapshot = NotificationMenuSnapshotBuilder.make(notifications: [])
// Workspace-level unread drives sidebar workspace badges; pane-level manual
// unread remains owned by Workspace.manualUnreadPanelIds.
@Published private(set) var manualUnreadWorkspaceIds: Set<UUID> = [] {
didSet { refreshUnreadPresentation() }
didSet {
refreshUnreadPresentation()
publishWorkspaceManualUnreadInvalidations(oldValue: oldValue, newValue: manualUnreadWorkspaceIds)
}
}
@Published private(set) var panelDerivedUnreadWorkspaceIds: Set<UUID> = [] {
didSet { refreshUnreadPresentation() }
}
@Published private(set) var restoredUnreadWorkspaceIds: Set<UUID> = [] {
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

Expand Down Expand Up @@ -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<TerminalNotificationBadgeInvalidation, Never>()

private init() {
indexes = Self.buildIndexes(for: notifications)
Expand Down Expand Up @@ -1277,6 +1297,12 @@ final class TerminalNotificationStore: ObservableObject {
)
}

func badgeInvalidations(forTabId tabId: UUID) -> AnyPublisher<TerminalNotificationBadgeInvalidation, Never> {
badgeInvalidationSubject
.filter { $0.tabId == tabId }
.eraseToAnyPublisher()
}

private func commitCooldownReservation(
_ reservation: NotificationCooldownReservation?,
at date: Date
Expand Down Expand Up @@ -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<UUID>,
newValue: Set<UUID>
) {
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<UUID>()
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))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
austinywang marked this conversation as resolved.
}

private static func unreadBadgePanelIdsByTabId(
for notifications: [TerminalNotification]
) -> [UUID: Set<UUID>] {
var panelIdsByTabId: [UUID: Set<UUID>] = [:]
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
Expand Down
74 changes: 59 additions & 15 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10502,6 +10502,63 @@ final class Workspace: Identifiable, ObservableObject {
}
}

func syncBonsplitTabPresentationState(forPanelIds panelIds: Set<UUID>) {
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
}
Comment thread
austinywang marked this conversation as resolved.

bonsplitController.updateTab(
tabId,
kind: kindUpdate,
showsNotificationBadge: shouldShowUnread,
isPinned: expectedPinned
)
}

private func hasVisibleNotificationIndicator(panelId: UUID) -> Bool {
AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false
}
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading