summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-03 00:32:11 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit6a43880a7ef6899f0d66c1b0a63a7e7b5610236a (patch)
tree2fc12106e0b5f0eb0abcaa345eaa85b39b5bcdd7 /src/pardes.zig
parentc86d3bb210822b3128a0759533f1bec3654364b7 (diff)
downloadpardes-6a43880a7ef6899f0d66c1b0a63a7e7b5610236a.tar.gz
pardes-6a43880a7ef6899f0d66c1b0a63a7e7b5610236a.zip
pipe selections through shell commands
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig370
1 files changed, 370 insertions, 0 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 23611990..25b1ecb3 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -30,6 +30,7 @@ const term_pane = @import("term_pane.zig");
const file_pane = @import("file_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
+const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
pub const pdf_enabled = @import("pardes_config").mupdf;
const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {};
@@ -1071,6 +1072,160 @@ test "PDF builtins and leader paths follow the MuPDF feature gate" {
}
}
+fn nextPipeEffect(p: *Pardes) ?u32 {
+ while (p.nextEffect()) |effect| switch (effect) {
+ .pipe => |request| return request.id,
+ else => {},
+ };
+ return null;
+}
+
+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.hxOpenFileContent("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.pipe_edit);
+ try std.testing.expect(std.mem.endsWith(u8, pane.tag_tail.items, 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.pipe_edit);
+ 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.pipe_edit);
+ 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.hxOpenFileContent("aa bb cc\n");
+ const pl = try p.paneCursorLines(pane);
+ const ranges = [_]modal.HxRange{
+ .{ .anchor = 0, .head = 2 },
+ .{ .anchor = 6, .head = 8 },
+ };
+ Pardes.setPaneRanges(pane, pl, 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);
+
+ const outputs: []const []const u8 = &.{ "AA\n", "" };
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } });
+ try std.testing.expectEqualSlices(u8, "AA\n bb \n", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(u8, 1), pane.nsel);
+
+ p.update(.{ .key = .{ .cp = 'u' } });
+ try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content);
+}
+
+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.hxOpenFileContent("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.?.undo.items.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;
+ file_pane.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.?.undo.items.len);
+}
+
+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.hxOpenFileContent("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.hxOpenFileContent("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.?.undo.items.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.?.undo.items.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.hxOpenFileContent("x");
+
+ pane.file.?.output = .{ .from = .search };
+ p.update(.{ .key = .{ .cp = '|' } });
+ try std.testing.expect(!pane.pipe_edit);
+ pane.file.?.output = null;
+ pane.mode = .insert;
+ p.update(.{ .key = .{ .cp = '|', .text = "|" } });
+ try std.testing.expect(!pane.pipe_edit);
+ try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content);
+}
+
test "startup config runs builtin lines in order and isolates bad lines" {
const p = try Pardes.init(std.testing.allocator, .{
.startup_config =
@@ -2003,6 +2158,10 @@ pub const Event = union(enum) {
/// stale answer (the pane was closed, or a newer query superseded it) and
/// is dropped.
lsp_resp: struct { id: u32, rows: []const u8 },
+ /// A selection-pipe worker finished. Every output is borrowed for this
+ /// update only; success is atomic, so a failed/nonzero invocation carries
+ /// no usable outputs and changes nothing.
+ pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8 },
/// a file the shell was asked to watch changed on disk; `bytes` are its
/// new contents, borrowed for this call exactly like `output`. A shell
/// with no filesystem (the browser) or no watcher simply never sends one —
@@ -2043,6 +2202,9 @@ pub const Effect = union(enum) {
/// core (like save_file) and must SNAPSHOT them before the worker starts —
/// the core keeps editing while this is in flight.
lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) },
+ /// Snapshot the matching request with pipeRequest(id), then run it away
+ /// from the UI/event loop and answer with pipe_resp.
+ pipe: struct { id: u32 },
/// start (`on`) or stop watching this pane's file on disk. Starting, the
/// shell reads the path off the core exactly like save_file does; stopping
/// carries nothing, because by the time an `off` is drained the pane is
@@ -2169,6 +2331,10 @@ pub const FileSnap = struct {
pub const File = struct {
path: []u8,
content: []u8,
+ /// Monotonic content identity for asynchronous edits. Every content swap
+ /// goes through file_pane.setContent, which bumps this; a pipe completion
+ /// accepted against another revision would overwrite intervening work.
+ revision: u32 = 0,
/// set = this is an OUTPUT buffer (acme's +Errors): a file pane with no
/// file behind it, showing text the core produced itself. It records the
/// COMMAND that opened it, and output_pane.zig's one table turns that into
@@ -2396,6 +2562,11 @@ pub const Pane = struct {
/// output_pane.resultsFrom is how `]d` asks.
search_edit: bool = false,
search_at: u16 = 0,
+ /// `|` reuses the visible tag-tail input but submits a shell-filter
+ /// request rather than a search. pipe_at is a tail byte offset exactly
+ /// like search_at; Enter/Esc restore the tail to it.
+ pipe_edit: bool = false,
+ pipe_at: u16 = 0,
search_pane: ?usize = null,
search_row: ?usize = null,
/// The selection an `s`/`S` input was armed on, as gap offsets over the
@@ -2725,6 +2896,36 @@ pub const Options = struct {
rows: u16 = 24,
};
+const PendingPipe = struct {
+ id: u32,
+ pane: usize,
+ serial: u32,
+ revision: u32,
+ command: []u8,
+ cwd: []u8,
+ inputs: []selection_pipe.Input,
+ ranges: [MAX_SELS]modal.HxRange,
+ primary: u8,
+ explicit: bool,
+
+ 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 Pardes = struct {
gpa: std.mem.Allocator,
opts: Options,
@@ -2825,6 +3026,12 @@ pub const Pardes = struct {
lsp_seq: u32 = 0,
lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = null,
+ /// One current shell-filter request. A newer submit frees and supersedes
+ /// it; old worker answers then fail the id check. The request itself owns
+ /// every byte a shell snapshots while draining the id-only effect.
+ pipe_seq: u32 = 0,
+ pipe_wait: ?PendingPipe = null,
+
/// pending effects, drained by the shell after each update. Growable: one
/// update can emit unbounded writes (a big selection executed is chunked
/// into 64-byte write effects) — a fixed ring overflows and panics.
@@ -2910,6 +3117,7 @@ pub const Pardes = struct {
if (p.dump_out) |d| gpa.free(d);
if (p.last_dump) |d| gpa.free(d);
if (p.chord_arg) |a| gpa.free(a);
+ if (p.pipe_wait) |*wait| wait.deinit(gpa);
p.effects.deinit(gpa);
p.scratch.deinit();
gpa.free(p.surface.cells);
@@ -3172,6 +3380,7 @@ pub const Pardes = struct {
},
.eof => |e| p.removePane(e.pane),
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
+ .pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs),
.file_changed => |fc| p.fileChanged(fc.pane, fc.bytes),
.key => |key| p.handleKey(key),
.mouse => |m| p.handleMouse(m),
@@ -3301,6 +3510,7 @@ pub const Pardes = struct {
pane.tag_edit = false;
pane.tag_sel = false;
pane.search_edit = false; // an abandoned search input stays as tag text
+ pane.pipe_edit = false;
pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed
}
@@ -3761,6 +3971,17 @@ pub const Pardes = struct {
} else p.enterTty(p.active);
return;
}
+ // `|` owns the same visible one-line tag input as search, but Enter
+ // snapshots an asynchronous shell filter. Escape is a pure cancel:
+ // restore the old tail and never emit a request.
+ if (pane.pipe_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ if (hit(key, config.search_submit)) p.submitPipe(p.active);
+ pane.tag_tail.shrinkRetainingCapacity(@min(pane.pipe_at, pane.tag_tail.items.len));
+ exitTagEdit(pane);
+ pane.mode = .normal;
+ pane.pending = 0;
+ return;
+ }
// a search input in flight (`/` or Find): Enter searches, Esc abandons;
// both restore the tag tail and hand focus back to the body.
if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
@@ -5055,6 +5276,7 @@ pub const Pardes = struct {
hit(key, config.comment_toggle) or
hit(key, config.undo) or hit(key, config.redo) or
hit(key, config.command_line) or hit(key, config.search) or
+ hit(key, config.pipe_selection) or
hit(key, config.search_next) or hit(key, config.search_prev) or
hit(key, config.leader);
}
@@ -5424,6 +5646,9 @@ pub const Pardes = struct {
if (pane.tag_edit) pane.mode = .normal;
return;
}
+ // `|` — Helix's pipe command. Only a real, savable file can be
+ // replaced; output buffers, terminals, images and PDFs stay inert.
+ if (hit(key, config.pipe_selection)) return p.startPipe(pane);
// search: `/` types a pattern into the tag, n/N walk the results. Same
// keys on every kind of pane; a terminal with no search armed falls
// back to n/N as a motion over the lookable tokens in its output.
@@ -5436,6 +5661,151 @@ pub const Pardes = struct {
}
}
+ // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ----
+
+ fn startPipe(p: *Pardes, pane: *Pane) void {
+ const f = pane.file orelse return;
+ if (!output_pane.fileTraits(f.output).saves) return;
+ if (!pane.tag_init) {
+ pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
+ pane.tag_init = true;
+ }
+ pane.pipe_at = @intCast(pane.tag_tail.items.len);
+ pane.tag_tail.appendSlice(p.gpa, config.pipe_marker) catch return;
+ pane.pipe_edit = true;
+ pane.tag_edit = true;
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
+ }
+
+ /// Snapshot command/cwd/ranges/selection bytes before emitting the id-only
+ /// effect. Every allocation is owned by pipe_wait, so the frontend can
+ /// copy it synchronously and the core can keep editing immediately after.
+ fn submitPipe(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const f = pane.file orelse return;
+ if (!output_pane.fileTraits(f.output).saves) return;
+ const tail = pane.tag_tail.items;
+ const armed = tail[@min(pane.pipe_at, tail.len)..];
+ if (!std.mem.startsWith(u8, armed, config.pipe_marker)) return;
+ const command = armed[config.pipe_marker.len..];
+ if (command.len == 0) return;
+
+ var ranges: [MAX_SELS]modal.HxRange = undefined;
+ const got = paneRanges(pane, f.content, 0, &ranges);
+ const inputs = p.gpa.alloc(selection_pipe.Input, got.n) catch return;
+ var made: usize = 0;
+ for (ranges[0..got.n], 0..) |range, i| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (hi > f.content.len) break;
+ const copy = p.gpa.dupe(u8, f.content[lo..hi]) catch break;
+ inputs[i] = .{ .bytes = copy };
+ made += 1;
+ }
+ if (made != got.n) {
+ 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,
+ };
+ 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 pipeResponse(p: *Pardes, id: u32, success: bool, outputs: []const []const u8) void {
+ 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;
+
+ const pane = p.panes[wait.pane] orelse return;
+ if (pane.serial != wait.serial) return;
+ const f = if (pane.file) |*file| file else return;
+ if (!output_pane.fileTraits(f.output).saves or f.revision != wait.revision) return;
+
+ var total_output: usize = 0;
+ var removed: usize = 0;
+ var previous_end: usize = 0;
+ for (outputs, wait.ranges[0..outputs.len]) |output, range| {
+ 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;
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (lo < previous_end or hi > f.content.len) return;
+ removed = std.math.add(usize, removed, hi - lo) catch return;
+ previous_end = 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: [MAX_SELS]modal.HxRange = undefined;
+ var read_at: usize = 0;
+ var write_at: usize = 0;
+ for (outputs, wait.ranges[0..outputs.len], 0..) |output, range, i| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ @memcpy(replacement[write_at .. write_at + (lo - read_at)], f.content[read_at..lo]);
+ write_at += 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 = hi;
+ }
+ @memcpy(replacement[write_at..], f.content[read_at..]);
+
+ // One async request is one history transaction, even at 64 cursors.
+ p.pushUndo(pane);
+ file_pane.setContent(p, f, replacement);
+ const pl = p.paneCursorLines(pane) catch return;
+ setPaneRanges(pane, pl, f.content, new_ranges[0..outputs.len], &.{}, 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 };