diff --git a/include/ghostty.h b/include/ghostty.h index 1a0a7c9c6..826e919af 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -309,6 +309,7 @@ typedef struct { void *nsview; double scale_factor; uint16_t font_size; + const char *working_directory; } ghostty_surface_config_s; typedef void (*ghostty_runtime_wakeup_cb)(void *); diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index eb64fed93..950fd73cc 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,21 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + Folders + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.directory + + + LSEnvironment GHOSTTY_MAC_APP diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 53c6c159c..f210dcf7e 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -138,6 +138,38 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp return false } + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + // Ghostty will validate as well but we can avoid creating an entirely new + // surface by doing our own validation here. We can also show a useful error + // this way. + var isDirectory = ObjCBool(true) + guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false } + guard isDirectory.boolValue else { + let alert = NSAlert() + alert.messageText = "Dropped File is Not a Directory" + alert.informativeText = "Ghostty can currently only open directory paths." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + _ = alert.runModal() + return false + } + + // Build our config + var config = Ghostty.SurfaceConfiguration() + config.workingDirectory = filename + + // If we don't have a window open through the window manager, we launch + // a new window. + guard let mainWindow = windowManager.mainWindow else { + windowManager.addNewWindow(withBaseConfig: config) + return true + } + + // Add a new tab + windowManager.addNewTab(to: mainWindow, withBaseConfig: config) + return true + } + /// This is called for the dock right-click menu. func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { return dockMenu diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index e54276a6f..a0047e4f8 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -12,7 +12,7 @@ struct PrimaryView: View { let focusedSurfaceWrapper: FocusedSurfaceWrapper // If this is set, this is the base configuration that we build our surface out of. - let baseConfig: ghostty_surface_config_s? + let baseConfig: Ghostty.SurfaceConfiguration? // We need access to our window to know if we're the key window to determine // if we show the quit confirmation or not. diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 0b199921b..1d7f53b26 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -24,7 +24,7 @@ class PrimaryWindow: NSWindow { return true } - static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate, baseConfig: ghostty_surface_config_s? = nil) -> PrimaryWindow { + static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate, baseConfig: Ghostty.SurfaceConfiguration? = nil) -> PrimaryWindow { let window = PrimaryWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: getStyleMask(renderDecoration: ghostty.windowDecorations), diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 249b624c9..994bf3bcf 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -94,7 +94,7 @@ class PrimaryWindowManager { ghostty.newWindow(surface: surface) } - func addNewWindow(withBaseConfig config: ghostty_surface_config_s? = nil) { + func addNewWindow(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) { guard let controller = createWindowController(withBaseConfig: config) else { return } controller.showWindow(self) guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } @@ -103,7 +103,7 @@ class PrimaryWindowManager { @objc private func onNewWindow(notification: SwiftUI.Notification) { let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? ghostty_surface_config_s + let config = configAny as? Ghostty.SurfaceConfiguration self.addNewWindow(withBaseConfig: config) } @@ -128,19 +128,19 @@ class PrimaryWindowManager { guard let window = surfaceView.window else { return } let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? ghostty_surface_config_s + let config = configAny as? Ghostty.SurfaceConfiguration self.addNewTab(to: window, withBaseConfig: config) } - private func addNewTab(to window: NSWindow, withBaseConfig config: ghostty_surface_config_s? = nil) { + func addNewTab(to window: NSWindow, withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) { guard let controller = createWindowController(withBaseConfig: config, cascade: false) else { return } guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } window.addTabbedWindow(newWindow, ordered: .above) newWindow.makeKeyAndOrderFront(nil) } - private func createWindowController(withBaseConfig config: ghostty_surface_config_s? = nil, cascade: Bool = true) -> PrimaryWindowController? { + private func createWindowController(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil, cascade: Bool = true) -> PrimaryWindowController? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate, baseConfig: config) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index e01ce96af..d3a4ba7d1 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -282,7 +282,7 @@ extension Ghostty { guard let surface = self.surfaceUserdata(from: userdata) else { return } NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [ "direction": direction, - Notification.NewSurfaceConfigKey: config, + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), ]) } @@ -434,12 +434,12 @@ extension Ghostty { _ = alert.runModal() return } - + NotificationCenter.default.post( name: Notification.ghosttyNewTab, object: surface, userInfo: [ - Notification.NewSurfaceConfigKey: config + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), ] ) } @@ -451,7 +451,7 @@ extension Ghostty { name: Notification.ghosttyNewWindow, object: surface, userInfo: [ - Notification.NewSurfaceConfigKey: config + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), ] ) } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 5991b88a7..6990daae9 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -7,7 +7,7 @@ extension Ghostty { /// split direction by splitting the terminal. struct TerminalSplit: View { let onClose: (() -> Void)? - let baseConfig: ghostty_surface_config_s? + let baseConfig: SurfaceConfiguration? @Environment(\.ghosttyApp) private var app @@ -118,7 +118,7 @@ extension Ghostty { @Published var surface: SurfaceView /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { + init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { self.app = app self.surface = SurfaceView(app, baseConfig) } @@ -132,7 +132,7 @@ extension Ghostty { /// A container is always initialized from some prior leaf because a split has to originate /// from a non-split value. When initializing, we inherit the leaf's surface and then /// initialize a new surface for the new pane. - init(from: Leaf, baseConfig: ghostty_surface_config_s? = nil) { + init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) { self.app = from.app // Initially, both topLeft and bottomRight are in the "nosplit" @@ -197,7 +197,7 @@ extension Ghostty { @State private var node: SplitNode @State private var requestClose: Bool = false let onClose: (() -> Void)? - let baseConfig: ghostty_surface_config_s? + let baseConfig: SurfaceConfiguration? /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay @@ -209,7 +209,7 @@ extension Ghostty { init(app: ghostty_app_t, zoomedSurface: Binding, onClose: (() ->Void)? = nil, - baseConfig: ghostty_surface_config_s? = nil) { + baseConfig: SurfaceConfiguration? = nil) { self.onClose = onClose self.baseConfig = baseConfig self._zoomedSurface = zoomedSurface @@ -395,7 +395,7 @@ extension Ghostty { private func onNewSplit(notification: SwiftUI.Notification) { let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? ghostty_surface_config_s + let config = configAny as? SurfaceConfiguration // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d686055c9..de8a434a4 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -184,6 +184,39 @@ extension Ghostty { view.sizeDidChange(size) } } + + /// The configuration for a surface. For any configuration not set, defaults will be chosen from + /// libghostty, usually from the Ghostty configuration. + struct SurfaceConfiguration { + /// Explicit font size to use in points + var fontSize: UInt16? = nil + + /// Explicit working directory to set + var workingDirectory: String? = nil + + init() {} + + init(from config: ghostty_surface_config_s) { + self.fontSize = config.font_size + self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) + } + + /// Returns the ghostty configuration for this surface configuration struct. The memory + /// in the returned struct is only valid as long as this struct is retained. + func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s { + var config = ghostty_surface_config_new() + config.userdata = Unmanaged.passUnretained(view).toOpaque() + config.nsview = Unmanaged.passUnretained(view).toOpaque() + config.scale_factor = NSScreen.main!.backingScaleFactor + + if let fontSize = fontSize { config.font_size = fontSize } + if let workingDirectory = workingDirectory { + config.working_directory = (workingDirectory as NSString).utf8String + } + + return config + } + } /// The NSView implementation for a terminal surface. class SurfaceView: NSView, NSTextInputClient, ObservableObject { @@ -221,7 +254,7 @@ extension Ghostty { case pendingHidden } - init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { + init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { self.markedText = NSMutableAttributedString() // Initialize with some default frame size. The important thing is that this @@ -230,12 +263,10 @@ extension Ghostty { super.init(frame: NSMakeRect(0, 0, 800, 600)) // Setup our surface. This will also initialize all the terminal IO. - var surface_cfg = baseConfig ?? ghostty_surface_config_new() - surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque() - surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque() - surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor + var surface_cfg = baseConfig ?? SurfaceConfiguration() + var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) - guard let surface = ghostty_surface_new(app, &surface_cfg) else { + guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { self.error = AppError.surfaceCreateError return } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index da5a60273..bffc8a95b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -208,6 +208,9 @@ pub const Surface = struct { /// The font size to inherit. If 0, default font size will be used. font_size: u16 = 0, + + /// The working directory to load into. + working_directory: [*:0]const u8 = "", }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -236,6 +239,37 @@ pub const Surface = struct { var config = try apprt.surface.newConfig(app.core_app, app.config); defer config.deinit(); + // If we have a working directory from the options then we set it. + const wd = std.mem.sliceTo(opts.working_directory, 0); + if (wd.len > 0) wd: { + var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { + log.warn( + "error opening requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + defer dir.close(); + + const stat = dir.stat() catch |err| { + log.warn( + "failed to stat requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + + if (stat.kind != .directory) { + log.warn( + "requested working directory is not a directory dir={s}", + .{wd}, + ); + break :wd; + } + + config.@"working-directory" = wd; + } + // Initialize our surface right away. We're given a view that is // ready to use. try self.core_surface.init(