diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 464 |
1 files changed, 444 insertions, 20 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index db20ff32..55461c5d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -82,6 +82,22 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config" /// target - see `build.zig`. pub const theme_animation = @import("pardes_config").theme_animation; +/// WHICH BUILD THIS IS, for `--version` and for any bug report that follows it. +/// +/// `version` is `build.zig.zon`'s `.version`, read from the manifest by +/// `build.zig` rather than copied beside it, so there is exactly one place to +/// bump. `commit` is the git revision it was built from, and it is OPTIONAL +/// because a source drop is not a repository: a tarball, a container with no +/// `git`, or any checkout outside version control all yield null, and a +/// frontend must say the version happily without one. +/// +/// Both are strings the build baked in, never questions asked at runtime. A +/// binary that shelled out to `git` would describe whatever tree it was +/// standing in rather than the one it came from — and on the board there is +/// neither a `git` nor a process to run it with. +pub const version = @import("pardes_config").version; +pub const commit: ?[]const u8 = @import("pardes_config").commit; + /// A build with no host but its display: the embedded source filesystem, the /// in-process clipboard, silent ptys. Comptime, and its own option module /// rather than a `pardes_config` field, because it is the one setting that @@ -100,6 +116,22 @@ pub const font_picker = platform == .gui or platform == .macos; /// reads this instead so a third such platform cannot forget one of them. pub const hosted = platform == .tty or platform == .gui or platform == .macos; +/// Builds whose frontend can hand its screen to a detached core — which is +/// narrower than `hosted`, and the gap is a bug this predicate exists to close. +/// +/// macOS is hosted, has a unix socket, and compiles `detached/`; what it does +/// not do is POLL. `takeAttach` is a poll rather than a host method precisely +/// because attaching replaces the core the call is running inside (see +/// `Effect.attach`), and `src/macos.zig` never calls it. Gated on `hosted`, the +/// `Attach` word therefore parsed, queued an effect, stored a request in +/// `attach_buf` — and did nothing at all, for ever, silently. That is the +/// failure this codebase refuses everywhere else, so the word does not exist +/// on a frontend that cannot serve it. +/// +/// The two here are exactly the two `main.zig` accepts `--attach` for, which is +/// the same question asked at the command line instead of in a tag. +pub const can_attach = platform == .tty or platform == .gui; + /// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt /// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and /// nothing that could produce a VT byte, so there the emulator is ~400 KiB of @@ -288,6 +320,67 @@ fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { return if (len) |n| buf[0..n] else null; } +/// Drain the queue through the in-process host — what a shell's pump does with +/// it — and report the Attach among those effects. Going through `perform` is +/// the point: what a frontend acts on is what `takeAttach` hands back AFTER the +/// drain, not the effect value, which dies in the loop that read it. +fn drainForAttach(p: *Pardes) ?AttachRequest { + while (p.nextEffect()) |effect| p.perform(effect); + return p.takeAttach(); +} + +test "Attach asks for a session and tears nothing down" { + if (comptime !hosted) return; // no unix socket on this platform, so no word + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + // Bare means whichever session is there, and that is an EMPTY name rather + // than an absent request: the frontend still has to go and look. + try std.testing.expect(p.executeBuiltinLine(0, "Attach")); + const bare = drainForAttach(p) orelse return error.NoAttachAsked; + try std.testing.expectEqualStrings("", bare.name); + try std.testing.expectEqual(@as(u8, 0), bare.pane); + + // ...and the named form carries its tail, the way `Theme <name>` does. + try std.testing.expect(p.executeBuiltinLine(0, "Attach work")); + const named = drainForAttach(p) orelse return error.NoAttachAsked; + try std.testing.expectEqualStrings("work", named.name); + try std.testing.expect(p.takeAttach() == null); // taken once, then gone + + // The word only ASKS. A core that tore itself down here could not be handed + // back intact when the connect fails, and that is the entire guarantee. + try std.testing.expect(!p.quit); + try std.testing.expect(p.panes[0] != null); +} + +test "Detach asks the frontend to leave, and says so when there is nothing to leave" { + if (comptime !hosted) return; // no unix socket on this platform, so no word + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + // Whole-word only, like Kill: the effect names the pane that ran it and + // carries nothing else, because the daemon that serves it already knows + // which frontend's keystroke arrived. + try std.testing.expect(p.executeBuiltinLine(0, "Detach")); + const asked = while (p.nextEffect()) |effect| switch (effect) { + .detach => |d| break d, + else => {}, + } else return error.NoDetachAsked; + try std.testing.expectEqual(@as(u8, 0), asked.pane); + + // ...and this core is a LOCAL shell — a bare `Host{}` fills in no + // `push_detach` — so performing it reports on that pane instead of + // dismissing a session this process is not part of. + p.perform(.{ .detach = asked }); + const pane = p.panes[0].?; + try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); + try std.testing.expect(!p.quit); +} + test "selection pipe prompt submits exact request and Escape cancels" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -3697,6 +3790,25 @@ pub const Event = union(enum) { fs_req: acmefs.Req, }; +/// The longest session name `Effect.attach` can carry. A name is ONE path +/// component under the runtime socket directory (detached/server.zig +/// `socketPath`), so `sun_path`'s 108 bytes cap a usable one far below this; +/// 256 is the width `spawn`'s cwd and `open_link` already reserve, and reusing +/// it is why the new arm costs the effect ring nothing — `save_text`'s +/// {pane, serial, Buf(256)} is still the widest thing in the union. +pub const attach_name_max = 256; + +/// What `takeAttach` hands the shell: the session to reach for (empty means +/// "whichever one is there") and the pane whose message row a failed connect +/// is reported on, the way `Effect.dump_themes` carries the pane that receives +/// the native host's answer. +pub const AttachRequest = struct { pane: u8, name: []const u8 }; + +/// A pane's pty bytes waiting for room in the effect ring. Core-owned (the +/// `Event.paste` slice they came from is borrowed for one `update` only), and +/// freed the moment `off` reaches the end or the pane goes away. +pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; + /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { @@ -3758,6 +3870,27 @@ pub const Effect = union(enum) { /// blocking `event` read, with the waiting left where the kernel's /// request already is. fs_reply: acmefs.Reply, + /// `Attach [name]` — hand this frontend's screen to a detached core, the + /// one `pardes --detach [name]` left running; an empty name means "the + /// session that is there". `pane` is where a failed connect is reported. + /// + /// CONNECT FIRST, SWAP SECOND is what the shell owes this, and it is the + /// whole point of the word: the session's socket must be open before + /// anything local is torn down, so an attach that fails leaves this + /// instance running with every pane and every undo intact instead of half + /// dead. Which is also why no host method performs it — see `perform`. + attach: struct { pane: u8, name: Buf(attach_name_max) }, + /// `Detach` — this frontend leaves; the session and every other frontend + /// carry on. tmux's detach-client, and deliberately NOT the inverse of + /// `attach`: turning a live LOCAL session into a daemon needs setsid and a + /// fork, or closing the terminal takes the session with it. + /// + /// No name travels because there is nobody to name. The word is typed in a + /// frontend that has no core of its own, reaches the daemon as an ordinary + /// `Event.command`, and the daemon routes the effect back to the frontend + /// whose keystroke caused it. `pane` is only for the report a local shell + /// gets instead — `perform`'s null-method arm. + detach: struct { pane: u8 }, quit, fn Buf(comptime n: usize) type { @@ -5834,6 +5967,19 @@ pub const Pardes = struct { effects_head: usize = 0, effects_len: usize = 0, + /// Pty bytes that did not fit the ring, per pane, kept because refusing a + /// `write` TRUNCATES a byte stream rather than merely delaying it: a 1 MiB + /// paste used to reach a program as its first 256 KiB, silently. `emitWrite` + /// parks the tail here and `nextEffect` refills the ring from it as the host + /// drains, so the stream is delayed and never cut. See `limits.pending_write_cap`. + /// + /// Per pane because two ptys are independent streams: only order WITHIN one + /// matters, so a pane whose tail is waiting never delays another's writes. + pending_write: [MAX_PANES]?PendingWrite = @splat(null), + /// Total bytes parked above, so the drain path costs one comparison when + /// nothing is waiting — which is every frame that is not a large paste. + pending_write_bytes: usize = 0, + /// WHO SERVES THIS CORE. Every method optional; a null one is answered by /// `fallback` below, so a `Host{}` is a complete in-process pardes. host: Host = .{}, @@ -5864,6 +6010,14 @@ pub const Pardes = struct { /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, + /// An Attach builtin wants this frontend's screen handed to a detached + /// core; the shell consumes it via takeAttach from its OUTER loop, beside + /// takeRestore and for the same reason — both END this core, and nothing + /// running inside `pump` may destroy the core it is running in. `name` + /// points into `attach_buf`, which the effect's inline copy is unpacked + /// into: the effect is a value the drain loop owns and dies with it. + attach_req: ?AttachRequest = null, + attach_buf: [attach_name_max]u8 = undefined, surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update @@ -5987,6 +6141,7 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { p.cancelLookHover(); + for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; @@ -6033,6 +6188,14 @@ pub const Pardes = struct { return r; } + /// the shell polls this each frame beside takeRestore: a pending Attach's + /// session and reporting pane, or null + pub fn takeAttach(p: *Pardes) ?AttachRequest { + const a = p.attach_req; + p.attach_req = null; + return a; + } + /// the topbar line: the fixed builtins, plus `Restore <path>` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { @@ -6056,6 +6219,8 @@ pub const Pardes = struct { // `event` file open leaving the editor suppressing button actions // for whatever pane lands in this slot next. p.fs.forget(p.gpa, id); + // ...and so do bytes still queued for the pty it no longer has. + p.dropPendingWrite(id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); p.pane_alloc.doom(pane); @@ -6221,14 +6386,47 @@ pub const Pardes = struct { p.look_walk_owner = pane.serial; } - /// chunk arbitrary-length bytes into fixed-size write effects (order kept) + /// Chunk arbitrary-length bytes into fixed-size write effects, order kept. + /// + /// The ring refuses when full rather than evicting, which is right for every + /// other effect and WRONG for a byte stream: the tail of a large paste was + /// dropped where the program needed it whole (and, under bracketed paste, the + /// closing marker went with it, leaving the program in paste mode). What does + /// not fit parks in `pending_write` and `nextEffect` feeds it back as the host + /// drains, so this never truncates while `pending_write_cap` has room. pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; - while (off < bytes.len) { - const n = @min(bytes.len - off, 64); - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); - off += n; + // Anything already parked for this pane owns the stream's position, so + // new bytes queue BEHIND it — emitting them now would reorder the pty. + if (p.pending_write[id] == null) { + while (off < bytes.len and p.effects_len < p.effects.len) { + const n = @min(bytes.len - off, 64); + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); + off += n; + } } + if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); + } + + /// Take ownership of bytes the ring had no room for. A failed allocation or + /// an exhausted cap degrades to the old behaviour — dropping the tail — because + /// the alternative on a full heap is refusing to run at all. + fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { + if (comptime limits.pending_write_cap == 0) return; + if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; + if (p.pending_write[id]) |*pw| { + // Compact what the drain already sent before growing: a burst that + // arrives in pieces must not keep re-copying bytes nobody wants. + const keep = pw.bytes.len - pw.off; + const grown = p.gpa.alloc(u8, keep + bytes.len) catch return; + @memcpy(grown[0..keep], pw.bytes[pw.off..]); + @memcpy(grown[keep..], bytes); + p.gpa.free(pw.bytes); + pw.* = .{ .bytes = grown }; + } else { + p.pending_write[id] = .{ .bytes = p.gpa.dupe(u8, bytes) catch return }; + } + p.pending_write_bytes += bytes.len; } /// The shell reports the spawned pane's working directory (and later cwd @@ -6533,6 +6731,10 @@ pub const Pardes = struct { } pub fn nextEffect(p: *Pardes) ?Effect { + // Before the emptiness test, not after: a pane whose tail is parked + // must not read as "no effects left" while the host's drain loop is + // still asking. This is what turns a truncated paste into a delayed one. + p.refillPendingWrites(); if (p.effects_len == 0) { p.effects_head = 0; return null; @@ -6543,6 +6745,38 @@ pub const Pardes = struct { return e; } + /// Move parked pty bytes into whatever room the ring now has, oldest pane + /// slot first. Costs one comparison when nothing is parked. + fn refillPendingWrites(p: *Pardes) void { + if (p.pending_write_bytes == 0) return; + for (&p.pending_write, 0..) |*slot, id| { + while (p.effects_len < p.effects.len) { + // `|*pw|` points INTO the slot: capturing by value would advance + // a copy's cursor and re-send the same chunk for ever. + const pw = if (slot.*) |*live| live else break; + const n = @min(pw.bytes.len - pw.off, 64); + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pw.bytes[pw.off..][0..n]) } }); + pw.off += n; + p.pending_write_bytes -= n; + if (pw.off == pw.bytes.len) { + p.gpa.free(pw.bytes); + slot.* = null; + break; + } + } + if (p.effects_len == p.effects.len) return; + } + } + + /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied + /// may be handed to a different pane next frame. + fn dropPendingWrite(p: *Pardes, id: usize) void { + const pw = p.pending_write[id] orelse return; + p.pending_write_bytes -= pw.bytes.len - pw.off; + p.gpa.free(pw.bytes); + p.pending_write[id] = null; + } + /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an /// event that carries a borrowed slice cannot survive the trip, so this /// asserts rather than documents it. Hand those to `update` directly inside @@ -6683,6 +6917,33 @@ pub const Pardes = struct { // this is the borrow window. A host with no filesystem serving // cannot have asked, so a null method is not a dropped answer. .fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)), + // THE ONE EFFECT NO HOST METHOD CAN SERVE: attaching REPLACES the + // core this call is running inside — `pump` is two frames up the + // stack — so all it may do here is record the request where the + // shell's outer loop finds it, which is Restore's shape exactly. + // Reaching it through the ring rather than straight from the + // builtin is what ORDERS it: a `Save` queued by the same update is + // performed first, so nothing you typed is still unwritten when + // the screen changes owners. A shell that never polls (the + // browser, the board) simply cannot attach, which is the truth + // about a machine with no unix socket to attach to. + .attach => |a| { + const name = a.name.slice(); + @memcpy(p.attach_buf[0..name.len], name); + p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] }; + }, + // ...and its counterpart, which a host CAN serve and usually does + // not. Only a detached core's host fills `push_detach` in; a local + // tty or SDL shell leaves it null, and the honest answer there is + // a message row rather than a frontend that quits or a word that + // silently does nothing. A null-method fallback and not a comptime + // gate, because whether there is a session to leave is a fact + // about this RUN — the same binary attaches one minute and does + // not the next. + .detach => |d| if (v.push_detach) |f| + f(p.host.ctx) + else + p.reportError(d.pane, "detach", error.NotAttached), // the loop's own condition; a host tears down after its own loop .quit => p.quit = true, } @@ -13423,15 +13684,37 @@ pub const Pardes = struct { // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- /// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal - /// cursor there (`at.col` likewise, 0 = line start): files recenter the - /// view on it, terminals ride their scrollback to it. Both look targets + /// cursor there (`at.col` likewise, 0 = line start). `landing` says how far + /// the view may MOVE to show it: `.center` recenters a file on the line and + /// reveals a PDF's page — a look target, a `:NN`, a search hit, where the + /// context around the destination is the whole point of going there — while + /// `.keep` leaves the view alone and lets `ensureCursorVisible` do the least + /// that shows the cursor, usually nothing at all. A terminal has no recenter + /// to skip, so `landing` does not gate it — but it is not therefore free of + /// movement: a modal cursor PINNED high in the scrollback still pulls the + /// view up to it, which is `ensureCursorVisible` keeping its promise and the + /// reason `Last` records `line = 0` for an unpinned shell. Both look targets /// that name a live pane land here — a path a pane already holds, and /// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below). - pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void { + pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; if (hasPdf(pane)) { + // A page reveal IS this pane's view, so `.keep` is simply not doing + // it. Not a formality: a reveal of the page you are already on still + // sets `document_scroll_y` to `page_starts[page]`, so returning to a + // PDF threw away the offset WITHIN the page you were reading. + // + // Read that literally — under `.keep` a PDF's page is not restored + // AT ALL, and the spot's line is a page. That only shows when + // something moved the pane while you were away, and something can: + // the wheel scrolls the pane under the POINTER, not the active one. + // Then Esc leaves the PDF on the page the wheel reached rather than + // the one the jumplist recorded, which is the answer a RETURN wants + // and not the answer a jump wants — so Ctrl-o and Ctrl-i, which + // centre, are still how you reach the recorded page. + if (landing == .keep) return; if (comptime pdf_enabled) { const pv = &pane.pdf.?; if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane); @@ -13440,16 +13723,23 @@ pub const Pardes = struct { } if (at.line == 0) return; if (pane.file) |*f| { + // The clamp holds either way: a stale jump naming a line past the + // end must not land the cursor there just because the view is not + // moving. if (at.line > file_pane.nlines(p.gpa, f)) return; - const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top - if (next != f.scroll) { - f.scroll = next; - f.syntax_dirty = true; + if (landing == .center) { + const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top + if (next != f.scroll) { + f.scroll = next; + f.syntax_dirty = true; + } } } - // land the modal cursor on the target line (and keep - // ensureCursorVisible agreeing with the recenter — a stale cursor - // would yank the view right back) + // Land the modal cursor on the target line. Under `.center` that keeps + // ensureCursorVisible agreeing with the recenter — a stale cursor would + // yank the view right back. Under `.keep` it IS the whole policy: no + // recenter ran, so the nudge below is the only thing that can move the + // view, and it moves it only far enough to show the cursor. pane.cur_row = @intCast(at.line - 1); pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.cur_pinned = true; @@ -13495,12 +13785,12 @@ pub const Pardes = struct { return true; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { - p.focusPaneLine(i, at); + p.focusPaneLine(i, at, .center); return true; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; - p.focusPaneLine(i, at); + p.focusPaneLine(i, at, .center); return true; } return false; @@ -13606,7 +13896,7 @@ pub const Pardes = struct { // `@p7:10:5`: pane 7, line 10, column 5 — how a search result // points at a terminal or an output buffer, neither of which // has a path. - .pane => |t| p.focusPaneLine(t.id, t.at), + .pane => |t| p.focusPaneLine(t.id, t.at, .center), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below. @@ -14303,12 +14593,20 @@ pub const Pardes = struct { /// the stack and go to what it names. Nothing is pushed and nothing is /// dropped — walking history is not making it — and trackJump agrees, /// because after the move the live spot IS `jumps[jcur]` again. + /// + /// `.center` where `Last` keeps the view, and the asymmetry is structural + /// rather than arbitrary: `Last` only ever CROSSES panes, so the pane it + /// lands on already holds the view you left it with. This may land in the + /// SAME pane, where there is no such view to keep — a long in-file jump + /// would arrive on the very top or bottom row with `scroll_off` lines of + /// context on one side. Helix splits the same pair the same way: its + /// jumplist centres, its buffer switch does not. pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; if (p.njumps == 0 or next < 0 or next >= p.njumps) return; p.jcur = @intCast(next); const j = p.jumps[p.jcur]; - p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }); + p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center); } /// Recompute geometry, push grid-size changes to each emulator + pty, fire @@ -15418,7 +15716,16 @@ pub const Pardes = struct { // this same choice named. Order is load-bearing — gutter, recolor, then // wrap markers; the selection/cursor passes below win over all three. switch (pane.colorAlgo()) { - .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h), + // Every mode, not just `.tty`: `recolorAnsi` translates a row's + // colour anchor through the same slide the edit buffer applied to + // its text, so leaving a shell for normal mode no longer drains + // the screen of colour. A row the user typed has no ANSI and is + // skipped there, which is why this needs no mode test. + // `body` is the very text printed above: `recolorAnsi` pairs its + // graphemes with the cells that spelled them, which is the only way + // to stay on the right glyph when the emulator and this surface + // disagree about how many columns a cluster is worth. + .tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body), .source, .diff => { const f = &pane.file.?; file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); @@ -15792,6 +16099,50 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset); } +test "Esc back into a file leaves its view where it was" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/allocators.zig" }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + const shell = p.active; + p.update(.{ .key = .{ .cp = Key.escape } }); // back onto the doc + p.sync(); + const doc = p.active; + try std.testing.expect(doc != shell); + const dp = p.panes[doc].?; + try std.testing.expect(dp.file != null); + + // Ride the cursor down until the view has scrolled: it now sits in the + // bottom band, which is exactly where a recenter is visible. + for (0..20) |_| p.update(.{ .key = .{ .cp = 'j' } }); + p.sync(); + const view = dp.file.?.scroll; + const row = dp.cur_row; + try std.testing.expect(view > 0); + + p.update(.{ .key = .{ .cp = Key.escape } }); // out to the shell + p.sync(); + try std.testing.expectEqual(shell, p.active); + p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back + p.sync(); + try std.testing.expectEqual(doc, p.active); + + // Nothing moved. The old landing recentred the line under the cursor, so + // coming back repainted the whole screen to show what was already on it. + try std.testing.expectEqual(view, dp.file.?.scroll); + try std.testing.expectEqual(row, dp.cur_row); + // ...and the cursor is still on screen, which is all `.nearest` promises. + try std.testing.expect(dp.cur_row >= @as(i32, @intCast(view))); + try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows)); + + // The other arm still centres: a look target, a `:NN`, a search hit. + p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center); + try std.testing.expect(dp.file.?.scroll != view); + try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll); +} + test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -16017,6 +16368,79 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { try std.testing.expect(pane.ovl == null); } +test "a paste larger than the effect ring reaches the program whole and in order" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + const buf = try gpa.alloc(u8, 1 << 20); + defer gpa.free(buf); + _ = drainWrites(p, buf); + term_pane.enterTty(p, 0); + + // Bigger than `effect_cap * 64` (256 KiB), which is where the ring stops + // taking chunks: the tail used to be refused and the program saw 256 KiB of + // a 300 KiB paste with nothing said. Position-dependent bytes, so a + // reordered or duplicated chunk fails as loudly as a missing one. + const text = try gpa.alloc(u8, 300 * 1024); + defer gpa.free(text); + for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26)); + p.update(.{ .paste = text }); + try std.testing.expectEqualSlices(u8, text, drainWrites(p, buf)); + // ...and the core is not still holding a copy afterwards + try std.testing.expectEqual(@as(usize, 0), p.pending_write_bytes); +} + +test "a bracketed paste larger than the ring still closes its bracket" { + if (platform == .web) return; + if (comptime !term_pane.enabled) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + const buf = try gpa.alloc(u8, 1 << 20); + defer gpa.free(buf); + _ = drainWrites(p, buf); + term_pane.enterTty(p, 0); + + // The program asks for brackets, so `typeToTty` emits marker, text, marker. + // The CLOSING one is queued last and was therefore the first casualty of a + // full ring: the program stayed in paste mode and read every later + // keystroke as pasted text. Worse than losing the bytes. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + try std.testing.expect(term_pane.bracketedPaste(p.panes[0].?)); + const text = try gpa.alloc(u8, 300 * 1024); + defer gpa.free(text); + @memset(text, 'z'); + p.update(.{ .paste = text }); + const got = drainWrites(p, buf); + try std.testing.expect(std.mem.startsWith(u8, got, "\x1b[200~")); + try std.testing.expect(std.mem.endsWith(u8, got, "\x1b[201~")); + try std.testing.expectEqualSlices(u8, text, got["\x1b[200~".len .. got.len - "\x1b[201~".len]); +} + +test "pasted bytes still queued at shutdown are freed, not leaked" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + const buf = try gpa.alloc(u8, 1 << 20); + defer gpa.free(buf); + _ = drainWrites(p, buf); + term_pane.enterTty(p, 0); + + const text = try gpa.alloc(u8, 300 * 1024); + defer gpa.free(text); + @memset(text, 'q'); + p.update(.{ .paste = text }); + // Parked and deliberately NOT drained — a session killed mid-paste. The + // copy is the core's, so `std.testing.allocator` fails this test through + // the `deinit` above if shutdown forgets it. + try std.testing.expect(p.pending_write_bytes > 0); +} + test "leaving tty hides the prompt and keeps the command typed at it" { if (platform == .web) return; const gpa = std.testing.allocator; |
