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.
This commit is contained in:
Jeffrey C. Ollie
2024-08-18 00:22:20 -05:00
parent 7b400c8367
commit 22935c5034
11 changed files with 535 additions and 108 deletions

View File

@ -224,6 +224,8 @@ const DerivedConfig = struct {
window_padding_balance: bool, window_padding_balance: bool,
title: ?[:0]const u8, title: ?[:0]const u8,
links: []Link, links: []Link,
editor: ?[]const u8,
uri_handlers: configpkg.RepeatableURIHandler,
const Link = struct { const Link = struct {
regex: oni.Regex, regex: oni.Regex,
@ -284,6 +286,8 @@ const DerivedConfig = struct {
.window_padding_balance = config.@"window-padding-balance", .window_padding_balance = config.@"window-padding-balance",
.title = config.title, .title = config.title,
.links = links, .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 // Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above. // so that the memory is captured from allocs above.
@ -2611,7 +2615,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false, .trim = false,
}); });
defer self.alloc.free(str); defer self.alloc.free(str);
try internal_os.open(self.alloc, str); try self.handleURI(str);
}, },
._open_osc8 => { ._open_osc8 => {
@ -2619,7 +2623,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
log.warn("failed to get URI for OSC8 hyperlink", .{}); log.warn("failed to get URI for OSC8 hyperlink", .{});
return false; 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]; 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( pub fn mousePressureCallback(
self: *Surface, self: *Surface,
stage: input.MousePressureStage, stage: input.MousePressureStage,
@ -3666,21 +3757,28 @@ fn writeScreenFile(
self.alloc, self.alloc,
path, path,
), .unlocked), ), .unlocked),
.edit_window => { else => {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) const editor = try internal_os.getEditor(self.alloc, self.config.editor);
try self.rt_surface.openEditorWithPath(.window, path); defer self.alloc.free(editor);
}, const command = try std.fmt.allocPrint(
.edit_tab => { self.alloc,
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) "{s} {s}",
try self.rt_surface.openEditorWithPath(.tab, path); .{ editor, path },
}, );
.edit_split_right => { defer self.alloc.free(command);
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) switch (write_action) {
try self.rt_surface.openEditorWithPath(.split_right, path); .open, .paste => unreachable,
}, .silent => try internal_os.run(self.alloc, command),
.edit_split_down => { else => if (@hasDecl(apprt.runtime.Surface, "runInternalCommand")) switch (write_action) {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath")) .open, .paste, .silent => unreachable,
try self.rt_surface.openEditorWithPath(.split_down, path); .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", .{});
},
}
}, },
} }
} }

View File

