summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Pipe.zig534
-rw-r--r--src/detached/server.zig3
-rw-r--r--src/fs.zig1
-rw-r--r--src/gui/gui.zig3
-rw-r--r--src/macos.zig3
-rw-r--r--src/pardes.zig10
-rw-r--r--src/selection_pipe.zig542
-rw-r--r--src/tty/tty.zig3
8 files changed, 554 insertions, 545 deletions
diff --git a/src/Pipe.zig b/src/Pipe.zig
new file mode 100644
index 00000000..0fde654a
--- /dev/null
+++ b/src/Pipe.zig
@@ -0,0 +1,534 @@
+//! The pipe inside the editor (`|`): the prompt, the request the core emits
+//! for the shell to run through selection_pipe.zig, and the atomic edit its
+//! answer makes.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const modal = @import("modal.zig");
+const panes = @import("panes.zig");
+const config = @import("config.zig");
+const selection_pipe = @import("selection_pipe.zig");
+const edit = @import("edit.zig");
+const Pane = panes.Pane;
+const Key = pardes.Key;
+const Pardes = pardes.Pardes;
+
+fn nextPipeEffect(p: *Pardes) ?u32 {
+ while (p.nextEffect()) |effect| switch (effect) {
+ .pipe => |request| return request.id,
+ else => {},
+ };
+ return null;
+}
+
+/// The tag-tail marker each behaviour arms with. One function so the prompt
+/// that is DRAWN and the command that is PARSED can never disagree.
+pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 {
+ return switch (how) {
+ .replace => config.pipe_marker,
+ .ignore => config.pipe_marker_to,
+ .insert => config.pipe_marker_insert,
+ .append => config.pipe_marker_append,
+ };
+}
+
+pub const PendingPipe = struct {
+ id: u32,
+ pane: usize,
+ serial: u32,
+ revision: u32,
+ command: []u8,
+ cwd: []u8,
+ inputs: []selection_pipe.Input,
+ ranges: [Pane.max_selections]modal.Selection,
+ primary: u8,
+ explicit: bool,
+ how: modal.Normal.PipeBehavior,
+ nranges: u8,
+
+ pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void {
+ gpa.free(wait.command);
+ gpa.free(wait.cwd);
+ for (wait.inputs) |input| gpa.free(@constCast(input.bytes));
+ gpa.free(wait.inputs);
+ wait.* = undefined;
+ }
+
+ fn request(wait: *const PendingPipe) selection_pipe.Request {
+ return .{
+ .id = wait.id,
+ .command = wait.command,
+ .cwd = wait.cwd,
+ .inputs = wait.inputs,
+ };
+ }
+};
+
+pub fn submitPipe(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const f = pane.file orelse return;
+ if (!panes.Output.fileTraits(f.output).saves) return;
+ const prompt = switch (pane.prompt) {
+ .pipe => |pipe| pipe,
+ else => return,
+ };
+ const tail = pane.tagSlice();
+ const armed = tail[@min(prompt.at, tail.len)..];
+ const marker = pipeMarker(prompt.how);
+ if (!std.mem.startsWith(u8, armed, marker)) return;
+ const command = armed[marker.len..];
+ if (command.len == 0) return;
+
+ var ranges: [Pane.max_selections]modal.Selection = undefined;
+ const got = pane.ranges(f.content, 0, &ranges);
+ // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`.
+ const ninputs = if (prompt.how.pipes()) got.n else 1;
+ const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return;
+ var made: usize = 0;
+ for (ranges[0..ninputs], 0..) |range, i| {
+ const bytes = if (prompt.how.pipes()) bytes: {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (hi > f.content.len) break;
+ break :bytes f.content[lo..hi];
+ } else "";
+ const copy = p.gpa.dupe(u8, bytes) catch break;
+ inputs[i] = .{ .bytes = copy };
+ made += 1;
+ }
+ if (made != ninputs) {
+ for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes));
+ p.gpa.free(inputs);
+ return;
+ }
+ const command_copy = p.gpa.dupe(u8, command) catch {
+ for (inputs) |input| p.gpa.free(@constCast(input.bytes));
+ p.gpa.free(inputs);
+ return;
+ };
+ const cwd_copy = p.gpa.dupe(u8, Pardes.paneDir(pane)) catch {
+ p.gpa.free(command_copy);
+ for (inputs) |input| p.gpa.free(@constCast(input.bytes));
+ p.gpa.free(inputs);
+ return;
+ };
+
+ p.pipe_seq +%= 1;
+ if (p.pipe_wait) |*old| old.deinit(p.gpa);
+ p.pipe_wait = .{
+ .id = p.pipe_seq,
+ .pane = id,
+ .serial = pane.serial,
+ .revision = f.revision,
+ .command = command_copy,
+ .cwd = cwd_copy,
+ .inputs = inputs,
+ .ranges = ranges,
+ .primary = @intCast(got.pri),
+ .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active,
+ .how = prompt.how,
+ .nranges = @intCast(got.n),
+ };
+ p.emit(.{ .pipe = .{ .id = p.pipe_seq } });
+}
+
+/// Frontends call this while draining a .pipe effect, then immediately
+/// copy the borrowed view into a worker-owned selection_pipe.Job.
+pub fn pipeRequest(p: *const Pardes, id: u32) ?selection_pipe.Request {
+ if (p.pipe_wait) |*wait| {
+ if (wait.id == id) return wait.request();
+ }
+ return null;
+}
+
+fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ defer out.deinit();
+ const w = &out.writer;
+ w.print("| {s}\n\n", .{wait.command}) catch return;
+ if (failure) |fail| {
+ if (wait.inputs.len > 1)
+ w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return;
+ switch (fail.kind) {
+ .exit => w.print("exit status {d}\n", .{fail.code}) catch return,
+ .signal => w.writeAll("killed by a signal\n") catch return,
+ .timeout => w.print(
+ "still running after {d} seconds, and stopped\n",
+ .{selection_pipe.command_timeout_seconds},
+ ) catch return,
+ .too_large => w.writeAll("produced more output than a filter may return\n") catch return,
+ .spawn => w.writeAll("could not be started\n") catch return,
+ .io => w.writeAll("could not be read\n") catch return,
+ }
+ if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return;
+ } else {
+ // No diagnosis at all: nobody ran it. The detached daemon, the
+ // browser and the board all leave `pipe` null on purpose.
+ w.writeAll("this session cannot run filters\n") catch return;
+ }
+ const content = out.toOwnedSlice() catch return;
+ const was = p.active;
+ panes.Output.openErrors(p, wait.pane, content) catch |err| {
+ p.gpa.free(content);
+ // Nowhere to put the report is itself worth one line.
+ p.reportError(wait.pane, "pipe", err);
+ return;
+ };
+ if (p.panes[was] != null) p.active = was;
+}
+
+fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } {
+ const range = wait.ranges[i];
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (hi > content.len) return null;
+ return switch (wait.how) {
+ .replace, .ignore => .{ .lo = lo, .hi = hi },
+ .insert => .{ .lo = lo, .hi = lo },
+ .append => .{ .lo = hi, .hi = hi },
+ };
+}
+
+fn pipeOutput(
+ wait: *const PendingPipe,
+ outputs: []const []const u8,
+ content: []const u8,
+ i: usize,
+) []const u8 {
+ var out = if (wait.how.pipes()) outputs[i] else outputs[0];
+ const range = wait.ranges[i];
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ const selected = if (hi <= content.len) content[lo..hi] else "";
+ if (!std.mem.endsWith(u8, selected, "\n") and std.mem.endsWith(u8, out, "\n")) {
+ out = out[0 .. out.len - 1];
+ if (std.mem.endsWith(u8, out, "\r")) out = out[0 .. out.len - 1];
+ }
+ return out;
+}
+
+pub fn pipeResponse(
+ p: *Pardes,
+ id: u32,
+ success: bool,
+ outputs: []const []const u8,
+ failure: ?selection_pipe.Failure,
+) void {
+ // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is
+ // the answer to a question nobody is still asking.
+ if (p.pipe_wait == null or p.pipe_wait.?.id != id) return;
+ var wait = p.pipe_wait.?;
+ p.pipe_wait = null;
+ defer wait.deinit(p.gpa);
+
+ if (!success or outputs.len != wait.inputs.len) return pipeFailed(p, &wait, failure);
+
+ const pane = p.panes[wait.pane] orelse return;
+ if (pane.serial != wait.serial) return;
+ const f = if (pane.file) |*file| file else return;
+ if (!panes.Output.fileTraits(f.output).saves) return;
+ if (f.revision != wait.revision)
+ return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering);
+
+ if (wait.how == .ignore) return;
+
+ const n = wait.nranges;
+ var total_output: usize = 0;
+ var removed: usize = 0;
+ var previous_end: usize = 0;
+ for (0..n) |i| {
+ const cut = pipeCut(&wait, f.content, i) orelse return;
+ const output = pipeOutput(&wait, outputs, f.content, i);
+ if (output.len > selection_pipe.max_stdout_bytes) return;
+ total_output = std.math.add(usize, total_output, output.len) catch return;
+ if (total_output > selection_pipe.max_total_stdout_bytes) return;
+ if (cut.lo < previous_end) return;
+ removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return;
+ previous_end = cut.hi;
+ }
+ const kept = f.content.len - removed;
+ const final_len = std.math.add(usize, kept, total_output) catch return;
+ const replacement = p.gpa.alloc(u8, final_len) catch return;
+
+ var new_ranges: [Pane.max_selections]modal.Selection = undefined;
+ var read_at: usize = 0;
+ var write_at: usize = 0;
+ for (0..n) |i| {
+ const range = wait.ranges[i];
+ const cut = pipeCut(&wait, f.content, i).?;
+ const output = pipeOutput(&wait, outputs, f.content, i);
+ @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]);
+ write_at += cut.lo - read_at;
+ const out_start = write_at;
+ @memcpy(replacement[write_at .. write_at + output.len], output);
+ write_at += output.len;
+ const out_end = write_at;
+ new_ranges[i] = if (range.anchor > range.head)
+ .{ .anchor = out_end, .head = out_start }
+ else
+ .{ .anchor = out_start, .head = out_end };
+ read_at = cut.hi;
+ }
+ @memcpy(replacement[write_at..], f.content[read_at..]);
+
+ // One async request is one history transaction, even at 64 cursors.
+ edit.pushUndo(p, pane);
+ panes.File.setContent(p, f, replacement);
+ pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit);
+ pane.select = false;
+ pane.ensureCursorVisible();
+}
+
+test "selection pipe prompt submits exact request and Escape cancels" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("one\ntwo\n");
+ pane.cur_row = 0;
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ try std.testing.expect(pane.tag_edit and pane.prompt == .pipe);
+ try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker));
+ try std.testing.expect(nextPipeEffect(p) == null);
+ p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
+ try std.testing.expectEqualStrings("tr a-z A-Z", request.command);
+ try std.testing.expectEqualStrings("/", request.cwd);
+ try std.testing.expectEqual(@as(usize, 1), request.inputs.len);
+ try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes);
+
+ const before = pane.file.?.content;
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
+ try std.testing.expectEqualSlices(u8, before, pane.file.?.content);
+ try std.testing.expect(nextPipeEffect(p) == null);
+}
+
+test "selection pipe replaces all ranges atomically and undo restores them" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("aa bb cc\n");
+ const ranges = [_]modal.Selection{
+ .{ .anchor = 0, .head = 2 },
+ .{ .anchor = 6, .head = 8 },
+ };
+ pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true);
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
+ try std.testing.expectEqual(@as(usize, 2), request.inputs.len);
+ try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes);
+ try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes);
+
+ var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.scratch.deinit();
+ p.scratch = .init(denied.allocator());
+ const outputs: []const []const u8 = &.{ "AA\n", "" };
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } });
+ try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
+ try std.testing.expectEqual(@as(u8, 1), pane.nsel);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col);
+ try std.testing.expect(!denied.has_induced_failure);
+
+ p.update(.{ .key = .{ .cp = 'u' } });
+ try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content);
+}
+
+test "the four shell behaviours put their output where helix puts it" {
+ const gpa = std.testing.allocator;
+ const Case = struct {
+ key: Key,
+ outputs: []const []const u8,
+ want: []const u8,
+ want_effect: bool = true,
+ };
+ // One selection, `bb`, in `aa bb cc\n` — bytes 3..5.
+ for ([_]Case{
+ // `|` replaces the selection with what the command wrote.
+ .{ .key = .{ .cp = '|' }, .outputs = &.{"XX"}, .want = "aa XX cc\n" },
+ // `!` inserts BEFORE it, and the selection survives to its right.
+ .{ .key = .{ .cp = '!' }, .outputs = &.{"XX"}, .want = "aa XXbb cc\n" },
+ // `A-!` appends AFTER it.
+ .{ .key = .{ .cp = '!', .alt = true }, .outputs = &.{"XX"}, .want = "aa bbXX cc\n" },
+ // `A-|` runs the command and leaves the text completely alone: the
+ // point is the side effect, not the output.
+ .{ .key = .{ .cp = '|', .alt = true }, .outputs = &.{"XX"}, .want = "aa bb cc\n" },
+ }) |case| {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("aa bb cc\n");
+ pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true);
+
+ p.update(.{ .key = case.key });
+ for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = case.outputs } });
+ try std.testing.expectEqualSlices(u8, case.want, pane.file.?.content);
+ }
+}
+
+test "a command with no stdin runs once and every cursor gets that one answer" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("aa bb cc\n");
+ const ranges = [_]modal.Selection{
+ .{ .anchor = 0, .head = 2 },
+ .{ .anchor = 6, .head = 8 },
+ };
+ pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true);
+
+ p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin
+ for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
+
+ try std.testing.expectEqual(@as(usize, 1), request.inputs.len);
+ try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes);
+
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } });
+ try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
+}
+
+test "selection pipe failure and stale completion never mutate the file" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("abc\n");
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } });
+ try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n"));
+ p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } });
+ try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+}
+
+test "a failed filter opens an errors buffer carrying the command's own words" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("abc\n");
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ for ("trr") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+
+ // The shape a real runner brings home for `| trr`: nonzero exit, and the
+ // shell's own sentence about it.
+ var stderr = "sh: line 1: trr: command not found\n".*;
+ p.update(.{ .pipe_resp = .{
+ .id = id,
+ .success = false,
+ .outputs = &.{},
+ .failure = .{ .kind = .exit, .code = 127, .stderr = &stderr },
+ } });
+
+ // The text is untouched — a failed filter is not an edit...
+ try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+ // ...and the cursor did not go anywhere, so `|` again edits the same file.
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+
+ var found: ?[]const u8 = null;
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const qf = q.file orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) == .errors) found = qf.content;
+ }
+ const report = found orelse return error.NoErrorsBuffer;
+ try std.testing.expect(std.mem.indexOf(u8, report, "| trr") != null);
+ try std.testing.expect(std.mem.indexOf(u8, report, "exit status 127") != null);
+ try std.testing.expect(std.mem.indexOf(u8, report, "command not found") != null);
+}
+
+test "selection pipe rejects a reused pane slot and a superseded request" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ var pane = try p.setTestFile("old\n");
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ pane = try p.setTestFile("new\n"); // same slot, different serial
+ p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } });
+ try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ p.update(.{ .key = .{ .cp = '|' } });
+ p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ try std.testing.expect(first_id != latest_id);
+
+ p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } });
+ try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
+ p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } });
+ try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
+}
+
+test "selection pipe binding is file-normal-only" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("x");
+
+ pane.file.?.output = .{ .from = .search };
+ p.update(.{ .key = .{ .cp = '|' } });
+ try std.testing.expect(pane.prompt != .pipe);
+ pane.file.?.output = null;
+ pane.mode = .insert;
+ p.update(.{ .key = .{ .cp = '|', .text = "|" } });
+ try std.testing.expect(pane.prompt != .pipe);
+ try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content);
+}
diff --git a/src/detached/server.zig b/src/detached/server.zig
index b75fa5ad..9b23f8d6 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -9,6 +9,7 @@ const wire = @import("wire.zig");
const host_io = @import("../host_io.zig");
const selection_pipe = @import("../selection_pipe.zig");
+const Pipe = @import("../Pipe.zig");
const file_watch = @import("../file_watch.zig");
@@ -411,7 +412,7 @@ pub const Session = struct {
s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
- const request = selection_pipe.pipeRequest(s.core, id) orelse return;
+ const request = Pipe.pipeRequest(s.core, id) orelse return;
const job = selection_pipe.Job.copy(s.worker_gpa, request) catch |err| {
s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return s.core.reportError(s.core.active, "pipe", err);
diff --git a/src/fs.zig b/src/fs.zig
index 825e63bb..5374c843 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -44,6 +44,7 @@ const source_files = [_]Source{
.{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") },
.{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") },
.{ .path = "src/exec.zig", .contents = @embedFile("exec.zig") },
+ .{ .path = "src/Pipe.zig", .contents = @embedFile("Pipe.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f044ca17..ef94d5b3 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -16,6 +16,7 @@ const crt = @import("crt.zig");
const pet = @import("pet.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
+const Pipe = @import("../Pipe.zig");
const c_heap = @import("c_heap");
const ninep_io = @import("../9p_io.zig");
@@ -1703,7 +1704,7 @@ fn spawnPipe(
core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
- const view = selection_pipe.pipeRequest(core, id) orelse return;
+ const view = Pipe.pipeRequest(core, id) orelse return;
const job = selection_pipe.Job.copy(gpa, view) catch |err| {
core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return core.reportError(core.active, "pipe", err);
diff --git a/src/macos.zig b/src/macos.zig
index 78356810..dc5b79d0 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -18,6 +18,7 @@ const host_io = @import("host_io.zig");
const fonts = @import("fonts.zig"); // the shared fallback preference order
const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout
const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks
+const Pipe = @import("Pipe.zig");
const crash = @import("crash.zig");
pub const panic = std.debug.FullPanic(struct {
@@ -2351,7 +2352,7 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void {
st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
- const view = selection_pipe.pipeRequest(st.core, id) orelse return;
+ const view = Pipe.pipeRequest(st.core, id) orelse return;
const job = selection_pipe.Job.copy(st.gpa, view) catch |err| {
st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return st.core.reportError(st.core.active, "pipe", err);
diff --git a/src/pardes.zig b/src/pardes.zig
index d2ac5868..00439a36 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -17,6 +17,7 @@ pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
pub const Messages = @import("Messages.zig");
+pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
pub const themes = colors.themes;
@@ -443,6 +444,7 @@ test {
_ = @import("edit.zig");
_ = @import("Messages.zig");
_ = @import("selection_pipe.zig");
+ _ = @import("Pipe.zig");
_ = @import("colors.zig");
_ = @import("surface.zig");
_ = @import("body_layer.zig");
@@ -3745,7 +3747,7 @@ pub const Pardes = struct {
} = null,
pipe_seq: u32 = 0,
- pipe_wait: ?selection_pipe.PendingPipe = null,
+ pipe_wait: ?Pipe.PendingPipe = null,
fs: filesystem.Namespace = .{},
boot: Boot = .shell,
@@ -4688,7 +4690,7 @@ pub const Pardes = struct {
p.reportError(e.pane, "terminal exited; Del retries close", err);
},
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
- .pipe_resp => |r| selection_pipe.pipeResponse(p, r.id, r.success, r.outputs, r.failure),
+ .pipe_resp => |r| Pipe.pipeResponse(p, r.id, r.success, r.outputs, r.failure),
.file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes),
.key => |key| {
p.can_ask = true;
@@ -5386,7 +5388,7 @@ pub const Pardes = struct {
const submit = hit(key, config.search_submit);
switch (pane.prompt) {
.pipe => |pipe| {
- if (submit) selection_pipe.submitPipe(p, p.active);
+ if (submit) Pipe.submitPipe(p, p.active);
pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len);
},
.save => |at| {
@@ -5501,7 +5503,7 @@ pub const Pardes = struct {
.pipe => |how| pipe: {
const file = pane.file orelse return;
if (!panes.Output.fileTraits(file.output).saves) return;
- break :pipe selection_pipe.pipeMarker(how);
+ break :pipe Pipe.pipeMarker(how);
},
};
p.seedTail(pane);
diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig
index 90c8e951..e1a42962 100644
--- a/src/selection_pipe.zig
+++ b/src/selection_pipe.zig
@@ -1,19 +1,10 @@
-//! `|`: pipe every editor selection through one shell command. First the
-//! native runner and its boundary-owned values: native shells call `Job.copy`
-//! before starting a worker, then `runOne` on that worker, so no subprocess or
-//! borrowed core memory reaches the editor/event-loop thread. Then the editor
-//! side: the prompt, the request the core emits, and the atomic edit its
-//! answer makes.
+//! Native runner and boundary-owned values for `|`: pipe every editor
+//! selection through one shell command. The core imports only the plain value
+//! types below; native shells call `Job.copy` before starting a worker, then
+//! `runOne` on that worker. No subprocess or borrowed core memory reaches the
+//! editor/event-loop thread.
const std = @import("std");
const filesystem = @import("fs.zig");
-const pardes = @import("pardes.zig");
-const edit = @import("edit.zig");
-const modal = @import("modal.zig");
-const panes = @import("panes.zig");
-const config = @import("config.zig");
-const Pane = panes.Pane;
-const Key = pardes.Key;
-const Pardes = pardes.Pardes;
/// A deliberately finite answer. One selection cannot retain more than 1 MiB
/// and a multi-selection command cannot retain more than 4 MiB in total.
@@ -401,526 +392,3 @@ test "native pipe runner preserves stdin/stdout bytes and reports how it failed"
.failed => return error.EarlyStdinCloseShouldNotFail,
}
}
-
-// ---- the pipe inside the editor: the prompt, the request, the atomic edit ----
-
-fn nextPipeEffect(p: *Pardes) ?u32 {
- while (p.nextEffect()) |effect| switch (effect) {
- .pipe => |request| return request.id,
- else => {},
- };
- return null;
-}
-
-/// The tag-tail marker each behaviour arms with. One function so the prompt
-/// that is DRAWN and the command that is PARSED can never disagree.
-pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 {
- return switch (how) {
- .replace => config.pipe_marker,
- .ignore => config.pipe_marker_to,
- .insert => config.pipe_marker_insert,
- .append => config.pipe_marker_append,
- };
-}
-
-pub const PendingPipe = struct {
- id: u32,
- pane: usize,
- serial: u32,
- revision: u32,
- command: []u8,
- cwd: []u8,
- inputs: []Input,
- ranges: [Pane.max_selections]modal.Selection,
- primary: u8,
- explicit: bool,
- how: modal.Normal.PipeBehavior,
- nranges: u8,
-
- pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void {
- gpa.free(wait.command);
- gpa.free(wait.cwd);
- for (wait.inputs) |input| gpa.free(@constCast(input.bytes));
- gpa.free(wait.inputs);
- wait.* = undefined;
- }
-
- fn request(wait: *const PendingPipe) Request {
- return .{
- .id = wait.id,
- .command = wait.command,
- .cwd = wait.cwd,
- .inputs = wait.inputs,
- };
- }
-};
-
-pub fn submitPipe(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const f = pane.file orelse return;
- if (!panes.Output.fileTraits(f.output).saves) return;
- const prompt = switch (pane.prompt) {
- .pipe => |pipe| pipe,
- else => return,
- };
- const tail = pane.tagSlice();
- const armed = tail[@min(prompt.at, tail.len)..];
- const marker = pipeMarker(prompt.how);
- if (!std.mem.startsWith(u8, armed, marker)) return;
- const command = armed[marker.len..];
- if (command.len == 0) return;
-
- var ranges: [Pane.max_selections]modal.Selection = undefined;
- const got = pane.ranges(f.content, 0, &ranges);
- // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`.
- const ninputs = if (prompt.how.pipes()) got.n else 1;
- const inputs = p.gpa.alloc(Input, ninputs) catch return;
- var made: usize = 0;
- for (ranges[0..ninputs], 0..) |range, i| {
- const bytes = if (prompt.how.pipes()) bytes: {
- const lo = @min(range.anchor, range.head);
- const hi = @max(range.anchor, range.head);
- if (hi > f.content.len) break;
- break :bytes f.content[lo..hi];
- } else "";
- const copy = p.gpa.dupe(u8, bytes) catch break;
- inputs[i] = .{ .bytes = copy };
- made += 1;
- }
- if (made != ninputs) {
- for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes));
- p.gpa.free(inputs);
- return;
- }
- const command_copy = p.gpa.dupe(u8, command) catch {
- for (inputs) |input| p.gpa.free(@constCast(input.bytes));
- p.gpa.free(inputs);
- return;
- };
- const cwd_copy = p.gpa.dupe(u8, Pardes.paneDir(pane)) catch {
- p.gpa.free(command_copy);
- for (inputs) |input| p.gpa.free(@constCast(input.bytes));
- p.gpa.free(inputs);
- return;
- };
-
- p.pipe_seq +%= 1;
- if (p.pipe_wait) |*old| old.deinit(p.gpa);
- p.pipe_wait = .{
- .id = p.pipe_seq,
- .pane = id,
- .serial = pane.serial,
- .revision = f.revision,
- .command = command_copy,
- .cwd = cwd_copy,
- .inputs = inputs,
- .ranges = ranges,
- .primary = @intCast(got.pri),
- .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active,
- .how = prompt.how,
- .nranges = @intCast(got.n),
- };
- p.emit(.{ .pipe = .{ .id = p.pipe_seq } });
-}
-
-/// Frontends call this while draining a .pipe effect, then immediately
-/// copy the borrowed view into a worker-owned selection_pipe.Job.
-pub fn pipeRequest(p: *const Pardes, id: u32) ?Request {
- if (p.pipe_wait) |*wait| {
- if (wait.id == id) return wait.request();
- }
- return null;
-}
-
-fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?Failure) void {
- var out: std.Io.Writer.Allocating = .init(p.gpa);
- defer out.deinit();
- const w = &out.writer;
- w.print("| {s}\n\n", .{wait.command}) catch return;
- if (failure) |fail| {
- if (wait.inputs.len > 1)
- w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return;
- switch (fail.kind) {
- .exit => w.print("exit status {d}\n", .{fail.code}) catch return,
- .signal => w.writeAll("killed by a signal\n") catch return,
- .timeout => w.print(
- "still running after {d} seconds, and stopped\n",
- .{command_timeout_seconds},
- ) catch return,
- .too_large => w.writeAll("produced more output than a filter may return\n") catch return,
- .spawn => w.writeAll("could not be started\n") catch return,
- .io => w.writeAll("could not be read\n") catch return,
- }
- if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return;
- } else {
- // No diagnosis at all: nobody ran it. The detached daemon, the
- // browser and the board all leave `pipe` null on purpose.
- w.writeAll("this session cannot run filters\n") catch return;
- }
- const content = out.toOwnedSlice() catch return;
- const was = p.active;
- panes.Output.openErrors(p, wait.pane, content) catch |err| {
- p.gpa.free(content);
- // Nowhere to put the report is itself worth one line.
- p.reportError(wait.pane, "pipe", err);
- return;
- };
- if (p.panes[was] != null) p.active = was;
-}
-
-fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } {
- const range = wait.ranges[i];
- const lo = @min(range.anchor, range.head);
- const hi = @max(range.anchor, range.head);
- if (hi > content.len) return null;
- return switch (wait.how) {
- .replace, .ignore => .{ .lo = lo, .hi = hi },
- .insert => .{ .lo = lo, .hi = lo },
- .append => .{ .lo = hi, .hi = hi },
- };
-}
-
-fn pipeOutput(
- wait: *const PendingPipe,
- outputs: []const []const u8,
- content: []const u8,
- i: usize,
-) []const u8 {
- var out = if (wait.how.pipes()) outputs[i] else outputs[0];
- const range = wait.ranges[i];
- const lo = @min(range.anchor, range.head);
- const hi = @max(range.anchor, range.head);
- const selected = if (hi <= content.len) content[lo..hi] else "";
- if (!std.mem.endsWith(u8, selected, "\n") and std.mem.endsWith(u8, out, "\n")) {
- out = out[0 .. out.len - 1];
- if (std.mem.endsWith(u8, out, "\r")) out = out[0 .. out.len - 1];
- }
- return out;
-}
-
-pub fn pipeResponse(
- p: *Pardes,
- id: u32,
- success: bool,
- outputs: []const []const u8,
- failure: ?Failure,
-) void {
- // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is
- // the answer to a question nobody is still asking.
- if (p.pipe_wait == null or p.pipe_wait.?.id != id) return;
- var wait = p.pipe_wait.?;
- p.pipe_wait = null;
- defer wait.deinit(p.gpa);
-
- if (!success or outputs.len != wait.inputs.len) return pipeFailed(p, &wait, failure);
-
- const pane = p.panes[wait.pane] orelse return;
- if (pane.serial != wait.serial) return;
- const f = if (pane.file) |*file| file else return;
- if (!panes.Output.fileTraits(f.output).saves) return;
- if (f.revision != wait.revision)
- return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering);
-
- if (wait.how == .ignore) return;
-
- const n = wait.nranges;
- var total_output: usize = 0;
- var removed: usize = 0;
- var previous_end: usize = 0;
- for (0..n) |i| {
- const cut = pipeCut(&wait, f.content, i) orelse return;
- const output = pipeOutput(&wait, outputs, f.content, i);
- if (output.len > max_stdout_bytes) return;
- total_output = std.math.add(usize, total_output, output.len) catch return;
- if (total_output > max_total_stdout_bytes) return;
- if (cut.lo < previous_end) return;
- removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return;
- previous_end = cut.hi;
- }
- const kept = f.content.len - removed;
- const final_len = std.math.add(usize, kept, total_output) catch return;
- const replacement = p.gpa.alloc(u8, final_len) catch return;
-
- var new_ranges: [Pane.max_selections]modal.Selection = undefined;
- var read_at: usize = 0;
- var write_at: usize = 0;
- for (0..n) |i| {
- const range = wait.ranges[i];
- const cut = pipeCut(&wait, f.content, i).?;
- const output = pipeOutput(&wait, outputs, f.content, i);
- @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]);
- write_at += cut.lo - read_at;
- const out_start = write_at;
- @memcpy(replacement[write_at .. write_at + output.len], output);
- write_at += output.len;
- const out_end = write_at;
- new_ranges[i] = if (range.anchor > range.head)
- .{ .anchor = out_end, .head = out_start }
- else
- .{ .anchor = out_start, .head = out_end };
- read_at = cut.hi;
- }
- @memcpy(replacement[write_at..], f.content[read_at..]);
-
- // One async request is one history transaction, even at 64 cursors.
- edit.pushUndo(p, pane);
- panes.File.setContent(p, f, replacement);
- pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit);
- pane.select = false;
- pane.ensureCursorVisible();
-}
-
-test "selection pipe prompt submits exact request and Escape cancels" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("one\ntwo\n");
- pane.cur_row = 0;
- pane.cur_col = 2;
- pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
-
- p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expect(pane.tag_edit and pane.prompt == .pipe);
- try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker));
- try std.testing.expect(nextPipeEffect(p) == null);
- p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
- const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
- try std.testing.expectEqualStrings("tr a-z A-Z", request.command);
- try std.testing.expectEqualStrings("/", request.cwd);
- try std.testing.expectEqual(@as(usize, 1), request.inputs.len);
- try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes);
-
- const before = pane.file.?.content;
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
- try std.testing.expectEqualSlices(u8, before, pane.file.?.content);
- try std.testing.expect(nextPipeEffect(p) == null);
-}
-
-test "selection pipe replaces all ranges atomically and undo restores them" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("aa bb cc\n");
- const ranges = [_]modal.Selection{
- .{ .anchor = 0, .head = 2 },
- .{ .anchor = 6, .head = 8 },
- };
- pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true);
-
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
- try std.testing.expectEqual(@as(usize, 2), request.inputs.len);
- try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes);
- try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes);
-
- var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- p.scratch.deinit();
- p.scratch = .init(denied.allocator());
- const outputs: []const []const u8 = &.{ "AA\n", "" };
- p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } });
- try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
- try std.testing.expectEqual(@as(u8, 1), pane.nsel);
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col);
- try std.testing.expect(!denied.has_induced_failure);
-
- p.update(.{ .key = .{ .cp = 'u' } });
- try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content);
-}
-
-test "the four shell behaviours put their output where helix puts it" {
- const gpa = std.testing.allocator;
- const Case = struct {
- key: Key,
- outputs: []const []const u8,
- want: []const u8,
- want_effect: bool = true,
- };
- // One selection, `bb`, in `aa bb cc\n` — bytes 3..5.
- for ([_]Case{
- // `|` replaces the selection with what the command wrote.
- .{ .key = .{ .cp = '|' }, .outputs = &.{"XX"}, .want = "aa XX cc\n" },
- // `!` inserts BEFORE it, and the selection survives to its right.
- .{ .key = .{ .cp = '!' }, .outputs = &.{"XX"}, .want = "aa XXbb cc\n" },
- // `A-!` appends AFTER it.
- .{ .key = .{ .cp = '!', .alt = true }, .outputs = &.{"XX"}, .want = "aa bbXX cc\n" },
- // `A-|` runs the command and leaves the text completely alone: the
- // point is the side effect, not the output.
- .{ .key = .{ .cp = '|', .alt = true }, .outputs = &.{"XX"}, .want = "aa bb cc\n" },
- }) |case| {
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("aa bb cc\n");
- pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true);
-
- p.update(.{ .key = case.key });
- for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = case.outputs } });
- try std.testing.expectEqualSlices(u8, case.want, pane.file.?.content);
- }
-}
-
-test "a command with no stdin runs once and every cursor gets that one answer" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("aa bb cc\n");
- const ranges = [_]modal.Selection{
- .{ .anchor = 0, .head = 2 },
- .{ .anchor = 6, .head = 8 },
- };
- pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true);
-
- p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin
- for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- const request = pipeRequest(p, id) orelse return error.MissingPipeRequest;
-
- try std.testing.expectEqual(@as(usize, 1), request.inputs.len);
- try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes);
-
- p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } });
- try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
-}
-
-test "selection pipe failure and stale completion never mutate the file" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("abc\n");
- pane.cur_col = 2;
- pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
-
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } });
- try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
-
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n"));
- p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } });
- try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
-}
-
-test "a failed filter opens an errors buffer carrying the command's own words" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("abc\n");
- pane.cur_col = 2;
- pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
-
- p.update(.{ .key = .{ .cp = '|' } });
- for ("trr") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
-
- // The shape a real runner brings home for `| trr`: nonzero exit, and the
- // shell's own sentence about it.
- var stderr = "sh: line 1: trr: command not found\n".*;
- p.update(.{ .pipe_resp = .{
- .id = id,
- .success = false,
- .outputs = &.{},
- .failure = .{ .kind = .exit, .code = 127, .stderr = &stderr },
- } });
-
- // The text is untouched — a failed filter is not an edit...
- try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
- // ...and the cursor did not go anywhere, so `|` again edits the same file.
- try std.testing.expectEqual(@as(usize, 0), p.active);
-
- var found: ?[]const u8 = null;
- for (p.panes) |slot| {
- const q = slot orelse continue;
- const qf = q.file orelse continue;
- const o = qf.output orelse continue;
- if (std.meta.activeTag(o.from) == .errors) found = qf.content;
- }
- const report = found orelse return error.NoErrorsBuffer;
- try std.testing.expect(std.mem.indexOf(u8, report, "| trr") != null);
- try std.testing.expect(std.mem.indexOf(u8, report, "exit status 127") != null);
- try std.testing.expect(std.mem.indexOf(u8, report, "command not found") != null);
-}
-
-test "selection pipe rejects a reused pane slot and a superseded request" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- var pane = try p.setTestFile("old\n");
- pane.cur_col = 2;
- pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
-
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- pane = try p.setTestFile("new\n"); // same slot, different serial
- p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } });
- try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
-
- pane.cur_col = 2;
- pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- p.update(.{ .key = .{ .cp = '|' } });
- p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
- try std.testing.expect(first_id != latest_id);
-
- p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } });
- try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
- p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } });
- try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
-}
-
-test "selection pipe binding is file-normal-only" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("x");
-
- pane.file.?.output = .{ .from = .search };
- p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expect(pane.prompt != .pipe);
- pane.file.?.output = null;
- pane.mode = .insert;
- p.update(.{ .key = .{ .cp = '|', .text = "|" } });
- try std.testing.expect(pane.prompt != .pipe);
- try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content);
-}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index fe968650..24403ba8 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -11,6 +11,7 @@ const look = @import("../look.zig");
const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
const selection_pipe = @import("../selection_pipe.zig");
+const Pipe = @import("../Pipe.zig");
const ninep_io = @import("../9p_io.zig");
const panel_compositor = @import("panel_compositor.zig");
const detached_client = @import("../detached/client.zig");
@@ -1325,7 +1326,7 @@ const Shell = struct {
s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
- const view = selection_pipe.pipeRequest(s.core, id) orelse return;
+ const view = Pipe.pipeRequest(s.core, id) orelse return;
const job = selection_pipe.Job.copy(s.gpa, view) catch |err| {
s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return s.core.reportError(s.core.active, "pipe", err);