Skip to content
Merged
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
14 changes: 14 additions & 0 deletions SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions SupacodeSettingsShared/Models/GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 158 additions & 5 deletions supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions supacode/Features/Repositories/Views/WorktreeDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand Down Expand Up @@ -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).")
}
}

Expand Down
Loading
Loading