Compare commits
73 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd907345a | |||
| 524372ed33 | |||
| 42d0e541e8 | |||
| 1e037375bd | |||
| 3553fbf0d2 | |||
| 2f5d4ded3c | |||
| c4f6b6c945 | |||
| 581bbdb210 | |||
| ce240c534c | |||
| 8027096f3e | |||
| a21b1318ed | |||
| 97f8d024c6 | |||
| 646db3b374 | |||
| b1e13f036d | |||
| df5c426383 | |||
| 5f9b7b7c13 | |||
| 21b7995393 | |||
| 29c3424913 | |||
| 0a37c2b05b | |||
| 57aae0d45c | |||
| fc78e8cf02 | |||
| a35edeaa9b | |||
| 94f6b342fa | |||
| 632a7c4453 | |||
| 737236db01 | |||
| bd507d48e2 | |||
| d98a40ab9e | |||
| 49d4cda7ef | |||
| 7e7cb511a8 | |||
| 4bba8d9715 | |||
| 885c9682eb | |||
| 8a3cd776e9 | |||
| 424fd3efc3 | |||
| 519d8dd886 | |||
| f68102e448 | |||
| 770fa884cd | |||
| 871d40f906 | |||
| 05cba52397 | |||
| ec8379ce51 | |||
| 598c2a58aa | |||
| 3ad37b3b70 | |||
| 35ef58d0e1 | |||
| 4affdf5688 | |||
| 4157638892 | |||
| f88f779410 | |||
| 3e265dade5 | |||
| 69b0885f4b | |||
| 61a509cf2f | |||
| 316b65a0f7 | |||
| 582d3d1066 | |||
| 45de943d84 | |||
| f17ceb282a | |||
| 7e01eae389 | |||
| ee7a3ed2ce | |||
| 330d2b1f66 | |||
| aff2a7919b | |||
| 7d51b09aac | |||
| 558c59368b | |||
| 5c2ae84602 | |||
| 43b46d179f | |||
| cc6c84be15 | |||
| 341c652333 | |||
| 3d81631679 | |||
| 6643341574 | |||
| 613b95c2af | |||
| 3d1658541a | |||
| 7de0d27a54 | |||
| f8dd9f85b6 | |||
| d423696e7e | |||
| 367c532596 | |||
| 9a68918ada | |||
| f1a8efa318 | |||
| ff0495a265 |
26 changed files with 1238 additions and 77 deletions
10
build.zig
10
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(.{
|
const lsp_config_mod = b.createModule(.{
|
||||||
.root_source_file = b.path("src/lsp_config.zig"),
|
.root_source_file = b.path("src/lsp_config.zig"),
|
||||||
.imports = &.{
|
.imports = &.{
|
||||||
|
|
@ -660,6 +667,7 @@ pub fn build_exe(
|
||||||
.{ .name = "project_manager", .module = project_manager_mod },
|
.{ .name = "project_manager", .module = project_manager_mod },
|
||||||
.{ .name = "syntax", .module = syntax_mod },
|
.{ .name = "syntax", .module = syntax_mod },
|
||||||
.{ .name = "text_manip", .module = text_manip_mod },
|
.{ .name = "text_manip", .module = text_manip_mod },
|
||||||
|
.{ .name = "argv", .module = argv_mod },
|
||||||
.{ .name = "Buffer", .module = Buffer_mod },
|
.{ .name = "Buffer", .module = Buffer_mod },
|
||||||
.{ .name = "keybind", .module = keybind_mod },
|
.{ .name = "keybind", .module = keybind_mod },
|
||||||
.{ .name = "shell", .module = shell_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("cbor", cbor_mod);
|
||||||
exe.root_module.addImport("config", config_mod);
|
exe.root_module.addImport("config", config_mod);
|
||||||
exe.root_module.addImport("text_manip", text_manip_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("Buffer", Buffer_mod);
|
||||||
exe.root_module.addImport("tui", tui_mod);
|
exe.root_module.addImport("tui", tui_mod);
|
||||||
exe.root_module.addImport("thespian", thespian_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("cbor", cbor_mod);
|
||||||
check_exe.root_module.addImport("config", config_mod);
|
check_exe.root_module.addImport("config", config_mod);
|
||||||
check_exe.root_module.addImport("text_manip", text_manip_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("Buffer", Buffer_mod);
|
||||||
check_exe.root_module.addImport("tui", tui_mod);
|
check_exe.root_module.addImport("tui", tui_mod);
|
||||||
check_exe.root_module.addImport("thespian", thespian_mod);
|
check_exe.root_module.addImport("thespian", thespian_mod);
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@
|
||||||
.hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D",
|
.hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D",
|
||||||
},
|
},
|
||||||
.vaxis = .{
|
.vaxis = .{
|
||||||
.url = "git+https://github.com/neurocyte/libvaxis?ref=main#1f6c7222f59607bff0ee8d7c6a0637a05bceffcd",
|
.url = "git+https://github.com/neurocyte/libvaxis?ref=main#cecc97d9ff8da9df13499da0d0b19c5cd18742c3",
|
||||||
.hash = "vaxis-0.5.1-BWNV_CNLCQDmr-D_UzqGRAngktQt7hiGTRf1gyozwxcG",
|
.hash = "vaxis-0.5.1-BWNV_BcgCgDG3wpSPxCHxaRAZukEfnnKrBa-52zjnjex",
|
||||||
},
|
},
|
||||||
.zeit = .{
|
.zeit = .{
|
||||||
.url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88",
|
.url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88",
|
||||||
|
|
|
||||||
33
src/argv.zig
Normal file
33
src/argv.zig
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -109,8 +109,8 @@ fn clamp_row(self: *Self, cursor: *const Cursor, abs: bool, bottom_offset: usize
|
||||||
}
|
}
|
||||||
if (cursor.row < self.row) {
|
if (cursor.row < self.row) {
|
||||||
self.row = 0;
|
self.row = 0;
|
||||||
} else if (cursor.row > self.row + self.rows - bottom_min_border_distance) {
|
} else if (cursor.row > self.row + self.rows -| bottom_min_border_distance) {
|
||||||
self.row = cursor.row + bottom_min_border_distance - self.rows;
|
self.row = cursor.row + bottom_min_border_distance -| self.rows;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ gutter_width_minimum: usize = 4,
|
||||||
gutter_width_maximum: usize = 8,
|
gutter_width_maximum: usize = 8,
|
||||||
enable_terminal_cursor: bool = true,
|
enable_terminal_cursor: bool = true,
|
||||||
enable_terminal_color_scheme: bool = false,
|
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_sgr_pixel_mode_support: bool = true,
|
||||||
enable_modal_dim: bool = true,
|
enable_modal_dim: bool = true,
|
||||||
highlight_current_line: bool = true,
|
highlight_current_line: bool = true,
|
||||||
|
|
@ -247,3 +249,9 @@ pub const AgeFormat = enum {
|
||||||
short,
|
short,
|
||||||
long,
|
long,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const TerminalOnExit = enum {
|
||||||
|
hold_on_error,
|
||||||
|
close,
|
||||||
|
hold,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ pub fn parse(link: []const u8) error{InvalidFileLink}!Dest {
|
||||||
file.path = link;
|
file.path = link;
|
||||||
break :blk null;
|
break :blk null;
|
||||||
};
|
};
|
||||||
|
} else if (line_.len > 5 and std.mem.eql(u8, "line ", line_[0..5])) {
|
||||||
|
file.line = std.fmt.parseInt(usize, line_[5..], 10) catch blk: {
|
||||||
|
file.path = link;
|
||||||
|
break :blk null;
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
file.line = std.fmt.parseInt(usize, line_, 10) catch blk: {
|
file.line = std.fmt.parseInt(usize, line_, 10) catch blk: {
|
||||||
file.path = link;
|
file.path = link;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@
|
||||||
["ctrl+6", "focus_split", 5],
|
["ctrl+6", "focus_split", 5],
|
||||||
["ctrl+7", "focus_split", 6],
|
["ctrl+7", "focus_split", 6],
|
||||||
["ctrl+8", "focus_split", 7],
|
["ctrl+8", "focus_split", 7],
|
||||||
|
["ctrl+`", "open_terminal"],
|
||||||
["ctrl+j", "toggle_panel"],
|
["ctrl+j", "toggle_panel"],
|
||||||
|
["ctrl+shift+j", "toggle_maximize_panel"],
|
||||||
["ctrl+q", "quit"],
|
["ctrl+q", "quit"],
|
||||||
["ctrl+w", "close_split"],
|
["ctrl+w", "close_split"],
|
||||||
["ctrl+o", "open_file"],
|
["ctrl+o", "open_file"],
|
||||||
|
|
@ -61,8 +63,8 @@
|
||||||
["ctrl+shift+tab", "previous_tab"],
|
["ctrl+shift+tab", "previous_tab"],
|
||||||
["ctrl+page_down", "next_tab"],
|
["ctrl+page_down", "next_tab"],
|
||||||
["ctrl+page_up", "previous_tab"],
|
["ctrl+page_up", "previous_tab"],
|
||||||
["ctrl+shift+page_down", "move_tab_next"],
|
["ctrl+shift+page_down", "move_tab_next_or_scroll_terminal_down"],
|
||||||
["ctrl+shift+page_up", "move_tab_previous"],
|
["ctrl+shift+page_up", "move_tab_previous_or_scroll_terminal_up"],
|
||||||
["ctrl+k e", "switch_buffers"],
|
["ctrl+k e", "switch_buffers"],
|
||||||
["alt+shift+v", "clipboard_history"],
|
["alt+shift+v", "clipboard_history"],
|
||||||
["ctrl+0", "reset_fontsize"],
|
["ctrl+0", "reset_fontsize"],
|
||||||
|
|
@ -349,6 +351,7 @@
|
||||||
["alt+f9", "overlay_next_widget_style"],
|
["alt+f9", "overlay_next_widget_style"],
|
||||||
["alt+!", "add_task"],
|
["alt+!", "add_task"],
|
||||||
["ctrl+j", "toggle_panel"],
|
["ctrl+j", "toggle_panel"],
|
||||||
|
["ctrl+shift+j", "toggle_maximize_panel"],
|
||||||
["ctrl+q", "quit"],
|
["ctrl+q", "quit"],
|
||||||
["ctrl+w", "close_file"],
|
["ctrl+w", "close_file"],
|
||||||
["ctrl+shift+f", "find_in_files"],
|
["ctrl+shift+f", "find_in_files"],
|
||||||
|
|
@ -517,6 +520,7 @@
|
||||||
["shift+f1", "scroll_keybind_hints"],
|
["shift+f1", "scroll_keybind_hints"],
|
||||||
["ctrl+alt+?", "scroll_keybind_hints"],
|
["ctrl+alt+?", "scroll_keybind_hints"],
|
||||||
["alt+f9", "panel_next_widget_style"],
|
["alt+f9", "panel_next_widget_style"],
|
||||||
|
["ctrl+shift+j", "toggle_maximize_panel"],
|
||||||
["ctrl+q", "quit"],
|
["ctrl+q", "quit"],
|
||||||
["ctrl+v", "system_paste"],
|
["ctrl+v", "system_paste"],
|
||||||
["ctrl+u", "mini_mode_reset"],
|
["ctrl+u", "mini_mode_reset"],
|
||||||
|
|
@ -581,5 +585,30 @@
|
||||||
["enter", "mini_mode_select"],
|
["enter", "mini_mode_select"],
|
||||||
["backspace", "mini_mode_delete_backwards"]
|
["backspace", "mini_mode_delete_backwards"]
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"on_match_failure": "nothing",
|
||||||
|
"press": [
|
||||||
|
["ctrl+1", "focus_split", 0],
|
||||||
|
["ctrl+2", "focus_split", 1],
|
||||||
|
["ctrl+3", "focus_split", 2],
|
||||||
|
["ctrl+4", "focus_split", 3],
|
||||||
|
["ctrl+5", "focus_split", 4],
|
||||||
|
["ctrl+6", "focus_split", 5],
|
||||||
|
["ctrl+7", "focus_split", 6],
|
||||||
|
["ctrl+8", "focus_split", 7],
|
||||||
|
["ctrl+`", "unfocus_terminal"],
|
||||||
|
["ctrl+j", "toggle_panel"],
|
||||||
|
["ctrl+shift+page_down", "terminal_scroll_down"],
|
||||||
|
["ctrl+shift+page_up", "terminal_scroll_up"],
|
||||||
|
["ctrl+shift+j", "toggle_maximize_panel"],
|
||||||
|
["ctrl+shift+p", "open_command_palette"],
|
||||||
|
["alt+shift+p", "open_command_palette"],
|
||||||
|
["alt+x", "open_command_palette"],
|
||||||
|
["alt+!", "run_task"],
|
||||||
|
["alt+f9", "panel_next_widget_style"],
|
||||||
|
["ctrl+shift+q", "quit_without_saving"],
|
||||||
|
["ctrl+alt+shift+r", "restart"]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -485,7 +485,7 @@ const BindingSet = struct {
|
||||||
deinit_command: ?Command = null,
|
deinit_command: ?Command = null,
|
||||||
|
|
||||||
const KeySyntax = enum { flow, vim };
|
const KeySyntax = enum { flow, vim };
|
||||||
const OnMatchFailure = enum { insert, ignore };
|
const OnMatchFailure = enum { insert, ignore, nothing };
|
||||||
|
|
||||||
fn load(allocator: std.mem.Allocator, namespace_name: []const u8, config_section: []const u8, mode_bindings: std.json.Value, fallback: ?*const BindingSet, namespace: *Namespace) (error{ OutOfMemory, WriteFailed } || parse_flow.ParseError || parse_vim.ParseError || std.json.ParseFromValueError)!@This() {
|
fn load(allocator: std.mem.Allocator, namespace_name: []const u8, config_section: []const u8, mode_bindings: std.json.Value, fallback: ?*const BindingSet, namespace: *Namespace) (error{ OutOfMemory, WriteFailed } || parse_flow.ParseError || parse_vim.ParseError || std.json.ParseFromValueError)!@This() {
|
||||||
var self: @This() = .{ .name = undefined, .config_section = config_section, .selection_style = undefined };
|
var self: @This() = .{ .name = undefined, .config_section = config_section, .selection_style = undefined };
|
||||||
|
|
@ -667,7 +667,7 @@ const BindingSet = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn receive(self: *const @This(), _: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
pub fn receive(self: *const @This(), _: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
||||||
var event: input.Event = 0;
|
var event: input.Event = 0;
|
||||||
var keypress: input.Key = 0;
|
var keypress: input.Key = 0;
|
||||||
var keypress_shifted: input.Key = 0;
|
var keypress_shifted: input.Key = 0;
|
||||||
|
|
@ -696,6 +696,7 @@ const BindingSet = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (binding.commands) |*cmd| try cmd.execute();
|
for (binding.commands) |*cmd| try cmd.execute();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else if (try m.match(.{"F"})) {
|
} else if (try m.match(.{"F"})) {
|
||||||
self.flush() catch |e| return tp.exit_error(e, @errorReturnTrace());
|
self.flush() catch |e| return tp.exit_error(e, @errorReturnTrace());
|
||||||
|
|
@ -786,6 +787,7 @@ const BindingSet = struct {
|
||||||
else
|
else
|
||||||
log_keyhints_message(),
|
log_keyhints_message(),
|
||||||
.ignore => log_keyhints_message(),
|
.ignore => log_keyhints_message(),
|
||||||
|
.nothing => {},
|
||||||
}
|
}
|
||||||
globals.current_sequence.clearRetainingCapacity();
|
globals.current_sequence.clearRetainingCapacity();
|
||||||
globals.current_sequence_egc.clearRetainingCapacity();
|
globals.current_sequence_egc.clearRetainingCapacity();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const file_type_config = @import("file_type_config");
|
||||||
const text_manip = @import("text_manip");
|
const text_manip = @import("text_manip");
|
||||||
const write_string = text_manip.write_string;
|
const write_string = text_manip.write_string;
|
||||||
const write_padding = text_manip.write_padding;
|
const write_padding = text_manip.write_padding;
|
||||||
|
const argv = @import("argv");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
const RGB = @import("color").RGB;
|
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| {
|
for (file_type_config.get_all_names()) |file_type_name| {
|
||||||
const file_type = try file_type_config.get(file_type_name) orelse unreachable;
|
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_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_langserver_len = @max(max_langserver_len, argv.len(file_type.language_server));
|
||||||
max_formatter_len = @max(max_formatter_len, args_string_length(file_type.formatter));
|
max_formatter_len = @max(max_formatter_len, argv.len(file_type.formatter));
|
||||||
max_extensions_len = @max(max_extensions_len, args_string_length(file_type.extensions));
|
max_extensions_len = @max(max_extensions_len, argv.len(file_type.extensions));
|
||||||
}
|
}
|
||||||
|
|
||||||
try tty_config.setColor(writer, .yellow);
|
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 tty_config.setColor(writer, .reset);
|
||||||
try writer.writeAll(" ");
|
try writer.writeAll(" ");
|
||||||
try write_string(writer, file_type.name, max_language_len + 1);
|
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|
|
if (file_type.language_server) |language_server|
|
||||||
try write_checkmark(writer, bin_path.can_execute(allocator, language_server[0]), tty_config);
|
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|
|
if (file_type.formatter) |formatter|
|
||||||
try write_checkmark(writer, bin_path.can_execute(allocator, formatter[0]), tty_config);
|
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");
|
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 {
|
fn write_checkmark(writer: anytype, success: bool, tty_config: std.io.tty.Config) !void {
|
||||||
try tty_config.setColor(writer, if (success) .green else .red);
|
try tty_config.setColor(writer, if (success) .green else .red);
|
||||||
if (success) try writer.writeAll(success_mark) else try writer.writeAll(fail_mark);
|
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 {
|
fn setColorRgb(writer: anytype, color: u24) !void {
|
||||||
const fg_rgb_legacy = "\x1b[38;2;{d};{d};{d}m";
|
const fg_rgb_legacy = "\x1b[38;2;{d};{d};{d}m";
|
||||||
const rgb = RGB.from_u24(color);
|
const rgb = RGB.from_u24(color);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const cbor = @import("cbor");
|
||||||
const log = @import("log");
|
const log = @import("log");
|
||||||
const Style = @import("theme").Style;
|
const Style = @import("theme").Style;
|
||||||
const Color = @import("theme").Color;
|
const Color = @import("theme").Color;
|
||||||
const vaxis = @import("vaxis");
|
pub const vaxis = @import("vaxis");
|
||||||
const input = @import("input");
|
const input = @import("input");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
const RGB = @import("color").RGB;
|
const RGB = @import("color").RGB;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ pub const log_name = "renderer";
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const cbor = @import("cbor");
|
const cbor = @import("cbor");
|
||||||
const vaxis = @import("vaxis");
|
pub const vaxis = @import("vaxis");
|
||||||
const Style = @import("theme").Style;
|
const Style = @import("theme").Style;
|
||||||
const Color = @import("theme").Color;
|
const Color = @import("theme").Color;
|
||||||
pub const CursorShape = vaxis.Cell.CursorShape;
|
pub const CursorShape = vaxis.Cell.CursorShape;
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ const Process = struct {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fn parse_arg0_to_argv(allocator: std.mem.Allocator, arg0: *[]const u8) !tp.message {
|
pub fn parse_arg0_to_argv(allocator: std.mem.Allocator, arg0: *[]const u8) !tp.message {
|
||||||
// this is horribly simplistic
|
// this is horribly simplistic
|
||||||
// TODO: add quotes parsing and workspace variables, etc.
|
// TODO: add quotes parsing and workspace variables, etc.
|
||||||
var args: std.ArrayList([]const u8) = .empty;
|
var args: std.ArrayList([]const u8) = .empty;
|
||||||
|
|
|
||||||
|
|
@ -237,11 +237,11 @@ pub fn walk(self: *const Self, walk_ctx: *anyopaque, f: WalkFn) bool {
|
||||||
return if (self.vtable.walk(self.ptr, walk_ctx, f)) true else f(walk_ctx, self.*);
|
return if (self.vtable.walk(self.ptr, walk_ctx, f)) true else f(walk_ctx, self.*);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus(self: *Self) void {
|
pub fn focus(self: *const Self) void {
|
||||||
self.vtable.focus(self.ptr);
|
self.vtable.focus(self.ptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unfocus(self: *Self) void {
|
pub fn unfocus(self: *const Self) void {
|
||||||
self.vtable.unfocus(self.ptr);
|
self.vtable.unfocus(self.ptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7479,7 +7479,7 @@ pub const EditorWidget = struct {
|
||||||
|
|
||||||
fn mouse_click_event(self: *Self, event: input.Event, btn: input.Mouse, y: c_int, x: c_int, ypx: c_int, xpx: c_int) Result {
|
fn mouse_click_event(self: *Self, event: input.Event, btn: input.Mouse, y: c_int, x: c_int, ypx: c_int, xpx: c_int) Result {
|
||||||
if (event != input.event.press) return;
|
if (event != input.event.press) return;
|
||||||
if (!self.focused) switch (btn) {
|
if (!self.focused or tui.is_keyboard_focused()) switch (btn) {
|
||||||
input.mouse.BUTTON1, input.mouse.BUTTON2, input.mouse.BUTTON3 => _ = tui.set_focus_by_mouse_event(),
|
input.mouse.BUTTON1, input.mouse.BUTTON2, input.mouse.BUTTON3 => _ = tui.set_focus_by_mouse_event(),
|
||||||
else => {},
|
else => {},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/// Expand variables in arg
|
/// Expand variables in arg
|
||||||
/// {{project}} - The path to the current project directory
|
/// {{project}} - The path to the current project directory
|
||||||
|
/// {{project_name}} - The basename of the current project directory
|
||||||
/// {{file}} - The path to the current file
|
/// {{file}} - The path to the current file
|
||||||
/// {{line}} - The line number of the primary cursor
|
/// {{line}} - The line number of the primary cursor
|
||||||
/// {{column}} - The column of the primary cursor
|
/// {{column}} - The column of the primary cursor
|
||||||
|
|
@ -76,6 +77,13 @@ const functions = struct {
|
||||||
return try allocator.dupe(u8, tp.env.get().str("project"));
|
return try allocator.dupe(u8, tp.env.get().str("project"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn project_name(allocator: Allocator) Error![]const u8 {
|
||||||
|
const project_ = tp.env.get().str("project");
|
||||||
|
const basename_begin = std.mem.lastIndexOfScalar(u8, project_, std.fs.path.sep);
|
||||||
|
const basename = if (basename_begin) |begin| project_[begin + 1 ..] else project_;
|
||||||
|
return try allocator.dupe(u8, basename);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn file(allocator: Allocator) Error![]const u8 {
|
pub fn file(allocator: Allocator) Error![]const u8 {
|
||||||
const mv = tui.mainview() orelse return &.{};
|
const mv = tui.mainview() orelse return &.{};
|
||||||
const ed = mv.get_active_editor() orelse return &.{};
|
const ed = mv.get_active_editor() orelse return &.{};
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ const Entry = struct {
|
||||||
pos_type: editor.PosType,
|
pos_type: editor.PosType,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
const self = try allocator.create(Self);
|
const self = try allocator.create(Self);
|
||||||
errdefer allocator.destroy(self);
|
errdefer allocator.destroy(self);
|
||||||
self.* = .{
|
self.* = .{
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const style = struct {
|
||||||
\\open_recent_project
|
\\open_recent_project
|
||||||
\\find_in_files
|
\\find_in_files
|
||||||
\\open_command_palette
|
\\open_command_palette
|
||||||
|
\\open_terminal
|
||||||
\\run_task
|
\\run_task
|
||||||
\\add_task
|
\\add_task
|
||||||
\\open_config
|
\\open_config
|
||||||
|
|
@ -52,6 +53,7 @@ const style = struct {
|
||||||
\\open_recent_project
|
\\open_recent_project
|
||||||
\\find_in_files
|
\\find_in_files
|
||||||
\\open_command_palette
|
\\open_command_palette
|
||||||
|
\\open_terminal
|
||||||
\\run_task
|
\\run_task
|
||||||
\\add_task
|
\\add_task
|
||||||
\\open_config
|
\\open_config
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = @import("std").mem.Allocator;
|
const Allocator = @import("std").mem.Allocator;
|
||||||
const Plane = @import("renderer").Plane;
|
const Plane = @import("renderer").Plane;
|
||||||
|
const command = @import("command");
|
||||||
const Widget = @import("Widget.zig");
|
const Widget = @import("Widget.zig");
|
||||||
const WidgetList = @import("WidgetList.zig");
|
const WidgetList = @import("WidgetList.zig");
|
||||||
const reflow = @import("Buffer").reflow;
|
const reflow = @import("Buffer").reflow;
|
||||||
|
|
@ -19,7 +20,7 @@ widget_type: Widget.Type,
|
||||||
|
|
||||||
const default_widget_type: Widget.Type = .panel;
|
const default_widget_type: Widget.Type = .panel;
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
return create_widget_type(allocator, parent, default_widget_type);
|
return create_widget_type(allocator, parent, default_widget_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const cbor = @import("cbor");
|
||||||
const Plane = @import("renderer").Plane;
|
const Plane = @import("renderer").Plane;
|
||||||
const EventHandler = @import("EventHandler");
|
const EventHandler = @import("EventHandler");
|
||||||
const input = @import("input");
|
const input = @import("input");
|
||||||
|
const command = @import("command");
|
||||||
|
|
||||||
const tui = @import("tui.zig");
|
const tui = @import("tui.zig");
|
||||||
const Widget = @import("Widget.zig");
|
const Widget = @import("Widget.zig");
|
||||||
|
|
@ -33,7 +34,7 @@ const Entry = struct {
|
||||||
};
|
};
|
||||||
const Buffer = ArrayList(Entry);
|
const Buffer = ArrayList(Entry);
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent);
|
var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent);
|
||||||
errdefer n.deinit();
|
errdefer n.deinit();
|
||||||
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const Plane = @import("renderer").Plane;
|
||||||
const style = @import("renderer").style;
|
const style = @import("renderer").style;
|
||||||
const styles = @import("renderer").styles;
|
const styles = @import("renderer").styles;
|
||||||
const EventHandler = @import("EventHandler");
|
const EventHandler = @import("EventHandler");
|
||||||
|
const command = @import("command");
|
||||||
|
|
||||||
const tui = @import("tui.zig");
|
const tui = @import("tui.zig");
|
||||||
const Widget = @import("Widget.zig");
|
const Widget = @import("Widget.zig");
|
||||||
|
|
@ -25,7 +26,7 @@ last_node: usize = 0,
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
const widget_type: Widget.Type = .panel;
|
const widget_type: Widget.Type = .panel;
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
const editor = tui.get_active_editor() orelse return error.NotFound;
|
const editor = tui.get_active_editor() orelse return error.NotFound;
|
||||||
const self = try allocator.create(Self);
|
const self = try allocator.create(Self);
|
||||||
errdefer allocator.destroy(self);
|
errdefer allocator.destroy(self);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const cbor = @import("cbor");
|
||||||
|
|
||||||
const Plane = @import("renderer").Plane;
|
const Plane = @import("renderer").Plane;
|
||||||
const input = @import("input");
|
const input = @import("input");
|
||||||
|
const command = @import("command");
|
||||||
|
|
||||||
const tui = @import("tui.zig");
|
const tui = @import("tui.zig");
|
||||||
const Widget = @import("Widget.zig");
|
const Widget = @import("Widget.zig");
|
||||||
|
|
@ -33,7 +34,7 @@ const Entry = struct {
|
||||||
};
|
};
|
||||||
const Buffer = ArrayList(Entry);
|
const Buffer = ArrayList(Entry);
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent);
|
var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent);
|
||||||
errdefer n.deinit();
|
errdefer n.deinit();
|
||||||
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const array_list = @import("std").array_list;
|
||||||
|
|
||||||
const tp = @import("thespian");
|
const tp = @import("thespian");
|
||||||
const cbor = @import("cbor");
|
const cbor = @import("cbor");
|
||||||
|
const command = @import("command");
|
||||||
|
|
||||||
const Plane = @import("renderer").Plane;
|
const Plane = @import("renderer").Plane;
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ const Level = enum {
|
||||||
err,
|
err,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget {
|
||||||
const self = try allocator.create(Self);
|
const self = try allocator.create(Self);
|
||||||
errdefer allocator.destroy(self);
|
errdefer allocator.destroy(self);
|
||||||
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const filelist_view = @import("filelist_view.zig");
|
||||||
const info_view = @import("info_view.zig");
|
const info_view = @import("info_view.zig");
|
||||||
const input_view = @import("inputview.zig");
|
const input_view = @import("inputview.zig");
|
||||||
const keybind_view = @import("keybindview.zig");
|
const keybind_view = @import("keybindview.zig");
|
||||||
|
const terminal_view = @import("terminal_view.zig");
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
const Commands = command.Collection(cmds);
|
const Commands = command.Collection(cmds);
|
||||||
|
|
@ -58,6 +59,7 @@ buffer_manager: Buffer.Manager,
|
||||||
find_in_files_state: enum { init, adding, done } = .done,
|
find_in_files_state: enum { init, adding, done } = .done,
|
||||||
file_list_type: FileListType = .find_in_files,
|
file_list_type: FileListType = .find_in_files,
|
||||||
panel_height: ?usize = null,
|
panel_height: ?usize = null,
|
||||||
|
panel_maximized: bool = false,
|
||||||
symbols: std.ArrayListUnmanaged(u8) = .empty,
|
symbols: std.ArrayListUnmanaged(u8) = .empty,
|
||||||
symbols_complete: bool = true,
|
symbols_complete: bool = true,
|
||||||
closing_project: bool = false,
|
closing_project: bool = false,
|
||||||
|
|
@ -124,6 +126,7 @@ pub fn create(allocator: std.mem.Allocator) CreateError!Widget {
|
||||||
|
|
||||||
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
|
||||||
self.close_all_panel_views();
|
self.close_all_panel_views();
|
||||||
|
terminal_view.shutdown(allocator);
|
||||||
self.commands.deinit();
|
self.commands.deinit();
|
||||||
self.widgets.deinit(allocator);
|
self.widgets.deinit(allocator);
|
||||||
self.symbols.deinit(allocator);
|
self.symbols.deinit(allocator);
|
||||||
|
|
@ -258,6 +261,10 @@ pub fn handle_resize(self: *Self, pos: Box) void {
|
||||||
if (self.panel_height) |h| if (h >= self.box().h) {
|
if (self.panel_height) |h| if (h >= self.box().h) {
|
||||||
self.panel_height = null;
|
self.panel_height = null;
|
||||||
};
|
};
|
||||||
|
if (self.panel_maximized) {
|
||||||
|
if (self.panels) |panels|
|
||||||
|
panels.layout_ = .{ .static = self.box().h -| 1 };
|
||||||
|
}
|
||||||
self.widgets.handle_resize(pos);
|
self.widgets.handle_resize(pos);
|
||||||
self.floating_views.resize(pos);
|
self.floating_views.resize(pos);
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +286,7 @@ fn bottom_bar_primary_drag(self: *Self, y: usize) tp.result {
|
||||||
};
|
};
|
||||||
const h = self.plane.dim_y();
|
const h = self.plane.dim_y();
|
||||||
self.panel_height = @max(1, h - @min(h, y + 1));
|
self.panel_height = @max(1, h - @min(h, y + 1));
|
||||||
|
self.panel_maximized = false;
|
||||||
panels.layout_ = .{ .static = self.panel_height.? };
|
panels.layout_ = .{ .static = self.panel_height.? };
|
||||||
if (self.panel_height == 1) {
|
if (self.panel_height == 1) {
|
||||||
self.panel_height = null;
|
self.panel_height = null;
|
||||||
|
|
@ -290,7 +298,13 @@ pub fn get_panel_height(self: *Self) usize {
|
||||||
return self.panel_height orelse self.box().h / 5;
|
return self.panel_height orelse self.box().h / 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_panel_view(self: *Self, view: anytype, mode: enum { toggle, enable, disable }) !void {
|
pub const PanelToggleMode = enum { toggle, enable, disable };
|
||||||
|
|
||||||
|
fn toggle_panel_view(self: *Self, view: anytype, mode: PanelToggleMode) !void {
|
||||||
|
return self.toggle_panel_view_with_args(view, mode, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_panel_view_with_args(self: *Self, view: anytype, mode: PanelToggleMode, ctx: command.Context) !void {
|
||||||
if (self.panels) |panels| {
|
if (self.panels) |panels| {
|
||||||
if (self.get_panel(@typeName(view))) |w| {
|
if (self.get_panel(@typeName(view))) |w| {
|
||||||
if (mode != .enable) {
|
if (mode != .enable) {
|
||||||
|
|
@ -302,12 +316,12 @@ fn toggle_panel_view(self: *Self, view: anytype, mode: enum { toggle, enable, di
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mode != .disable)
|
if (mode != .disable)
|
||||||
try panels.add(try view.create(self.allocator, self.widgets.plane));
|
try panels.add(try view.create(self.allocator, self.widgets.plane, ctx));
|
||||||
}
|
}
|
||||||
} else if (mode != .disable) {
|
} else if (mode != .disable) {
|
||||||
const panels = try WidgetList.createH(self.allocator, self.widgets.plane, "panel", .{ .static = self.get_panel_height() });
|
const panels = try WidgetList.createH(self.allocator, self.widgets.plane, "panel", .{ .static = self.get_panel_height() });
|
||||||
try self.widgets.add(panels.widget());
|
try self.widgets.add(panels.widget());
|
||||||
try panels.add(try view.create(self.allocator, self.widgets.plane));
|
try panels.add(try view.create(self.allocator, self.widgets.plane, ctx));
|
||||||
self.panels = panels;
|
self.panels = panels;
|
||||||
}
|
}
|
||||||
tui.resize();
|
tui.resize();
|
||||||
|
|
@ -475,6 +489,7 @@ const cmds = struct {
|
||||||
{
|
{
|
||||||
self.closing_project = true;
|
self.closing_project = true;
|
||||||
defer self.closing_project = false;
|
defer self.closing_project = false;
|
||||||
|
terminal_view.shutdown(self.allocator);
|
||||||
try close_splits(self, .{});
|
try close_splits(self, .{});
|
||||||
try self.close_all_editors();
|
try self.close_all_editors();
|
||||||
self.delete_all_buffers();
|
self.delete_all_buffers();
|
||||||
|
|
@ -911,11 +926,29 @@ const cmds = struct {
|
||||||
try self.toggle_panel_view(keybind_view, .toggle)
|
try self.toggle_panel_view(keybind_view, .toggle)
|
||||||
else if (self.is_panel_view_showing(input_view))
|
else if (self.is_panel_view_showing(input_view))
|
||||||
try self.toggle_panel_view(input_view, .toggle)
|
try self.toggle_panel_view(input_view, .toggle)
|
||||||
|
else if (self.is_panel_view_showing(terminal_view))
|
||||||
|
try self.toggle_panel_view(terminal_view, .toggle)
|
||||||
else
|
else
|
||||||
try self.toggle_panel_view(logview, .toggle);
|
try open_terminal(self, .{});
|
||||||
}
|
}
|
||||||
pub const toggle_panel_meta: Meta = .{ .description = "Toggle panel" };
|
pub const toggle_panel_meta: Meta = .{ .description = "Toggle panel" };
|
||||||
|
|
||||||
|
pub fn toggle_maximize_panel(self: *Self, _: Ctx) Result {
|
||||||
|
const panels = self.panels orelse return;
|
||||||
|
const max_h = self.box().h -| 1;
|
||||||
|
if (self.panel_maximized) {
|
||||||
|
// Restore previous height
|
||||||
|
self.panel_maximized = false;
|
||||||
|
panels.layout_ = .{ .static = self.get_panel_height() };
|
||||||
|
} else {
|
||||||
|
// Maximize: fill screen minus status bar
|
||||||
|
self.panel_maximized = true;
|
||||||
|
panels.layout_ = .{ .static = max_h };
|
||||||
|
}
|
||||||
|
tui.resize();
|
||||||
|
}
|
||||||
|
pub const toggle_maximize_panel_meta: Meta = .{ .description = "Toggle maximize panel" };
|
||||||
|
|
||||||
pub fn toggle_logview(self: *Self, _: Ctx) Result {
|
pub fn toggle_logview(self: *Self, _: Ctx) Result {
|
||||||
try self.toggle_panel_view(logview, .toggle);
|
try self.toggle_panel_view(logview, .toggle);
|
||||||
}
|
}
|
||||||
|
|
@ -946,6 +979,53 @@ const cmds = struct {
|
||||||
}
|
}
|
||||||
pub const show_inspector_view_meta: Meta = .{};
|
pub const show_inspector_view_meta: Meta = .{};
|
||||||
|
|
||||||
|
pub fn toggle_terminal_view(self: *Self, _: Ctx) Result {
|
||||||
|
try self.toggle_panel_view(terminal_view, .toggle);
|
||||||
|
}
|
||||||
|
pub const toggle_terminal_view_meta: Meta = .{ .description = "Toggle terminal" };
|
||||||
|
|
||||||
|
pub fn open_terminal(self: *Self, ctx: Ctx) Result {
|
||||||
|
const have_args = ctx.args.buf.len > 0 and try ctx.args.match(.{ tp.string, tp.more });
|
||||||
|
|
||||||
|
if (have_args and terminal_view.is_vt_running()) {
|
||||||
|
var msg: std.Io.Writer.Allocating = .init(self.allocator);
|
||||||
|
defer msg.deinit();
|
||||||
|
try msg.writer.writeAll("terminal is already running '");
|
||||||
|
try terminal_view.get_running_cmd(&msg.writer);
|
||||||
|
try msg.writer.writeAll("'");
|
||||||
|
return tp.exit(msg.written());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminal_view.is_vt_running()) if (self.get_panel_view(terminal_view)) |vt| {
|
||||||
|
std.log.debug("open_terminal: toggle_focus", .{});
|
||||||
|
vt.toggle_focus();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
var buf: [tp.max_message_size]u8 = undefined;
|
||||||
|
std.log.debug("open_terminal: {s}", .{if (ctx.args.buf.len > 0) ctx.args.to_json(&buf) catch "(error)" else "(none)"});
|
||||||
|
if (self.get_panel_view(terminal_view)) |vt| {
|
||||||
|
try vt.run_cmd(ctx);
|
||||||
|
} else {
|
||||||
|
try self.toggle_panel_view_with_args(terminal_view, .enable, ctx);
|
||||||
|
if (self.get_panel_view(terminal_view)) |vt|
|
||||||
|
vt.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const open_terminal_meta: Meta = .{ .description = "Open terminal" };
|
||||||
|
|
||||||
|
pub fn unfocus_terminal(self: *Self, _: Ctx) Result {
|
||||||
|
if (self.get_panel_view(terminal_view)) |vt|
|
||||||
|
vt.toggle_focus();
|
||||||
|
}
|
||||||
|
pub const unfocus_terminal_meta: Meta = .{};
|
||||||
|
|
||||||
|
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 {
|
pub fn close_find_in_files_results(self: *Self, _: Ctx) Result {
|
||||||
if (self.file_list_type == .find_in_files)
|
if (self.file_list_type == .find_in_files)
|
||||||
try self.toggle_panel_view(filelist_view, .disable);
|
try self.toggle_panel_view(filelist_view, .disable);
|
||||||
|
|
@ -1522,6 +1602,22 @@ const cmds = struct {
|
||||||
}
|
}
|
||||||
pub const move_tab_previous_meta: Meta = .{ .description = "Move tab to previous position" };
|
pub const move_tab_previous_meta: Meta = .{ .description = "Move tab to previous position" };
|
||||||
|
|
||||||
|
pub fn move_tab_next_or_scroll_terminal_down(self: *Self, _: Ctx) Result {
|
||||||
|
if (self.is_panel_view_showing(terminal_view))
|
||||||
|
try command.executeName("terminal_scroll_down", .{})
|
||||||
|
else
|
||||||
|
_ = try self.widgets_widget.msg(.{"move_tab_next"});
|
||||||
|
}
|
||||||
|
pub const move_tab_next_or_scroll_terminal_down_meta: Meta = .{ .description = "Move tab next or scroll terminal down" };
|
||||||
|
|
||||||
|
pub fn move_tab_previous_or_scroll_terminal_up(self: *Self, _: Ctx) Result {
|
||||||
|
if (self.is_panel_view_showing(terminal_view))
|
||||||
|
try command.executeName("terminal_scroll_up", .{})
|
||||||
|
else
|
||||||
|
_ = try self.widgets_widget.msg(.{"move_tab_previous"});
|
||||||
|
}
|
||||||
|
pub const move_tab_previous_or_scroll_terminal_up_meta: Meta = .{ .description = "Move tab previous or scroll terminal up" };
|
||||||
|
|
||||||
pub fn place_next_tab(self: *Self, ctx: Ctx) Result {
|
pub fn place_next_tab(self: *Self, ctx: Ctx) Result {
|
||||||
var pos: enum { before, after } = undefined;
|
var pos: enum { before, after } = undefined;
|
||||||
var buffer_ref: Buffer.Ref = undefined;
|
var buffer_ref: Buffer.Ref = undefined;
|
||||||
|
|
@ -1780,6 +1876,7 @@ pub fn focus_view_by_widget(self: *Self, w: Widget) tui.FocusAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_view(self: *Self, n: usize) !void {
|
pub fn focus_view(self: *Self, n: usize) !void {
|
||||||
|
tui.clear_keyboard_focus();
|
||||||
if (n == self.active_view) return;
|
if (n == self.active_view) return;
|
||||||
if (n > self.views.widgets.items.len) return;
|
if (n > self.views.widgets.items.len) return;
|
||||||
if (n == self.views.widgets.items.len)
|
if (n == self.views.widgets.items.len)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ pub fn load_entries(palette: *Type) !usize {
|
||||||
var longest_hint: usize = 0;
|
var longest_hint: usize = 0;
|
||||||
longest_hint = @max(longest_hint, try add_palette_command(palette, "add_task", hints));
|
longest_hint = @max(longest_hint, try add_palette_command(palette, "add_task", hints));
|
||||||
longest_hint = @max(longest_hint, try add_palette_command(palette, "palette_menu_delete_item", hints));
|
longest_hint = @max(longest_hint, try add_palette_command(palette, "palette_menu_delete_item", hints));
|
||||||
|
longest_hint = @max(longest_hint, try add_palette_command(palette, "run_task_in_terminal", hints));
|
||||||
return longest_hint - @min(longest_hint, longest) + 3;
|
return longest_hint - @min(longest_hint, longest) + 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,13 +130,18 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void {
|
||||||
var entry: Entry = undefined;
|
var entry: Entry = undefined;
|
||||||
var iter = button.opts.label;
|
var iter = button.opts.label;
|
||||||
if (!(cbor.matchValue(&iter, cbor.extract(&entry)) catch false)) return;
|
if (!(cbor.matchValue(&iter, cbor.extract(&entry)) catch false)) return;
|
||||||
|
const activate = menu.*.opts.ctx.activate;
|
||||||
|
menu.*.opts.ctx.activate = .normal;
|
||||||
if (entry.command) |command_name| {
|
if (entry.command) |command_name| {
|
||||||
tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e);
|
tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e);
|
||||||
tp.self_pid().send(.{ "cmd", command_name, .{} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e);
|
tp.self_pid().send(.{ "cmd", command_name, .{} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e);
|
||||||
} else {
|
} else {
|
||||||
tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e);
|
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 {};
|
project_manager.add_task(entry.label) catch {};
|
||||||
tp.self_pid().send(.{ "cmd", "run_task", .{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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
909
src/tui/terminal_view.zig
Normal file
909
src/tui/terminal_view.zig
Normal file
|
|
@ -0,0 +1,909 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
const build_options = @import("build_options");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const tp = @import("thespian");
|
||||||
|
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");
|
||||||
|
const WidgetList = @import("WidgetList.zig");
|
||||||
|
const MessageFilter = @import("MessageFilter.zig");
|
||||||
|
const tui = @import("tui.zig");
|
||||||
|
const input = @import("input");
|
||||||
|
const keybind = @import("keybind");
|
||||||
|
pub const Mode = keybind.Mode;
|
||||||
|
const RGB = @import("color").RGB;
|
||||||
|
|
||||||
|
pub const name = @typeName(Self);
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
const widget_type: Widget.Type = .panel;
|
||||||
|
|
||||||
|
const Terminal = vaxis.widgets.Terminal;
|
||||||
|
const TerminalOnExit = config.TerminalOnExit;
|
||||||
|
|
||||||
|
allocator: Allocator,
|
||||||
|
plane: Plane,
|
||||||
|
focused: bool = false,
|
||||||
|
input_mode: Mode,
|
||||||
|
hover: bool = false,
|
||||||
|
vt: *Vt,
|
||||||
|
last_cmd: ?[]const u8,
|
||||||
|
commands: Commands = undefined,
|
||||||
|
|
||||||
|
pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget {
|
||||||
|
const container = try WidgetList.createHStyled(
|
||||||
|
allocator,
|
||||||
|
parent,
|
||||||
|
"panel_frame",
|
||||||
|
.dynamic,
|
||||||
|
widget_type,
|
||||||
|
);
|
||||||
|
|
||||||
|
var plane = try Plane.init(&(Widget.Box{}).opts(name), parent);
|
||||||
|
errdefer plane.deinit();
|
||||||
|
|
||||||
|
const self = try allocator.create(Self);
|
||||||
|
errdefer allocator.destroy(self);
|
||||||
|
|
||||||
|
self.* = .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.plane = plane,
|
||||||
|
.input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }),
|
||||||
|
.vt = undefined,
|
||||||
|
.last_cmd = null,
|
||||||
|
};
|
||||||
|
try self.run_cmd(ctx);
|
||||||
|
|
||||||
|
try self.commands.init(self);
|
||||||
|
try tui.message_filters().add(MessageFilter.bind(self, receive_filter));
|
||||||
|
|
||||||
|
container.ctx = self;
|
||||||
|
try container.add(Widget.to(self));
|
||||||
|
|
||||||
|
return container.widget();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_cmd(self: *Self, ctx: command.Context) !void {
|
||||||
|
var env = try std.process.getEnvMap(self.allocator);
|
||||||
|
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(self.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(self.allocator, &cmd_arg)
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
defer if (argv_msg) |msg| self.allocator.free(msg.buf);
|
||||||
|
|
||||||
|
var argv_list: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||||
|
defer argv_list.deinit(self.allocator);
|
||||||
|
if (argv_msg) |msg| {
|
||||||
|
var iter = msg.buf;
|
||||||
|
var len = try cbor.decodeArrayHeader(&iter);
|
||||||
|
while (len > 0) : (len -= 1) {
|
||||||
|
var arg: []const u8 = undefined;
|
||||||
|
if (try cbor.matchValue(&iter, cbor.extract(&arg)))
|
||||||
|
try argv_list.append(self.allocator, arg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const default_shell = if (builtin.os.tag == .windows)
|
||||||
|
env.get("COMSPEC") orelse "cmd.exe"
|
||||||
|
else
|
||||||
|
env.get("SHELL") orelse "/bin/sh";
|
||||||
|
try argv_list.append(self.allocator, default_shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the current plane dimensions for the initial pty size. The plane
|
||||||
|
// starts at 0×0 before the first resize, so use a sensible fallback
|
||||||
|
// so the pty isn't created with a zero-cell screen.
|
||||||
|
const cols: u16 = @intCast(@max(80, self.plane.dim_x()));
|
||||||
|
const rows: u16 = @intCast(@max(24, self.plane.dim_y()));
|
||||||
|
|
||||||
|
if (global_vt) |*vt| {
|
||||||
|
if (!vt.process_exited) {
|
||||||
|
var msg: std.Io.Writer.Allocating = .init(self.allocator);
|
||||||
|
defer msg.deinit();
|
||||||
|
try msg.writer.writeAll("terminal is already running '");
|
||||||
|
try get_running_cmd(&msg.writer);
|
||||||
|
try msg.writer.writeAll("'");
|
||||||
|
return tp.exit(msg.written());
|
||||||
|
}
|
||||||
|
vt.deinit(self.allocator);
|
||||||
|
global_vt = null;
|
||||||
|
}
|
||||||
|
try Vt.init(self.allocator, argv_list.items, env, rows, cols, on_exit);
|
||||||
|
self.vt = &global_vt.?;
|
||||||
|
|
||||||
|
if (self.last_cmd) |cmd| {
|
||||||
|
self.allocator.free(cmd);
|
||||||
|
self.last_cmd = null;
|
||||||
|
}
|
||||||
|
self.last_cmd = try self.allocator.dupe(u8, ctx.args.buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn re_run_cmd(self: *Self) !void {
|
||||||
|
return if (self.last_cmd) |cmd|
|
||||||
|
self.run_cmd(.{ .args = .{ .buf = cmd } })
|
||||||
|
else
|
||||||
|
tp.exit("no command to re-run");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
||||||
|
if (try m.match(.{ "terminal_view", "output" })) {
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
} else if (try m.match(.{ "H", tp.extract(&self.hover) })) {
|
||||||
|
tui.rdr().request_mouse_cursor_default(self.hover);
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Mouse button press - set focus first, then forward to terminal if reporting is on
|
||||||
|
{
|
||||||
|
var btn: i64 = 0;
|
||||||
|
var col: i64 = 0;
|
||||||
|
var row: i64 = 0;
|
||||||
|
var xoffset: i64 = 0;
|
||||||
|
var yoffset: i64 = 0;
|
||||||
|
if (try m.match(.{ "B", input.event.press, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) }) or
|
||||||
|
try m.match(.{ "B", input.event.release, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) }))
|
||||||
|
{
|
||||||
|
const button: vaxis.Mouse.Button = @enumFromInt(btn);
|
||||||
|
const is_press = try m.match(.{ "B", input.event.press, tp.more });
|
||||||
|
// Set focus on left/middle/right button press
|
||||||
|
if (is_press) switch (button) {
|
||||||
|
.left, .middle, .right => switch (tui.set_focus_by_mouse_event()) {
|
||||||
|
.changed => return true,
|
||||||
|
.same, .notfound => {},
|
||||||
|
},
|
||||||
|
// Scroll wheel: forward to vt if reporting active, else scroll scrollback
|
||||||
|
.wheel_up => {
|
||||||
|
if (self.vt.vt.mode.mouse == .none) {
|
||||||
|
if (self.vt.vt.scroll(3)) tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.wheel_down => {
|
||||||
|
if (self.vt.vt.mode.mouse == .none) {
|
||||||
|
if (self.vt.vt.scroll(-3)) tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
// Forward to vt if terminal mouse reporting is active
|
||||||
|
if (self.focused and self.vt.vt.mode.mouse != .none) {
|
||||||
|
const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col));
|
||||||
|
const mouse_event: vaxis.Mouse = .{
|
||||||
|
.col = @intCast(rel[1]),
|
||||||
|
.row = @intCast(rel[0]),
|
||||||
|
.xoffset = @intCast(xoffset),
|
||||||
|
.yoffset = @intCast(yoffset),
|
||||||
|
.button = button,
|
||||||
|
.mods = .{},
|
||||||
|
.type = if (is_press) .press else .release,
|
||||||
|
};
|
||||||
|
self.vt.vt.update(.{ .mouse = mouse_event }) catch {};
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Mouse drag
|
||||||
|
if (try m.match(.{ "D", input.event.press, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) })) {
|
||||||
|
if (self.focused and self.vt.vt.mode.mouse != .none) {
|
||||||
|
const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col));
|
||||||
|
const mouse_event: vaxis.Mouse = .{
|
||||||
|
.col = @intCast(rel[1]),
|
||||||
|
.row = @intCast(rel[0]),
|
||||||
|
.xoffset = @intCast(xoffset),
|
||||||
|
.yoffset = @intCast(yoffset),
|
||||||
|
.button = @enumFromInt(btn),
|
||||||
|
.mods = .{},
|
||||||
|
.type = .drag,
|
||||||
|
};
|
||||||
|
self.vt.vt.update(.{ .mouse = mouse_event }) catch {};
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Mouse motion (no button held)
|
||||||
|
if (try m.match(.{ "M", tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) })) {
|
||||||
|
if (self.focused and self.vt.vt.mode.mouse == .any_event) {
|
||||||
|
const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col));
|
||||||
|
const mouse_event: vaxis.Mouse = .{
|
||||||
|
.col = @intCast(rel[1]),
|
||||||
|
.row = @intCast(rel[0]),
|
||||||
|
.xoffset = @intCast(xoffset),
|
||||||
|
.yoffset = @intCast(yoffset),
|
||||||
|
.button = .none,
|
||||||
|
.mods = .{},
|
||||||
|
.type = .motion,
|
||||||
|
};
|
||||||
|
self.vt.vt.update(.{ .mouse = mouse_event }) catch {};
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(try m.match(.{ "I", tp.more })))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!self.focused) return false;
|
||||||
|
|
||||||
|
if (try self.input_mode.bindings.receive(from, m))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var event: input.Event = 0;
|
||||||
|
var keypress: input.Key = 0;
|
||||||
|
var keypress_shifted: input.Key = 0;
|
||||||
|
var text: []const u8 = "";
|
||||||
|
var modifiers: u8 = 0;
|
||||||
|
|
||||||
|
if (!try m.match(.{ "I", tp.extract(&event), tp.extract(&keypress), tp.extract(&keypress_shifted), tp.extract(&text), tp.extract(&modifiers) }))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only forward press and repeat events; ignore releases.
|
||||||
|
if (event != input.event.press and event != input.event.repeat) return true;
|
||||||
|
const key: vaxis.Key = .{
|
||||||
|
.codepoint = keypress,
|
||||||
|
.shifted_codepoint = if (keypress_shifted != keypress) keypress_shifted else null,
|
||||||
|
.mods = @bitCast(modifiers),
|
||||||
|
.text = if (text.len > 0) text else null,
|
||||||
|
};
|
||||||
|
if (self.vt.process_exited) {
|
||||||
|
if (keypress == input.key.enter) {
|
||||||
|
self.re_run_cmd() 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});
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_focus(self: *Self) void {
|
||||||
|
if (self.focused) self.unfocus() else self.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus(self: *Self) void {
|
||||||
|
self.focused = true;
|
||||||
|
tui.set_keyboard_focus(Widget.to(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unfocus(self: *Self) void {
|
||||||
|
self.focused = false;
|
||||||
|
tui.release_keyboard_focus(Widget.to(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Self, allocator: Allocator) void {
|
||||||
|
if (self.last_cmd) |cmd| {
|
||||||
|
self.allocator.free(cmd);
|
||||||
|
self.last_cmd = null;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
allocator.destroy(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(allocator: Allocator) void {
|
||||||
|
if (global_vt) |*vt| {
|
||||||
|
vt.deinit(allocator);
|
||||||
|
global_vt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(self: *Self, theme: *const Widget.Theme) bool {
|
||||||
|
// Drain the vt event queue.
|
||||||
|
while (self.vt.vt.tryEvent()) |event| {
|
||||||
|
switch (event) {
|
||||||
|
.exited => |code| {
|
||||||
|
self.vt.process_exited = true;
|
||||||
|
self.handle_child_exit(code);
|
||||||
|
tui.need_render(@src());
|
||||||
|
},
|
||||||
|
.redraw, .bell => {},
|
||||||
|
.pwd_change => |path| {
|
||||||
|
self.vt.cwd.clearRetainingCapacity();
|
||||||
|
self.vt.cwd.appendSlice(self.allocator, path) catch {};
|
||||||
|
},
|
||||||
|
.title_change => |t| {
|
||||||
|
self.vt.title.clearRetainingCapacity();
|
||||||
|
self.vt.title.appendSlice(self.allocator, t) catch {};
|
||||||
|
},
|
||||||
|
.color_change => |cc| {
|
||||||
|
self.vt.app_fg = cc.fg;
|
||||||
|
self.vt.app_bg = cc.bg;
|
||||||
|
self.vt.app_cursor = cc.cursor;
|
||||||
|
},
|
||||||
|
.osc_copy => |text| {
|
||||||
|
// Terminal app wrote to clipboard via OSC 52.
|
||||||
|
// Add to flow clipboard history and forward to system clipboard.
|
||||||
|
const owned = tui.clipboard_allocator().dupe(u8, text) catch break;
|
||||||
|
tui.clipboard_clear_all();
|
||||||
|
tui.clipboard_start_group();
|
||||||
|
tui.clipboard_add_chunk(owned);
|
||||||
|
tui.clipboard_send_to_system() catch {};
|
||||||
|
},
|
||||||
|
.osc_paste_request => {
|
||||||
|
// Terminal app requested clipboard contents via OSC 52.
|
||||||
|
// Assemble from flow clipboard history and respond.
|
||||||
|
if (tui.clipboard_get_history()) |history| {
|
||||||
|
var buf: std.Io.Writer.Allocating = .init(self.allocator);
|
||||||
|
defer buf.deinit();
|
||||||
|
var first = true;
|
||||||
|
for (history) |chunk| {
|
||||||
|
if (first) first = false else buf.writer.writeByte('\n') catch break;
|
||||||
|
buf.writer.writeAll(chunk.text) catch break;
|
||||||
|
}
|
||||||
|
self.vt.vt.respondOsc52Paste(buf.written());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the terminal's fg/bg color cache from the current theme so that
|
||||||
|
// OSC 10/11 colour queries return accurate values.
|
||||||
|
if (theme.editor.fg) |fg| {
|
||||||
|
const c = fg.color;
|
||||||
|
self.vt.vt.fg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) };
|
||||||
|
}
|
||||||
|
if (theme.editor.bg) |bg| {
|
||||||
|
const c = bg.color;
|
||||||
|
self.vt.vt.bg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blit the terminal's front screen into our vaxis.Window.
|
||||||
|
const software_cursor = build_options.gui or !tui.config().enable_terminal_cursor;
|
||||||
|
const focused_cursor_color: ?[3]u8 = if (theme.editor_cursor.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else null;
|
||||||
|
const unfocused_cursor_color: ?[3]u8 = if (theme.editor_cursor_secondary.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else focused_cursor_color;
|
||||||
|
self.vt.vt.draw(self.allocator, self.plane.window, self.focused and tui.terminal_has_focus(), software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| {
|
||||||
|
std.log.err("terminal_view: draw failed: {}", .{e});
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
const w = &msg.writer;
|
||||||
|
w.writeAll("\r\n") catch {};
|
||||||
|
w.writeAll("\x1b[0m\x1b[2m") catch {};
|
||||||
|
w.writeAll("[process exited") catch {};
|
||||||
|
if (code != 0)
|
||||||
|
w.print(" with code {d}", .{code}) catch {};
|
||||||
|
w.writeAll("]") catch {};
|
||||||
|
// Re-run prompt
|
||||||
|
const cmd_argv = self.vt.vt.cmd.argv;
|
||||||
|
if (cmd_argv.len > 0) {
|
||||||
|
w.writeAll(" Press enter to re-run '") catch {};
|
||||||
|
_ = argv.write(w, cmd_argv) catch {};
|
||||||
|
w.writeAll("' or escape to close") catch {};
|
||||||
|
} else {
|
||||||
|
w.writeAll(" Press esc to close") 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 {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
self.vt.resize(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool {
|
||||||
|
if (m.match(.{ "terminal_view", "output" }) catch false) {
|
||||||
|
tui.need_render(@src());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Commands = command.Collection(cmds);
|
||||||
|
|
||||||
|
const cmds = struct {
|
||||||
|
pub const Target = Self;
|
||||||
|
const Ctx = command.Context;
|
||||||
|
const Meta = command.Metadata;
|
||||||
|
const Result = command.Result;
|
||||||
|
|
||||||
|
pub fn terminal_scroll_up(self: *Self, _: Ctx) Result {
|
||||||
|
const half_page = @max(1, self.vt.vt.front_screen.height / 2);
|
||||||
|
if (self.vt.vt.scroll(@intCast(half_page)))
|
||||||
|
tui.need_render(@src());
|
||||||
|
}
|
||||||
|
pub const terminal_scroll_up_meta: Meta = .{ .description = "Terminal: Scroll up" };
|
||||||
|
|
||||||
|
pub fn terminal_scroll_down(self: *Self, _: Ctx) Result {
|
||||||
|
const half_page = @max(1, self.vt.vt.front_screen.height / 2);
|
||||||
|
if (self.vt.vt.scroll(-@as(i32, @intCast(half_page))))
|
||||||
|
tui.need_render(@src());
|
||||||
|
}
|
||||||
|
pub const terminal_scroll_down_meta: Meta = .{ .description = "Terminal: Scroll down" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const Vt = struct {
|
||||||
|
vt: Terminal,
|
||||||
|
env: std.process.EnvMap,
|
||||||
|
write_buf: [4096]u8,
|
||||||
|
pty_pid: ?tp.pid = null,
|
||||||
|
cwd: std.ArrayListUnmanaged(u8) = .empty,
|
||||||
|
title: std.ArrayListUnmanaged(u8) = .empty,
|
||||||
|
/// App-specified override colours (from OSC 10/11/12). null = use theme.
|
||||||
|
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, 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 = .{
|
||||||
|
.vt = undefined,
|
||||||
|
.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,
|
||||||
|
cmd_argv,
|
||||||
|
&env,
|
||||||
|
.{
|
||||||
|
.winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 },
|
||||||
|
.scrollback_size = tui.config().terminal_scrollback_size,
|
||||||
|
.initial_working_directory = blk: {
|
||||||
|
const project = tp.env.get().str("project");
|
||||||
|
break :blk if (project.len > 0) project else home;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&self.write_buf,
|
||||||
|
);
|
||||||
|
|
||||||
|
try self.vt.spawn();
|
||||||
|
self.pty_pid = try pty.spawn(allocator, &self.vt);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deinit(self: *@This(), allocator: std.mem.Allocator) void {
|
||||||
|
self.cwd.deinit(allocator);
|
||||||
|
self.title.deinit(allocator);
|
||||||
|
if (self.pty_pid) |pid| {
|
||||||
|
pid.send(.{"quit"}) catch {};
|
||||||
|
pid.deinit();
|
||||||
|
self.pty_pid = null;
|
||||||
|
}
|
||||||
|
self.vt.deinit();
|
||||||
|
self.env.deinit();
|
||||||
|
std.log.debug("terminal: vt destroyed", .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(self: *@This(), pos: Widget.Box) void {
|
||||||
|
const cols: u16 = @intCast(@max(1, pos.w));
|
||||||
|
const rows: u16 = @intCast(@max(1, pos.h));
|
||||||
|
self.vt.resize(.{
|
||||||
|
.rows = rows,
|
||||||
|
.cols = cols,
|
||||||
|
.x_pixel = 0,
|
||||||
|
.y_pixel = 0,
|
||||||
|
}) catch |e| {
|
||||||
|
std.log.err("terminal: resize failed: {}", .{e});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var global_vt: ?Vt = null;
|
||||||
|
|
||||||
|
pub fn is_vt_running() bool {
|
||||||
|
return if (global_vt) |vt| !vt.process_exited else false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_running_cmd(writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||||
|
const cmd_argv = if (global_vt) |vt| vt.vt.cmd.argv else &.{};
|
||||||
|
if (cmd_argv.len > 0) {
|
||||||
|
_ = argv.write(writer, cmd_argv) catch {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific pty actor: POSIX uses tp.file_descriptor + SIGCHLD,
|
||||||
|
// Windows uses tp.file_stream with IOCP overlapped reads on the ConPTY output pipe.
|
||||||
|
const pty = if (builtin.os.tag == .windows) pty_windows else pty_posix;
|
||||||
|
|
||||||
|
const pty_posix = struct {
|
||||||
|
const Parser = Terminal.Parser;
|
||||||
|
|
||||||
|
const Receiver = tp.Receiver(*@This());
|
||||||
|
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
vt: *Terminal,
|
||||||
|
fd: tp.file_descriptor,
|
||||||
|
pty_fd: std.posix.fd_t,
|
||||||
|
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());
|
||||||
|
errdefer allocator.destroy(self);
|
||||||
|
self.* = .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.vt = vt,
|
||||||
|
.fd = undefined,
|
||||||
|
.pty_fd = vt.ptyFd(),
|
||||||
|
.parser = .{ .buf = try .initCapacity(allocator, 128) },
|
||||||
|
.receiver = Receiver.init(pty_receive, self),
|
||||||
|
.parent = tp.self_pid().clone(),
|
||||||
|
};
|
||||||
|
return tp.spawn_link(allocator, self, start, "pty_actor");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
self.allocator.destroy(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(self: *@This()) tp.result {
|
||||||
|
errdefer self.deinit();
|
||||||
|
self.fd = tp.file_descriptor.init("pty", self.pty_fd) catch |e| {
|
||||||
|
std.log.debug("terminal: pty fd init failed: {}", .{e});
|
||||||
|
return tp.exit_error(e, @errorReturnTrace());
|
||||||
|
};
|
||||||
|
self.fd.wait_read() catch |e| {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result {
|
||||||
|
errdefer self.deinit();
|
||||||
|
|
||||||
|
if (try m.match(.{ "fd", "pty", "read_ready" })) {
|
||||||
|
self.read_and_process() catch |e| return switch (e) {
|
||||||
|
error.Terminated => {
|
||||||
|
std.log.debug("terminal: pty exiting: read loop terminated (process exited)", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
},
|
||||||
|
error.InputOutput => {
|
||||||
|
std.log.debug("terminal: pty exiting: EIO on read (process exited)", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
},
|
||||||
|
error.SendFailed => {
|
||||||
|
std.log.debug("terminal: pty exiting: send to parent failed", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
},
|
||||||
|
error.Unexpected => {
|
||||||
|
std.log.debug("terminal: pty exiting: unexpected error (see preceding log)", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) {
|
||||||
|
// thespian fires read_error when the pty fd signals an error condition
|
||||||
|
// 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();
|
||||||
|
} else {
|
||||||
|
std.log.debug("terminal: pty exiting: unexpected message", .{});
|
||||||
|
return tp.unexpected(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_and_process(self: *@This()) error{ Terminated, InputOutput, SendFailed, Unexpected }!void {
|
||||||
|
var buf: [4096]u8 = undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) {
|
||||||
|
error.WouldBlock => {
|
||||||
|
// No more data right now. On Linux, a clean child exit may not
|
||||||
|
// generate a readable event on the pty master - it just starts
|
||||||
|
// returning EIO. Poll for exit here before sleeping in wait_read.
|
||||||
|
// On macOS/FreeBSD the pty master raises EIO directly, so the
|
||||||
|
// try_wait check here is just an extra safety net.
|
||||||
|
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});
|
||||||
|
self.vt.event_queue.push(.{ .exited = code });
|
||||||
|
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
return error.InputOutput;
|
||||||
|
},
|
||||||
|
error.SystemResources,
|
||||||
|
error.IsDir,
|
||||||
|
error.OperationAborted,
|
||||||
|
error.BrokenPipe,
|
||||||
|
error.ConnectionResetByPeer,
|
||||||
|
error.ConnectionTimedOut,
|
||||||
|
error.NotOpenForReading,
|
||||||
|
error.SocketNotConnected,
|
||||||
|
error.Canceled,
|
||||||
|
error.AccessDenied,
|
||||||
|
error.ProcessNotFound,
|
||||||
|
error.LockViolation,
|
||||||
|
error.Unexpected,
|
||||||
|
=> {
|
||||||
|
std.log.debug("terminal: read unexpected error: {} (pid={?})", .{ e, self.vt.cmd.pid });
|
||||||
|
return error.Unexpected;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (n == 0) {
|
||||||
|
const code = self.vt.cmd.wait();
|
||||||
|
std.log.debug("terminal: read returned 0 bytes (EOF), process exited with code={d}", .{code});
|
||||||
|
self.vt.event_queue.push(.{ .exited = code });
|
||||||
|
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
return error.Terminated;
|
||||||
|
}
|
||||||
|
|
||||||
|
defer self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
|
||||||
|
switch (self.vt.processOutput(&self.parser, buf[0..n]) catch |e| switch (e) {
|
||||||
|
error.WriteFailed,
|
||||||
|
error.ReadFailed,
|
||||||
|
error.OutOfMemory,
|
||||||
|
=> {
|
||||||
|
std.log.debug("terminal: processOutput error: {} (pid={?})", .{ e, self.vt.cmd.pid });
|
||||||
|
return error.Unexpected;
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
.exited => {
|
||||||
|
std.log.debug("terminal: processOutput returned .exited (process EOF)", .{});
|
||||||
|
return error.Terminated;
|
||||||
|
},
|
||||||
|
.running => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
return error.Unexpected;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Windows pty actor: reads ConPTY output pipe via tp.file_stream (IOCP overlapped I/O).
|
||||||
|
///
|
||||||
|
/// Exit detection: ConPTY does NOT close the output pipe when the child process exits -
|
||||||
|
/// it keeps it open until ClosePseudoConsole is called. So a pending async read would
|
||||||
|
/// block forever. Instead we use RegisterWaitForSingleObject on the process handle;
|
||||||
|
/// when it fires the threadpool callback posts "child_exited" to this actor, which
|
||||||
|
/// cancels the stream and tears down cleanly.
|
||||||
|
const pty_windows = struct {
|
||||||
|
const Parser = Terminal.Parser;
|
||||||
|
const Receiver = tp.Receiver(*@This());
|
||||||
|
const windows = std.os.windows;
|
||||||
|
|
||||||
|
// Context struct allocated on the heap and passed to the wait callback.
|
||||||
|
// Heap-allocated so its lifetime is independent of the actor.
|
||||||
|
const WaitCtx = struct {
|
||||||
|
self_pid: tp.pid,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
};
|
||||||
|
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
vt: *Terminal,
|
||||||
|
stream: ?tp.file_stream = null,
|
||||||
|
parser: Parser,
|
||||||
|
receiver: Receiver,
|
||||||
|
parent: tp.pid,
|
||||||
|
wait_handle: ?windows.HANDLE = null,
|
||||||
|
|
||||||
|
pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid {
|
||||||
|
const self = try allocator.create(@This());
|
||||||
|
errdefer allocator.destroy(self);
|
||||||
|
self.* = .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.vt = vt,
|
||||||
|
.parser = .{ .buf = try .initCapacity(allocator, 128) },
|
||||||
|
.receiver = Receiver.init(pty_receive, self),
|
||||||
|
.parent = tp.self_pid().clone(),
|
||||||
|
};
|
||||||
|
return tp.spawn_link(allocator, self, start, "pty_actor");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deinit(self: *@This()) void {
|
||||||
|
std.log.debug("terminal: pty actor (windows) deinit", .{});
|
||||||
|
if (self.wait_handle) |wh| {
|
||||||
|
_ = UnregisterWait(wh);
|
||||||
|
self.wait_handle = null;
|
||||||
|
}
|
||||||
|
if (self.stream) |s| s.deinit();
|
||||||
|
self.parser.buf.deinit();
|
||||||
|
self.parent.deinit();
|
||||||
|
self.allocator.destroy(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(self: *@This()) tp.result {
|
||||||
|
errdefer self.deinit();
|
||||||
|
self.stream = tp.file_stream.init("pty_out", self.vt.ptyOutputHandle()) catch |e| {
|
||||||
|
std.log.debug("terminal: pty stream init failed: {}", .{e});
|
||||||
|
return tp.exit_error(e, @errorReturnTrace());
|
||||||
|
};
|
||||||
|
self.stream.?.start_read() catch |e| {
|
||||||
|
std.log.debug("terminal: pty stream start_read failed: {}", .{e});
|
||||||
|
return tp.exit_error(e, @errorReturnTrace());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register a one-shot wait on the process handle. When the child exits
|
||||||
|
// the threadpool fires on_child_exit, which sends "child_exited" to us.
|
||||||
|
// This is the only reliable way to detect ConPTY child exit without polling,
|
||||||
|
// since ConPTY keeps the output pipe open until ClosePseudoConsole.
|
||||||
|
const process_handle = self.vt.cmd.process_handle orelse {
|
||||||
|
std.log.debug("terminal: pty actor: no process handle to wait on", .{});
|
||||||
|
return tp.exit_error(error.NoProcessHandle, @errorReturnTrace());
|
||||||
|
};
|
||||||
|
const ctx = self.allocator.create(WaitCtx) catch |e|
|
||||||
|
return tp.exit_error(e, @errorReturnTrace());
|
||||||
|
ctx.* = .{
|
||||||
|
.self_pid = tp.self_pid().clone(),
|
||||||
|
.allocator = self.allocator,
|
||||||
|
};
|
||||||
|
var wh: windows.HANDLE = undefined;
|
||||||
|
// WT_EXECUTEONLYONCE: callback fires once then the wait is auto-unregistered.
|
||||||
|
const WT_EXECUTEONLYONCE: windows.ULONG = 0x00000008;
|
||||||
|
if (RegisterWaitForSingleObject(&wh, process_handle, on_child_exit, ctx, windows.INFINITE, WT_EXECUTEONLYONCE) == windows.FALSE) {
|
||||||
|
ctx.self_pid.deinit();
|
||||||
|
self.allocator.destroy(ctx);
|
||||||
|
std.log.debug("terminal: RegisterWaitForSingleObject failed", .{});
|
||||||
|
return tp.exit_error(error.RegisterWaitFailed, @errorReturnTrace());
|
||||||
|
}
|
||||||
|
self.wait_handle = wh;
|
||||||
|
|
||||||
|
tp.receive(&self.receiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Threadpool callback - called when the process handle becomes signaled.
|
||||||
|
/// Must be fast and non-blocking. Sends "child_exited" to the pty actor.
|
||||||
|
fn on_child_exit(ctx_ptr: ?*anyopaque, _: windows.BOOLEAN) callconv(.winapi) void {
|
||||||
|
const ctx: *WaitCtx = @ptrCast(@alignCast(ctx_ptr orelse return));
|
||||||
|
defer {
|
||||||
|
ctx.self_pid.deinit();
|
||||||
|
ctx.allocator.destroy(ctx);
|
||||||
|
}
|
||||||
|
ctx.self_pid.send(.{"child_exited"}) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result {
|
||||||
|
errdefer self.deinit();
|
||||||
|
|
||||||
|
var bytes: []const u8 = "";
|
||||||
|
var err_code: i64 = 0;
|
||||||
|
var err_msg: []const u8 = "";
|
||||||
|
|
||||||
|
if (try m.match(.{"child_exited"})) {
|
||||||
|
self.wait_handle = null;
|
||||||
|
if (self.stream) |s| s.cancel() catch {};
|
||||||
|
const code = self.vt.cmd.wait();
|
||||||
|
std.log.debug("terminal: child exited (process wait), code={d}", .{code});
|
||||||
|
self.vt.event_queue.push(.{ .exited = code });
|
||||||
|
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
return tp.exit_normal();
|
||||||
|
} else if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) {
|
||||||
|
defer self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
switch (self.vt.processOutput(&self.parser, bytes) catch |e| {
|
||||||
|
std.log.debug("terminal: processOutput error: {}", .{e});
|
||||||
|
return tp.exit_normal();
|
||||||
|
}) {
|
||||||
|
.exited => {
|
||||||
|
std.log.debug("terminal: processOutput returned .exited", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
},
|
||||||
|
.running => {},
|
||||||
|
}
|
||||||
|
self.stream.?.start_read() catch |e| {
|
||||||
|
std.log.debug("terminal: pty stream re-arm failed: {}", .{e});
|
||||||
|
return tp.exit_normal();
|
||||||
|
};
|
||||||
|
} else if (try m.match(.{ "stream", "pty_out", "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) {
|
||||||
|
std.log.debug("terminal: ConPTY stream error: {d} {s}", .{ err_code, err_msg });
|
||||||
|
const code = self.vt.cmd.wait();
|
||||||
|
self.vt.event_queue.push(.{ .exited = code });
|
||||||
|
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||||||
|
return tp.exit_normal();
|
||||||
|
} else if (try m.match(.{"quit"})) {
|
||||||
|
std.log.debug("terminal: pty actor (windows) received quit", .{});
|
||||||
|
return tp.exit_normal();
|
||||||
|
} else {
|
||||||
|
std.log.debug("terminal: pty actor (windows) unexpected message", .{});
|
||||||
|
return tp.unexpected(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win32 extern declarations
|
||||||
|
extern "kernel32" fn RegisterWaitForSingleObject(
|
||||||
|
phNewWaitObject: *windows.HANDLE,
|
||||||
|
hObject: windows.HANDLE,
|
||||||
|
Callback: *const fn (?*anyopaque, windows.BOOLEAN) callconv(.winapi) void,
|
||||||
|
Context: ?*anyopaque,
|
||||||
|
dwMilliseconds: windows.DWORD,
|
||||||
|
dwFlags: windows.ULONG,
|
||||||
|
) callconv(.winapi) windows.BOOL;
|
||||||
|
|
||||||
|
extern "kernel32" fn UnregisterWait(
|
||||||
|
WaitHandle: windows.HANDLE,
|
||||||
|
) callconv(.winapi) windows.BOOL;
|
||||||
|
};
|
||||||
|
|
@ -53,8 +53,10 @@ delayed_init_input_mode: ?Mode = null,
|
||||||
input_mode_outer_: ?Mode = null,
|
input_mode_outer_: ?Mode = null,
|
||||||
input_listeners_: EventHandler.List,
|
input_listeners_: EventHandler.List,
|
||||||
keyboard_focus: ?Widget = null,
|
keyboard_focus: ?Widget = null,
|
||||||
|
keyboard_focus_outer: ?Widget = null,
|
||||||
mini_mode_: ?MiniMode = null,
|
mini_mode_: ?MiniMode = null,
|
||||||
hover_focus: ?Widget = null,
|
hover_focus: ?Widget = null,
|
||||||
|
terminal_focus: bool = true,
|
||||||
last_hover_x: c_int = -1,
|
last_hover_x: c_int = -1,
|
||||||
last_hover_y: c_int = -1,
|
last_hover_y: c_int = -1,
|
||||||
commands: Commands = undefined,
|
commands: Commands = undefined,
|
||||||
|
|
@ -518,15 +520,19 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (try m.match(.{"focus_in"})) {
|
if (try m.match(.{"focus_in"})) {
|
||||||
|
self.terminal_focus = true;
|
||||||
std.log.debug("focus_in", .{});
|
std.log.debug("focus_in", .{});
|
||||||
|
need_render(@src());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (try m.match(.{"focus_out"})) {
|
if (try m.match(.{"focus_out"})) {
|
||||||
|
self.terminal_focus = false;
|
||||||
std.log.debug("focus_out", .{});
|
std.log.debug("focus_out", .{});
|
||||||
self.clear_hover_focus(@src()) catch {};
|
self.clear_hover_focus(@src()) catch {};
|
||||||
self.last_hover_x = -1;
|
self.last_hover_x = -1;
|
||||||
self.last_hover_y = -1;
|
self.last_hover_y = -1;
|
||||||
|
need_render(@src());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -833,22 +839,54 @@ pub const FocusAction = enum { same, changed, notfound };
|
||||||
|
|
||||||
pub fn set_focus_by_widget(w: Widget) FocusAction {
|
pub fn set_focus_by_widget(w: Widget) FocusAction {
|
||||||
const mv = mainview() orelse return .notfound;
|
const mv = mainview() orelse return .notfound;
|
||||||
|
clear_keyboard_focus();
|
||||||
return mv.focus_view_by_widget(w);
|
return mv.focus_view_by_widget(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_focus_by_mouse_event() FocusAction {
|
pub fn set_focus_by_mouse_event() FocusAction {
|
||||||
const self = current();
|
const self = current();
|
||||||
const mv = mainview() orelse return .notfound;
|
const mv = mainview() orelse return .notfound;
|
||||||
return mv.focus_view_by_widget(self.hover_focus orelse return .notfound);
|
const hover_focus = self.hover_focus orelse return .notfound;
|
||||||
|
const keyboard_focus = if (self.keyboard_focus) |prev| prev.ptr else null;
|
||||||
|
if (hover_focus.ptr == keyboard_focus) return .same;
|
||||||
|
clear_keyboard_focus();
|
||||||
|
switch (mv.focus_view_by_widget(hover_focus)) {
|
||||||
|
.notfound => {},
|
||||||
|
else => |action| return action,
|
||||||
|
}
|
||||||
|
hover_focus.focus();
|
||||||
|
return .changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_keyboard_focused() bool {
|
||||||
|
const self = current();
|
||||||
|
return self.keyboard_focus != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_keyboard_focus(w: Widget) void {
|
||||||
|
const self = current();
|
||||||
|
if (self.keyboard_focus) |prev| prev.unfocus();
|
||||||
|
self.keyboard_focus = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_keyboard_focus(w: Widget) void {
|
||||||
|
const self = current();
|
||||||
|
if (self.keyboard_focus) |cur| if (cur.ptr == w.ptr) {
|
||||||
|
self.keyboard_focus = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_keyboard_focus() void {
|
||||||
|
const self = current();
|
||||||
|
if (self.keyboard_focus) |prev| prev.unfocus();
|
||||||
|
self.keyboard_focus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
||||||
const frame = tracy.initZone(@src(), .{ .name = "tui widgets" });
|
const frame = tracy.initZone(@src(), .{ .name = "tui widgets" });
|
||||||
defer frame.deinit();
|
defer frame.deinit();
|
||||||
tp.trace(tp.channel.widget, m);
|
tp.trace(tp.channel.widget, m);
|
||||||
return if (self.keyboard_focus) |w|
|
return if (self.mainview_) |mv|
|
||||||
w.send(from, m)
|
|
||||||
else if (self.mainview_) |mv|
|
|
||||||
mv.send(from, m)
|
mv.send(from, m)
|
||||||
else
|
else
|
||||||
false;
|
false;
|
||||||
|
|
@ -857,10 +895,6 @@ fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
||||||
fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result {
|
fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result {
|
||||||
tp.trace(tp.channel.input, m);
|
tp.trace(tp.channel.input, m);
|
||||||
_ = self.input_listeners_.send(from, m) catch {};
|
_ = self.input_listeners_.send(from, m) catch {};
|
||||||
if (self.keyboard_focus) |w| {
|
|
||||||
_ = try w.send(from, m);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (try self.update_hover(y, x)) |w|
|
if (try self.update_hover(y, x)) |w|
|
||||||
_ = try w.send(from, m);
|
_ = try w.send(from, m);
|
||||||
}
|
}
|
||||||
|
|
@ -868,10 +902,6 @@ fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message)
|
||||||
fn send_mouse_drag(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result {
|
fn send_mouse_drag(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result {
|
||||||
tp.trace(tp.channel.input, m);
|
tp.trace(tp.channel.input, m);
|
||||||
_ = self.input_listeners_.send(from, m) catch {};
|
_ = self.input_listeners_.send(from, m) catch {};
|
||||||
if (self.keyboard_focus) |w| {
|
|
||||||
_ = try w.send(from, m);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_ = try self.update_hover(y, x);
|
_ = try self.update_hover(y, x);
|
||||||
if (self.drag_source) |w| _ = try w.send(from, m);
|
if (self.drag_source) |w| _ = try w.send(from, m);
|
||||||
}
|
}
|
||||||
|
|
@ -924,10 +954,12 @@ pub fn save_config() (root.ConfigDirError || root.ConfigWriteError)!void {
|
||||||
|
|
||||||
pub fn is_mainview_focused() bool {
|
pub fn is_mainview_focused() bool {
|
||||||
const self = current();
|
const self = current();
|
||||||
return self.mini_mode_ == null and self.input_mode_outer_ == null;
|
return self.mini_mode_ == null and self.input_mode_outer_ == null and !is_keyboard_focused() and self.terminal_focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_overlay_mode(self: *Self, mode: type) command.Result {
|
fn enter_overlay_mode(self: *Self, mode: type) command.Result {
|
||||||
|
self.keyboard_focus_outer = self.keyboard_focus;
|
||||||
|
clear_keyboard_focus();
|
||||||
command.executeName("disable_fast_scroll", .{}) catch {};
|
command.executeName("disable_fast_scroll", .{}) catch {};
|
||||||
command.executeName("disable_alt_scroll", .{}) catch {};
|
command.executeName("disable_alt_scroll", .{}) catch {};
|
||||||
command.executeName("disable_jump_mode", .{}) catch {};
|
command.executeName("disable_jump_mode", .{}) catch {};
|
||||||
|
|
@ -1483,6 +1515,30 @@ const cmds = struct {
|
||||||
.arguments = &.{.string},
|
.arguments = &.{.string},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn run_task_in_terminal(self: *Self, ctx: Ctx) Result {
|
||||||
|
var buf: [tp.max_message_size]u8 = undefined;
|
||||||
|
std.log.debug("run_task_in_terminal: {s}", .{if (ctx.args.buf.len > 0) ctx.args.to_json(&buf) catch "(error)" else "(none)"});
|
||||||
|
const expansion = @import("expansion.zig");
|
||||||
|
var task: []const u8 = undefined;
|
||||||
|
var on_exit: @import("config").TerminalOnExit = self.config_.terminal_on_exit;
|
||||||
|
if (!(try ctx.args.match(.{tp.extract(&task)}) or
|
||||||
|
try ctx.args.match(.{ tp.extract(&task), tp.extract(&on_exit) }))) return;
|
||||||
|
const args = expansion.expand_cbor(self.allocator, ctx.args.buf) catch |e| switch (e) {
|
||||||
|
error.NotFound => return error.Stop,
|
||||||
|
else => |e_| return e_,
|
||||||
|
};
|
||||||
|
defer self.allocator.free(args);
|
||||||
|
var cmd: []const u8 = undefined;
|
||||||
|
if (!try cbor.match(args, .{tp.extract(&cmd)}))
|
||||||
|
cmd = task;
|
||||||
|
call_add_task(task);
|
||||||
|
try command.executeName("open_terminal", try command.fmtbuf(&buf, .{ cmd, on_exit }));
|
||||||
|
}
|
||||||
|
pub const run_task_in_terminal_meta: Meta = .{
|
||||||
|
.description = "Run a task in terminal",
|
||||||
|
.arguments = &.{.string},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn delete_task(_: *Self, ctx: Ctx) Result {
|
pub fn delete_task(_: *Self, ctx: Ctx) Result {
|
||||||
var task: []const u8 = undefined;
|
var task: []const u8 = undefined;
|
||||||
if (!try ctx.args.match(.{tp.extract(&task)}))
|
if (!try ctx.args.match(.{tp.extract(&task)}))
|
||||||
|
|
@ -1507,6 +1563,8 @@ const cmds = struct {
|
||||||
if (self.input_mode_) |*mode| mode.deinit();
|
if (self.input_mode_) |*mode| mode.deinit();
|
||||||
self.input_mode_ = self.input_mode_outer_;
|
self.input_mode_ = self.input_mode_outer_;
|
||||||
self.input_mode_outer_ = null;
|
self.input_mode_outer_ = null;
|
||||||
|
if (self.keyboard_focus_outer) |widget| if (self.is_live_widget_ptr(widget))
|
||||||
|
widget.focus();
|
||||||
refresh_hover(@src());
|
refresh_hover(@src());
|
||||||
}
|
}
|
||||||
pub const exit_overlay_mode_meta: Meta = .{};
|
pub const exit_overlay_mode_meta: Meta = .{};
|
||||||
|
|
@ -2659,3 +2717,8 @@ pub fn jump_mode() bool {
|
||||||
const self = current();
|
const self = current();
|
||||||
return self.jump_mode_;
|
return self.jump_mode_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn terminal_has_focus() bool {
|
||||||
|
const self = current();
|
||||||
|
return self.terminal_focus;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue