diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 370 |
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 }; |
