- Fix auto-updating Text(date, style: .time) causing continuous SwiftUI updates by using static formatting with .formatted(date:time:) - Fix notification popover keeping SwiftUI observers active when closed by clearing contentViewController on popover close and recreating on open - Fix focus loss when notifications arrive while typing by only setting focus in NotificationsPage when the page is visible - Make Update Pill and Update Logs debug-only features - Add CPU regression tests: test_cpu_usage.py, test_cpu_notifications.py - Add lint test for auto-updating Text patterns: test_lint_swiftui_patterns.py
188 lines
6.7 KiB
Swift
188 lines
6.7 KiB
Swift
import SwiftUI
|
|
|
|
struct NotificationsPage: View {
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@Binding var selection: SidebarSelection
|
|
@FocusState private var focusedNotificationId: UUID?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
|
|
if notificationStore.notifications.isEmpty {
|
|
emptyState
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(notificationStore.notifications) { notification in
|
|
NotificationRow(
|
|
notification: notification,
|
|
tabTitle: tabTitle(for: notification.tabId),
|
|
onOpen: {
|
|
tabManager.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
|
|
markReadIfFocused(notification)
|
|
selection = .tabs
|
|
},
|
|
onClear: {
|
|
notificationStore.remove(id: notification.id)
|
|
},
|
|
focusedNotificationId: $focusedNotificationId
|
|
)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.onAppear(perform: setInitialFocus)
|
|
.onChange(of: notificationStore.notifications.first?.id) { _ in
|
|
setInitialFocus()
|
|
}
|
|
}
|
|
|
|
private func setInitialFocus() {
|
|
// Only set focus when the notifications page is visible
|
|
// to avoid stealing focus from the terminal when notifications arrive
|
|
guard selection == .notifications else { return }
|
|
guard let firstId = notificationStore.notifications.first?.id else {
|
|
focusedNotificationId = nil
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
focusedNotificationId = firstId
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text("Notifications")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
if !notificationStore.notifications.isEmpty {
|
|
Button("Clear All") {
|
|
notificationStore.clearAll()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "bell.slash")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.secondary)
|
|
Text("No notifications yet")
|
|
.font(.headline)
|
|
Text("Desktop notifications will appear here for quick review.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func tabTitle(for tabId: UUID) -> String? {
|
|
tabManager.tabs.first(where: { $0.id == tabId })?.title
|
|
}
|
|
|
|
private func markReadIfFocused(_ notification: TerminalNotification) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
guard tabManager.selectedTabId == notification.tabId else { return }
|
|
if let surfaceId = notification.surfaceId {
|
|
guard tabManager.focusedSurfaceId(for: notification.tabId) == surfaceId else { return }
|
|
}
|
|
notificationStore.markRead(id: notification.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NotificationRow: View {
|
|
let notification: TerminalNotification
|
|
let tabTitle: String?
|
|
let onOpen: () -> Void
|
|
let onClear: () -> Void
|
|
let focusedNotificationId: FocusState<UUID?>.Binding
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Button(action: onOpen) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
|
.frame(width: 8, height: 8)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
|
)
|
|
.padding(.top, 6)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
Text(notification.createdAt.formatted(date: .omitted, time: .shortened))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
if !notification.body.isEmpty {
|
|
Text(notification.body)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(3)
|
|
}
|
|
|
|
if let tabTitle {
|
|
Text(tabTitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.trailing, 6)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable()
|
|
.focused(focusedNotificationId, equals: notification.id)
|
|
.modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id))
|
|
|
|
Button(action: onClear) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct DefaultActionModifier: ViewModifier {
|
|
let isActive: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
if isActive {
|
|
content.keyboardShortcut(.defaultAction)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|