@ -1783,7 +1783,7 @@ pub const CAPI = struct {
/// Request that the surface split in the given direction. /// Request that the surface split in the given direction.
export fn ghostty_surface_split(ptr: *Surface, direction: apprt.SplitDirection) void { 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). /// Focus on the next split (if any).

View File

@ -2018,7 +2018,8 @@ fn translateMods(state: c.GdkModifierType) input.Mods {
return mods; return mods;
} }
pub fn openEditorWithPath( /// Run the specified command in a new window, tab or a split.
pub fn runInternalCommand(
self: *Surface, self: *Surface,
location: enum { location: enum {
window, window,
@ -2026,23 +2027,23 @@ pub fn openEditorWithPath(
split_right, split_right,
split_down, split_down,
}, },
path: []const u8, command: []const u8,
) !void { ) !void {
const alloc = self.app.core_app.alloc; const gpa_alloc = self.app.core_app.alloc;
const config = try alloc.create(configpkg.Config);
config.* = try self.app.config.clone(alloc);
const editor = try internal_os.getEditor(alloc, config); const config = try gpa_alloc.create(configpkg.Config);
defer alloc.free(editor); config.* = try self.app.config.clone(gpa_alloc);
errdefer config.deinit();
config.command = try std.fmt.allocPrint( const arena_alloc = config._arena.?.allocator();
config._arena.?.allocator(),
"{s} {s}", config.command = try arena_alloc.dupe(u8, command);
.{ editor, path },
);
switch (location) { 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 }), .tab => try self.newTab(.{ .config = config }),
.split_right => try self.newSplit(.right, .{ .config = config }), .split_right => try self.newSplit(.right, .{ .config = config }),
.split_down => try self.newSplit(.down, .{ .config = config }), .split_down => try self.newSplit(.down, .{ .config = config }),

View File

@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions();
fn comptimeGenerateFishCompletions() []const u8 { fn comptimeGenerateFishCompletions() []const u8 {
comptime { comptime {
@setEvalBranchQuota(16000); @setEvalBranchQuota(17000);
var counter = std.io.countingWriter(std.io.null_writer); var counter = std.io.countingWriter(std.io.null_writer);
try writeFishCompletions(&counter.writer()); try writeFishCompletions(&counter.writer());

View File

@ -24,6 +24,7 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation;
pub const RepeatableString = Config.RepeatableString; pub const RepeatableString = Config.RepeatableString;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
pub const WindowPaddingColor = Config.WindowPaddingColor; pub const WindowPaddingColor = Config.WindowPaddingColor;
pub const RepeatableURIHandler = Config.RepeatableURIHandler;
// Alternate APIs // Alternate APIs
pub const CAPI = @import("config/CAPI.zig"); pub const CAPI = @import("config/CAPI.zig");

View File

@ -460,24 +460,107 @@ palette: Palette = .{},
command: ?[]const u8 = null, command: ?[]const u8 = null,
/// A command to use to open/edit text files in a terminal window (using /// 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, /// something like Helix, Flow, Vim, NeoVim, Emacs, or Nano). If this is not
/// Ghostty will check the `EDITOR` environment variable for the command. If /// set, Ghostty will check the `EDITOR` environment variable for the command.
/// the `EDITOR` environment variable is not set, Ghostty will fall back to `vi` /// If the `EDITOR` environment variable is not set, Ghostty will fall back to
/// (similar to how many Linux/Unix systems operate). /// `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.
/// ///
/// The command may contain additional arguments besides the path to the /// 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 /// 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 /// 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, 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 <a> is used in HTML).
///
/// Entries look like:
///
/// uri-handler = <scheme>:<location>:<command>
///
/// 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:
///
/// <command> ssh://(<user>@)<host>(:<port)
///
/// The `user` and `port` parts will be left off if they were not in the
/// original URI. If the URI does not have a host clicking on the URI will be
/// silently ignored.
///
/// For `file` URIs, the `path` part of the URI is parsed out of the URI and a
/// command that looks like this is constructed:
///
/// <command> <path>
///
/// 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 /// If true, keep the terminal open after the command exits. Normally, the
/// terminal window closes when the running command (such as a shell) exits. /// 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 /// 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. /// This can be changed at runtime but will only affect new terminal surfaces.
@"scrollback-limit": u32 = 10_000_000, // 10MB @"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 /// 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 /// does not apply to tabs, splits, etc. However, this setting will apply to all
/// new windows, not just the first one. /// new windows, not just the first one.
@ -4346,3 +4407,188 @@ test "test entryFormatter" {
try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); 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); 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);
}
};

View File

@ -232,13 +232,23 @@ pub const Action = union(enum) {
/// ///
/// - `paste`: Paste the file path into the terminal. /// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files. /// - `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. /// - `silent`: Run a command "in the background" to process the file. The
/// - `edit_tab`: Create a new tab, and open the file in your editor. /// command is constructed using the `editor` configuration setting or
/// - `edit_split_right`: Create a new split right, and open the file in your editor. /// your `EDITOR` environment variable.
/// - `edit_split_down`: Create a new split down, and open the file in your editor. /// - `edit_window`: Create a new window, and open the file in your
/// /// editor. The command is constructed using the `editor` configuration
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are /// setting or your `EDITOR` environment variable. (GTK only.)
/// supported on 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 /// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor. /// Ghostty determines your editor.
@ -249,13 +259,23 @@ pub const Action = union(enum) {
/// ///
/// - `paste`: Paste the file path into the terminal. /// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files. /// - `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. /// - `silent`: Run a command "in the background" to process the file. The
/// - `edit_tab`: Create a new tab, and open the file in your editor. /// command is constructed using the `editor` configuration setting or
/// - `edit_split_right`: Create a new split right, and open the file in your editor. /// your `EDITOR` environment variable.
/// - `edit_split_down`: Create a new split down, and open the file in your editor. /// - `edit_window`: Create a new window, and open the file in your
/// /// editor. The command is constructed using the `editor` configuration
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are /// setting or your `EDITOR` environment variable. (GTK only.)
/// supported on 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 /// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor. /// Ghostty determines your editor.
@ -267,13 +287,23 @@ pub const Action = union(enum) {
/// ///
/// - `paste`: Paste the file path into the terminal. /// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files. /// - `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. /// - `silent`: Run a command "in the background" to process the file. The
/// - `edit_tab`: Create a new tab, and open the file in your editor. /// command is constructed using the `editor` configuration setting or
/// - `edit_split_right`: Create a new split right, and open the file in your editor. /// your `EDITOR` environment variable.
/// - `edit_split_down`: Create a new split down, and open the file in your editor. /// - `edit_window`: Create a new window, and open the file in your
/// /// editor. The command is constructed using the `editor` configuration
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are /// setting or your `EDITOR` environment variable. (GTK only.)
/// supported on 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 /// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor. /// Ghostty determines your editor.
@ -401,6 +431,7 @@ pub const Action = union(enum) {
pub const WriteScreenAction = enum { pub const WriteScreenAction = enum {
paste, paste,
open, open,
silent,
edit_window, edit_window,
edit_tab, edit_tab,
edit_split_right, edit_split_right,

View File

@ -20,8 +20,8 @@ action: Action,
highlight: Highlight, highlight: Highlight,
pub const Action = union(enum) { pub const Action = union(enum) {
/// Open the full matched value using the default open program. /// Use the `uri-handler` configuation setting to handle clicking on the
/// For example, on macOS this is "open" and on Linux this is "xdg-open". /// link.
open: void, open: void,
/// Open the OSC8 hyperlink under the mouse position. _-prefixed means /// Open the OSC8 hyperlink under the mouse position. _-prefixed means

View File

@ -1,17 +1,16 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); 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 // 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) { switch (builtin.os.tag) {
.windows => { .windows => {
if (std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("EDITOR"))) |win_editor| { if (std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("EDITOR"))) |win_editor| {
return try std.unicode.utf16leToUtf8Alloc(alloc, 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"); return alloc.dupe(u8, "vi");
} }

View File

@ -40,6 +40,7 @@ pub const ensureLocale = locale.ensureLocale;
pub const macosVersionAtLeast = macos_version.macosVersionAtLeast; pub const macosVersionAtLeast = macos_version.macosVersionAtLeast;
pub const clickInterval = mouse.clickInterval; pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open; pub const open = openpkg.open;
pub const run = openpkg.run;
pub const pipe = pipepkg.pipe; pub const pipe = pipepkg.pipe;
pub const resourcesDir = resourcesdir.resourcesDir; pub const resourcesDir = resourcesdir.resourcesDir;
pub const getEditor = editor.getEditor; pub const getEditor = editor.getEditor;

View File

@ -1,23 +1,9 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const isFlatpak = @import("flatpak.zig").isFlatpak;
/// Open a URL in the default handling application. fn execute(alloc: Allocator, argv: []const []const u8, comptime wait: bool) !void {
///
/// 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"),
};
var exe = std.process.Child.init(argv, alloc); var exe = std.process.Child.init(argv, alloc);
if (comptime wait) { 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}); 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);
}