summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig464
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;