diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-03 00:32:11 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 6a43880a7ef6899f0d66c1b0a63a7e7b5610236a (patch) | |
| tree | 2fc12106e0b5f0eb0abcaa345eaa85b39b5bcdd7 /src | |
| parent | c86d3bb210822b3128a0759533f1bec3654364b7 (diff) | |
| download | pardes-6a43880a7ef6899f0d66c1b0a63a7e7b5610236a.tar.gz pardes-6a43880a7ef6899f0d66c1b0a63a7e7b5610236a.zip | |
pipe selections through shell commands
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 7 | ||||
| -rw-r--r-- | src/file_pane.zig | 1 | ||||
| -rw-r--r-- | src/gui/gui.zig | 101 | ||||
| -rw-r--r-- | src/pardes.zig | 370 | ||||
| -rw-r--r-- | src/selection_pipe.zig | 214 | ||||
| -rw-r--r-- | src/tty/tty.zig | 61 | ||||
| -rw-r--r-- | src/web.zig | 47 |
7 files changed, 794 insertions, 7 deletions
diff --git a/src/config.zig b/src/config.zig index 3eedf707..a14ed797 100644 --- a/src/config.zig +++ b/src/config.zig @@ -356,6 +356,9 @@ pub const command_line: []const Chord = &.{.{ .cp = ':' }}; pub const search: []const Chord = &.{.{ .cp = '/' }}; pub const search_next: []const Chord = &.{.{ .cp = 'n' }}; pub const search_prev: []const Chord = &.{.{ .cp = 'N' }}; +/// Helix `|`: in body normal mode, pipe every file selection through one +/// command typed in the pane's visible tag-tail input. +pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; /// Enter on an armed search input runs it; `escape` (PART 3) abandons it. pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; @@ -574,6 +577,10 @@ pub const symbol_marker = " WsSymbols /"; /// the one input that previews as you type (pardes.zig, previewSelRegex). pub const select_marker = " Select /"; pub const split_marker = " Split /"; +/// The `|` prompt is not an executable tag word: the marker only makes the +/// pending shell filter visible, and everything after it is preserved as the +/// exact command passed to `/bin/sh -c`. +pub const pipe_marker = " |"; /// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a /// buffer is DERIVED from the command that opened it (output_pane.traits), and diff --git a/src/file_pane.zig b/src/file_pane.zig index b164a7b3..2f2a0acb 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -94,6 +94,7 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { pub fn setContent(p: *Pardes, f: *File, new: []u8) void { p.gpa.free(f.content); f.content = new; + f.revision +%= 1; if (f.line_starts.len > 0) p.gpa.free(f.line_starts); f.line_starts = &.{}; // the highlights go too, and not just because they are stale: their byte diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 5744b3f2..6bd2c715 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -23,6 +23,7 @@ const look = @import("../look.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("fonts.zig"); // the Font builtin's half of the seam +const selection_pipe = @import("../selection_pipe.zig"); const is_emscripten = builtin.os.tag == .emscripten; const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig"); @@ -578,6 +579,8 @@ const Msg = union(enum) { eof: struct { pane: u8, gen: u32, fd: c_int }, /// a language query finished on its own thread (see lspThread) lsp: struct { id: u32, rows: []u8 }, + /// a selection-filter worker finished; every stdout is gpa-owned + pipe: selection_pipe.Response, /// something happened in a watched directory (see watchThread) files_changed, }; @@ -610,6 +613,11 @@ const LspJob = struct { } }; +const PipeTask = struct { + id: u32, + future: std.Io.Future(anyerror!void), +}; + const Queue = struct { gpa: std.mem.Allocator, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push @@ -630,6 +638,10 @@ const Queue = struct { switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), + .pipe => |response_value| { + var response = response_value; + response.deinit(q.gpa); + }, .eof, .files_changed => {}, } return; @@ -639,6 +651,10 @@ const Queue = struct { switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), + .pipe => |response_value| { + var response = response_value; + response.deinit(q.gpa); + }, .eof, .files_changed => {}, } return; @@ -667,6 +683,10 @@ const Queue = struct { for (q.items.items) |m| switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), + .pipe => |response_value| { + var response = response_value; + response.deinit(q.gpa); + }, .eof, .files_changed => {}, }; q.items.deinit(q.gpa); @@ -831,6 +851,35 @@ fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) th.detach(); } +fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { + defer job.deinit(gpa); + const response = selection_pipe.runJob(gpa, io, job); + q.push(.{ .pipe = response }); +} + +/// Copy every borrowed core byte before the tracked worker starts. The queue +/// owns the response and already has close-time disposal for a late answer. +fn spawnPipe( + core: *pardes.Pardes, + io: std.Io, + gpa: std.mem.Allocator, + q: *Queue, + tasks: *std.ArrayList(PipeTask), + request: anytype, +) void { + const view = core.pipeRequest(request.id) orelse return; + const job = selection_pipe.Job.copy(gpa, view) catch return; + tasks.ensureUnusedCapacity(gpa, 1) catch { + job.deinit(gpa); + return; + }; + const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { + job.deinit(gpa); + return; + }; + tasks.appendAssumeCapacity(.{ .id = request.id, .future = future }); +} + // ---- the renderer state ---- const Gui = struct { @@ -1192,6 +1241,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; defer queue.close(); + var pipe_tasks: std.ArrayList(PipeTask) = .empty; + defer { + for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; + pipe_tasks.deinit(gpa); + } // One inotify instance for every watched pane, opened here — before any // thread exists — so the pre-loop drain below can already mark the file a // positional path argument opened. -1 off linux: watchPane goes quiet and @@ -1205,7 +1259,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // initial spawns BEFORE any worker thread exists: forkpty from a // multithreaded process can wedge the child before exec (see tty.zig). - drainEffects(core, &ptys, &gens, gpa, &queue, &g, inotify_fd, &watches, false); + drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false); for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); // ...and the one file watcher. Started even with nothing marked yet: the fd // already exists and an unwatched inotify instance just parks in read(2) — @@ -1252,6 +1306,20 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); gpa.free(l.rows); }, + .pipe => |response_value| { + var response = response_value; + core.update(.{ .pipe_resp = .{ + .id = response.id, + .success = response.success, + .outputs = response.outputs, + } }); + response.deinit(gpa); + for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { + task.future.await(io) catch {}; + _ = pipe_tasks.orderedRemove(i); + break; + }; + }, // Coalesced on purpose: a burst of writes (a formatter, a build, a // `git checkout`) collapses into ONE pass below, so it cannot queue // a reload — or an undo entry — per write. @@ -1262,7 +1330,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events pollGamepad(&g, core); // 4. effects - drainEffects(core, &ptys, &gens, gpa, &queue, &g, inotify_fd, &watches, true); + drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; kill the live // shells (their detached readers wake on child death; gens bumped so @@ -1669,11 +1737,16 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; defer queue.close(); + var pipe_tasks: std.ArrayList(PipeTask) = .empty; + defer { + for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; + pipe_tasks.deinit(gpa); + } // no inotify here on purpose: this mode's whole contract is one frame per // scripted input event, and a reload that arrives on its own clock would // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. var watches: [pardes.MAX_PANES]?Watch = @splat(null); - drainEffects(core, &ptys, &gens, gpa, &queue, null, -1, &watches, false); + drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false); for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort @@ -1712,10 +1785,25 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { gpa.free(l.rows); n_events += 1; }, + .pipe => |response_value| { + var response = response_value; + core.update(.{ .pipe_resp = .{ + .id = response.id, + .success = response.success, + .outputs = response.outputs, + } }); + response.deinit(gpa); + n_events += 1; + for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { + task.future.await(io) catch {}; + _ = pipe_tasks.orderedRemove(i); + break; + }; + }, .files_changed => {}, // unreachable: no watcher thread in this mode }; msgs.deinit(gpa); - drainEffects(core, &ptys, &gens, gpa, &queue, null, -1, &watches, true); + drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); pollCwds(core, &ptys); if (n_events == 0) continue; // idle tick: nothing changed, no frame _ = frame_arena.reset(.retain_capacity); @@ -2423,8 +2511,10 @@ fn drainEffects( core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, + io: std.Io, gpa: std.mem.Allocator, queue: *Queue, + pipe_tasks: *std.ArrayList(PipeTask), g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) inotify_fd: c_int, watches: *[pardes.MAX_PANES]?Watch, @@ -2507,6 +2597,7 @@ fn drainEffects( _ = c.SDL_SetClipboardText(z.ptr); }, .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), + .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e), .watch => |w| { // starting, the path and the on-disk bytes are read off the core // (same split as save_file); stopping, the pane is already gone @@ -2557,7 +2648,7 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { .open_link => |url| openLinkWeb(gpa, url.slice()), // nothing to spawn/write/resize/save/dump into, and no filesystem to // watch — all no-ops - .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .watch, .quit => {}, + .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .pipe, .watch, .quit => {}, }; } 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 }; diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig new file mode 100644 index 00000000..65c4c7fd --- /dev/null +++ b/src/selection_pipe.zig @@ -0,0 +1,214 @@ +//! 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"); + +/// 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. +/// stderr is diagnostic-only and has a smaller independent ceiling. Crossing +/// any ceiling fails the whole atomic request. +pub const max_stdout_bytes: usize = 1024 * 1024; +pub const max_total_stdout_bytes: usize = 4 * 1024 * 1024; +pub const max_stderr_bytes: usize = 64 * 1024; +pub const command_timeout_seconds: u64 = 10; + +pub const Input = struct { bytes: []const u8 }; + +/// Borrowed view exposed by the core while an effect is being drained. +pub const Request = struct { + id: u32, + command: []const u8, + cwd: []const u8, + inputs: []const Input, +}; + +/// Worker-owned snapshot. `copy` is intentionally called synchronously while +/// draining the effect: the worker can start after arbitrary later edits and +/// still owns exactly the command, directory, and selection bytes submitted. +pub const Job = struct { + id: u32, + command: []u8, + cwd: []u8, + inputs: [][]u8, + + pub fn copy(gpa: std.mem.Allocator, request: Request) !*Job { + const job = try gpa.create(Job); + errdefer gpa.destroy(job); + job.* = .{ + .id = request.id, + .command = try gpa.dupe(u8, request.command), + .cwd = &.{}, + .inputs = &.{}, + }; + errdefer gpa.free(job.command); + job.cwd = try gpa.dupe(u8, request.cwd); + errdefer gpa.free(job.cwd); + job.inputs = try gpa.alloc([]u8, request.inputs.len); + errdefer gpa.free(job.inputs); + var made: usize = 0; + errdefer for (job.inputs[0..made]) |input| gpa.free(input); + for (request.inputs, 0..) |input, i| { + job.inputs[i] = try gpa.dupe(u8, input.bytes); + made += 1; + } + return job; + } + + pub fn deinit(job: *Job, gpa: std.mem.Allocator) void { + gpa.free(job.command); + gpa.free(job.cwd); + for (job.inputs) |input| gpa.free(input); + gpa.free(job.inputs); + gpa.destroy(job); + } +}; + +/// One worker answer. `outputs` owns each slice; a failure owns an empty list. +pub const Response = struct { + id: u32, + success: bool, + outputs: [][]u8, + + pub fn deinit(response: *Response, gpa: std.mem.Allocator) void { + for (response.outputs) |output| gpa.free(output); + if (response.outputs.len > 0) gpa.free(response.outputs); + response.* = undefined; + } +}; + +const WriterContext = struct { + io: std.Io, + file: std.Io.File, + input: []const u8, + ok: bool = false, +}; + +fn writeInput(context: *WriterContext) void { + defer context.file.close(context.io); + context.file.writeStreamingAll(context.io, context.input) catch return; + context.ok = true; +} + +/// Run one POSIX shell command with exact stdin, concurrently draining stdout +/// and stderr so a producer cannot deadlock against a full pipe. The caller is +/// already a worker. Nonzero exit, signal, timeout, IO failure, or either +/// output ceiling is reported as `null`; only an exited-zero command transfers +/// ownership of stdout to the caller. +pub fn runOne( + gpa: std.mem.Allocator, + io: std.Io, + command: []const u8, + cwd: []const u8, + input: []const u8, +) ?[]u8 { + if (std.mem.indexOfScalar(u8, command, 0) != null or + std.mem.indexOfScalar(u8, cwd, 0) != null) return null; + + var child = std.process.spawn(io, .{ + .argv = &.{ "/bin/sh", "-c", command }, + .cwd = if (cwd.len == 0) .inherit else .{ .path = cwd }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, + }) catch return null; + + var writer_context: WriterContext = .{ + .io = io, + .file = child.stdin.?, + .input = input, + }; + child.stdin = null; // writer_context owns and closes this endpoint + var writer: ?std.Thread = std.Thread.spawn(.{}, writeInput, .{&writer_context}) catch { + writer_context.file.close(io); + child.kill(io); + return null; + }; + // On every early return kill first, unblocking a command which never read + // stdin, then join the short-lived writer before its borrowed input dies. + defer if (writer) |thread| thread.join(); + defer child.kill(io); + + var multi_reader_buffer: std.Io.File.MultiReader.Buffer(2) = undefined; + var multi_reader: std.Io.File.MultiReader = undefined; + multi_reader.init(gpa, io, multi_reader_buffer.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer multi_reader.deinit(); + + const stdout_reader = multi_reader.reader(0); + const stderr_reader = multi_reader.reader(1); + const deadline: std.Io.Timeout = (std.Io.Timeout{ .duration = .{ + .clock = .awake, + .raw = .fromSeconds(command_timeout_seconds), + } }).toDeadline(io); + while (multi_reader.fill(64, deadline)) |_| { + if (stdout_reader.buffered().len > max_stdout_bytes or + stderr_reader.buffered().len > max_stderr_bytes) return null; + } else |err| switch (err) { + error.EndOfStream => {}, + else => return null, + } + if (stdout_reader.buffered().len > max_stdout_bytes or + stderr_reader.buffered().len > max_stderr_bytes) return null; + multi_reader.checkAnyError() catch return null; + const term = child.wait(io) catch return null; + writer.?.join(); + writer = null; + + const stdout = multi_reader.toOwnedSlice(0) catch return null; + const stderr = multi_reader.toOwnedSlice(1) catch { + gpa.free(stdout); + return null; + }; + defer gpa.free(stderr); + const exited_zero = switch (term) { + .exited => |code| code == 0, + else => false, + }; + if (!writer_context.ok or !exited_zero) { + gpa.free(stdout); + return null; + } + return stdout; +} + +/// Invoke the command independently for every selection. The response is all +/// or nothing: one failure frees every earlier stdout and returns a failed, +/// empty answer for the core to ignore. +pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { + var response: Response = .{ .id = job.id, .success = false, .outputs = &.{} }; + const outputs = gpa.alloc([]u8, job.inputs.len) catch return response; + var made: usize = 0; + var total: usize = 0; + for (job.inputs, 0..) |input, i| { + const output = runOne(gpa, io, job.command, job.cwd, input) orelse break; + if (std.math.add(usize, total, output.len) catch null) |next_total| { + if (next_total <= max_total_stdout_bytes) { + outputs[i] = output; + total = next_total; + made += 1; + continue; + } + } + gpa.free(output); + break; + } + if (made != job.inputs.len) { + for (outputs[0..made]) |output| gpa.free(output); + gpa.free(outputs); + return response; + } + response.success = true; + response.outputs = outputs; + return response; +} + +test "native pipe runner preserves stdin/stdout bytes and rejects failure" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const output = runOne(gpa, io, "printf 'prefix:'; cat; printf '\\n'", "/tmp", "a\x00b\n") orelse + return error.PipeCommandFailed; + defer gpa.free(output); + try std.testing.expectEqualSlices(u8, "prefix:a\x00b\n\n", output); + try std.testing.expect(runOne(gpa, io, "printf ignored; exit 7", "/tmp", "") == null); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 90477d06..908f17d7 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -12,6 +12,7 @@ const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const temp_file = @import("../temp_file.zig"); +const selection_pipe = @import("../selection_pipe.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -38,6 +39,8 @@ pub const Command = struct { paste: []const u8, /// a language query finished on a worker; rows are gpa-owned lsp_done: struct { id: u32, rows: []u8 }, + /// a selection-filter worker finished; every stdout is gpa-owned + pipe_done: selection_pipe.Response, /// something happened in a watched directory (see watchFiles) files_changed, } = .nop; @@ -86,6 +89,11 @@ const LspJob = struct { } }; +const PipeTask = struct { + id: u32, + future: std.Io.Future(anyerror!void), +}; + const Pty = struct { file: std.Io.File, pid: posix.pid_t, @@ -387,6 +395,9 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; + // Selection filters may overlap: a second submit supersedes the first in + // core without synchronously canceling a possibly slow shell command. + var pipe_tasks: std.ArrayList(PipeTask) = .empty; // One inotify instance for every watched pane, opened here — before any // thread exists — so the pre-loop effect drain below can already mark the // file a positional path argument opened. -1 off linux: watchPane goes @@ -411,6 +422,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { t.cancel(io) catch {}; lsp_task = null; } + for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; + pipe_tasks.deinit(gpa); // same contract as the pty readers: cancel unblocks the watcher's // read, and only then is the fd safe to close if (watch_task) |*t| { @@ -425,13 +438,17 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), .lsp_done => |d| gpa.free(d.rows), + .pipe_done => |response_value| { + var response = response_value; + response.deinit(gpa); + }, else => {}, }; } // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, false); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false); try loop.start(); defer loop.stop(); @@ -589,6 +606,20 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { lsp_task = null; } }, + .pipe_done => |response_value| { + var response = response_value; + core.update(.{ .pipe_resp = .{ + .id = response.id, + .success = response.success, + .outputs = response.outputs, + } }); + response.deinit(gpa); + for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { + task.future.await(io) catch {}; + _ = pipe_tasks.orderedRemove(i); + break; + }; + }, } batch += 1; if (stop or output or native_pdf_page_changed or batch >= 64) break; @@ -619,7 +650,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, true); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live @@ -781,6 +812,7 @@ fn drainEffects( vx: *vaxis.Vaxis, tty: *vaxis.Tty, lsp_task: *?std.Io.Future(anyerror!void), + pipe_tasks: *std.ArrayList(PipeTask), inotify_fd: c_int, watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, @@ -910,6 +942,20 @@ fn drainEffects( continue; }; }, + .pipe => |request| { + if (!threads_ok) continue; + const view = core.pipeRequest(request.id) orelse continue; + const job = selection_pipe.Job.copy(gpa, view) catch continue; + pipe_tasks.ensureUnusedCapacity(gpa, 1) catch { + job.deinit(gpa); + continue; + }; + const future = io.concurrent(pipeWorker, .{ io, gpa, job, loop }) catch { + job.deinit(gpa); + continue; + }; + pipe_tasks.appendAssumeCapacity(.{ .id = request.id, .future = future }); + }, .watch => |w| { // starting, the path and the on-disk bytes are read off the core // (same split as save_file); stopping, the pane is already gone @@ -949,6 +995,17 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { return; } +fn pipeWorker( + io: std.Io, + gpa: std.mem.Allocator, + job: *selection_pipe.Job, + loop: *Loop, +) anyerror!void { + defer job.deinit(gpa); + var response = selection_pipe.runJob(gpa, io, job); + loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); +} + /// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any /// other OS this returns silently, the core never receives a file_changed /// event, and the feature is simply off — a state the core has to tolerate diff --git a/src/web.zig b/src/web.zig index 6ceca904..42359329 100644 --- a/src/web.zig +++ b/src/web.zig @@ -188,6 +188,37 @@ export fn pardes_lsp_response(id: u32, len_arg: u32) void { s.core.update(.{ .lsp_resp = .{ .id = id, .rows = input[0..len] } }); } +/// Complete host-handled effect kind 12. On success `input` is +/// `[count:u32][count * output_len:u32][output bytes...]`, little-endian. +/// A static browser has no process host and simply never calls this; an +/// embedding such as Electron can run the request without another WASM ABI. +export fn pardes_pipe_response(id: u32, success_arg: u32, len_arg: u32) void { + const s = &(state orelse return); + if (success_arg == 0) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return; + } + const len: usize = @min(len_arg, input.len); + if (len < 4) return; + const count: usize = std.mem.readInt(u32, input[0..4], .little); + if (count == 0 or count > pardes.MAX_SELS) return; + const header_len = std.math.add(usize, 4, std.math.mul(usize, count, 4) catch return) catch return; + if (header_len > len) return; + var outputs: [pardes.MAX_SELS][]const u8 = undefined; + var at = header_len; + for (0..count) |i| { + const off = 4 + i * 4; + const encoded_len: *const [4]u8 = @ptrCast(&input[off]); + const output_len: usize = std.mem.readInt(u32, encoded_len, .little); + const end = std.math.add(usize, at, output_len) catch return; + if (end > len) return; + outputs[i] = input[at..end]; + at = end; + } + if (at != len) return; + s.core.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs[0..count] } }); +} + export fn pardes_file_changed(pane: u32, len_arg: u32) void { const s = &(state orelse return); if (pane >= pardes.MAX_PANES) return; @@ -369,6 +400,22 @@ export fn pardes_effect_next() u32 { putEffect(s, e.arg.slice()); break :blk 8; }, + // Process-capable embeddings receive exact command/cwd/input bytes: + // [command_len][cwd_len][count][input lengths...][all payloads...]. + // The ordinary static browser surfaces this as `pardes-io` and stays + // inert because it has no process authority. + .pipe => |e| blk: { + effect_aux0 = e.id; + const request = s.core.pipeRequest(e.id) orelse break :blk 12; + putU32(s, @intCast(request.command.len)); + putU32(s, @intCast(request.cwd.len)); + putU32(s, @intCast(request.inputs.len)); + for (request.inputs) |selection| putU32(s, @intCast(selection.bytes.len)); + appendEffect(s, request.command); + appendEffect(s, request.cwd); + for (request.inputs) |selection| appendEffect(s, selection.bytes); + break :blk 12; + }, .watch => |e| blk: { effect_aux0 = e.pane; effect_aux1 = @intFromBool(e.on); |
