summaryrefslogtreecommitdiff
path: root/octosentry/SecurityEventListView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 16:41:52 -0500
committerChristian Cleberg <[email protected]>2026-07-17 16:41:52 -0500
commit2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0 (patch)
treeca6bfe8277883d87bc6e45a9393f19c642f3a502 /octosentry/SecurityEventListView.swift
parentd8862a9b78b0d6cf4e11cf537be7678794e08811 (diff)
downloadoctosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.tar.gz
octosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.tar.bz2
octosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.zip
Add persistence, multi-repo watch list, and background polling0.3.0
Closes #1-#5 (milestones 0.2.0, 0.3.0). - Flat JSON persistence (PersistedState/PersistenceStore) in Application Support, chosen over SwiftData since the dataset is small and the existing model types are plain Codable value types passed across actor boundaries. - Repo watch list, per-event seen-state, and last-fetch timestamps are now persisted instead of living only in memory. - Configurable minimum severity filter, applied from a cached raw fetch so changing it doesn't require a network round-trip. - Multi-repo support: SecurityEventStore now loops over a persisted watch list instead of one hardcoded repo, with an in-popover UI (gear button) to add/remove repos. - Background polling every 15 minutes, layered on top of the existing refresh-on-open and manual refresh, so the feed stays fresh even while the popover is closed.
Diffstat (limited to 'octosentry/SecurityEventListView.swift')
-rw-r--r--octosentry/SecurityEventListView.swift105
1 files changed, 100 insertions, 5 deletions
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 4542275..a5dba3e 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -8,16 +8,22 @@ import SwiftUI
struct SecurityEventListView: View {
var store: SecurityEventStore
+ @State private var showingRepoManager = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
- content
+ if showingRepoManager {
+ RepoManagerView(store: store)
+ } else {
+ content
+ }
}
.frame(width: 380, height: 420)
.task {
await store.refresh()
+ store.startPolling()
}
}
@@ -33,13 +39,34 @@ struct SecurityEventListView: View {
Spacer()
+ if !showingRepoManager {
+ Picker("Minimum severity", selection: Binding(
+ get: { store.minimumSeverity },
+ set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
+ )) {
+ ForEach(SecurityEventSeverity.allCases, id: \.self) { severity in
+ Text(severity.displayName).tag(severity)
+ }
+ }
+ .pickerStyle(.menu)
+ .labelsHidden()
+ .fixedSize()
+
+ Button {
+ Task { await store.refresh() }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.plain)
+ .disabled(store.isLoading)
+ }
+
Button {
- Task { await store.refresh() }
+ showingRepoManager.toggle()
} label: {
- Image(systemName: "arrow.clockwise")
+ Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
}
.buttonStyle(.plain)
- .disabled(store.isLoading)
Button("Quit") {
NSApplication.shared.terminate(nil)
@@ -56,7 +83,15 @@ struct SecurityEventListView: View {
StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
} else if store.events.isEmpty && !store.isLoading {
VStack(spacing: 8) {
- StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
+ if store.totalFetchedCount > 0 {
+ StatusView(
+ systemImage: "line.3.horizontal.decrease.circle",
+ tint: .secondary,
+ message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter"
+ )
+ } else {
+ StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
+ }
if !store.unavailableNotices.isEmpty {
NoticeBanner(messages: store.unavailableNotices)
.padding(.horizontal)
@@ -84,6 +119,66 @@ struct SecurityEventListView: View {
}
}
+private struct RepoManagerView: View {
+ var store: SecurityEventStore
+ @State private var newRepoText = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Watched Repositories")
+ .font(.subheadline.weight(.semibold))
+
+ if store.watchedRepos.isEmpty {
+ Text("No repos watched yet.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(store.watchedRepos, id: \.self) { repo in
+ HStack {
+ Text(repo)
+ .font(.callout)
+ Spacer()
+ Button {
+ Task { await store.removeRepo(repo) }
+ } label: {
+ Image(systemName: "minus.circle.fill")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ Divider()
+
+ HStack {
+ TextField("owner/repo", text: $newRepoText)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit(addRepo)
+
+ Button("Add", action: addRepo)
+ .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ if let errorMessage = store.watchListErrorMessage {
+ Text(errorMessage)
+ .font(.caption2)
+ .foregroundStyle(.red)
+ }
+
+ Spacer()
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func addRepo() {
+ let text = newRepoText
+ newRepoText = ""
+ Task { await store.addRepo(text) }
+ }
+}
+
private struct ErrorBanner: View {
let messages: [String]