Add notifications and clipboard context menu
This commit is contained in:
parent
f7c421c56a
commit
62136dbdd3
8 changed files with 754 additions and 24 deletions
|
|
@ -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 = "<group>"; };
|
||||
A5001018 /* GhosttyTabs-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "GhosttyTabs-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; };
|
||||
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
111
Sources/AppDelegate.swift
Normal file
111
Sources/AppDelegate.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<GhosttyNSView>.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<GhosttyNSView>.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<GhosttyNSView>.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 {
|
||||
|
|
|
|||
137
Sources/NotificationsPage.swift
Normal file
137
Sources/NotificationsPage.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
139
Sources/TerminalNotificationStore.swift
Normal file
139
Sources/TerminalNotificationStore.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue