diff --git a/build.zig b/build.zig index 8d3c126e..3168c030 100644 --- a/build.zig +++ b/build.zig @@ -392,6 +392,13 @@ pub fn build_exe( }, }); + const argv_mod = b.createModule(.{ + .root_source_file = b.path("src/argv.zig"), + .imports = &.{ + .{ .name = "cbor", .module = cbor_mod }, + }, + }); + const lsp_config_mod = b.createModule(.{ .root_source_file = b.path("src/lsp_config.zig"), .imports = &.{ @@ -660,6 +667,7 @@ pub fn build_exe( .{ .name = "project_manager", .module = project_manager_mod }, .{ .name = "syntax", .module = syntax_mod }, .{ .name = "text_manip", .module = text_manip_mod }, + .{ .name = "argv", .module = argv_mod }, .{ .name = "Buffer", .module = Buffer_mod }, .{ .name = "keybind", .module = keybind_mod }, .{ .name = "shell", .module = shell_mod }, @@ -709,6 +717,7 @@ pub fn build_exe( exe.root_module.addImport("cbor", cbor_mod); exe.root_module.addImport("config", config_mod); exe.root_module.addImport("text_manip", text_manip_mod); + exe.root_module.addImport("argv", argv_mod); exe.root_module.addImport("Buffer", Buffer_mod); exe.root_module.addImport("tui", tui_mod); exe.root_module.addImport("thespian", thespian_mod); @@ -759,6 +768,7 @@ pub fn build_exe( check_exe.root_module.addImport("cbor", cbor_mod); check_exe.root_module.addImport("config", config_mod); check_exe.root_module.addImport("text_manip", text_manip_mod); + check_exe.root_module.addImport("argv", argv_mod); check_exe.root_module.addImport("Buffer", Buffer_mod); check_exe.root_module.addImport("tui", tui_mod); check_exe.root_module.addImport("thespian", thespian_mod); diff --git a/build.zig.zon b/build.zig.zon index 590a0639..84e13bba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e83e3f871786fab577bb26e2e4800dd7e9bf4390", - .hash = "vaxis-0.5.1-BWNV_Ke0CQCnpGN5qbzPOFD1V_oBgcIWd1O0PrBYmTMa", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#9c8e0a7d61c75ea6aee3b69761acfb36899709e6", + .hash = "vaxis-0.5.1-BWNV_LC4CQDtTTjkRwC90yjbqAtv2AnDiRpU0e8c_BtF", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/argv.zig b/src/argv.zig new file mode 100644 index 00000000..3e6d5ec9 --- /dev/null +++ b/src/argv.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +/// Write a `[]const []const u8` argv array as a space-separated command string. +/// Args that contain spaces are wrapped in double-quotes. +/// Writes nothing if argv is null or empty. +pub fn write(writer: *std.Io.Writer, argv: ?[]const []const u8) error{WriteFailed}!usize { + const args = argv orelse return 0; + var count: usize = 0; + for (args, 0..) |arg, i| { + if (i > 0) { + try writer.writeByte(' '); + count += 1; + } + const needs_quote = std.mem.indexOfScalar(u8, arg, ' ') != null; + if (needs_quote) { + try writer.writeByte('"'); + count += 1; + } + try writer.writeAll(arg); + count += arg.len; + if (needs_quote) { + try writer.writeByte('"'); + count += 1; + } + } + return count; +} + +/// Return the display length of an argv array rendered by write_argv. +pub fn len(argv: ?[]const []const u8) usize { + var discard: std.Io.Writer.Discarding = .init(&.{}); + return write(&discard.writer, argv) catch return 0; +} diff --git a/src/config.zig b/src/config.zig index c4572b00..5cf6325f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -13,6 +13,7 @@ gutter_width_maximum: usize = 8, enable_terminal_cursor: bool = true, enable_terminal_color_scheme: bool = false, terminal_scrollback_size: u16 = 500, +terminal_on_exit: TerminalOnExit = .hold_on_error, enable_sgr_pixel_mode_support: bool = true, enable_modal_dim: bool = true, highlight_current_line: bool = true, @@ -248,3 +249,9 @@ pub const AgeFormat = enum { short, long, }; + +pub const TerminalOnExit = enum { + hold_on_error, + close, + hold, +}; diff --git a/src/list_languages.zig b/src/list_languages.zig index 8296e87f..669c7863 100644 --- a/src/list_languages.zig +++ b/src/list_languages.zig @@ -3,6 +3,7 @@ const file_type_config = @import("file_type_config"); const text_manip = @import("text_manip"); const write_string = text_manip.write_string; const write_padding = text_manip.write_padding; +const argv = @import("argv"); const builtin = @import("builtin"); const RGB = @import("color").RGB; @@ -22,9 +23,9 @@ pub fn list(allocator: std.mem.Allocator, writer: *std.io.Writer, tty_config: st for (file_type_config.get_all_names()) |file_type_name| { const file_type = try file_type_config.get(file_type_name) orelse unreachable; max_language_len = @max(max_language_len, file_type.name.len); - max_langserver_len = @max(max_langserver_len, args_string_length(file_type.language_server)); - max_formatter_len = @max(max_formatter_len, args_string_length(file_type.formatter)); - max_extensions_len = @max(max_extensions_len, args_string_length(file_type.extensions)); + max_langserver_len = @max(max_langserver_len, argv.len(file_type.language_server)); + max_formatter_len = @max(max_formatter_len, argv.len(file_type.formatter)); + max_extensions_len = @max(max_extensions_len, argv.len(file_type.extensions)); } try tty_config.setColor(writer, .yellow); @@ -43,59 +44,42 @@ pub fn list(allocator: std.mem.Allocator, writer: *std.io.Writer, tty_config: st try tty_config.setColor(writer, .reset); try writer.writeAll(" "); try write_string(writer, file_type.name, max_language_len + 1); - try write_segmented(writer, file_type.extensions, ",", max_extensions_len + 1, tty_config); + { + const exts = file_type.extensions orelse &.{}; + var ext_len: usize = 0; + for (exts, 0..) |ext, i| { + if (i > 0) { + try writer.writeByte(','); + ext_len += 1; + } + try writer.writeAll(ext); + ext_len += ext.len; + } + try tty_config.setColor(writer, .reset); + try write_padding(writer, ext_len, max_extensions_len + 1); + } if (file_type.language_server) |language_server| try write_checkmark(writer, bin_path.can_execute(allocator, language_server[0]), tty_config); - try write_segmented(writer, file_type.language_server, " ", max_langserver_len + 1, tty_config); + const len = try argv.write(writer, file_type.language_server); + try tty_config.setColor(writer, .reset); + try write_padding(writer, len, max_langserver_len + 1); if (file_type.formatter) |formatter| try write_checkmark(writer, bin_path.can_execute(allocator, formatter[0]), tty_config); - try write_segmented(writer, file_type.formatter, " ", null, tty_config); + _ = try argv.write(writer, file_type.formatter); + try tty_config.setColor(writer, .reset); try writer.writeAll("\n"); } } -fn args_string_length(args_: ?[]const []const u8) usize { - const args = args_ orelse return 0; - var len: usize = 0; - var first: bool = true; - for (args) |arg| { - if (first) first = false else len += 1; - len += arg.len; - } - return len; -} - fn write_checkmark(writer: anytype, success: bool, tty_config: std.io.tty.Config) !void { try tty_config.setColor(writer, if (success) .green else .red); if (success) try writer.writeAll(success_mark) else try writer.writeAll(fail_mark); } -fn write_segmented( - writer: anytype, - args_: ?[]const []const u8, - sep: []const u8, - pad: ?usize, - tty_config: std.io.tty.Config, -) !void { - const args = args_ orelse return; - var len: usize = 0; - var first: bool = true; - for (args) |arg| { - if (first) first = false else { - len += 1; - try writer.writeAll(sep); - } - len += arg.len; - try writer.writeAll(arg); - } - try tty_config.setColor(writer, .reset); - if (pad) |pad_| try write_padding(writer, len, pad_); -} - fn setColorRgb(writer: anytype, color: u24) !void { const fg_rgb_legacy = "\x1b[38;2;{d};{d};{d}m"; const rgb = RGB.from_u24(color); diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 76ad8461..fe4507a5 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1014,7 +1014,13 @@ const cmds = struct { vt.focus(); } } - pub const focus_terminal_meta: Meta = .{ .description = "Terminal" }; + pub const focus_terminal_meta: Meta = .{ .description = "Open terminal" }; + + pub fn close_terminal(self: *Self, _: Ctx) Result { + if (self.get_panel_view(terminal_view)) |_| + try self.toggle_panel_view(terminal_view, .disable); + } + pub const close_terminal_meta: Meta = .{ .description = "Close terminal" }; pub fn close_find_in_files_results(self: *Self, _: Ctx) Result { if (self.file_list_type == .find_in_files) diff --git a/src/tui/mode/overlay/task_palette.zig b/src/tui/mode/overlay/task_palette.zig index 54d3ae27..4f1a1604 100644 --- a/src/tui/mode/overlay/task_palette.zig +++ b/src/tui/mode/overlay/task_palette.zig @@ -138,11 +138,10 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { } else { tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); project_manager.add_task(entry.label) catch {}; - const run_cmd = switch (activate) { - .normal => "run_task", - .alternate => "run_task_in_terminal", - }; - tp.self_pid().send(.{ "cmd", run_cmd, .{entry.label} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + (switch (activate) { + .normal => tp.self_pid().send(.{ "cmd", "run_task", .{entry.label} }), + .alternate => tp.self_pid().send(.{ "cmd", "run_task_in_terminal", .{ entry.label, "hold" } }), + }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); } } diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 39aa3719..86f765fc 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -6,6 +6,8 @@ const cbor = @import("cbor"); const command = @import("command"); const vaxis = @import("renderer").vaxis; const shell = @import("shell"); +const argv = @import("argv"); +const config = @import("config"); const Plane = @import("renderer").Plane; const Widget = @import("Widget.zig"); @@ -22,6 +24,7 @@ const Self = @This(); const widget_type: Widget.Type = .panel; const Terminal = vaxis.widgets.Terminal; +const TerminalOnExit = config.TerminalOnExit; allocator: Allocator, plane: Plane, @@ -51,8 +54,11 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex errdefer env.deinit(); var cmd_arg: []const u8 = ""; + var on_exit: TerminalOnExit = tui.config().terminal_on_exit; const argv_msg: ?tp.message = if (ctx.args.match(.{tp.extract(&cmd_arg)}) catch false and cmd_arg.len > 0) try shell.parse_arg0_to_argv(allocator, &cmd_arg) + else if (ctx.args.match(.{ tp.extract(&cmd_arg), tp.extract(&on_exit) }) catch false and cmd_arg.len > 0) + try shell.parse_arg0_to_argv(allocator, &cmd_arg) else null; defer if (argv_msg) |msg| allocator.free(msg.buf); @@ -68,7 +74,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex try argv_list.append(allocator, arg); } } else { - try argv_list.append(allocator, env.get("SHELL") orelse "bash"); + try argv_list.append(allocator, env.get("SHELL") orelse "/bin/sh"); } // Use the current plane dimensions for the initial pty size. The plane @@ -77,7 +83,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex const cols: u16 = @intCast(@max(80, plane.dim_x())); const rows: u16 = @intCast(@max(24, plane.dim_y())); - if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols); + if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols, on_exit); const self = try allocator.create(Self); errdefer allocator.destroy(self); @@ -154,6 +160,19 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; + if (self.vt.process_exited) { + if (keypress == input.key.enter) { + self.vt.process_exited = false; + self.restart() catch |e| + std.log.err("terminal_view: restart failed: {}", .{e}); + tui.need_render(@src()); + return true; + } + if (keypress == input.key.escape) { + tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {}; + return true; + } + } self.vt.vt.scrollToBottom(); self.vt.vt.update(.{ .key_press = key }) catch |e| std.log.err("terminal_view: input failed: {}", .{e}); @@ -176,6 +195,10 @@ pub fn unfocus(self: *Self) void { } pub fn deinit(self: *Self, allocator: Allocator) void { + if (global_vt) |*vt| if (vt.process_exited) { + vt.deinit(allocator); + global_vt = null; + }; if (self.focused) tui.release_keyboard_focus(Widget.to(self)); self.commands.unregister(); self.plane.deinit(); @@ -194,7 +217,8 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { while (self.vt.vt.tryEvent()) |event| { switch (event) { .exited => |code| { - self.show_exit_message(code); + self.vt.process_exited = true; + self.handle_child_exit(code); tui.need_render(@src()); }, .redraw, .bell => {}, @@ -256,6 +280,17 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { return false; } +fn handle_child_exit(self: *Self, code: u8) void { + switch (self.vt.on_exit) { + .hold => self.show_exit_message(code), + .hold_on_error => if (code == 0) + tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {} + else + self.show_exit_message(code), + .close => tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {}, + } +} + fn show_exit_message(self: *Self, code: u8) void { var msg: std.Io.Writer.Allocating = .init(self.allocator); defer msg.deinit(); @@ -266,11 +301,30 @@ fn show_exit_message(self: *Self, code: u8) void { if (code != 0) w.print(" with code {d}", .{code}) catch {}; w.writeAll("]\x1b[0m\r\n") catch {}; + // Re-run prompt + const cmd_argv = self.vt.vt.cmd.argv; + if (cmd_argv.len > 0) { + w.writeAll("\x1b[0m\x1b[2mPress enter to re-run '") catch {}; + _ = argv.write(w, cmd_argv) catch {}; + w.writeAll("'\x1b[0m\r\n") catch {}; + } var parser: pty.Parser = .{ .buf = .init(self.allocator) }; defer parser.buf.deinit(); _ = self.vt.vt.processOutput(&parser, msg.written()) catch {}; } +fn restart(self: *Self) !void { + // Kill the old pty actor if still alive + if (self.vt.pty_pid) |pid| { + pid.send(.{"quit"}) catch {}; + pid.deinit(); + self.vt.pty_pid = null; + } + // Re-spawn the child process and a fresh pty actor + try self.vt.vt.spawn(); + self.vt.pty_pid = try pty.spawn(self.allocator, &self.vt.vt); +} + pub fn handle_resize(self: *Self, pos: Widget.Box) void { self.plane.move_yx(@intCast(pos.y), @intCast(pos.x)) catch return; self.plane.resize_simple(@intCast(pos.h), @intCast(pos.w)) catch return; @@ -319,8 +373,10 @@ const Vt = struct { app_fg: ?[3]u8 = null, app_bg: ?[3]u8 = null, app_cursor: ?[3]u8 = null, + process_exited: bool = false, + on_exit: TerminalOnExit, - fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { + fn init(allocator: std.mem.Allocator, cmd_argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16, on_exit: TerminalOnExit) !void { const home = env.get("HOME") orelse "/tmp"; global_vt = .{ @@ -328,11 +384,12 @@ const Vt = struct { .env = env, .write_buf = undefined, // managed via self.vt's pty_writer pointer .pty_pid = null, + .on_exit = on_exit, }; const self = &global_vt.?; self.vt = try Terminal.init( allocator, - argv, + cmd_argv, &env, .{ .winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 }, @@ -389,6 +446,8 @@ const pty = struct { parser: Parser, receiver: Receiver, parent: tp.pid, + err_code: i64 = 0, + sigchld: ?tp.signal = null, pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); @@ -407,6 +466,7 @@ const pty = struct { fn deinit(self: *@This()) void { std.log.debug("terminal: pty actor deinit (pid={?})", .{self.vt.cmd.pid}); + if (self.sigchld) |s| s.deinit(); self.fd.deinit(); self.parser.buf.deinit(); self.parent.deinit(); @@ -423,6 +483,10 @@ const pty = struct { std.log.debug("terminal: pty initial wait_read failed: {}", .{e}); return tp.exit_error(e, @errorReturnTrace()); }; + self.sigchld = tp.signal.init(std.posix.SIG.CHLD, tp.message.fmt(.{"sigchld"})) catch |e| { + std.log.debug("terminal: SIGCHLD signal init failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; tp.receive(&self.receiver); } @@ -448,6 +512,25 @@ const pty = struct { return tp.exit_normal(); }, }; + } else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { + // thespian fires read_error with EPOLLHUP when the child exits cleanly. + // Treat it the same as EIO: reap the child and signal exit. + const code = self.vt.cmd.wait(); + std.log.debug("terminal: read_error from fd (err={d}), process exited with code={d}", .{ self.err_code, code }); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{"sigchld"})) { + // SIGCHLD fires when any child exits. Check if it's our child. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (SIGCHLD) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } + // Not our child (or already reaped) - re-arm the signal and continue. + if (self.sigchld) |s| s.deinit(); + self.sigchld = tp.signal.init(std.posix.SIG.CHLD, tp.message.fmt(.{"sigchld"})) catch null; } else if (try m.match(.{"quit"})) { std.log.debug("terminal: pty exiting: received quit", .{}); return tp.exit_normal(); @@ -462,7 +545,19 @@ const pty = struct { while (true) { const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { - error.WouldBlock => break, + error.WouldBlock => { + // No more data right now. Check if the child already exited - + // on Linux a clean exit may not make the pty fd readable again + // (no EPOLLIN), it just starts returning EIO on the next read. + // Polling here catches that case before we arm wait_read again. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (detected via try_wait) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return error.InputOutput; + } + break; + }, error.InputOutput => { const code = self.vt.cmd.wait(); std.log.debug("terminal: read EIO, process exited with code={d}", .{code}); @@ -515,6 +610,16 @@ const pty = struct { } } + // Check for child exit once more before sleeping in wait_read. + // A clean exit with no final output will never make the pty fd readable, + // so we must detect it here rather than waiting forever. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (pre-wait_read check) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return error.InputOutput; + } + self.fd.wait_read() catch |e| switch (e) { error.ThespianFileDescriptorWaitReadFailed => { std.log.debug("terminal: wait_read failed: {} (pid={?})", .{ e, self.vt.cmd.pid });