From 277e2a7209047ed1514ea7b34034c510a4e51a4f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 17:12:46 -0500 Subject: Add mark-seen action, critical-alert badge, and a dedicated window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #8-#10 (milestone 0.5.0). - Mark-seen: each row gets a checkmark button (separate from the click-to-open button) that persists the event into seenEventIDs and removes it from the active stream immediately, no API write. - Menu bar icon switches to a filled exclamation-shield with a red badge showing the count of unseen critical alerts when any exist. - Added a dedicated Window (singleton scene, not WindowGroup — avoids spawning duplicates) opened via a header button, sharing the exact same store/authStore instances as the popover. The button hides itself when already viewing the standalone window. --- octosentry/SecurityEventListView.swift | 18 ++++++- octosentry/SecurityEventRow.swift | 99 +++++++++++++++++++--------------- octosentry/SecurityEventStore.swift | 16 ++++++ octosentry/octosentryApp.swift | 33 +++++++++++- 4 files changed, 121 insertions(+), 45 deletions(-) diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift index 298aa2f..d817370 100644 --- a/octosentry/SecurityEventListView.swift +++ b/octosentry/SecurityEventListView.swift @@ -9,7 +9,9 @@ import SwiftUI struct SecurityEventListView: View { var store: SecurityEventStore var authStore: AuthStore + var isStandaloneWindow: Bool = false @State private var showingRepoManager = false + @Environment(\.openWindow) private var openWindow var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -23,7 +25,6 @@ struct SecurityEventListView: View { content } } - .frame(width: 380, height: 420) .task(id: authStore.isSignedIn) { guard authStore.isSignedIn else { return } await store.refresh() @@ -66,6 +67,16 @@ struct SecurityEventListView: View { } if authStore.isSignedIn { + if !isStandaloneWindow { + Button { + openWindow(id: SecurityEventWindow.id) + } label: { + Image(systemName: "macwindow") + } + .buttonStyle(.plain) + .help("Open in Window") + } + Button { showingRepoManager.toggle() } label: { @@ -116,7 +127,9 @@ struct SecurityEventListView: View { Divider() } ForEach(store.events) { event in - SecurityEventRow(event: event) + SecurityEventRow(event: event) { + Task { await store.markSeen(event.id) } + } Divider() } } @@ -250,4 +263,5 @@ private struct StatusView: View { #Preview { SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) + .frame(width: 380, height: 420) } diff --git a/octosentry/SecurityEventRow.swift b/octosentry/SecurityEventRow.swift index 9a73037..85dbfa2 100644 --- a/octosentry/SecurityEventRow.swift +++ b/octosentry/SecurityEventRow.swift @@ -8,6 +8,7 @@ import SwiftUI struct SecurityEventRow: View { let event: SecurityEvent + var onMarkSeen: () -> Void private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() @@ -16,58 +17,72 @@ struct SecurityEventRow: View { }() var body: some View { - Button { - NSWorkspace.shared.open(event.detailURL) - } label: { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(event.nativeSeverityLabel.uppercased()) - .font(.caption2.weight(.bold)) - .foregroundStyle(event.severity.color) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(event.severity.color.opacity(0.18), in: Capsule()) + HStack(alignment: .top, spacing: 0) { + Button { + NSWorkspace.shared.open(event.detailURL) + } label: { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(event.nativeSeverityLabel.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(event.severity.color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(event.severity.color.opacity(0.18), in: Capsule()) - Text(event.source.displayName) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.secondary.opacity(0.15), in: Capsule()) + Text(event.source.displayName) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.15), in: Capsule()) - Text(event.repoFullName) - .font(.caption) - .foregroundStyle(.secondary) + Text(event.repoFullName) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) - .font(.caption2) - .foregroundStyle(.secondary) + Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Text(event.summary) + .font(.callout) + .lineLimit(1) + .foregroundStyle(.primary) } + .padding(10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Text(event.summary) - .font(.callout) - .lineLimit(1) - .foregroundStyle(.primary) + Button(action: onMarkSeen) { + Image(systemName: "checkmark.circle") } - .padding(10) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Mark as seen") + .padding(.top, 12) + .padding(.trailing, 10) } - .buttonStyle(.plain) } } #Preview { - SecurityEventRow(event: SecurityEvent( - id: "preview-1", - source: .dependabot, - repoFullName: "zerolabsco/octosentry", - severity: .critical, - nativeSeverityLabel: "Critical", - summary: "Denial of service in some-package", - detailURL: URL(string: "https://github.com")!, - createdAt: .now.addingTimeInterval(-3600 * 26), - updatedAt: .now, - seenLocally: false - )) + SecurityEventRow( + event: SecurityEvent( + id: "preview-1", + source: .dependabot, + repoFullName: "zerolabsco/octosentry", + severity: .critical, + nativeSeverityLabel: "Critical", + summary: "Denial of service in some-package", + detailURL: URL(string: "https://github.com")!, + createdAt: .now.addingTimeInterval(-3600 * 26), + updatedAt: .now, + seenLocally: false + ), + onMarkSeen: {} + ) } diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift index 65a7280..26dcc16 100644 --- a/octosentry/SecurityEventStore.swift +++ b/octosentry/SecurityEventStore.swift @@ -28,6 +28,10 @@ final class SecurityEventStore { private(set) var watchedRepos: [String] = [] private(set) var watchListErrorMessage: String? + var unseenCriticalCount: Int { + rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count + } + private let persistenceStore = PersistenceStore() private var rawEvents: [SecurityEvent] = [] private var pollingTask: Task? @@ -130,6 +134,18 @@ final class SecurityEventStore { await refresh() } + /// Local-only triage state (spec §11) — no API write, no scope beyond + /// read needed. Removes the event from the active stream. + func markSeen(_ eventID: String) async { + var state = await persistenceStore.load() + state.seenEventIDs.insert(eventID) + await persistenceStore.save(state) + + rawEvents.removeAll { $0.id == eventID } + totalFetchedCount = rawEvents.count + applyMinimumSeverityFilter() + } + func removeRepo(_ repoFullName: String) async { var state = await persistenceStore.load() state.watchedRepos.removeAll { $0 == repoFullName } diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift index cf7522d..e80c4a0 100644 --- a/octosentry/octosentryApp.swift +++ b/octosentry/octosentryApp.swift @@ -7,15 +7,46 @@ import SwiftUI +enum SecurityEventWindow { + static let id = "security-events-window" +} + @main struct octosentryApp: App { @State private var store = SecurityEventStore() @State private var authStore = AuthStore() var body: some Scene { - MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { + MenuBarExtra { SecurityEventListView(store: store, authStore: authStore) + .frame(width: 380, height: 420) + } label: { + MenuBarIconView(criticalCount: store.unseenCriticalCount) } .menuBarExtraStyle(.window) + + Window("Security Events", id: SecurityEventWindow.id) { + SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true) + .frame(minWidth: 420, minHeight: 480) + } + } +} + +private struct MenuBarIconView: View { + let criticalCount: Int + + var body: some View { + ZStack(alignment: .topTrailing) { + Image(systemName: criticalCount > 0 ? "exclamationmark.shield.fill" : "shield.lefthalf.filled") + + if criticalCount > 0 { + Text(criticalCount > 9 ? "9+" : "\(criticalCount)") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + .padding(2) + .background(Circle().fill(.red)) + .offset(x: 8, y: -6) + } + } } } -- cgit v1.2.3