krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

277e2a7209047ed1514ea7b34034c510a4e51a4f

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T22:12:46Z

Add mark-seen action, critical-alert badge, and a dedicated window

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<Void, Never>?
@@ -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)
+            }
+        }
     }
 }