From 77bda7e14e0ea81db5da54c233fc30d218773f47 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 01:14:37 -0300 Subject: pardes - keeps one +Pager per directory, refilled, named so from its first log record; streams the text in bounded writes; never loses it; handles \r, NUL and BEL as a terminal does Each paged command made another +Pager until placement failed, and then git's text was lost with exit 0; a 130 MB page timed out half written and dirty; a directory whose name held a newline left a stray +New; and the log said new +New then rename. The root ctl's `pager ` now makes the directory's +Pager (named before it is announced, placed without the keyboard) or empties the one there; the text goes into it a line-ended 8 MiB chunk a write, one undo step, up to the file limit and its note, and is left clean. A directory a ctl line cannot name pages into the session's. Any failure prints the text to stderr with why and exits 1. A carriage return keeps a progress line's last state and CRLF is a newline; NUL, BEL, SO and SI go; empty stdin makes no pane. Co-Authored-By: Claude Opus 5.5 --- src/Output.zig | 43 +++++++++++++++++++++++++++ src/main.zig | 88 ++++++++++++++++++++++++++++++++++++++++++++++++------- src/ninep/ctl.zig | 12 ++++++++ 3 files changed, 132 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/Output.zig b/src/Output.zig index 1160c455..e6448e36 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -666,6 +666,49 @@ pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { p.active = id; } +/// `pardes -`'s pane: the one `/+Pager`, emptied for the next paged +/// text when it is there (a directory's paged commands refill it, as its +/// command pane is reused), else made, named so before it is announced, +/// and placed wherever there is room without taking the keyboard. `dir` +/// empty (or not absolute): the session's directory. Its id; NoPaneRoom +/// or NoPaneSlots when it has no room at all. +pub fn openPager(p: *Pardes, from: usize, dir_arg: []const u8) !usize { + const dir = if (dir_arg.len > 0 and dir_arg[0] == '/') dir_arg else p.sessionDir(); + const path = try std.fs.path.join(p.gpa, &.{ dir, "+Pager" }); + for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + const f = if (pane.file) |*file| file else continue; + if (!std.mem.eql(u8, f.path, path)) continue; + p.gpa.free(path); + File.setContent(p, f, try p.gpa.alloc(u8, 0)); + resetBody(p, pane); + f.saved_revision = f.revision; + return i; + } + const free = p.freeSlot() orelse { + p.gpa.free(path); + return error.NoPaneSlots; + }; + const empty = p.gpa.alloc(u8, 0) catch |err| { + p.gpa.free(path); + return err; + }; + const pane = open(p, free, dir, .{ .cmd = .New }, "", empty) catch |err| { + p.gpa.free(path); + return err; + }; + // The pane owns the name from here. + p.gpa.free(pane.file.?.path); + pane.file.?.path = path; + const keyboard = p.active; + exec.placeNew(p, from, null, free, .fs); + if (p.unplaced.isSet(free)) return error.NoPaneRoom; + // Shown, not asked for: the keyboard stays where it was, as a command + // pane's does. + if (p.panes[keyboard] != null) p.active = keyboard; + return free; +} + /// Pane `gone` closed: its row goes from +Unsaved, which lists only panes /// still there to lose. pub fn dropUnsaved(p: *Pardes, gone: *const Pane) void { diff --git a/src/main.zig b/src/main.zig index 8818645e..6627bece 100644 --- a/src/main.zig +++ b/src/main.zig @@ -100,6 +100,58 @@ const help_text = \\ ; +/// `pardes -` inside a session: `text` into the directory's one +Pager +/// (root ctl `pager `, refilled when it is there), streamed into its +/// body a bounded chunk at a time as one undo step, and left clean. A text +/// the session would not take is never lost: it goes to stderr, with why, +/// and the exit is 1. +fn pageInto(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, text: []const u8) void { + const Fail = struct { + fn with(o: std.Io, t: []const u8, what: []const u8, err: anyerror) noreturn { + var words_buf: [128]u8 = undefined; + const why = if (err == error.Remote) ninep_io.Client.lastSaid() else forwardWords(err, &words_buf); + var buf: [512]u8 = undefined; + const head = std.fmt.bufPrint(&buf, "pardes: -: {s}: {s}; the text follows\n", .{ what, why }) catch "pardes: -: refused; the text follows\n"; + std.Io.File.stderr().writeStreamingAll(o, head) catch {}; + std.Io.File.stderr().writeStreamingAll(o, t) catch {}; + std.process.exit(1); + } + }; + // The pane's directory: this one, unless its name could not be one line + // of a ctl (a newline, a control byte): then the session's. + var cwd_buf: [4096]u8 = undefined; + const cwd: []const u8 = if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]u8, @ptrCast(c))) else ""; + const plain = for (cwd) |c| { + if (c < ' ' or c == 0x7f) break false; + } else true; + var line_buf: [4200]u8 = undefined; + const ctl_line = std.fmt.bufPrint(&line_buf, "pager {s}\n", .{if (plain) cwd else ""}) catch "pager\n"; + ninep_io.Client.write(gpa, dial, "/ctl", ctl_line) catch |err| Fail.with(io, text, "no +Pager pane", err); + const answer = ninep_io.Client.read(gpa, dial, "/exec", "/exec") catch |err| Fail.with(io, text, "no +Pager pane", err); + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, answer, " \n"), 10) catch |err| Fail.with(io, text, "no +Pager pane", err); + var path_buf: [64]u8 = undefined; + const mark = std.fmt.bufPrint(&path_buf, "/pane/{d}/mark", .{serial}) catch unreachable; + // One undo step for the whole text, however many writes carry it. + ninep_io.Client.write(gpa, dial, mark, "0\n") catch |err| Fail.with(io, text, "the +Pager pane went", err); + var body_buf: [64]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, "/pane/{d}/body", .{serial}) catch unreachable; + // A chunk ends on a line's end (never inside a character), each its own + // write with its own deadline, so a large text arrives whole. + const chunk_max = 8 * 1024 * 1024; + var at: usize = 0; + while (at < text.len) { + var end = @min(text.len, at + chunk_max); + if (end < text.len) if (std.mem.lastIndexOfScalar(u8, text[at..end], '\n')) |nl| { + end = at + nl + 1; + }; + ninep_io.Client.write(gpa, dial, body, text[at..end]) catch |err| Fail.with(io, text[at..], "the +Pager pane would not take it", err); + at = end; + } + ninep_io.Client.write(gpa, dial, mark, "1\n") catch {}; + var ctl_buf: [64]u8 = undefined; + ninep_io.Client.write(gpa, dial, std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}) catch unreachable, "clean\n") catch {}; +} + /// Stdin to its end, its terminal escapes stripped (a pager's input is /// coloured for a terminal; a text pane shows text), at most a file's /// limit, a longer one cut there and said so at its end. @@ -167,6 +219,23 @@ pub fn stripEscapes(gpa: std.mem.Allocator, bytes: []const u8) ![]u8 { i += 1; continue; } + // A carriage return: CRLF is a newline; alone, the line starts + // over, as on a terminal, so a progress line keeps its last state. + if (c == '\r') { + if (i + 1 < bytes.len and bytes[i + 1] == '\n') { + i += 1; + continue; + } + const line_start = if (std.mem.lastIndexOfScalar(u8, out.items, '\n')) |nl| nl + 1 else 0; + out.shrinkRetainingCapacity(line_start); + i += 1; + continue; + } + // Bytes a terminal acts on and never shows: NUL, BEL, SO, SI. + if (c == 0 or c == 0x07 or c == 0x0e or c == 0x0f) { + i += 1; + continue; + } out.appendAssumeCapacity(c); i += 1; } @@ -181,6 +250,9 @@ test "stripEscapes takes out colour, other escapes and overstrike, and keeps the .{ "N\x08NA\x08AM\x08ME\x08E _\x08u", "NAME u" }, .{ "plain\ttext\n", "plain\ttext\n" }, .{ "\x1b(Bdone", "done" }, + .{ "a\r\nb\r\n", "a\nb\n" }, + .{ "10%\r50%\r100%\ndone\n", "100%\ndone\n" }, + .{ "x\x00y\x07z\x0e\x0f\n", "xyz\n" }, }; for (cases) |c| { const got = try stripEscapes(gpa, c[0]); @@ -537,17 +609,9 @@ fn nativeMain(init: std.process.Init) !void { // and the body), and back at once: the PAGER a pane's shell gets. if (positional) |word| if (std.mem.eql(u8, word, "-")) { const text = readStdinText(arena) catch |err| Refuse.with(init.io, "stdin", err); - const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, "-", err); - const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, "-", err); - var cwd_buf: [4096]u8 = undefined; - const cwd = if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]u8, @ptrCast(c))) else "/"; - var path_buf: [64]u8 = undefined; - var line_buf: [4200]u8 = undefined; - const name_line = std.fmt.bufPrint(&line_buf, "{s}/+Pager\n", .{std.mem.trimEnd(u8, cwd, "/")}) catch "/+Pager\n"; - ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/name", .{serial}), name_line) catch |err| Refuse.with(init.io, "-", err); - if (text.len > 0) ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/body", .{serial}), text) catch |err| Refuse.with(init.io, "-", err); - // Shown, not edited: nothing to ask about at Exit. - ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/ctl", .{serial}), "clean\n") catch {}; + // Nothing paged: no pane, and done. + if (text.len == 0) return; + pageInto(init.io, arena, parent.dial, text); return; }; const typed_word = positional orelse { @@ -644,6 +708,8 @@ fn nativeMain(init: std.process.Init) !void { // as `vim -` does; the terminal is /dev/tty, never stdin. if (positional) |a| if (std.mem.eql(u8, a, "-")) { opts.stdin_text = readStdinText(arena) catch |err| badArgs(init.io, "stdin: {s}", .{@errorName(err)}); + // Nothing paged: nothing to show. + if (opts.stdin_text.?.len == 0) return; positional = null; }; if (positional) |a| { diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index fd3418a1..82001f32 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -642,6 +642,18 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { var it = Messages.init(p, req.data); while (it.next()) |line| { if (line.len == 0 or exec_line.isComment(line)) continue; + // `pardes -`'s: the directory's +Pager made or emptied, its + // serial what /exec reads back. + if (std.mem.startsWith(u8, line, "pager ") or std.mem.eql(u8, line, "pager")) { + if (!apply) continue; + const id = pardes.panes.Output.openPager(p, p.active, std.mem.trim(u8, line["pager".len..], " \t")) catch |err| + return tree.failText(req.tag, if (err == error.NoPaneRoom or err == error.NoPaneSlots) E.NOSPC else E.IO, switch (err) { + error.NoPaneRoom, error.NoPaneSlots => "pager: no space for a +Pager pane", + else => "pager: could not make a +Pager pane", + }); + noteResult(p, p.panes[id].?.serial); + continue; + } if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { const size = sizeOf(line) orelse return refuse(p, req, e_size, line); // A frontend's window is the size; only a session none is -- cgit v1.3