diff --git a/include/ghostty.h b/include/ghostty.h index 2a4a7fb6e..312e6595a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -350,6 +350,11 @@ typedef struct { const char* message; } ghostty_diagnostic_s; +typedef struct { + const char* ptr; + uintptr_t len; +} ghostty_string_s; + typedef struct { double tl_px_x; double tl_px_y; @@ -797,6 +802,7 @@ int ghostty_init(uintptr_t, char**); void ghostty_cli_try_action(void); ghostty_info_s ghostty_info(void); const char* ghostty_translate(const char*); +void ghostty_string_free(ghostty_string_s); ghostty_config_t ghostty_config_new(); void ghostty_config_free(ghostty_config_t); @@ -811,7 +817,7 @@ ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, uintptr_t); uint32_t ghostty_config_diagnostics_count(ghostty_config_t); ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); -void ghostty_config_open(); +ghostty_string_s ghostty_config_open_path(void); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_config_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 08c3ef3b3..f6eedd864 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A505D21D2E1A2FA20018808F /* FileHandle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A505D21C2E1A2F9E0018808F /* FileHandle+Extension.swift */; }; + A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */; }; A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; @@ -160,6 +161,7 @@ 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A505D21C2E1A2F9E0018808F /* FileHandle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileHandle+Extension.swift"; sourceTree = ""; }; + A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWorkspace+Extension.swift"; sourceTree = ""; }; A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = ""; }; @@ -531,6 +533,7 @@ AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, + A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, @@ -819,6 +822,7 @@ A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, + A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 53b6dce88..38500b7d3 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -932,7 +932,7 @@ class AppDelegate: NSObject, //MARK: - IB Actions @IBAction func openConfig(_ sender: Any?) { - ghostty.openConfig() + Ghostty.App.openConfig() } @IBAction func reloadConfig(_ sender: Any?) { diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index dfdb0bff5..a6559600d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -40,4 +40,34 @@ extension Ghostty.Action { self.amount = c.amount } } + + struct OpenURL { + enum Kind { + case unknown + case text + + init(_ c: ghostty_action_open_url_kind_e) { + switch c { + case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: + self = .text + default: + self = .unknown + } + } + } + + let kind: Kind + let url: String + + init(c: ghostty_action_open_url_s) { + self.kind = Kind(c.kind) + + if let urlCString = c.url { + let data = Data(bytes: urlCString, count: Int(c.len)) + self.url = String(data: data, encoding: .utf8) ?? "" + } else { + self.url = "" + } + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 17abe2b0e..0fdea1760 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -114,9 +114,21 @@ extension Ghostty { ghostty_app_tick(app) } - func openConfig() { - guard let app = self.app else { return } - ghostty_app_open_config(app) + static func openConfig() { + let str = Ghostty.AllocatedString(ghostty_config_open_path()).string + guard !str.isEmpty else { return } + #if os(macOS) + let fileURL = URL(fileURLWithPath: str).absoluteString + var action = ghostty_action_open_url_s() + action.kind = GHOSTTY_ACTION_OPEN_URL_KIND_TEXT + fileURL.withCString { cStr in + action.url = cStr + action.len = UInt(fileURL.count) + _ = openURL(action) + } + #else + fatalError("Unsupported platform for opening config file") + #endif } /// Reload the configuration. @@ -488,7 +500,7 @@ extension Ghostty { pwdChanged(app, target: target, v: action.action.pwd) case GHOSTTY_ACTION_OPEN_CONFIG: - ghostty_config_open() + openConfig() case GHOSTTY_ACTION_FLOAT_WINDOW: toggleFloatWindow(app, target: target, mode: action.action.float_window) @@ -546,6 +558,9 @@ extension Ghostty { case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) + + case GHOSTTY_ACTION_OPEN_URL: + return openURL(action.action.open_url) case GHOSTTY_ACTION_UNDO: return undo(app, target: target) @@ -598,6 +613,34 @@ extension Ghostty { appDelegate.checkForUpdates(nil) } } + + private static func openURL( + _ v: ghostty_action_open_url_s + ) -> Bool { + let action = Ghostty.Action.OpenURL(c: v) + + // Convert the URL string to a URL object + guard let url = URL(string: action.url) else { + Ghostty.logger.warning("invalid URL for open URL action: \(action.url)") + return false + } + + switch action.kind { + case .text: + // Open with the default text editor + if let textEditor = NSWorkspace.shared.defaultTextEditor { + NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration()) + return true + } + + case .unknown: + break + } + + // Open with the default application for the URL + NSWorkspace.shared.open(url) + return true + } private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { let undoManager: UndoManager? diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f30f2f6f9..9b05934df 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -73,6 +73,26 @@ extension Ghostty { // MARK: Swift Types for C Types +extension Ghostty { + class AllocatedString { + private let cString: ghostty_string_s + + init(_ c: ghostty_string_s) { + self.cString = c + } + + var string: String { + guard let ptr = cString.ptr else { return "" } + let data = Data(bytes: ptr, count: Int(cString.len)) + return String(data: data, encoding: .utf8) ?? "" + } + + deinit { + ghostty_string_free(cString) + } + } +} + extension Ghostty { enum SetFloatWIndow { case on diff --git a/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift b/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift new file mode 100644 index 000000000..bc2d028b5 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift @@ -0,0 +1,29 @@ +import AppKit +import UniformTypeIdentifiers + +extension NSWorkspace { + /// Returns the URL of the default text editor application. + /// - Returns: The URL of the default text editor, or nil if no default text editor is found. + var defaultTextEditor: URL? { + defaultApplicationURL(forContentType: UTType.plainText.identifier) + } + + /// Returns the URL of the default application for opening files with the specified content type. + /// - Parameter contentType: The content type identifier (UTI) to find the default application for. + /// - Returns: The URL of the default application, or nil if no default application is found. + func defaultApplicationURL(forContentType contentType: String) -> URL? { + return LSCopyDefaultApplicationURLForContentType( + contentType as CFString, + .all, + nil + )?.takeRetainedValue() as? URL + } + + /// Returns the URL of the default application for opening files with the specified file extension. + /// - Parameter ext: The file extension to find the default application for. + /// - Returns: The URL of the default application, or nil if no default application is found. + func defaultApplicationURL(forExtension ext: String) -> URL? { + guard let uti = UTType(filenameExtension: ext) else { return nil} + return defaultApplicationURL(forContentType: uti.identifier) + } +} diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 369090ee2..907f3a36d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -496,7 +496,7 @@ pub fn performAction( .resize_split => self.resizeSplit(target, value), .equalize_splits => self.equalizeSplits(target), .goto_split => return self.gotoSplit(target, value), - .open_config => try configpkg.edit.open(self.core_app.alloc), + .open_config => return self.openConfig(), .config_change => self.configChange(target, value.config), .reload_config => try self.reloadConfig(target, value), .inspector => self.controlInspector(target, value), @@ -1759,7 +1759,22 @@ fn initActions(self: *App) void { } } -pub fn openUrl( +fn openConfig(self: *App) !bool { + // Get the config file path + const alloc = self.core_app.alloc; + const path = configpkg.edit.openPath(alloc) catch |err| { + log.warn("error getting config file path: {}", .{err}); + return false; + }; + defer alloc.free(path); + + // Open it using openURL. "path" isn't actually a URL but + // at the time of writing that works just fine for GTK. + self.openUrl(.{ .kind = .text, .url = path }); + return true; +} + +fn openUrl( app: *App, value: apprt.action.OpenUrl, ) void { diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index 0b7108a59..bdc59797a 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -1,7 +1,9 @@ const std = @import("std"); +const assert = std.debug.assert; const cli = @import("../cli.zig"); const inputpkg = @import("../input.zig"); -const global = &@import("../global.zig").state; +const state = &@import("../global.zig").state; +const c = @import("../main_c.zig"); const Config = @import("Config.zig"); const c_get = @import("c_get.zig"); @@ -12,14 +14,14 @@ const log = std.log.scoped(.config); /// Create a new configuration filled with the initial default values. export fn ghostty_config_new() ?*Config { - const result = global.alloc.create(Config) catch |err| { + const result = state.alloc.create(Config) catch |err| { log.err("error allocating config err={}", .{err}); return null; }; - result.* = Config.default(global.alloc) catch |err| { + result.* = Config.default(state.alloc) catch |err| { log.err("error creating config err={}", .{err}); - global.alloc.destroy(result); + state.alloc.destroy(result); return null; }; @@ -29,20 +31,20 @@ export fn ghostty_config_new() ?*Config { export fn ghostty_config_free(ptr: ?*Config) void { if (ptr) |v| { v.deinit(); - global.alloc.destroy(v); + state.alloc.destroy(v); } } /// Deep clone the configuration. export fn ghostty_config_clone(self: *Config) ?*Config { - const result = global.alloc.create(Config) catch |err| { + const result = state.alloc.create(Config) catch |err| { log.err("error allocating config err={}", .{err}); return null; }; - result.* = self.clone(global.alloc) catch |err| { + result.* = self.clone(state.alloc) catch |err| { log.err("error cloning config err={}", .{err}); - global.alloc.destroy(result); + state.alloc.destroy(result); return null; }; @@ -51,7 +53,7 @@ export fn ghostty_config_clone(self: *Config) ?*Config { /// Load the configuration from the CLI args. export fn ghostty_config_load_cli_args(self: *Config) void { - self.loadCliArgs(global.alloc) catch |err| { + self.loadCliArgs(state.alloc) catch |err| { log.err("error loading config err={}", .{err}); }; } @@ -60,7 +62,7 @@ export fn ghostty_config_load_cli_args(self: *Config) void { /// is usually done first. The default file locations are locations /// such as the home directory. export fn ghostty_config_load_default_files(self: *Config) void { - self.loadDefaultFiles(global.alloc) catch |err| { + self.loadDefaultFiles(state.alloc) catch |err| { log.err("error loading config err={}", .{err}); }; } @@ -69,7 +71,7 @@ export fn ghostty_config_load_default_files(self: *Config) void { /// file locations in the previously loaded configuration. This will /// recursively continue to load up to a built-in limit. export fn ghostty_config_load_recursive_files(self: *Config) void { - self.loadRecursiveFiles(global.alloc) catch |err| { + self.loadRecursiveFiles(state.alloc) catch |err| { log.err("error loading config err={}", .{err}); }; } @@ -122,10 +124,13 @@ export fn ghostty_config_get_diagnostic(self: *Config, idx: u32) Diagnostic { return .{ .message = message.ptr }; } -export fn ghostty_config_open() void { - edit.open(global.alloc) catch |err| { +export fn ghostty_config_open_path() c.String { + const path = edit.openPath(state.alloc) catch |err| { log.err("error opening config in editor err={}", .{err}); + return .empty; }; + + return .fromSlice(path); } /// Sync with ghostty_diagnostic_s diff --git a/src/config/edit.zig b/src/config/edit.zig index ae4394942..38dc98169 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -5,18 +5,19 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const internal_os = @import("../os/main.zig"); -/// Open the configuration in the OS default editor according to the default -/// paths the main config file could be in. +/// The path to the configuration that should be opened for editing. /// -/// On Linux, this will open the file at the XDG config path. This is the +/// On Linux, this will use the file at the XDG config path. This is the /// only valid path for Linux so we don't need to check for other paths. /// /// On macOS, both XDG and AppSupport paths are valid. Because Ghostty -/// prioritizes AppSupport over XDG, we will open AppSupport if it exists, +/// prioritizes AppSupport over XDG, we will use AppSupport if it exists, /// followed by XDG if it exists, and finally AppSupport if neither exist. /// For the existence check, we also prefer non-empty files over empty /// files. -pub fn open(alloc_gpa: Allocator) !void { +/// +/// The returned value is allocated using the provided allocator. +pub fn openPath(alloc_gpa: Allocator) ![:0]const u8 { // Use an arena to make memory management easier in here. var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); @@ -41,7 +42,7 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc_gpa, .text, config_path); + return try alloc_gpa.dupeZ(u8, config_path); } /// Returns the config path to use for open for the current OS. diff --git a/src/main_c.zig b/src/main_c.zig index 0722900e7..2c266cfb5 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -19,7 +19,12 @@ const internal_os = @import("os/main.zig"); // Some comptime assertions that our C API depends on. comptime { - assert(apprt.runtime == apprt.embedded); + // We allow tests to reference this file because we unit test + // some of the C API. At runtime though we should never get these + // functions unless we are building libghostty. + if (!builtin.is_test) { + assert(apprt.runtime == apprt.embedded); + } } /// Global options so we can log. This is identical to main. @@ -29,7 +34,9 @@ comptime { // These structs need to be referenced so the `export` functions // are truly exported by the C API lib. _ = @import("config.zig").CAPI; - _ = apprt.runtime.CAPI; + if (@hasDecl(apprt.runtime, "CAPI")) { + _ = apprt.runtime.CAPI; + } } /// ghostty_info_s @@ -46,6 +53,24 @@ const Info = extern struct { }; }; +/// ghostty_string_s +pub const String = extern struct { + ptr: ?[*]const u8, + len: usize, + + pub const empty: String = .{ + .ptr = null, + .len = 0, + }; + + pub fn fromSlice(slice: []const u8) String { + return .{ + .ptr = slice.ptr, + .len = slice.len, + }; + } +}; + /// Initialize ghostty global state. export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int { assert(builtin.link_libc); @@ -95,3 +120,8 @@ export fn ghostty_info() Info { export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { return internal_os.i18n._(msgid); } + +/// Free a string allocated by Ghostty. +export fn ghostty_string_free(str: String) void { + state.alloc.free(str.ptr.?[0..str.len]); +}