summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 17:12:46 -0500
committerChristian Cleberg <[email protected]>2026-07-17 17:12:46 -0500
commit277e2a7209047ed1514ea7b34034c510a4e51a4f (patch)
tree3a0f5ae9c088a14f09831e23f8cb8608210d78cd
parent8e6d29f1806cc569b48d913564c7d80a1c2f2355 (diff)
downloadoctosentry-0.5.0.tar.gz
octosentry-0.5.0.tar.bz2
octosentry-0.5.0.zip
Add mark-seen action, critical-alert badge, and a dedicated window0.5.0
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.
-rw-r--r--octosentry/SecurityEventListView.swift18
-rw-r--r--octosentry/SecurityEventRow.swift99
-rw-r--r--octosentry/SecurityEventStore.swift16
-rw-r--r--octosentry/octosentryApp.swift33
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)
+ }
+ }
}
}