diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..c12ed318c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1054,6 +1054,7 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83..198f1d4c1 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -455,7 +455,7 @@ pub const Action = union(Key) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 16, + 4 => 24, 8 => 24, else => unreachable, }); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7310159cc..257581cc6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -336,6 +336,7 @@ pub const App = struct { ) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { switch (action) { .new_window => return false, + .new_tab => return false, .toggle_quick_terminal => return false, } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 8a7b3a8e5..9fbea7c1e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; +const ipcNewTab = @import("ipc/new_tab.zig").newTab; const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal; const log = std.log.scoped(.gtk); @@ -85,6 +86,7 @@ pub fn performIpc( ) !bool { switch (action) { .new_window => return try ipcNewWindow(alloc, target, value), + .new_tab => return try ipcNewTab(alloc, target, value), .toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target), } } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 107510b43..2eff084a7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -708,7 +708,7 @@ pub const Application = extern struct { .new_split => return Action.newSplit(target, value), - .new_tab => return Action.newTab(target), + .new_tab => return Action.newTab(target, .none), .new_window => try Action.newWindow( self, @@ -1412,9 +1412,13 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + const actions = [_]ext.actions.Action(Self){ .init("new-window", actionNewWindow, null), .init("new-window-command", actionNewWindow, as_variant_type), + .init("new-tab", actionNewTab, tas_variant_type), .init("open-config", actionOpenConfig, null), .init("present-surface", actionPresentSurface, t_variant_type), .init("quit", actionQuit, null), @@ -1700,93 +1704,207 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + var arena: std.heap.ArenaAllocator = .init(self.core().alloc); defer arena.deinit(); const alloc = arena.allocator(); - var working_directory: ?[:0]const u8 = null; - var title: ?[:0]const u8 = null; - var command: ?configpkg.Command = null; - var args: std.ArrayList([:0]const u8) = .empty; - - overrides: { + const overrides = overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :overrides; + const arguments = parameter_ orelse break :overrides null; const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); // ensure that the supplied parameter is an array of strings - if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { + if (glib.Variant.isOfType(arguments, as_variant_type) == 0) { log.warn("parameter is of type '{s}', not '{s}'", .{ - parameter.getTypeString(), + arguments.getTypeString(), as_variant_type.peekString()[0..as_variant_type.getStringLength()], }); - break :overrides; + break :overrides null; } - const s_variant_type = glib.VariantType.new("s"); - defer s_variant_type.free(); + var arguments_it: glib.VariantIter = undefined; + _ = arguments_it.init(arguments); - var it: glib.VariantIter = undefined; - _ = it.init(parameter); + break :overrides parseOverrides(alloc, &arguments_it) catch null; + }; - var e_seen: bool = false; - var i: usize = 0; + Action.newWindow( + self, + null, + if (overrides) |o| .{ + .command = o.command, + .working_directory = o.working_directory, + .title = o.title, + } else .none, + ) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; + } - while (it.nextValue()) |value| : (i += 1) { - defer value.unref(); + /// Handle `app.new-tab` GTK action + pub fn actionNewTab( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + log.debug("received new tab action", .{}); - // just to be sure - if (value.isOfType(s_variant_type) == 0) continue; + const core_app = self.core(); - var len: usize = undefined; - const buf = value.getString(&len); - const str = buf[0..len]; + var arena: std.heap.ArenaAllocator = .init(core_app.alloc); + defer arena.deinit(); - log.debug("new-window argument: {d} {s}", .{ i, str }); + const alloc = arena.allocator(); - if (e_seen) { - const duplicated = alloc.dupeZ(u8, str) catch |err| { - log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; - args.append(alloc, duplicated) catch |err| { - log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; - continue; - } + const surface_id_, const overrides = result: { + // were we given a parameter? + const parameter = parameter_ orelse { + log.warn("app.new-tab did not receive a parameter", .{}); + return; + }; - if (std.mem.eql(u8, str, "-e")) { - e_seen = true; - continue; - } + log.warn("got parameter: {s}", .{parameter.getTypeString()}); - if (lib.cutPrefix(u8, str, "--command=")) |v| { - var cmd: configpkg.Command = undefined; - cmd.parseCLI(alloc, v) catch |err| { - log.warn("unable to parse command: {t}", .{err}); - continue; - }; - command = cmd; - continue; - } - if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { - working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { - log.warn("unable to duplicate working directory: {t}", .{err}); - break :wd null; - }; - continue; - } - if (lib.cutPrefix(u8, str, "--title=")) |v| { - title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { - log.warn("unable to duplicate title: {t}", .{err}); - break :t null; - }; - continue; - } + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + // ensure that the supplied parameter is an array of strings + if (glib.Variant.isOfType(parameter, tas_variant_type) == 0) { + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + tas_variant_type.peekString()[0..tas_variant_type.getStringLength()], + }); + return; + } + + var surface_id: u64 = 0; + var arguments_it_: ?*glib.VariantIter = null; + defer if (arguments_it_) |arguments_it| arguments_it.free(); + + parameter.get("(tas)", &surface_id, &arguments_it_); + + const arguments_it = arguments_it_ orelse return; + + const overrides = parseOverrides(alloc, arguments_it) catch return; + + break :result .{ if (surface_id == 0) null else surface_id, overrides }; + }; + + const surface_ = surface: { + if (surface_id_) |surface_id| find_by_id: { + break :surface core_app.findSurfaceByID(surface_id) orelse { + log.warn("new-tab: unable to find surface 0x{x:0>16}", .{surface_id}); + break :find_by_id; + }; + } + find_focused: { + break :surface core_app.focusedSurface() orelse { + log.warn("new-tab: unable to find surface", .{}); + break :find_focused; + }; + } + break :surface null; + }; + + if (surface_) |surface| { + if (!Action.newTab( + .{ + .surface = surface, + }, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + )) { + log.warn("new-tab: unable to create tab", .{}); + } + } else { + Action.newWindow( + self, + null, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ) catch |err| { + log.warn("new-tab: unable to create new window: {t}", .{err}); + }; + } + } + + fn parseOverrides(arena_alloc: Allocator, arguments_it: *glib.VariantIter) (Allocator.Error || error{ValueRequired})!struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + } { + var args: std.ArrayList([:0]const u8) = .empty; + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var e_seen: bool = false; + var i: usize = 0; + + while (arguments_it.nextValue()) |value| : (i += 1) { + defer value.unref(); + + // just to be sure + if (value.isOfType(s_variant_type) == 0) continue; + + var len: usize = undefined; + const buf = value.getString(&len); + const str = buf[0..len]; + + log.debug("argument: {d} {s}", .{ i, str }); + + if (e_seen) { + const copy = arena_alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + args.append(arena_alloc, copy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (lib.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(arena_alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + return err; + }; + command = cmd; + continue; + } + if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { + working_directory = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate working directory: {t}", .{err}); + return err; + }; + continue; + } + if (lib.cutPrefix(u8, str, "--title=")) |v| { + title = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate title: {t}", .{err}); + return err; + }; + continue; } } @@ -1796,12 +1914,10 @@ pub const Application = extern struct { }; } - Action.newWindow(self, null, .{ + return .{ .command = command, .working_directory = working_directory, .title = title, - }) catch |err| { - log.warn("unable to create new window: {t}", .{err}); }; } @@ -2221,7 +2337,16 @@ const Action = struct { } } - pub fn newTab(target: apprt.Target) bool { + pub fn newTab( + target: apprt.Target, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) bool { switch (target) { .app => { log.warn("new tab to app is unexpected", .{}); @@ -2240,7 +2365,11 @@ const Action = struct { log.warn("surface is not in a window, ignoring new_tab", .{}); return false; }; - window.newTab(core); + window.newTab(core, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); return true; }, } diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 0c8dfaa7c..0de6f7964 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -387,8 +387,18 @@ pub const Window = extern struct { /// Create a new tab with the given parent. The tab will be inserted /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. - pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab, .none); + pub fn newTab(self: *Self, parent_: ?*CoreSurface, overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) void { + _ = self.newTabPage(parent_, .tab, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); } pub fn newTabForWindow( diff --git a/src/apprt/gtk/ipc/new_tab.zig b/src/apprt/gtk/ipc/new_tab.zig new file mode 100644 index 000000000..c5e538950 --- /dev/null +++ b/src/apprt/gtk/ipc/new_tab.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); + +// Use a D-Bus method call to open a new tab on GTK. +// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI +// +// `ghostty +new-tab` is equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, [])>]' [] +// ``` +// +// `ghostty +new-tab -e echo hello` would be equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, ["-e" "echo" "hello"])>]' [] +// ``` +pub fn newTab(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewTab) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { + var dbus = try DBus.init(alloc, target, "new-tab"); + defer dbus.deinit(alloc); + + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + var parameter: glib.VariantBuilder = undefined; + parameter.init(tas_variant_type); + errdefer parameter.clear(); + + { + // Add the target surface ID to the parameter. + const t = glib.Variant.newUint64(value.surface_id); + parameter.addValue(t); + } + + { + // If any arguments were specified on the command line, this value is an + // array of strings that contain the arguments. They will be sent to the + // main Ghostty instance and interpreted as CLI arguments. + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var command: glib.VariantBuilder = undefined; + command.init(as_variant_type); + errdefer command.clear(); + + if (value.arguments) |arguments| { + for (arguments) |argument| { + const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); + defer bytes.unref(); + const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + command.addValue(string); + } + } + + parameter.addValue(command.end()); + } + + dbus.addParameter(parameter.end()); + + try dbus.send(); + + return true; +} diff --git a/src/apprt/ipc.zig b/src/apprt/ipc.zig index dda794ae1..be11dca61 100644 --- a/src/apprt/ipc.zig +++ b/src/apprt/ipc.zig @@ -70,23 +70,82 @@ pub const Action = union(enum) { // entry. If the value type is void then only the key needs to be // added. Ensure the order matches exactly with the Zig code. - /// The arguments to pass to Ghostty as the command. + /// Create a new window. new_window: NewWindow, + /// Create a new tab. + new_tab: NewTab, + /// Toggle the quick terminal. toggle_quick_terminal: void, pub const NewWindow = struct { - /// A list of command arguments to launch in the new window. If this is - /// `null` the command configured in the config or the user's default - /// shell should be launched. + /// A list of configuration entries that will override the configuration + /// of the first surface of the new window. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. /// /// It is an error for this to be non-`null`, but zero length. arguments: ?[][:0]const u8, pub const C = extern struct { - /// null terminated list of arguments - /// it will be null itself if there are no arguments + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[*]?[*:0]const u8, + + pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { + if (self.arguments) |arguments| alloc.free(arguments); + } + }; + + pub fn cval(self: *NewWindow, alloc: Allocator) Allocator.Error!NewWindow.C { + var result: NewWindow.C = undefined; + + if (self.arguments) |arguments| { + result.arguments = try alloc.alloc([*:0]const u8, arguments.len + 1); + + for (arguments, 0..) |argument, i| + result.arguments[i] = argument.ptr; + + // add null terminator + result.arguments[arguments.len] = null; + } else { + result.arguments = null; + } + + return result; + } + }; + + pub const NewTab = struct { + /// The unique ID of a surface. If the ID is zero the currently focused + /// surface is assumed. This will be used to identify the window to add + /// the tab to. + surface_id: u64, + + /// A list of configuration entries that will override the configuration + /// of the first surface of the new tab. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[][:0]const u8, + + pub const C = extern struct { + /// The unique ID of a surface. If the ID is zero the currently + /// focused surface is assumed. This will be used to identify the + /// window to add the tab to. + surface_id: u64, + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. arguments: ?[*]?[*:0]const u8, pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { @@ -116,6 +175,7 @@ pub const Action = union(enum) { /// Sync with: ghostty_ipc_action_tag_e pub const Key = enum(c_int) { new_window, + new_tab, toggle_quick_terminal, test "ghostty.h Action.Key" { @@ -162,8 +222,8 @@ pub const Action = union(enum) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 4, - 8 => 8, + 4 => 12, + 8 => 16, else => unreachable, }); } diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index e44bdf9ed..500da0151 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -20,6 +20,7 @@ const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); const boo = @import("boo.zig"); const new_window = @import("new_window.zig"); +const new_tab = @import("new_tab.zig"); const toggle_quick_terminal = @import("toggle_quick_terminal.zig"); /// Special commands that can be invoked via CLI flags. These are all @@ -74,6 +75,9 @@ pub const Action = enum { // Use IPC to tell the running Ghostty to open a new window. @"new-window", + // Use IPC to tell the running Ghostty to open a new tab. + @"new-tab", + // Use IPC to tell the running Ghostty to toggle the quick terminal. @"toggle-quick-terminal", @@ -156,6 +160,7 @@ pub const Action = enum { .@"show-face" => try show_face.run(alloc), .boo => try boo.run(alloc), .@"new-window" => try new_window.run(alloc), + .@"new-tab" => try new_tab.run(alloc), .@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc), }; } @@ -197,6 +202,7 @@ pub const Action = enum { .@"show-face" => show_face.Options, .boo => boo.Options, .@"new-window" => new_window.Options, + .@"new-tab" => new_tab.Options, .@"toggle-quick-terminal" => toggle_quick_terminal.Options, }; } diff --git a/src/cli/new_tab.zig b/src/cli/new_tab.zig new file mode 100644 index 000000000..e0d9a0341 --- /dev/null +++ b/src/cli/new_tab.zig @@ -0,0 +1,252 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const Action = @import("../cli.zig").ghostty.Action; +const apprt = @import("../apprt.zig"); +const args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const lib = @import("../lib/main.zig"); +const homedir = @import("../os/homedir.zig"); +const internal_os = @import("../os/main.zig"); + +pub const Options = struct { + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// If set, open up a new tab in a custom instance of Ghostty. + class: ?[:0]const u8 = null, + + /// The surface to target. + @"surface-id": ?u64 = null, + + /// Did the user specify a `--working-directory` argument on the command line? + _working_directory_seen: bool = false, + + /// All of the arguments after `+new-tab`. They will be sent to Ghosttty + /// for processing. + _arguments: std.ArrayList([:0]const u8) = .empty, + + /// Enable arg parsing diagnostics so that we don't get an error if + /// there is a "normal" config setting on the cli. + _diagnostics: diagnostics.DiagnosticList = .{}, + + /// Manual parse hook, collect all of the arguments after `+new-tab`. + pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) (error{InvalidValue} || std.fmt.ParseIntError || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!bool { + var e_seen: bool = std.mem.eql(u8, arg, "-e"); + + // Include the argument that triggered the manual parse hook. + if (try self.checkArg(alloc, arg)) |a| try self._arguments.append(alloc, a); + + // Gather up the rest of the arguments to use as the command. + while (iter.next()) |param| { + if (e_seen) { + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (std.mem.eql(u8, param, "-e")) { + e_seen = true; + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (try self.checkArg(alloc, param)) |a| try self._arguments.append(alloc, a); + } + + return false; + } + + fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) (error{InvalidValue} || std.fmt.ParseIntError || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!?[:0]const u8 { + if (lib.cutPrefix(u8, arg, "--surface-id=")) |rest| { + self.@"surface-id" = try std.fmt.parseUnsigned(u64, std.mem.trim(u8, rest, &std.ascii.whitespace), 0); + return null; + } + + if (lib.cutPrefix(u8, arg, "--class=")) |rest| { + self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace)); + return null; + } + + if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| { + const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); + if (std.mem.eql(u8, stripped, "home")) return try alloc.dupeZ(u8, arg); + if (std.mem.eql(u8, stripped, "inherit")) return try alloc.dupeZ(u8, arg); + const cwd: std.fs.Dir = std.fs.cwd(); + var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = try homedir.expandHome(stripped, &expandhome_buf); + var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; + const realpath = try cwd.realpath(expanded, &realpath_buf); + self._working_directory_seen = true; + return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0); + } + + return try alloc.dupeZ(u8, arg); + } + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `new-tab` will use native platform IPC to open up a new tab in a running +/// instance of Ghostty. +/// +/// If the `--class` flag is not set, the `new-tab` command will try and connect +/// to a running instance of Ghostty based on what optimizations the Ghostty +/// CLI was compiled with. Otherwise the `new-tab` command will try and contact +/// a running Ghostty instance that was configured with the same `class` as was +/// given on the command line. +/// +/// All of the arguments after the `+new-tab` argument (except for the `--class` +/// and `--surface-id` flag) will be sent to the remote Ghostty instance and +/// will be parsed as command line flags. These flags will override certain +/// settings when creating the first surface in the new tab. Currently, only +/// `--working-directory`, `--command`, and `--title` are supported. `-e` will +/// also work as an alias for `--command`, except that if `-e` is found on the +/// command line all following arguments will become part of the command and no +/// more arguments will be parsed for configuration settings. +/// +/// If `--working-directory` is found on the command line and is a relative +/// path (i.e. doesn't start with `/`) it will be resolved to an absolute +/// path relative to the current working directory that the `ghostty +new-tab` +/// command is run from. `~/` prefixes will also be expanded to the user's home +/// directory. +/// +/// If `--working-directory` is _not_ found on the command line, the working +/// directory that `ghostty +new-tab` is run from will be passed to Ghostty. +/// +/// GTK uses an application ID to identify instances of applications. If Ghostty +/// is compiled with release optimizations, the default application ID will be +/// `com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations, +/// the default application ID will be `com.mitchellh.ghostty-debug`. The +/// `class` configuration entry can be used to set up a custom application +/// ID. The class name must follow the requirements defined [in the GTK +/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html) +/// or it will be ignored and Ghostty will use the default as defined above. +/// +/// On GTK, D-Bus activation must be properly configured. Ghostty does not need +/// to be running for this to open a new tab, making it suitable for binding +/// to keys in your window manager (if other methods for configuring global +/// shortcuts are unavailable). D-Bus will handle launching a new instance +/// of Ghostty if it is not already running. See the Ghostty website for +/// information on properly configuring D-Bus activation. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--class=`: If set, open up a new window in a custom instance of +/// Ghostty. The class must be a valid GTK application ID. +/// +/// * `--surface-id=`: If set, specifies a Ghostty surface ID. +/// This is used to identify the window that the new tab will be added to. +/// If the surface ID is not specified or is `0` the currently focused +/// surface will be used. +/// +/// * `--command`: The command to be executed in the first surface of the new tab. +/// +/// * `--working-directory=`: The working directory to pass to Ghostty. +/// +/// * `--title`: A title that will override the title of the first surface in +/// the new tab. The title override may be edited or removed later. +/// +/// * `-e`: Any arguments after this will be interpreted as a command to +/// execute inside the first surface of the new tab instead of the +/// default command. +/// +/// Available since: 1.4.0 +pub fn run(alloc: Allocator) !u8 { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + + var buffer: [1024]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&buffer); + const stderr = &stderr_writer.interface; + + const result = runArgs(alloc, &iter, stderr); + stderr.flush() catch {}; + return result; +} + +fn runArgs( + alloc_gpa: Allocator, + argsIter: anytype, + stderr: *std.Io.Writer, +) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + args.parse(Options, alloc_gpa, &opts, argsIter) catch |err| switch (err) { + error.ActionHelpRequested => return err, + else => { + try stderr.print("Error parsing args: {}\n", .{err}); + return 1; + }, + }; + + // Print out any diagnostics, unless it's likely that the diagnostic was + // generated trying to parse a "normal" configuration setting. Exit with an + // error code if any diagnostics were printed. + if (!opts._diagnostics.empty()) { + var exit: bool = false; + outer: for (opts._diagnostics.items()) |diagnostic| { + if (diagnostic.location != .cli) continue :outer; + inner: inline for (@typeInfo(Options).@"struct".fields) |field| { + if (field.name[0] == '_') continue :inner; + if (std.mem.eql(u8, field.name, diagnostic.key)) { + try stderr.print("config error: {f}\n", .{diagnostic}); + exit = true; + } + } + } + if (exit) return 1; + } + + if (!opts._working_directory_seen) { + const alloc = opts._arena.?.allocator(); + const cwd: std.fs.Dir = std.fs.cwd(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const wd = try cwd.realpath(".", &buf); + // This should be inserted at the beginning of the list, just in case `-e` was used. + try opts._arguments.insert(alloc, 0, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); + } + + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + const surface_id = opts.@"surface-id" orelse surface: { + const e = try internal_os.getenv(alloc, "GHOSTTY_SURFACE") orelse break :surface 0; + defer e.deinit(alloc); + break :surface try std.fmt.parseUnsigned(u64, e.value, 0); + }; + + if (apprt.App.performIpc( + alloc, + if (opts.class) |class| .{ .class = class } else .detect, + .new_tab, + .{ + .surface_id = surface_id, + .arguments = if (opts._arguments.items.len == 0) null else opts._arguments.items, + }, + ) catch |err| switch (err) { + error.IPCFailed => { + // The apprt should have printed a more specific error message + // already. + return 1; + }, + else => { + try stderr.print("Sending the IPC failed: {}", .{err}); + return 1; + }, + }) return 0; + + // If we get here, the platform is not supported. + try stderr.print("+new-tab is not supported on this platform.\n", .{}); + return 1; +}