Skip to content

Commit 85f4656

Browse files
committed
Improve notification handling and workspace panel behavior
- Add system notification action button that, when clicked, focuses CodeEdit, runs the action, and dismisses the corresponding CodeEdit notification. - Dismissing a CodeEdit notification now also dismisses its corresponding system notification, and vice versa. - The notification panel in a workspace now closes when clicking outside of it, behaving like other menus. - Refactored notification state management: Moved display-related state from `NotificationService` to a dedicated view model to ensure notification panels remain independent across workspaces.
1 parent 8349007 commit 85f4656

13 files changed

+647
-229
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@
595595
B6966A342C34996B00259C2D /* SourceControlManager+GitClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */; };
596596
B696A7E62CFE20C40048CFE1 /* FeatureIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */; };
597597
B697937A29FF5668002027EC /* AccountsSettingsAccountLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */; };
598+
B69970322D63E5C700BB132D /* NotificationOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69970302D63E5C700BB132D /* NotificationOverlayViewModel.swift */; };
598599
B69BFDC72B0686910050D9A6 /* GitClient+Initiate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */; };
599600
B69D3EDE2C5E85A2005CF43A /* StopTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */; };
600601
B69D3EE12C5F5357005CF43A /* TaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69D3EE02C5F5357005CF43A /* TaskView.swift */; };
@@ -1292,6 +1293,7 @@
12921293
B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceControlManager+GitClient.swift"; sourceTree = "<group>"; };
12931294
B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIcon.swift; sourceTree = "<group>"; };
12941295
B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsAccountLink.swift; sourceTree = "<group>"; };
1296+
B69970302D63E5C700BB132D /* NotificationOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayViewModel.swift; sourceTree = "<group>"; };
12951297
B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Initiate.swift"; sourceTree = "<group>"; };
12961298
B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopTaskToolbarButton.swift; sourceTree = "<group>"; };
12971299
B69D3EE02C5F5357005CF43A /* TaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskView.swift; sourceTree = "<group>"; };
@@ -3585,6 +3587,7 @@
35853587
isa = PBXGroup;
35863588
children = (
35873589
B68DE5D82D5A61E5009A43EF /* Models */,
3590+
B69970312D63E5C700BB132D /* ViewModels */,
35883591
B68DE5DC2D5A61E5009A43EF /* Views */,
35893592
B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */,
35903593
B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */,
@@ -3609,6 +3612,14 @@
36093612
path = Views;
36103613
sourceTree = "<group>";
36113614
};
3615+
B69970312D63E5C700BB132D /* ViewModels */ = {
3616+
isa = PBXGroup;
3617+
children = (
3618+
B69970302D63E5C700BB132D /* NotificationOverlayViewModel.swift */,
3619+
);
3620+
path = ViewModels;
3621+
sourceTree = "<group>";
3622+
};
36123623
B69D3EDC2C5E856F005CF43A /* Views */ = {
36133624
isa = PBXGroup;
36143625
children = (
@@ -4232,6 +4243,7 @@
42324243
B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */,
42334244
587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */,
42344245
6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */,
4246+
B69970322D63E5C700BB132D /* NotificationOverlayViewModel.swift in Sources */,
42354247
B68DE5E52D5A7988009A43EF /* NotificationOverlayView.swift in Sources */,
42364248
61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */,
42374249
66AF6CE22BF17CC300D83C9D /* StatusBarViewModel.swift in Sources */,

CodeEdit/AppDelegate.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
2626

2727
NSApp.closeWindow(.welcome, .about)
2828

29+
// Add test notification
30+
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
31+
NotificationManager.shared.post(
32+
iconText: "👋",
33+
iconTextColor: .white,
34+
iconColor: .indigo,
35+
title: "Welcome to CodeEdit",
36+
description: "This is a test notification to demonstrate the notification system.",
37+
actionButtonTitle: "Learn More...",
38+
action: {
39+
print("Action button clicked!")
40+
}
41+
)
42+
}
43+
2944
DispatchQueue.main.async {
3045
var needToHandleOpen = true
3146

CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ extension CodeEditWindowController {
179179
return toolbarItem
180180
case .notificationItem:
181181
let toolbarItem = NSToolbarItem(itemIdentifier: .notificationItem)
182-
let view = NSHostingView(rootView: NotificationToolbarItem())
182+
guard let workspace = workspace else { return nil }
183+
let view = NSHostingView(
184+
rootView: NotificationToolbarItem()
185+
.environmentObject(workspace)
186+
)
183187
toolbarItem.view = view
184188
return toolbarItem
185189
default:

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,26 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
4343
var workspaceSettingsManager: CEWorkspaceSettings?
4444
var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler()
4545

46+
@Published var notificationOverlay = NotificationOverlayViewModel()
47+
private var notificationOverlaySubscription: AnyCancellable?
48+
4649
private var cancellables = Set<AnyCancellable>()
4750

51+
override init() {
52+
super.init()
53+
54+
// Observe changes to notification overlay
55+
notificationOverlaySubscription = notificationOverlay.objectWillChange
56+
.receive(on: DispatchQueue.main)
57+
.sink { [weak self] _ in
58+
self?.objectWillChange.send()
59+
}
60+
}
61+
4862
deinit {
4963
cancellables.forEach { $0.cancel() }
5064
NotificationCenter.default.removeObserver(self)
65+
notificationOverlaySubscription?.cancel()
5166
}
5267

5368
func getFromWorkspaceState(_ key: WorkspaceStateKey) -> Any? {

CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,71 @@ struct FileInspectorView: View {
5959
widthOptions
6060
wrapLinesToggle
6161
}
62+
Section("Test Notifications") {
63+
Button("Add Notification") {
64+
let (iconSymbol, iconColor) = randomSymbolAndColor()
65+
NotificationManager.shared.post(
66+
iconSymbol: iconSymbol,
67+
iconColor: iconColor,
68+
title: "Test Notification",
69+
description: "This is a test notification.",
70+
actionButtonTitle: "Action",
71+
action: {
72+
print("Test notification action triggered")
73+
}
74+
)
75+
}
76+
Button("Add Sticky Notification") {
77+
NotificationManager.shared.post(
78+
iconSymbol: "pin.fill",
79+
iconColor: .orange,
80+
title: "Sticky Notification",
81+
description: "This notification will stay until dismissed.",
82+
actionButtonTitle: "Acknowledge",
83+
action: {
84+
print("Sticky notification acknowledged")
85+
},
86+
isSticky: true
87+
)
88+
}
89+
Button("Add Image Notification") {
90+
NotificationManager.shared.post(
91+
iconImage: randomImage(),
92+
title: "Test Notification with Image",
93+
description: "This is a test notification with a custom image.",
94+
actionButtonTitle: "Action",
95+
action: {
96+
print("Test notification action triggered")
97+
}
98+
)
99+
}
100+
Button("Add Text Notification") {
101+
NotificationManager.shared.post(
102+
iconText: randomLetter(),
103+
iconTextColor: .white,
104+
iconColor: randomColor(),
105+
title: "Text Notification",
106+
description: "This is a test notification with text.",
107+
actionButtonTitle: "Acknowledge",
108+
action: {
109+
print("Test notification action triggered")
110+
}
111+
)
112+
}
113+
Button("Add Emoji Notification") {
114+
NotificationManager.shared.post(
115+
iconText: randomEmoji(),
116+
iconTextColor: .white,
117+
iconColor: randomColor(),
118+
title: "Emoji Notification",
119+
description: "This is a test notification with an emoji.",
120+
actionButtonTitle: "Acknowledge",
121+
action: {
122+
print("Test notification action triggered")
123+
}
124+
)
125+
}
126+
}
62127
}
63128
} else {
64129
NoSelectionInspectorView()
@@ -81,6 +146,62 @@ struct FileInspectorView: View {
81146
}
82147
}
83148

149+
func randomColor() -> Color {
150+
let colors: [Color] = [
151+
.red, .orange, .yellow, .green, .mint, .cyan,
152+
.teal, .blue, .indigo, .purple, .pink, .gray
153+
]
154+
return colors.randomElement() ?? .black
155+
}
156+
157+
func randomSymbolAndColor() -> (String, Color) {
158+
let symbols: [(String, Color)] = [
159+
("bell.fill", .red),
160+
("bell.badge.fill", .red),
161+
("exclamationmark.triangle.fill", .orange),
162+
("info.circle.fill", .blue),
163+
("checkmark.seal.fill", .green),
164+
("xmark.octagon.fill", .red),
165+
("bubble.left.fill", .teal),
166+
("envelope.fill", .blue),
167+
("phone.fill", .green),
168+
("megaphone.fill", .pink),
169+
("clock.fill", .gray),
170+
("calendar", .red),
171+
("flag.fill", .green),
172+
("bookmark.fill", .orange),
173+
("bolt.fill", .indigo),
174+
("shield.lefthalf.fill", .red),
175+
("gift.fill", .purple),
176+
("heart.fill", .pink),
177+
("star.fill", .orange),
178+
("curlybraces", .cyan),
179+
]
180+
return symbols.randomElement() ?? ("bell.fill", .red)
181+
}
182+
183+
func randomEmoji() -> String {
184+
let emoji: [String] = [
185+
"🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁",
186+
"😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌"
187+
]
188+
return emoji.randomElement() ?? "🔔"
189+
}
190+
191+
func randomImage() -> Image {
192+
let images: [Image] = [
193+
Image("GitHubIcon"),
194+
Image("BitBucketIcon"),
195+
Image("GitLabIcon")
196+
]
197+
return images.randomElement() ?? Image("GitHubIcon")
198+
}
199+
200+
func randomLetter() -> String {
201+
let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) }
202+
return letters.randomElement() ?? "A"
203+
}
204+
84205
@ViewBuilder private var fileNameField: some View {
85206
@State var isValid: Bool = true
86207

CodeEdit/Features/Notifications/NotificationManager+Delegate.swift

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Austin Condiff on 2/14/24.
66
//
77

8+
import AppKit
89
import UserNotifications
910

1011
extension NotificationManager: UNUserNotificationCenterDelegate {
@@ -13,11 +14,24 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
1314
didReceive response: UNNotificationResponse,
1415
withCompletionHandler completionHandler: @escaping () -> Void
1516
) {
16-
if let id = response.notification.request.content.userInfo["id"] as? String {
17-
DispatchQueue.main.async {
18-
self.handleSystemNotificationResponse(id: id)
17+
if let notification = notifications.first(where: {
18+
$0.id.uuidString == response.notification.request.identifier
19+
}) {
20+
// Focus CodeEdit and run action if action button was clicked
21+
if response.actionIdentifier == "ACTION_BUTTON" ||
22+
response.actionIdentifier == UNNotificationDefaultActionIdentifier {
23+
NSApp.activate(ignoringOtherApps: true)
24+
notification.action()
25+
}
26+
27+
// Remove the notification for both action and dismiss
28+
if response.actionIdentifier == "ACTION_BUTTON" ||
29+
response.actionIdentifier == UNNotificationDefaultActionIdentifier ||
30+
response.actionIdentifier == UNNotificationDismissActionIdentifier {
31+
dismissNotification(notification)
1932
}
2033
}
34+
2135
completionHandler()
2236
}
2337

@@ -26,7 +40,28 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
2640
willPresent notification: UNNotification,
2741
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
2842
) {
29-
// Don't show system notifications when app is active
30-
completionHandler([])
43+
completionHandler([.banner, .sound])
44+
}
45+
46+
func setupNotificationDelegate() {
47+
UNUserNotificationCenter.current().delegate = self
48+
49+
// Create action button
50+
let action = UNNotificationAction(
51+
identifier: "ACTION_BUTTON",
52+
title: "Action", // This will be replaced with actual button title
53+
options: .foreground
54+
)
55+
56+
// Create category with action button
57+
let actionCategory = UNNotificationCategory(
58+
identifier: "ACTIONABLE",
59+
actions: [action],
60+
intentIdentifiers: [],
61+
options: .customDismissAction
62+
)
63+
64+
UNUserNotificationCenter.current().setNotificationCategories([actionCategory])
65+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
3166
}
3267
}

CodeEdit/Features/Notifications/NotificationManager+System.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55
// Created by Austin Condiff on 2/14/24.
66
//
77

8-
import SwiftUI
8+
import Foundation
99
import UserNotifications
1010

1111
extension NotificationManager {
12-
/// Shows a notification in macOS Notification Center when app is in background
12+
/// Shows a system notification when app is in background
1313
func showSystemNotification(_ notification: CENotification) {
1414
let content = UNMutableNotificationContent()
1515
content.title = notification.title
1616
content.body = notification.description
17-
content.userInfo = ["id": notification.id.uuidString]
17+
18+
if !notification.actionButtonTitle.isEmpty {
19+
content.categoryIdentifier = "ACTIONABLE"
20+
}
1821

1922
let request = UNNotificationRequest(
2023
identifier: notification.id.uuidString,
@@ -25,6 +28,13 @@ extension NotificationManager {
2528
UNUserNotificationCenter.current().add(request)
2629
}
2730

31+
/// Removes a system notification
32+
func removeSystemNotification(_ notification: CENotification) {
33+
UNUserNotificationCenter.current().removeDeliveredNotifications(
34+
withIdentifiers: [notification.id.uuidString]
35+
)
36+
}
37+
2838
/// Handles response from system notification
2939
func handleSystemNotificationResponse(id: String) {
3040
if let uuid = UUID(uuidString: id),

0 commit comments

Comments
 (0)