From be404e0d4f91548af6ed600b2fd2027ad8a041c7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 01:47:40 -0300 Subject: focus history is a stack of locations, and the Jumplist is that same stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's directory order, Back/Forward, Last and Jumplist all walk the one list. A pane id is reused, so a location that only remembered an id would retarget after a respawn: panes now carry a monotonic serial and an entry whose slot holds a different serial is dead. trackJump compacts those out and fixes the pointer in the same pass. The push rule lives in ONE place and says: a location is worth remembering when you cannot see it any more — a different pane, or more than a bodyful of rows away in the same one. So hjkl never grows the list and 100G, a search hit and a goto-definition do. Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them. Ctrl-i IS Tab on a legacy host, where the binding simply never fires and Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere. Two goldens moved, both the SPC ? Help listing gaining four rows. --- src/output_pane.zig | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) (limited to 'src/output_pane.zig') diff --git a/src/output_pane.zig b/src/output_pane.zig index 45eb0931..8e08285f 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -20,6 +20,7 @@ const Pardes = pardes.Pardes; const Pane = pardes.Pane; const File = pardes.File; const file_pane = @import("file_pane.zig"); +const modal = @import("modal.zig"); const builtins = @import("builtins.zig"); const Builtin = builtins.Builtin(); const config = @import("config.zig"); @@ -110,6 +111,9 @@ pub fn traits(o: Origin) Traits { .search => .{ .name = config.search_buffer, .steps = true }, .cmd => |b| switch (b) { .Help => .{ .name = config.help_buffer }, + // the focus history, one location per row: not a search, but the + // same kind of list, so n/N walk it and a row is a look target + .Jumplist => .{ .name = config.jumps_buffer, .steps = true }, // Find (file names) and Grep (file contents) both list locations; // no other builtin opens a buffer, and the day one does it lands // here rather than in a call site. @@ -188,6 +192,73 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u return pane; } +/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text, +/// one row per location, oldest first — the same `location text` shape every +/// results buffer here has, which is what buys n/N stepping and Look-on-a-row +/// for nothing: the leading word is an ordinary look target, and the ordinary +/// look path is what goes there. +/// +/// A RENDERING, never a second list. The rows are spelled from the stack at +/// the moment you ask and go stale the moment you jump, exactly like a search +/// result — which is also why this opens a fresh buffer per press instead of +/// refreshing one the way Help does: Help is a document, this is a snapshot. +/// +/// How a location is spelled is the rule runSearch already follows: a REAL +/// file names itself (its path is absolute, so the row resolves from any +/// pane's directory), and everything else — a terminal, an output buffer, an +/// image — has no file to point at and gets `@pN`. The trailing text is the +/// content line for anything holding text, else the pane's directory: enough +/// to recognise the place without opening it. +pub fn openJumps(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const arena = p.scratch.allocator(); + var out: std.ArrayList(u8) = .empty; + for (p.jumps[0..p.njumps]) |j| { + const jp = p.panes[j.pane] orelse continue; + var idbuf: [16]u8 = undefined; + const has_path = if (jp.file) |f| f.output == null else false; + const loc: []const u8 = if (has_path) + jp.file.?.path + else + std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue; + const what: []const u8 = if (jp.file) |f| + std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") + else if (jp.image) |iv| + iv.path + else + jp.cwdSlice(); + // one line of a file can be the whole file: cut it, but never mid + // codepoint — a partial UTF-8 sequence reaches the renderer as a row + // and there is nothing sane for it to draw (grepText cuts the same way) + var cut = @min(what.len, 120); + while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; + // line 0 is a place with no spot in it (a shell whose cursor is still + // the program's): it is written WITHOUT the suffix, which is the same + // thing a bare path has always meant to a look. + const row = if (j.line == 0) + std.fmt.allocPrint(arena, "{s} {s}\n", .{ loc, what[0..cut] }) catch return + else + std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }) catch return; + out.appendSlice(arena, row) catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = open(p, free, dir, .{ .cmd = .Jumplist }, "", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + // focus stays with the pane that asked, exactly as it does after a search: + // n/N are read there, and they step the buffer they just armed. + p.active = id; + pane.search_pane = free; + pane.search_row = null; +} + /// The Help builtin: every builtin reachable from `prefix` (empty = all /// of them) with the keys that run it, written into an output buffer /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- -- cgit v1.3