From 22935c5034646f1bd32993efe49e108f3f5468e9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 18 Aug 2024 00:22:20 -0500 Subject: [PATCH] Generalize launching commands and use for clicking links. This makes launching commands in Ghostty surfaces a little more general and uses that to add the ability to launch commands in new surfaces when clicking on a URI. --- src/Surface.zig | 132 ++++++++++++-- src/apprt/embedded.zig | 2 +- src/apprt/gtk/Surface.zig | 27 +-- src/build/fish_completions.zig | 2 +- src/config.zig | 1 + src/config/Config.zig | 312 +++++++++++++++++++++++++++++---- src/input/Binding.zig | 73 +++++--- src/input/Link.zig | 4 +- src/os/editor.zig | 7 +- src/os/main.zig | 1 + src/os/open.zig | 82 +++++++-- 11 files changed, 535 insertions(+), 108 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 484b0f3e2..625f503d4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -224,6 +224,8 @@ const DerivedConfig = struct { window_padding_balance: bool, title: ?[:0]const u8, links: []Link, + editor: ?[]const u8, + uri_handlers: configpkg.RepeatableURIHandler, const Link = struct { regex: oni.Regex, @@ -284,6 +286,8 @@ const DerivedConfig = struct { .window_padding_balance = config.@"window-padding-balance", .title = config.title, .links = links, + .editor = if (config.editor) |editor| try alloc.dupe(u8, editor) else null, + .uri_handlers = try config.@"uri-handler".clone(alloc), // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -2611,7 +2615,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try internal_os.open(self.alloc, str); + try self.handleURI(str); }, ._open_osc8 => { @@ -2619,7 +2623,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try internal_os.open(self.alloc, uri); + try self.handleURI(uri); }, } @@ -2637,6 +2641,93 @@ fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { return entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; } +fn handleURI(self: *Surface, uri: []const u8) !void { + const parsed = std.Uri.parse(uri) catch |err| { + log.warn("URI \"{s}\" was not able to be parsed: {}", .{ uri, err }); + return; + }; + + const scheme = self.config.uri_handlers.getSupportedScheme(parsed.scheme) orelse { + log.warn("ignoring unsupported scheme \"{s}\"", .{parsed.scheme}); + return; + }; + const location = self.config.uri_handlers.getLocation(scheme); + + var arena = std.heap.ArenaAllocator.init(self.alloc); + const alloc = arena.allocator(); + defer arena.deinit(); + + const command = switch (location) { + .disabled => { + log.warn("handling {s} uris has beel disabled in the config, ignoring", .{@tagName(scheme)}); + return; + }, + .open => { + try internal_os.open(self.alloc, uri); + return; + }, + else => switch (scheme) { + .http => command: { + const cmd = self.config.uri_handlers.getCommand(.http) orelse { + log.warn("no command is configured for http, ignoring", .{}); + return; + }; + break :command try std.fmt.allocPrint(alloc, "{s} {}", .{ cmd, parsed }); + }, + .ssh => command: { + const cmd = self.config.uri_handlers.getCommand(.ssh) orelse { + log.warn("no command is configured for ssh, ignoring", .{}); + return; + }; + const host = parsed.host orelse { + log.warn("ssh url does not have a host!", .{}); + return; + }; + const user = if (parsed.user) |user| try std.fmt.allocPrint(alloc, "{s}@", .{try user.toRawMaybeAlloc(alloc)}) else ""; + const port = if (parsed.port) |port| try std.fmt.allocPrint(alloc, ":{d}", .{port}) else ""; + break :command try std.fmt.allocPrint( + alloc, + "{s} ssh://{s}{s}{s}", + .{ cmd, user, try host.toRawMaybeAlloc(alloc), port }, + ); + }, + .file => command: { + const cmd = self.config.uri_handlers.getCommand(.file) orelse { + log.warn("no command is configured for file, ignoring", .{}); + return; + }; + const path = try parsed.path.toRawMaybeAlloc(alloc); + if (path.len == 0) { + log.warn("zero length path", .{}); + } + break :command try std.fmt.allocPrint( + alloc, + "{s} {s}", + .{ cmd, path }, + ); + }, + }, + }; + + if (location == .silent) { + try internal_os.run(self.alloc, command); + return; + } + + if (@hasDecl(apprt.runtime.Surface, "runInternalCommand")) { + switch (location) { + // these should have been handled above + .disabled, .open, .silent => unreachable, + .window => try self.rt_surface.runInternalCommand(.window, command), + .tab => try self.rt_surface.runInternalCommand(.tab, command), + .split_right => try self.rt_surface.runInternalCommand(.split_right, command), + .split_down => try self.rt_surface.runInternalCommand(.split_down, command), + } + } else { + log.warn("runtime does not support running internal commands", .{}); + } +} + pub fn mousePressureCallback( self: *Surface, stage: input.MousePressureStage, @@ -3666,21 +3757,28 @@ fn writeScreenFile( self.alloc, path, ), .unlocked), - .edit_window => { - if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) - try self.rt_surface.openEditorWithPath(.window, path); - }, - .edit_tab => { - if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) - try self.rt_surface.openEditorWithPath(.tab, path); - }, - .edit_split_right => { - if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) - try self.rt_surface.openEditorWithPath(.split_right, path); - }, - .edit_split_down => { - if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) - try self.rt_surface.openEditorWithPath(.split_down, path); + else => { + const editor = try internal_os.getEditor(self.alloc, self.config.editor); + defer self.alloc.free(editor); + const command = try std.fmt.allocPrint( + self.alloc, + "{s} {s}", + .{ editor, path }, + ); + defer self.alloc.free(command); + switch (write_action) { + .open, .paste => unreachable, + .silent => try internal_os.run(self.alloc, command), + else => if (@hasDecl(apprt.runtime.Surface, "runInternalCommand")) switch (write_action) { + .open, .paste, .silent => unreachable, + .edit_window => try self.rt_surface.runInternalCommand(.window, command), + .edit_tab => try self.rt_surface.runInternalCommand(.tab, command), + .edit_split_right => try self.rt_surface.runInternalCommand(.split_right, command), + .edit_split_down => try self.rt_surface.runInternalCommand(.split_down, command), + } else { + log.warn("runtime does not support running internal commands", .{}); + }, + } }, } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index f636b71ea..e02000124 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1783,7 +1783,7 @@ pub const CAPI = struct { /// Request that the surface split in the given direction. export fn ghostty_surface_split(ptr: *Surface, direction: apprt.SplitDirection) void { - ptr.newSplit(direction) catch {}; + ptr.newSplit(direction, .{}) catch {}; } /// Focus on the next split (if any). diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 482d16e01..1248e6a0b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2018,7 +2018,8 @@ fn translateMods(state: c.GdkModifierType) input.Mods { return mods; } -pub fn openEditorWithPath( +/// Run the specified command in a new window, tab or a split. +pub fn runInternalCommand( self: *Surface, location: enum { window, @@ -2026,23 +2027,23 @@ pub fn openEditorWithPath( split_right, split_down, }, - path: []const u8, + command: []const u8, ) !void { - const alloc = self.app.core_app.alloc; - const config = try alloc.create(configpkg.Config); - config.* = try self.app.config.clone(alloc); + const gpa_alloc = self.app.core_app.alloc; - const editor = try internal_os.getEditor(alloc, config); - defer alloc.free(editor); + const config = try gpa_alloc.create(configpkg.Config); + config.* = try self.app.config.clone(gpa_alloc); + errdefer config.deinit(); - config.command = try std.fmt.allocPrint( - config._arena.?.allocator(), - "{s} {s}", - .{ editor, path }, - ); + const arena_alloc = config._arena.?.allocator(); + + config.command = try arena_alloc.dupe(u8, command); switch (location) { - .window => try self.app.newWindow(.{ .parent = &self.core_surface, .config = config }), + .window => try self.app.newWindow(.{ + .parent = &self.core_surface, + .config = config, + }), .tab => try self.newTab(.{ .config = config }), .split_right => try self.newSplit(.right, .{ .config = config }), .split_down => try self.newSplit(.down, .{ .config = config }), diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b6fe9b0dc..0ff0a2163 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { - @setEvalBranchQuota(16000); + @setEvalBranchQuota(17000); var counter = std.io.countingWriter(std.io.null_writer); try writeFishCompletions(&counter.writer()); diff --git a/src/config.zig b/src/config.zig index 3be645cc3..275b3faa3 100644 --- a/src/config.zig +++ b/src/config.zig @@ -24,6 +24,7 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const RepeatableURIHandler = Config.RepeatableURIHandler; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 10dd454cc..03bc33b69 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -460,24 +460,107 @@ palette: Palette = .{}, command: ?[]const u8 = null, /// A command to use to open/edit text files in a terminal window (using -/// something like Helix, Flow, Vim, NeoVim, Emacs, or Nano). If this is not set, -/// Ghostty will check the `EDITOR` environment variable for the command. If -/// the `EDITOR` environment variable is not set, Ghostty will fall back to `vi` -/// (similar to how many Linux/Unix systems operate). -/// -/// This command will be used to open/edit files when Ghostty receives a signal -/// from the operating system to open a file. Currently implemented on the GTK -/// runtime only. +/// something like Helix, Flow, Vim, NeoVim, Emacs, or Nano). If this is not +/// set, Ghostty will check the `EDITOR` environment variable for the command. +/// If the `EDITOR` environment variable is not set, Ghostty will fall back to +/// `vi` (similar to how many Linux/Unix systems operate). /// /// The command may contain additional arguments besides the path to the /// editor's binary. The files that are to be opened will be added to the end of -/// the command. For example, if `editor` was set to `emacs -nx` and you tried +/// the command. For example, if `editor` was set to `emacs -nw` and you tried /// to open `README` and `hello.c` in your home directory, the final command -/// would look like +/// would look like: /// -/// emacs -nx /home/user/README /home/user/hello.c +/// emacs -nw /home/user/README /home/user/hello.c editor: ?[]const u8 = null, +/// This setting controls how Ghostty handles clicking on a URI. Ghostty +/// detects URIs in the terminal in two ways. First by using a regular +/// expression to scan the output (see the `link` configuration setting for more +/// information). Second, commands can use the OSC 8 protocol to tell Ghostty +/// about URIs (much like how is used in HTML). +/// +/// Entries look like: +/// +/// uri-handler = :: +/// +/// Entries may be repeated, but later entries will override earlier ones. The handlers +/// can be reset to the default with an empty entry. +/// +/// uri-handler = +/// +/// The default is: +/// +/// uri-handler = http:open: +/// uri-handler = ssh:open: +/// uri-handler = file:open: +/// +/// The following schemes are supported: +/// +/// * `http` - This is also used for `https` URIs. +/// * `ssh` +/// * `file` +/// +/// The following location are supported: +/// +/// * `open` - Use an OS-provided utility to launch the OS's default handler +/// for that URI. On macOS the `open` command is used. On Linux the +/// `xdg-open` command is used. On Windows the `url.dll` library is used. +/// The `command` part of the configuration entry is ignored. +/// * `disabled` - Clicks on URIs with this scheme will be ignored. +/// * `silent` - The command will be run "in the background" without opening +/// a new window, tab, or split. Ths could be useful for running a command +/// that passes the URI to a long running command like an editor that has a +/// client-server model of operation. +/// * `window` - The command will open in a new window. (GTK only). +/// * `tab` - The command will open in a new tab. (GTK only). +/// * `split_right` - The focused surface will split horizontally and the +/// command will run in the right split. (GTK only). +/// * `split_down` - The focused surface will split vertically and the command +/// will run in the lower split. (GTK only). +/// +/// Clicking on unparsable (by Zig's URI parser) URIs will be silently ignored. +/// +/// For `ssh` URIs, the command that is actually run is constructed by parsing +/// the URI and then building a command that looks like this: +/// +/// ssh://(@)(: +/// +/// Note that `file` URIs cannot be used to access files located on a remote +/// system as `file` URIs do not include hostname information. +@"uri-handler": RepeatableURIHandler = .{}, + +/// Match a regular expression against the terminal text and associate clicking +/// it with an action. This can be used to match URLs, file paths, etc. Actions +/// can be "opened" using commands specified by the `uri-handlers` or by +/// executing any arbitrary binding action. +/// +/// Links that are configured earlier take precedence over links that are +/// configured later. +/// +/// A default link that matches a URL and opens it in the system opener always +/// exists. This can be disabled using `link-url`. +/// +/// TODO: This can't currently be set! +link: RepeatableLink = .{}, + +/// Enable URL matching. URLs are matched on hover with control (Linux) or +/// super (macOS) pressed and open using the default system application for +/// the linked URL. +/// +/// The URL matcher is always lowest priority of any configured links (see +/// `link`). If you want to customize URL matching, use `link` and disable this. +@"link-url": bool = true, + /// If true, keep the terminal open after the command exits. Normally, the /// terminal window closes when the running command (such as a shell) exits. /// With this true, the terminal window will stay open until any keypress is @@ -516,28 +599,6 @@ editor: ?[]const u8 = null, /// This can be changed at runtime but will only affect new terminal surfaces. @"scrollback-limit": u32 = 10_000_000, // 10MB -/// Match a regular expression against the terminal text and associate clicking -/// it with an action. This can be used to match URLs, file paths, etc. Actions -/// can be opening using the system opener (i.e. `open` or `xdg-open`) or -/// executing any arbitrary binding action. -/// -/// Links that are configured earlier take precedence over links that are -/// configured later. -/// -/// A default link that matches a URL and opens it in the system opener always -/// exists. This can be disabled using `link-url`. -/// -/// TODO: This can't currently be set! -link: RepeatableLink = .{}, - -/// Enable URL matching. URLs are matched on hover with control (Linux) or -/// super (macOS) pressed and open using the default system application for -/// the linked URL. -/// -/// The URL matcher is always lowest priority of any configured links (see -/// `link`). If you want to customize URL matching, use `link` and disable this. -@"link-url": bool = true, - /// Start new windows in fullscreen. This setting applies to new windows and /// does not apply to tabs, splits, etc. However, this setting will apply to all /// new windows, not just the first one. @@ -4346,3 +4407,188 @@ test "test entryFormatter" { try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.items); } + +/// See `uri-handler` documentation +pub const RepeatableURIHandler = struct { + const Self = @This(); + + const Scheme = enum { + // `http` handles both `http` and `https` + http, + ssh, + file, + }; + + const Location = enum { + /// Use OS utilities to launch default handler for a URL. + open, + /// Clicks on URLs with this scheme will be ignored. + disabled, + /// Commands will be run silently in the background. + silent, + /// A new window will be opened to run the command. + window, + /// A new tab will be opened to run the command. + tab, + /// The focused surface will be split right to run the command. + split_right, + /// The focused surface will be split down to run the command. + split_down, + }; + + const Entry = struct { + location: Location = .open, + command: ?[]const u8 = null, + }; + + const count = @typeInfo(Scheme).Enum.fields.len; + + entries: [count]Entry = [_]Entry{.{}} ** count, + + /// Return an enum if this is a scheme that we support, otherwise null. + /// `https` is converted to `http` + pub fn getSupportedScheme(_: *Self, scheme: []const u8) ?Scheme { + if (std.mem.eql(u8, "https", scheme)) return .http; + return std.meta.stringToEnum(Scheme, scheme); + } + + pub fn getLocation(self: *Self, scheme: Scheme) Location { + const index = @intFromEnum(scheme); + return self.entries[index].location; + } + + pub fn getCommand(self: *Self, scheme: Scheme) ?[]const u8 { + const index = @intFromEnum(scheme); + return self.entries[index].command; + } + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list + if (value.len == 0) { + self.entries = [_]Entry{.{}} ** count; + return; + } + + var it = std.mem.splitScalar(u8, value, ':'); + + const scheme = std.meta.stringToEnum(Scheme, it.next() orelse return error.ValueRequired) orelse return error.ValueRequired; + const location = std.meta.stringToEnum(Location, it.next() orelse return error.ValueRequired) orelse return error.ValueRequired; + const command = command: { + const command = it.rest(); + if (command.len == 0) break :command null; + break :command try alloc.dupe(u8, command); + }; + + const index = @intFromEnum(scheme); + self.entries[index] = .{ + .location = if (command == null) .open else location, + .command = command, + }; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) !Self { + var new: Self = .{}; + + for (self.entries, 0..) |entry, index| { + if (entry.command) |command| { + new.entries[index] = .{ + .location = entry.location, + .command = try alloc.dupe(u8, command), + }; + } + } + + return new; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + for (self.entries, other.entries) |a, b| { + if (a.location != b.location) return false; + if (a.command == null and b.command == null) continue; + if (a.command == null) return false; + if (b.command == null) return false; + if (!std.mem.eql(u8, a.command.?, b.command.?)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: anytype) !void { + for (self.entries, 0..) |value, index| { + const scheme: Scheme = @enumFromInt(index); + // this "should" be enough as most operating systems have limits on + // the length of commands that they will execute anyway + var buf: [512]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + writer.print("{s}:{s}:{s}", .{ + @tagName(scheme), + @tagName(value.location), + if (value.command) |command| command else "", + }) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{} ** count; + + try list.parseCLI(alloc, "ssh:window:ssh"); + try list.parseCLI(alloc, "file:split_right:hx"); + + try testing.expectEqual(@as(Location, .open), list.entries[@intFromEnum(@as(Scheme, .http))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .http))].command == null); + + try testing.expectEqual(@as(Location, .window), list.entries[@intFromEnum(@as(Scheme, .ssh))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .ssh))].command != null); + try testing.expectEqualStrings("ssh", list.entries[@intFromEnum(@as(Scheme, .ssh))].command.?); + + try testing.expectEqual(@as(Location, .split_right), list.entries[@intFromEnum(@as(Scheme, .file))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .file))].command != null); + try testing.expectEqualStrings("hx", list.entries[@intFromEnum(@as(Scheme, .file))].command.?); + + try list.parseCLI(alloc, ""); + + try testing.expectEqual(@as(Location, .open), list.entries[@intFromEnum(@as(Scheme, .http))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .http))].command == null); + + try testing.expectEqual(@as(Location, .open), list.entries[@intFromEnum(@as(Scheme, .ssh))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .ssh))].command == null); + + try testing.expectEqual(@as(Location, .open), list.entries[@intFromEnum(@as(Scheme, .file))].location); + try testing.expect(list.entries[@intFromEnum(@as(Scheme, .file))].command == null); + } + + test "formatEntry 1" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: Self = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = http:open:\na = ssh:open:\na = file:open:\n", buf.items); + } + + test "formatEntry 2" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "ssh:window:ssh"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = http:open:\na = ssh:window:ssh\na = file:open:\n", buf.items); + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 48e54b8fb..e39611bdc 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -232,13 +232,23 @@ pub const Action = union(enum) { /// /// - `paste`: Paste the file path into the terminal. /// - `open`: Open the file in the default OS editor for text files. - /// - `edit_window`: Create a new window, and open the file in your editor. - /// - `edit_tab`: Create a new tab, and open the file in your editor. - /// - `edit_split_right`: Create a new split right, and open the file in your editor. - /// - `edit_split_down`: Create a new split down, and open the file in your editor. - /// - /// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are - /// supported on GTK only. + /// - `silent`: Run a command "in the background" to process the file. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. + /// - `edit_window`: Create a new window, and open the file in your + /// editor. The command is constructed using the `editor` configuration + /// setting or your `EDITOR` environment variable. (GTK only.) + /// - `edit_tab`: Create a new tab, and open the file in your editor. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. (GTK only.) + /// - `edit_split_right`: Create a new split right, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) + /// - `edit_split_down`: Create a new split down, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) /// /// See the configuration setting `editor` for more information on how /// Ghostty determines your editor. @@ -249,13 +259,23 @@ pub const Action = union(enum) { /// /// - `paste`: Paste the file path into the terminal. /// - `open`: Open the file in the default OS editor for text files. - /// - `edit_window`: Create a new window, and open the file in your editor. - /// - `edit_tab`: Create a new tab, and open the file in your editor. - /// - `edit_split_right`: Create a new split right, and open the file in your editor. - /// - `edit_split_down`: Create a new split down, and open the file in your editor. - /// - /// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are - /// supported on GTK only. + /// - `silent`: Run a command "in the background" to process the file. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. + /// - `edit_window`: Create a new window, and open the file in your + /// editor. The command is constructed using the `editor` configuration + /// setting or your `EDITOR` environment variable. (GTK only.) + /// - `edit_tab`: Create a new tab, and open the file in your editor. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. (GTK only.) + /// - `edit_split_right`: Create a new split right, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) + /// - `edit_split_down`: Create a new split down, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) /// /// See the configuration setting `editor` for more information on how /// Ghostty determines your editor. @@ -267,13 +287,23 @@ pub const Action = union(enum) { /// /// - `paste`: Paste the file path into the terminal. /// - `open`: Open the file in the default OS editor for text files. - /// - `edit_window`: Create a new window, and open the file in your editor. - /// - `edit_tab`: Create a new tab, and open the file in your editor. - /// - `edit_split_right`: Create a new split right, and open the file in your editor. - /// - `edit_split_down`: Create a new split down, and open the file in your editor. - /// - /// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are - /// supported on GTK only. + /// - `silent`: Run a command "in the background" to process the file. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. + /// - `edit_window`: Create a new window, and open the file in your + /// editor. The command is constructed using the `editor` configuration + /// setting or your `EDITOR` environment variable. (GTK only.) + /// - `edit_tab`: Create a new tab, and open the file in your editor. The + /// command is constructed using the `editor` configuration setting or + /// your `EDITOR` environment variable. (GTK only.) + /// - `edit_split_right`: Create a new split right, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) + /// - `edit_split_down`: Create a new split down, and open the file + /// in your editor. The command is constructed using the `editor` + /// configuration setting or your `EDITOR` environment variable. (GTK + /// only.) /// /// See the configuration setting `editor` for more information on how /// Ghostty determines your editor. @@ -401,6 +431,7 @@ pub const Action = union(enum) { pub const WriteScreenAction = enum { paste, open, + silent, edit_window, edit_tab, edit_split_right, diff --git a/src/input/Link.zig b/src/input/Link.zig index adc52a270..78e56f167 100644 --- a/src/input/Link.zig +++ b/src/input/Link.zig @@ -20,8 +20,8 @@ action: Action, highlight: Highlight, pub const Action = union(enum) { - /// Open the full matched value using the default open program. - /// For example, on macOS this is "open" and on Linux this is "xdg-open". + /// Use the `uri-handler` configuation setting to handle clicking on the + /// link. open: void, /// Open the OSC8 hyperlink under the mouse position. _-prefixed means diff --git a/src/os/editor.zig b/src/os/editor.zig index aed1807d8..c985f45e1 100644 --- a/src/os/editor.zig +++ b/src/os/editor.zig @@ -1,17 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); -const Config = @import("../config.zig").Config; -pub fn getEditor(alloc: std.mem.Allocator, config: *const Config) ![]const u8 { +pub fn getEditor(alloc: std.mem.Allocator, editor: ?[]const u8) ![]const u8 { // figure out what our editor is - if (config.editor) |editor| return try alloc.dupe(u8, editor); + if (editor) |e| return try alloc.dupe(u8, e); switch (builtin.os.tag) { .windows => { if (std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("EDITOR"))) |win_editor| { return try std.unicode.utf16leToUtf8Alloc(alloc, win_editor); } }, - else => if (std.posix.getenv("EDITOR")) |editor| return alloc.dupe(u8, editor), + else => if (std.posix.getenv("EDITOR")) |e| return alloc.dupe(u8, e), } return alloc.dupe(u8, "vi"); } diff --git a/src/os/main.zig b/src/os/main.zig index 8d6557b68..7830d375b 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -40,6 +40,7 @@ pub const ensureLocale = locale.ensureLocale; pub const macosVersionAtLeast = macos_version.macosVersionAtLeast; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; +pub const run = openpkg.run; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; pub const getEditor = editor.getEditor; diff --git a/src/os/open.zig b/src/os/open.zig index 8df059487..a81a36e81 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -1,23 +1,9 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const isFlatpak = @import("flatpak.zig").isFlatpak; -/// Open a URL in the default handling application. -/// -/// Any output on stderr is logged as a warning in the application logs. -/// Output on stdout is ignored. -pub fn open(alloc: Allocator, url: []const u8) !void { - // Some opener commands terminate after opening (macOS open) and some do not - // (xdg-open). For those which do not terminate, we do not want to wait for - // the process to exit to collect stderr. - const argv, const wait = switch (builtin.os.tag) { - .linux => .{ &.{ "xdg-open", url }, false }, - .macos => .{ &.{ "open", url }, true }, - .windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false }, - .ios => return error.Unimplemented, - else => @compileError("unsupported OS"), - }; - +fn execute(alloc: Allocator, argv: []const []const u8, comptime wait: bool) !void { var exe = std.process.Child.init(argv, alloc); if (comptime wait) { @@ -47,3 +33,67 @@ pub fn open(alloc: Allocator, url: []const u8) !void { if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items}); } } + +/// Run a command using the system shell to simplify parsing the command line +pub fn run(gpa_alloc: Allocator, cmd: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(gpa_alloc); + const arena_alloc = arena.allocator(); + defer arena.deinit(); + + const argv, const wait = switch (builtin.os.tag) { + .ios => return error.Unimplemented, + .windows => result: { + // We run our shell wrapped in `cmd.exe` so that we don't have + // to parse the command line ourselves if it has arguments. + + // Note we don't free any of the memory below since it is + // allocated in the arena. + var list = std.ArrayList([]const u8).init(arena_alloc); + const windir = try std.process.getEnvVarOwned(arena_alloc, "WINDIR"); + const shell = try std.fs.path.join(arena_alloc, &[_][]const u8{ + windir, + "System32", + "cmd.exe", + }); + + try list.append(shell); + try list.append("/C"); + try list.append(cmd); + break :result .{ try list.toOwnedSlice(), false }; + }, + else => result: { + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + var list = std.ArrayList([]const u8).init(arena_alloc); + try list.append("/bin/sh"); + if (isFlatpak()) try list.append("-l"); + try list.append("-c"); + try list.append(cmd); + break :result .{ try list.toOwnedSlice(), true }; + }, + }; + + try execute(gpa_alloc, argv, wait); +} + +/// Open a URL in the default handling application. +/// +/// Any output on stderr is logged as a warning in the application logs. +/// Output on stdout is ignored. +pub fn open(gpa_alloc: Allocator, url: []const u8) !void { + // Some opener commands terminate after opening (macOS open) and some do not + // (xdg-open). For those which do not terminate, we do not want to wait for + // the process to exit to collect stderr. + const argv, const wait = switch (builtin.os.tag) { + .linux => .{ &.{ "xdg-open", url }, false }, + .macos => .{ &.{ "open", url }, true }, + .windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false }, + .ios => return error.Unimplemented, + else => @compileError("unsupported OS"), + }; + + try execute(gpa_alloc, argv, wait); +}