diff --git a/build.zig b/build.zig index 66ddd2e..a2879f4 100644 --- a/build.zig +++ b/build.zig @@ -380,6 +380,15 @@ pub fn build_exe( break :blk b.addRunArtifact(tests); }; + const shell_mod = b.createModule(.{ + .root_source_file = b.path("src/shell.zig"), + .imports = &.{ + .{ .name = "thespian", .module = thespian_mod }, + .{ .name = "cbor", .module = cbor_mod }, + .{ .name = "log", .module = log_mod }, + }, + }); + const ripgrep_mod = b.createModule(.{ .root_source_file = b.path("src/ripgrep.zig"), .imports = &.{ @@ -444,6 +453,7 @@ pub fn build_exe( .{ .name = "text_manip", .module = text_manip_mod }, .{ .name = "Buffer", .module = Buffer_mod }, .{ .name = "keybind", .module = keybind_mod }, + .{ .name = "shell", .module = shell_mod }, .{ .name = "ripgrep", .module = ripgrep_mod }, .{ .name = "theme", .module = themes_dep.module("theme") }, .{ .name = "themes", .module = themes_dep.module("themes") }, diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 24b3db8..af7dc10 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -1,5 +1,12 @@ { + "project": { + "press": [ + ["f5", ["create_scratch_buffer", "*test*"], ["shell_execute_insert", "zig", "build", "test"]], + ["f7", ["create_scratch_buffer", "*build*"], ["shell_execute_insert", "zig", "build"]] + ] + }, "normal": { + "inherit": "project", "press": [ ["ctrl+e", "open_recent"], ["ctrl+r", "open_recent_project"], @@ -120,12 +127,12 @@ ["ctrl+f2", "insert_command_name"], ["f3", "goto_next_match"], ["f15", "goto_prev_match"], - ["f5", "toggle_inspector_view"], - ["f6", "dump_current_line_tree"], - ["f7", "dump_current_line"], + ["ctrl+f5", "dump_current_line_tree"], + ["ctrl+f7", "dump_current_line"], ["f9", "theme_prev"], ["f10", "theme_next"], ["f11", "toggle_panel"], + ["ctrl+f11", "toggle_inspector_view"], ["f12", "goto_definition"], ["f34", "toggle_whitespace_mode"], ["escape", "cancel"], @@ -182,6 +189,7 @@ ] }, "home": { + "inherit": "project", "on_match_failure": "ignore", "press": [ ["h", "open_help"], diff --git a/src/shell.zig b/src/shell.zig new file mode 100644 index 0000000..7896a97 --- /dev/null +++ b/src/shell.zig @@ -0,0 +1,197 @@ +const std = @import("std"); +const tp = @import("thespian"); +const cbor = @import("cbor"); +const log = @import("log"); + +pid: ?tp.pid, +stdin_behavior: std.process.Child.StdIo, + +const Self = @This(); +const module_name = @typeName(Self); +pub const max_chunk_size = tp.subprocess.max_chunk_size; +pub const Writer = std.io.Writer(*Self, Error, write); +pub const BufferedWriter = std.io.BufferedWriter(max_chunk_size, Writer); +pub const Error = error{ InvalidShellArg0, OutOfMemory, Exit, ThespianSpawnFailed, Closed }; + +pub const OutputHandler = fn (parent: tp.pid_ref, arg0: []const u8, output: []const u8) void; +pub const ExitHandler = fn (parent: tp.pid_ref, arg0: []const u8, err_msg: []const u8, exit_code: i64) void; + +pub const Handlers = struct { + out: *const OutputHandler, + err: ?*const OutputHandler = null, + exit: *const ExitHandler = log_exit_handler, +}; + +pub fn execute(allocator: std.mem.Allocator, argv: tp.message, handlers: Handlers) Error!void { + const stdin_behavior = .Close; + var pid = try Process.create(allocator, argv, stdin_behavior, handlers); + pid.deinit(); +} + +pub fn execute_pipe(allocator: std.mem.Allocator, argv: tp.message, output_handler: ?OutputHandler, exit_handler: ?ExitHandler) Error!Self { + const stdin_behavior = .Pipe; + return .{ .pid = try Process.create(allocator, argv, stdin_behavior, output_handler, exit_handler), .stdin_behavior = stdin_behavior }; +} + +pub fn deinit(self: *Self) void { + if (self.pid) |pid| { + if (self.stdin_behavior == .Pipe) + pid.send(.{"close"}) catch {}; + self.pid = null; + pid.deinit(); + } +} + +pub fn write(self: *Self, bytes: []const u8) !usize { + try self.input(bytes); + return bytes.len; +} + +pub fn input(self: *const Self, bytes: []const u8) !void { + const pid = self.pid orelse return error.Closed; + var remaining = bytes; + while (remaining.len > 0) + remaining = loop: { + if (remaining.len > max_chunk_size) { + try pid.send(.{ "input", remaining[0..max_chunk_size] }); + break :loop remaining[max_chunk_size..]; + } else { + try pid.send(.{ "input", remaining }); + break :loop &[_]u8{}; + } + }; +} + +pub fn close(self: *Self) void { + self.deinit(); +} + +pub fn writer(self: *Self) Writer { + return .{ .context = self }; +} + +pub fn bufferedWriter(self: *Self) BufferedWriter { + return .{ .unbuffered_writer = self.writer() }; +} + +pub fn log_handler(parent: tp.pid_ref, arg0: []const u8, output: []const u8) void { + _ = parent; + _ = arg0; + const logger = log.logger(@typeName(Self)); + var it = std.mem.splitScalar(u8, output, '\n'); + while (it.next()) |line| logger.print("{s}", .{line}); +} + +pub fn log_err_handler(parent: tp.pid_ref, arg0: []const u8, output: []const u8) void { + _ = parent; + const logger = log.logger(@typeName(Self)); + var it = std.mem.splitScalar(u8, output, '\n'); + while (it.next()) |line| logger.print_err(arg0, "{s}", .{line}); +} + +pub fn log_exit_handler(parent: tp.pid_ref, arg0: []const u8, err_msg: []const u8, exit_code: i64) void { + _ = parent; + const logger = log.logger(@typeName(Self)); + if (exit_code > 0) { + logger.print_err(arg0, "'{s}' terminated {s} exitcode: {d}", .{ arg0, err_msg, exit_code }); + } else { + logger.print("'{s}' exited", .{arg0}); + } +} + +const Process = struct { + allocator: std.mem.Allocator, + arg0: [:0]const u8, + argv: tp.message, + receiver: Receiver, + sp: ?tp.subprocess = null, + parent: tp.pid, + logger: log.Logger, + stdin_behavior: std.process.Child.StdIo, + handlers: Handlers, + + const Receiver = tp.Receiver(*Process); + + pub fn create(allocator: std.mem.Allocator, argv_: tp.message, stdin_behavior: std.process.Child.StdIo, handlers: Handlers) Error!tp.pid { + const argv = try argv_.clone(allocator); + var arg0_: []const u8 = ""; + if (!try argv.match(.{ tp.extract(&arg0_), tp.more })) { + allocator.free(argv.buf); + return error.InvalidShellArg0; + } + const self = try allocator.create(Process); + self.* = .{ + .allocator = allocator, + .argv = argv, + .arg0 = try allocator.dupeZ(u8, arg0_), + .receiver = Receiver.init(receive, self), + .parent = tp.self_pid().clone(), + .logger = log.logger(@typeName(Self)), + .stdin_behavior = stdin_behavior, + .handlers = handlers, + }; + return tp.spawn_link(self.allocator, self, Process.start, self.arg0); + } + + fn deinit(self: *Process) void { + if (self.sp) |*sp| sp.deinit(); + self.parent.deinit(); + self.logger.deinit(); + self.allocator.free(self.arg0); + self.allocator.free(self.argv.buf); + self.close() catch {}; + self.allocator.destroy(self); + } + + fn close(self: *Process) tp.result { + if (self.sp) |*sp| { + defer self.sp = null; + try sp.close(); + } + } + + fn start(self: *Process) tp.result { + errdefer self.deinit(); + _ = tp.set_trap(true); + var buf: [1024]u8 = undefined; + const json = self.argv.to_json(&buf) catch |e| return tp.exit_error(e, @errorReturnTrace()); + self.logger.print("shell: execute {s}", .{json}); + self.sp = tp.subprocess.init(self.allocator, self.argv, module_name, self.stdin_behavior) catch |e| return tp.exit_error(e, @errorReturnTrace()); + tp.receive(&self.receiver); + } + + fn receive(self: *Process, _: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + var bytes: []u8 = ""; + + if (try m.match(.{ "input", tp.extract(&bytes) })) { + const sp = self.sp orelse return tp.exit_error(error.Closed, null); + try sp.send(bytes); + } else if (try m.match(.{"close"})) { + try self.close(); + } else if (try m.match(.{ module_name, "stdout", tp.extract(&bytes) })) { + self.handlers.out(self.parent.ref(), self.arg0, bytes); + } else if (try m.match(.{ module_name, "stderr", tp.extract(&bytes) })) { + (self.handlers.err orelse self.handlers.out)(self.parent.ref(), self.arg0, bytes); + } else if (try m.match(.{ module_name, "term", tp.more })) { + self.handle_terminated(m) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } else if (try m.match(.{ "exit", "normal" })) { + return tp.exit_normal(); + } else { + self.logger.err("receive", tp.unexpected(m)); + return tp.unexpected(m); + } + } + + fn handle_terminated(self: *Process, m: tp.message) !void { + var err_msg: []const u8 = undefined; + var exit_code: i64 = undefined; + if (try m.match(.{ tp.any, tp.any, "exited", 0 })) { + self.logger.print("'{s}' exited ok", .{self.arg0}); + } else if (try m.match(.{ tp.any, tp.any, "error.FileNotFound", 1 })) { + self.logger.print_err(self.arg0, "'{s}' executable not found", .{self.arg0}); + } else if (try m.match(.{ tp.any, tp.any, tp.extract(&err_msg), tp.extract(&exit_code) })) { + self.logger.print_err(self.arg0, "'{s}' terminated {s} exitcode: {d}", .{ self.arg0, err_msg, exit_code }); + } + } +}; diff --git a/src/tui/editor.zig b/src/tui/editor.zig index a0cb8c2..a87ff25 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3603,6 +3603,9 @@ pub const Editor = struct { if (ctx.args.match(.{ tp.extract(&file_path), tp.extract(&content) }) catch false) { try self.open_scratch(file_path, content); self.clamp(); + } else if (ctx.args.match(.{tp.extract(&file_path)}) catch false) { + try self.open_scratch(file_path, ""); + self.clamp(); } else return error.InvalidOpenScratchBufferArgument; } pub const open_scratch_buffer_meta = .{ .arguments = &.{ .string, .string } }; diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index ba54f9d..299ed86 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -7,6 +7,7 @@ const root = @import("root"); const location_history = @import("location_history"); const project_manager = @import("project_manager"); const log = @import("log"); +const shell = @import("shell"); const builtin = @import("builtin"); const Plane = @import("renderer").Plane; @@ -382,6 +383,15 @@ const cmds = struct { } pub const open_config_meta = .{ .description = "Edit configuration file" }; + pub fn create_scratch_buffer(self: *Self, ctx: Ctx) Result { + try self.check_all_not_dirty(); + tui.reset_drag_context(); + try self.create_editor(); + try command.executeName("open_scratch_buffer", ctx); + tui.need_render(); + } + pub const create_scratch_buffer_meta = .{ .arguments = &.{ .string, .string } }; + pub fn restore_session(self: *Self, _: Ctx) Result { if (tp.env.get().str("project").len == 0) { try open_project_cwd(self, .{}); @@ -592,6 +602,34 @@ const cmds = struct { defer rg.deinit(); } pub const find_in_files_query_meta = .{ .arguments = &.{.string} }; + + pub fn shell_execute_log(self: *Self, ctx: Ctx) Result { + if (!try ctx.args.match(.{ tp.string, tp.more })) + return error.InvalidShellArgument; + const cmd = ctx.args; + const handlers = struct { + fn out(_: tp.pid_ref, arg0: []const u8, output: []const u8) void { + const logger = log.logger(arg0); + var it = std.mem.splitScalar(u8, output, '\n'); + while (it.next()) |line| logger.print("{s}", .{std.fmt.fmtSliceEscapeLower(line)}); + } + }; + try shell.execute(self.allocator, cmd, .{ .out = handlers.out }); + } + pub const shell_execute_log_meta = .{ .arguments = &.{.string} }; + + pub fn shell_execute_insert(self: *Self, ctx: Ctx) Result { + if (!try ctx.args.match(.{ tp.string, tp.more })) + return error.InvalidShellArgument; + const cmd = ctx.args; + const handlers = struct { + fn out(parent: tp.pid_ref, _: []const u8, output: []const u8) void { + parent.send(.{ "cmd", "insert_chars", .{output} }) catch {}; + } + }; + try shell.execute(self.allocator, cmd, .{ .out = handlers.out }); + } + pub const shell_execute_insert_meta = .{ .arguments = &.{.string} }; }; pub fn handle_editor_event(self: *Self, _: tp.pid_ref, m: tp.message) tp.result {