From 62136dbdd3cb2668c370dca554a30a15f76c718d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:53:03 -0800 Subject: [PATCH] Add notifications and clipboard context menu --- GhosttyTabs.xcodeproj/project.pbxproj | 12 ++ Sources/AppDelegate.swift | 111 ++++++++++++ Sources/ContentView.swift | 95 ++++++++-- Sources/GhosttyTabsApp.swift | 4 + Sources/GhosttyTerminalView.swift | 223 ++++++++++++++++++++++-- Sources/NotificationsPage.swift | 137 +++++++++++++++ Sources/TabManager.swift | 57 +++++- Sources/TerminalNotificationStore.swift | 139 +++++++++++++++ 8 files changed, 754 insertions(+), 24 deletions(-) create mode 100644 Sources/AppDelegate.swift create mode 100644 Sources/NotificationsPage.swift create mode 100644 Sources/TerminalNotificationStore.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 61bdcf74..f0cbd51f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,9 @@ A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; + A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; + A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; + A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,6 +43,9 @@ A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* GhosttyTabs-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "GhosttyTabs-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; + A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -74,6 +80,9 @@ A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, A5001019 /* TerminalController.swift */, + A5001090 /* AppDelegate.swift */, + A5001091 /* NotificationsPage.swift */, + A5001092 /* TerminalNotificationStore.swift */, ); path = Sources; sourceTree = ""; @@ -145,6 +154,9 @@ A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, + A5001093 /* AppDelegate.swift in Sources */, + A5001094 /* NotificationsPage.swift in Sources */, + A5001095 /* TerminalNotificationStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 00000000..864aa03c --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,111 @@ +import AppKit +import UserNotifications + +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + static var shared: AppDelegate? + + weak var tabManager: TabManager? + weak var notificationStore: TerminalNotificationStore? + + override init() { + super.init() + Self.shared = self + } + + func applicationDidFinishLaunching(_ notification: Notification) { + configureUserNotifications() + } + + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore) { + self.tabManager = tabManager + self.notificationStore = notificationStore + } + + private func configureUserNotifications() { + let actions = [ + UNNotificationAction( + identifier: TerminalNotificationStore.actionShowIdentifier, + title: "Show" + ) + ] + + let category = UNNotificationCategory( + identifier: TerminalNotificationStore.categoryIdentifier, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([category]) + center.delegate = self + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + handleNotificationResponse(response) + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let shouldPresent = shouldPresentNotification(notification) + let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : [] + completionHandler(options) + } + + private func handleNotificationResponse(_ response: UNNotificationResponse) { + guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { + return + } + + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: + DispatchQueue.main.async { + if let notificationId = UUID(uuidString: response.notification.request.identifier) { + self.notificationStore?.markRead(id: notificationId) + } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, + let notificationId = UUID(uuidString: notificationIdString) { + self.notificationStore?.markRead(id: notificationId) + } + self.tabManager?.focusTab(tabId) + } + case UNNotificationDismissActionIdentifier: + DispatchQueue.main.async { + if let notificationId = UUID(uuidString: response.notification.request.identifier) { + self.notificationStore?.markRead(id: notificationId) + } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, + let notificationId = UUID(uuidString: notificationIdString) { + self.notificationStore?.markRead(id: notificationId) + } + } + default: + break + } + } + + private func shouldPresentNotification(_ notification: UNNotification) -> Bool { + guard let tabManager else { return true } + guard let tabIdString = notification.request.content.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { + return true + } + + let isAppActive = NSApp.isActive + let isTabActive = tabManager.selectedTabId == tabId + let isKeyWindow = NSApp.keyWindow?.isKeyWindow ?? false + + if isAppActive && isTabActive && isKeyWindow { + return false + } + + return true + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 58c916ae..3292e271 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,31 +1,60 @@ +import AppKit import SwiftUI struct ContentView: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var notificationStore: TerminalNotificationStore @State private var sidebarWidth: CGFloat = 200 + @State private var sidebarDragStart: CGFloat? @FocusState private var focusedTabId: UUID? + @State private var sidebarSelection: SidebarSelection = .tabs var body: some View { HStack(spacing: 0) { // Vertical Tabs Sidebar - VerticalTabsSidebar(sidebarWidth: sidebarWidth) + VerticalTabsSidebar( + sidebarWidth: sidebarWidth, + selection: $sidebarSelection + ) .frame(width: sidebarWidth) // Divider Rectangle() .fill(Color(nsColor: .separatorColor)) .frame(width: 1) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + if sidebarDragStart == nil { + sidebarDragStart = sidebarWidth + } + let base = sidebarDragStart ?? sidebarWidth + sidebarWidth = max(140, min(360, base + value.translation.width)) + } + .onEnded { _ in + sidebarDragStart = nil + } + ) // Terminal Content - use ZStack to keep all surfaces alive ZStack { - ForEach(tabManager.tabs) { tab in - let isActive = tabManager.selectedTabId == tab.id - GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) - .focusable() - .focused($focusedTabId, equals: tab.id) + ZStack { + ForEach(tabManager.tabs) { tab in + let isActive = tabManager.selectedTabId == tab.id + GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .focusable() + .focused($focusedTabId, equals: tab.id) + } } + .opacity(sidebarSelection == .tabs ? 1 : 0) + .allowsHitTesting(sidebarSelection == .tabs) + + NotificationsPage(selection: $sidebarSelection) + .opacity(sidebarSelection == .notifications ? 1 : 0) + .allowsHitTesting(sidebarSelection == .notifications) } } .frame(minWidth: 800, minHeight: 600) @@ -35,22 +64,57 @@ struct ContentView: View { } .onChange(of: tabManager.selectedTabId) { newValue in focusedTabId = newValue + if let newValue { + notificationStore.markRead(forTabId: newValue) + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + if let selected = tabManager.selectedTabId { + notificationStore.markRead(forTabId: selected) + } + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in + sidebarSelection = .tabs } } } struct VerticalTabsSidebar: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var notificationStore: TerminalNotificationStore let sidebarWidth: CGFloat + @Binding var selection: SidebarSelection var body: some View { VStack(spacing: 0) { // Header with title HStack { - Text("Tabs") - .font(.headline) - .foregroundColor(.secondary) + Button(action: { selection = .tabs }) { + Text("Tabs") + .font(.headline) + .foregroundColor(selection == .tabs ? .primary : .secondary) + } + .buttonStyle(.plain) + Spacer() + + Button(action: { selection = .notifications }) { + HStack(spacing: 6) { + Image(systemName: "bell") + .font(.system(size: 12, weight: .medium)) + if notificationStore.unreadCount > 0 { + Text("\(notificationStore.unreadCount)") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor)) + .foregroundColor(.white) + } + } + } + .buttonStyle(.plain) + .foregroundColor(selection == .notifications ? .primary : .secondary) + Button(action: { tabManager.addTab() }) { Image(systemName: "plus") .font(.system(size: 12, weight: .medium)) @@ -67,7 +131,7 @@ struct VerticalTabsSidebar: View { ScrollView { LazyVStack(spacing: 2) { ForEach(tabManager.tabs) { tab in - TabItemView(tab: tab) + TabItemView(tab: tab, selection: $selection) } } .padding(.vertical, 4) @@ -82,6 +146,7 @@ struct VerticalTabsSidebar: View { struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @ObservedObject var tab: Tab + @Binding var selection: SidebarSelection @State private var isHovering = false var isSelected: Bool { @@ -122,9 +187,15 @@ struct TabItemView: View { .contentShape(Rectangle()) .onTapGesture { tabManager.selectTab(tab) + selection = .tabs } .onHover { hovering in isHovering = hovering } } } + +enum SidebarSelection { + case tabs + case notifications +} diff --git a/Sources/GhosttyTabsApp.swift b/Sources/GhosttyTabsApp.swift index e7173ab2..a2b157f7 100644 --- a/Sources/GhosttyTabsApp.swift +++ b/Sources/GhosttyTabsApp.swift @@ -3,6 +3,8 @@ import SwiftUI @main struct GhosttyTabsApp: App { @StateObject private var tabManager = TabManager() + @StateObject private var notificationStore = TerminalNotificationStore.shared + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { // Start the terminal controller for programmatic control @@ -13,9 +15,11 @@ struct GhosttyTabsApp: App { WindowGroup { ContentView() .environmentObject(tabManager) + .environmentObject(notificationStore) .onAppear { // Start the Unix socket controller for programmatic access TerminalController.shared.start(tabManager: tabManager) + appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore) } } .windowStyle(.hiddenTitleBar) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c21b32e0..4c641e63 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3,6 +3,59 @@ import AppKit import Metal import QuartzCore +private enum GhosttyPasteboardHelper { + private static let selectionPasteboard = NSPasteboard( + name: NSPasteboard.Name("com.mitchellh.ghostty.selection") + ) + private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") + private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" + + static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { + switch location { + case GHOSTTY_CLIPBOARD_STANDARD: + return .general + case GHOSTTY_CLIPBOARD_SELECTION: + return selectionPasteboard + default: + return nil + } + } + + static func stringContents(from pasteboard: NSPasteboard) -> String? { + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + !urls.isEmpty { + return urls + .map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString } + .joined(separator: " ") + } + + if let value = pasteboard.string(forType: .string) { + return value + } + + return pasteboard.string(forType: utf8PlainTextType) + } + + static func hasString(for location: ghostty_clipboard_e) -> Bool { + guard let pasteboard = pasteboard(for: location) else { return false } + return (stringContents(from: pasteboard) ?? "").isEmpty == false + } + + static func writeString(_ string: String, to location: ghostty_clipboard_e) { + guard let pasteboard = pasteboard(for: location) else { return } + pasteboard.clearContents() + pasteboard.setString(string, forType: .string) + } + + private static func escapeForShell(_ value: String) -> String { + var result = value + for char in shellEscapeCharacters { + result = result.replacingOccurrences(of: String(char), with: "\\\(char)") + } + return result + } +} + // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation @@ -52,17 +105,49 @@ class GhosttyApp { } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard + guard let userdata else { return } + let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surface = surfaceView.terminalSurface?.surface else { return } + + let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) + let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? "" + + value.withCString { ptr in + ghostty_surface_complete_clipboard_request(surface, ptr, state, false) + } } - runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in + runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in + guard let userdata, let content else { return } + let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surface = surfaceView.terminalSurface?.surface else { return } + + ghostty_surface_complete_clipboard_request(surface, content, state, true) + } + runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in // Write clipboard - if let content = content { - let data = Data(bytes: content, count: Int(len)) - if let string = String(data: data, encoding: .utf8) { - DispatchQueue.main.async { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(string, forType: .string) + guard let content = content, len > 0 else { return } + let buffer = UnsafeBufferPointer(start: content, count: Int(len)) + + var fallback: String? + for item in buffer { + guard let dataPtr = item.data else { continue } + let value = String(cString: dataPtr) + + if let mimePtr = item.mime { + let mime = String(cString: mimePtr) + if mime.hasPrefix("text/plain") { + GhosttyPasteboardHelper.writeString(value, to: location) + return } } + + if fallback == nil { + fallback = value + } + } + + if let fallback { + GhosttyPasteboardHelper.writeString(fallback, to: location) } } runtimeConfig.close_surface_cb = { userdata, processAlive in @@ -107,7 +192,23 @@ class GhosttyApp { } private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { - guard target.tag == GHOSTTY_TARGET_SURFACE else { return false } + if target.tag != GHOSTTY_TARGET_SURFACE { + if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION, + let tabId = AppDelegate.shared?.tabManager?.selectedTabId { + let actionTitle = action.action.desktop_notification.title + .flatMap { String(cString: $0) } ?? "" + let actionBody = action.action.desktop_notification.body + .flatMap { String(cString: $0) } ?? "" + let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" + let body = actionBody.isEmpty ? actionTitle : actionBody + DispatchQueue.main.async { + TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body) + } + return true + } + + return false + } guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() @@ -133,6 +234,34 @@ class GhosttyApp { userInfo: [GhosttyNotificationKey.cellSize: cellSize] ) return true + case GHOSTTY_ACTION_SET_TITLE: + let title = action.action.set_title.title + .flatMap { String(cString: $0) } ?? "" + if let tabId = surfaceView.tabId { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .ghosttyDidSetTitle, + object: surfaceView, + userInfo: [ + GhosttyNotificationKey.tabId: tabId, + GhosttyNotificationKey.title: title, + ] + ) + } + } + return true + case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: + guard let tabId = surfaceView.tabId else { return true } + let actionTitle = action.action.desktop_notification.title + .flatMap { String(cString: $0) } ?? "" + let actionBody = action.action.desktop_notification.body + .flatMap { String(cString: $0) } ?? "" + let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" + let body = actionBody.isEmpty ? actionTitle : actionBody + DispatchQueue.main.async { + TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body) + } + return true default: return false } @@ -145,8 +274,10 @@ class TerminalSurface { private(set) var surface: ghostty_surface_t? private var displayLink: CVDisplayLink? private weak var attachedView: GhosttyNSView? + let tabId: UUID - init() { + init(tabId: UUID) { + self.tabId = tabId // Surface is created when attached to a view } @@ -276,12 +407,13 @@ class TerminalSurface { // MARK: - Ghostty Surface View -class GhosttyNSView: NSView { +class GhosttyNSView: NSView, NSUserInterfaceValidations { var terminalSurface: TerminalSurface? private var surfaceAttached = false var scrollbar: GhosttyScrollbar? var cellSize: CGSize = .zero var desiredFocus: Bool = false + var tabId: UUID? private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -345,6 +477,7 @@ class GhosttyNSView: NSView { func attachSurface(_ surface: TerminalSurface) { terminalSurface = surface + tabId = surface.tabId surfaceAttached = false attachSurfaceIfNeeded() } @@ -399,6 +532,30 @@ class GhosttyNSView: NSView { // MARK: - Input Handling + @IBAction func copy(_ sender: Any?) { + _ = performBindingAction("copy_to_clipboard") + } + + @IBAction func paste(_ sender: Any?) { + _ = performBindingAction("paste_from_clipboard") + } + + @IBAction func pasteAsPlainText(_ sender: Any?) { + _ = performBindingAction("paste_from_clipboard") + } + + func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(copy(_:)): + guard let surface = surface else { return false } + return ghostty_surface_has_selection(surface) + case #selector(paste(_:)), #selector(pasteAsPlainText(_:)): + return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) + default: + return true + } + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { @@ -580,6 +737,50 @@ class GhosttyNSView: NSView { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } + override func rightMouseDown(with event: NSEvent) { + guard let surface = surface else { return } + if !ghostty_surface_mouse_captured(surface) { + super.rightMouseDown(with: event) + return + } + + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + } + + override func rightMouseUp(with event: NSEvent) { + guard let surface = surface else { return } + if !ghostty_surface_mouse_captured(surface) { + super.rightMouseUp(with: event) + return + } + + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + } + + override func menu(for event: NSEvent) -> NSMenu? { + guard let surface = surface else { return nil } + if ghostty_surface_mouse_captured(surface) { + return nil + } + + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + + let menu = NSMenu() + if ghostty_surface_has_selection(surface) { + let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") + item.target = self + } + let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") + pasteItem.target = self + return menu + } + override func mouseMoved(with event: NSEvent) { guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) @@ -672,6 +873,8 @@ struct GhosttyScrollbar { enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" static let cellSize = "ghostty.cellSize" + static let tabId = "ghostty.tabId" + static let title = "ghostty.title" } extension Notification.Name { diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift new file mode 100644 index 00000000..32146fa1 --- /dev/null +++ b/Sources/NotificationsPage.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct NotificationsPage: View { + @EnvironmentObject var notificationStore: TerminalNotificationStore + @EnvironmentObject var tabManager: TabManager + @Binding var selection: SidebarSelection + + 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.focusTab(notification.tabId) + notificationStore.markRead(id: notification.id) + selection = .tabs + }, + onClear: { + notificationStore.remove(id: notification.id) + } + ) + } + } + .padding(16) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + + 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 struct NotificationRow: View { + let notification: TerminalNotification + let tabTitle: String? + let onOpen: () -> Void + let onClear: () -> Void + + var body: some View { + 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, style: .time) + .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) + + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onOpen) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 74ac20c1..458d882a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1,31 +1,49 @@ +import AppKit import SwiftUI import Foundation class Tab: Identifiable, ObservableObject { - let id = UUID() + let id: UUID @Published var title: String @Published var currentDirectory: String let terminalSurface: TerminalSurface init(title: String = "Terminal") { + self.id = UUID() self.title = title self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path - self.terminalSurface = TerminalSurface() + self.terminalSurface = TerminalSurface(tabId: id) } } class TabManager: ObservableObject { @Published var tabs: [Tab] = [] @Published var selectedTabId: UUID? + private var observers: [NSObjectProtocol] = [] init() { addTab() + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidSetTitle, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } + guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } + self.updateTabTitle(tabId: tabId, title: title) + }) } func addTab() { let newTab = Tab(title: "Terminal \(tabs.count + 1)") tabs.append(newTab) selectedTabId = newTab.id + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: newTab.id] + ) } func closeTab(_ tab: Tab) { @@ -54,6 +72,36 @@ class TabManager: ObservableObject { selectedTabId = tab.id } + func titleForTab(_ tabId: UUID) -> String? { + tabs.first(where: { $0.id == tabId })?.title + } + + private func updateTabTitle(tabId: UUID, title: String) { + guard !title.isEmpty else { return } + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + if tabs[index].title != title { + tabs[index].title = title + } + } + + func focusTab(_ tabId: UUID) { + guard tabs.contains(where: { $0.id == tabId }) else { return } + selectedTabId = tabId + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: tabId] + ) + + DispatchQueue.main.async { + NSApp.activate(ignoringOtherApps: true) + NSApp.unhide(nil) + if let window = NSApp.keyWindow ?? NSApp.windows.first { + window.makeKeyAndOrderFront(nil) + } + } + } + func selectNextTab() { guard let currentId = selectedTabId, let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } @@ -73,3 +121,8 @@ class TabManager: ObservableObject { selectedTabId = tabs[index].id } } + +extension Notification.Name { + static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") + static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") +} diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift new file mode 100644 index 00000000..56064374 --- /dev/null +++ b/Sources/TerminalNotificationStore.swift @@ -0,0 +1,139 @@ +import AppKit +import Foundation +import UserNotifications + +struct TerminalNotification: Identifiable, Hashable { + let id: UUID + let tabId: UUID + let title: String + let body: String + let createdAt: Date + var isRead: Bool +} + +final class TerminalNotificationStore: ObservableObject { + static let shared = TerminalNotificationStore() + + static let categoryIdentifier = "com.cmux.ghosttytabs.userNotification" + static let actionShowIdentifier = "com.cmux.ghosttytabs.userNotification.show" + + @Published private(set) var notifications: [TerminalNotification] = [] + + private let center = UNUserNotificationCenter.current() + private var hasRequestedAuthorization = false + + private init() {} + + var unreadCount: Int { + notifications.filter { !$0.isRead }.count + } + + func addNotification(tabId: UUID, title: String, body: String) { + let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId + let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab + let notification = TerminalNotification( + id: UUID(), + tabId: tabId, + title: title, + body: body, + createdAt: Date(), + isRead: shouldMarkRead + ) + notifications.insert(notification, at: 0) + if !shouldMarkRead { + scheduleUserNotification(notification) + } + } + + func markRead(id: UUID) { + guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } + if notifications[index].isRead { return } + notifications[index].isRead = true + center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + } + + func markRead(forTabId tabId: UUID) { + var idsToClear: [String] = [] + for index in notifications.indices { + if notifications[index].tabId == tabId && !notifications[index].isRead { + notifications[index].isRead = true + idsToClear.append(notifications[index].id.uuidString) + } + } + if !idsToClear.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToClear) + } + } + + func remove(id: UUID) { + notifications.removeAll { $0.id == id } + center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + } + + func clearAll() { + let ids = notifications.map { $0.id.uuidString } + notifications.removeAll() + if !ids.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: ids) + } + } + + private func scheduleUserNotification(_ notification: TerminalNotification) { + ensureAuthorization { [weak self] authorized in + guard let self, authorized else { return } + + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.body + content.sound = UNNotificationSound.default + content.categoryIdentifier = Self.categoryIdentifier + content.userInfo = [ + "tabId": notification.tabId.uuidString, + "notificationId": notification.id.uuidString, + ] + + let request = UNNotificationRequest( + identifier: notification.id.uuidString, + content: content, + trigger: nil + ) + + self.center.add(request) { error in + if let error { + NSLog("Failed to schedule notification: \(error)") + } + } + } + } + + private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) { + center.getNotificationSettings { [weak self] settings in + guard let self else { + completion(false) + return + } + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + completion(true) + case .denied: + completion(false) + case .notDetermined: + self.requestAuthorizationIfNeeded(completion) + @unknown default: + completion(false) + } + } + } + + private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) { + guard !hasRequestedAuthorization else { + completion(false) + return + } + hasRequestedAuthorization = true + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + completion(granted) + } + } +}