diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist
index 4d2203882..28499843d 100644
--- a/macos/Ghostty-Info.plist
+++ b/macos/Ghostty-Info.plist
@@ -22,6 +22,47 @@
GHOSTTY_MAC_APP
1
+ NSServices
+
+
+ NSMenuItem
+
+ default
+ New Ghostty Tab Here
+
+ NSMessage
+ openTab
+ NSRequiredContext
+
+ NSTextContent
+ FilePath
+
+ NSSendTypes
+
+ NSFilenamesPboardType
+ public.plain-text
+
+
+
+ NSMenuItem
+
+ default
+ New Ghostty Window Here
+
+ NSMessage
+ openWindow
+ NSRequiredContext
+
+ NSTextContent
+ FilePath
+
+ NSSendTypes
+
+ NSFilenamesPboardType
+ public.plain-text
+
+
+
NSHighResolutionCapable
NSAppleEventsUsageDescription
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index 18da5c7cc..dd6823fd0 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -23,6 +23,7 @@
A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */; };
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; };
+ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; };
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
@@ -55,6 +56,7 @@
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = ""; };
A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; };
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = ""; };
+ A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; };
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; };
@@ -89,6 +91,7 @@
A53426362A7DC53000EBB7A2 /* Features */ = {
isa = PBXGroup;
children = (
+ A56D58872ACDE6BE00508D2C /* Services */,
A53426372A7DC53A00EBB7A2 /* Primary Window */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
);
@@ -156,6 +159,14 @@
path = Ghostty;
sourceTree = "";
};
+ A56D58872ACDE6BE00508D2C /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */,
+ );
+ path = Services;
+ sourceTree = "";
+ };
A5A1F8862A489D7400D1E8BC /* Resources */ = {
isa = PBXGroup;
children = (
@@ -279,6 +290,7 @@
files = (
A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */,
85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */,
+ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift
index f210dcf7e..e23214154 100644
--- a/macos/Sources/AppDelegate.swift
+++ b/macos/Sources/AppDelegate.swift
@@ -40,7 +40,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
private var ghostty: Ghostty.AppState = Ghostty.AppState()
/// Manages windows and tabs, ensuring they're allocated/deallocated correctly
- private var windowManager: PrimaryWindowManager!
+ var windowManager: PrimaryWindowManager!
override init() {
super.init()
@@ -72,6 +72,10 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
// Initial config loading
configDidReload(ghostty)
+
+ // Register our service provider. This must happen after everything
+ // else is initialized.
+ NSApp.servicesProvider = ServiceProvider()
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift
new file mode 100644
index 000000000..a95dd4923
--- /dev/null
+++ b/macos/Sources/Features/Services/ServiceProvider.swift
@@ -0,0 +1,68 @@
+import Foundation
+import AppKit
+
+class ServiceProvider: NSObject {
+ static private let errorNoString = NSString(string: "Could not load any text from the clipboard.")
+
+ /// The target for an open operation
+ enum OpenTarget {
+ case tab
+ case window
+ }
+
+ @objc func openTab(
+ _ pasteboard: NSPasteboard,
+ userData: String?,
+ error: AutoreleasingUnsafeMutablePointer
+ ) {
+ guard let str = pasteboard.string(forType: .string) else {
+ error.pointee = Self.errorNoString
+ return
+ }
+
+ openTerminal(str, target: .tab)
+ }
+
+ @objc func openWindow(
+ _ pasteboard: NSPasteboard,
+ userData: String?,
+ error: AutoreleasingUnsafeMutablePointer
+ ) {
+ guard let str = pasteboard.string(forType: .string) else {
+ error.pointee = Self.errorNoString
+ return
+ }
+
+ openTerminal(str, target: .window)
+ }
+
+ private func openTerminal(_ path: String, target: OpenTarget) {
+ guard let delegateRaw = NSApp.delegate else { return }
+ guard let delegate = delegateRaw as? AppDelegate else { return }
+ guard let windowManager = delegate.windowManager else { return }
+
+ // We only open in directories.
+ var isDirectory = ObjCBool(true)
+ guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return }
+ guard isDirectory.boolValue else { return }
+
+ // Build our config
+ var config = Ghostty.SurfaceConfiguration()
+ config.workingDirectory = path
+
+ // If we don't have a window open through the window manager, we launch
+ // a new window even if they requested a tab.
+ guard let mainWindow = windowManager.mainWindow else {
+ windowManager.addNewWindow(withBaseConfig: config)
+ return
+ }
+
+ switch (target) {
+ case .window:
+ windowManager.addNewWindow(withBaseConfig: config)
+
+ case .tab:
+ windowManager.addNewTab(to: mainWindow, withBaseConfig: config)
+ }
+ }
+}