From 140afb395f51b96c0010366718e942d9b5ee527a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 17:34:44 -0700 Subject: [PATCH 1/6] apprt/embedded: supporting setting working directory in config --- include/ghostty.h | 1 + src/apprt/embedded.zig | 8 ++++++++ 2 files changed, 9 insertions(+) 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/src/apprt/embedded.zig b/src/apprt/embedded.zig index da5a60273..a4cf66074 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,11 @@ 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. + // TODO: validate the working directory + const wd = std.mem.sliceTo(opts.working_directory, 0); + if (wd.len > 0) config.@"working-directory" = wd; + // Initialize our surface right away. We're given a view that is // ready to use. try self.core_surface.init( From d5299fec25a4ac4a8f60c7ea65c1e092d43ea8ae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 22:00:56 -0700 Subject: [PATCH 2/6] macos: use SurfaceConfiguration everywhere instead of bare c struct --- .../Features/Primary Window/PrimaryView.swift | 2 +- .../Primary Window/PrimaryWindow.swift | 2 +- .../Primary Window/PrimaryWindowManager.swift | 10 ++--- macos/Sources/Ghostty/AppState.swift | 8 ++-- macos/Sources/Ghostty/Ghostty.SplitView.swift | 12 ++--- macos/Sources/Ghostty/SurfaceView.swift | 45 ++++++++++++++++--- 6 files changed, 56 insertions(+), 23 deletions(-) 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..4f0c20d96 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) { + private 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..8fbf9adf9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -184,6 +184,41 @@ 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) + let blah = workingDirectory! + AppDelegate.logger.warning("OPEN from=\(blah)") + } + + /// 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 +256,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 +265,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 } From 6249621d71cbb45bde706180d99b3f9f5f6dc5f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 22:03:04 -0700 Subject: [PATCH 3/6] macos: support drag and drop with no windows --- macos/Ghostty-Info.plist | 15 +++++++++++++++ macos/Sources/AppDelegate.swift | 17 +++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 2 -- 3 files changed, 32 insertions(+), 2 deletions(-) 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..ba6f19fd6 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -138,6 +138,23 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp return false } + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + AppDelegate.logger.warning("OPEN FILE=\(filename)") + + // 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 + } + + return false + } + /// This is called for the dock right-click menu. func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { return dockMenu diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 8fbf9adf9..de8a434a4 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -199,8 +199,6 @@ extension Ghostty { init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) - let blah = workingDirectory! - AppDelegate.logger.warning("OPEN from=\(blah)") } /// Returns the ghostty configuration for this surface configuration struct. The memory From 96b8fbb84d47015ff7f53a70c500eaf809c79945 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 22:07:58 -0700 Subject: [PATCH 4/6] macos: support dropping folder with window --- macos/Sources/AppDelegate.swift | 4 +++- .../Features/Primary Window/PrimaryWindowManager.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index ba6f19fd6..86bfb8984 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -152,7 +152,9 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp return true } - return false + // Add a new tab + windowManager.addNewTab(to: mainWindow, withBaseConfig: config) + return true } /// This is called for the dock right-click menu. diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 4f0c20d96..994bf3bcf 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -133,7 +133,7 @@ class PrimaryWindowManager { self.addNewTab(to: window, withBaseConfig: config) } - private func addNewTab(to window: NSWindow, withBaseConfig config: Ghostty.SurfaceConfiguration? = 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) From bb5246c65db66b11287393b4250adda4c6c7b079 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 22:13:36 -0700 Subject: [PATCH 5/6] apprt/embedded: validate directory for wd --- src/apprt/embedded.zig | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a4cf66074..bffc8a95b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -240,9 +240,35 @@ pub const Surface = struct { defer config.deinit(); // If we have a working directory from the options then we set it. - // TODO: validate the working directory const wd = std.mem.sliceTo(opts.working_directory, 0); - if (wd.len > 0) config.@"working-directory" = wd; + 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. From dc882edd31b9f3ab1b63d397d897654ab8afa885 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Oct 2023 22:18:39 -0700 Subject: [PATCH 6/6] macos: validation of dropped directory --- macos/Sources/AppDelegate.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 86bfb8984..f210dcf7e 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -139,7 +139,20 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp } func application(_ sender: NSApplication, openFile filename: String) -> Bool { - AppDelegate.logger.warning("OPEN FILE=\(filename)") + // 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()