diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 21:55:14 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 49b24196de1afe33023f0129a3d64bdc9775f1af (patch) | |
| tree | 02cb1e8cddc08d0055c16830a1ec7820dfa4a246 | |
| parent | 41bbbd05dccb02631ed785d19ed54fb4edfcc471 (diff) | |
| download | pardes-49b24196de1afe33023f0129a3d64bdc9775f1af.tar.gz pardes-49b24196de1afe33023f0129a3d64bdc9775f1af.zip | |
Move the pipe's editor side out of pardes.zig into selection_pipe.zig
Pure move, no behaviour change: submitPipe, pipeRequest, pipeFailed,
pipeCut, pipeOutput and pipeResponse, the PendingPipe they share,
pipeMarker, and the eight pipe tests with their nextPipeEffect helper go
verbatim to the end of selection_pipe.zig, so the whole of `|` (runner,
boundary values, prompt, request and atomic edit) is one file. Inside that
file the `selection_pipe.` prefix drops; the file doc now says it holds both
halves.
The methods become free functions taking `p: *Pardes`: the four shells'
`core.pipeRequest(id)` become `selection_pipe.pipeRequest(core, id)`, and
pardes.zig's two calls change the same way. pushUndo becomes pub because
the pipe's edit calls it; PendingPipe.deinit becomes pub for Pardes.deinit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | src/detached/server.zig | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/macos.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 532 | ||||
| -rw-r--r-- | src/selection_pipe.zig | 541 | ||||
| -rw-r--r-- | src/tty/tty.zig | 2 |
6 files changed, 546 insertions, 535 deletions
diff --git a/src/detached/server.zig b/src/detached/server.zig index 6959e577..c8d4b45e 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -410,7 +410,7 @@ pub const Session = struct { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const request = s.core.pipeRequest(id) orelse return; + const request = selection_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/gui/gui.zig b/src/gui/gui.zig index 0b9de624..6174c0d8 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1702,7 +1702,7 @@ fn spawnPipe( core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = core.pipeRequest(id) orelse return; + const view = selection_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 402b3c12..b4e03c13 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -2349,7 +2349,7 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = st.core.pipeRequest(id) orelse return; + const view = selection_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 cb020d89..6fefe081 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -430,6 +430,7 @@ pub const Loc = struct { }; test { + _ = @import("selection_pipe.zig"); _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); @@ -503,14 +504,6 @@ pub const test_api = if (@import("builtin").is_test) struct { } } else struct {}; -fn nextPipeEffect(p: *Pardes) ?u32 { - while (p.nextEffect()) |effect| switch (effect) { - .pipe => |request| return request.id, - else => {}, - }; - return null; -} - fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { var len: ?usize = null; while (p.nextEffect()) |effect| { @@ -578,39 +571,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le try std.testing.expect(!p.quit); } -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 = p.pipeRequest(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 "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -744,228 +704,6 @@ test "the acme chords act once per selection, not once on the primary" { try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel); } -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 = p.pipeRequest(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 = p.pipeRequest(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); -} - test "insert newline adds one indent level after a closing call" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -5113,17 +4851,6 @@ pub const Options = struct { rows: u16 = 24, }; -/// 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. -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 LoggedMessage = struct { pub const cap = 256; text: [cap]u8 = undefined, @@ -5141,38 +4868,6 @@ pub const LoggedMessage = struct { } }; -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, - - 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 const Haptic = enum { none, exec, look }; /// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads @@ -5306,7 +5001,7 @@ pub const Pardes = struct { } = null, pipe_seq: u32 = 0, - pipe_wait: ?PendingPipe = null, + pipe_wait: ?selection_pipe.PendingPipe = null, fs: filesystem.Namespace = .{}, boot: Boot = .shell, @@ -6627,7 +6322,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| p.pipeResponse(r.id, r.success, r.outputs, r.failure), + .pipe_resp => |r| selection_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; @@ -7818,7 +7513,7 @@ pub const Pardes = struct { const submit = hit(key, config.search_submit); switch (pane.prompt) { .pipe => |pipe| { - if (submit) p.submitPipe(p.active); + if (submit) selection_pipe.submitPipe(p, p.active); pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); }, .save => |at| { @@ -8450,221 +8145,6 @@ pub const Pardes = struct { // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- - 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, 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; - } - - 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 p.pipeFailed(&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. - p.pushUndo(pane); - panes.File.setContent(p, f, replacement); - pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); - pane.select = false; - pane.ensureCursorVisible(); - } - // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; @@ -8694,7 +8174,7 @@ pub const Pardes = struct { .pipe => |how| pipe: { const file = pane.file orelse return; if (!panes.Output.fileTraits(file.output).saves) return; - break :pipe pipeMarker(how); + break :pipe selection_pipe.pipeMarker(how); }, }; p.seedTail(pane); @@ -10525,7 +10005,7 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - fn pushUndo(p: *Pardes, pane: *Pane) void { + pub fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; if (pane.file != null) return panes.File.pushUndo(p, pane); diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index e1a42962..ea4a4d5b 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -1,10 +1,18 @@ -//! 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. +//! `|`: 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. const std = @import("std"); const filesystem = @import("fs.zig"); +const pardes = @import("pardes.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. @@ -392,3 +400,526 @@ 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. + p.pushUndo(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 5976852f..751bc42e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1324,7 +1324,7 @@ const Shell = struct { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = s.core.pipeRequest(id) orelse return; + const view = selection_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); |
