From a77e342eb7fa4add700a96a661226649e8c4763f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 00:52:02 -0300 Subject: output panes remember the command that opened them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An output buffer was a file pane with a flag, and everything special about it was re-derived from outside by asking the wrong question: the buffer NAME ("+Search", "+Help"), a parallel search_kind field, and f.output predicates sprinkled through kind-agnostic code. Now it carries its Origin — the Builtin, or the lsp.Kind for the five gotos helix binds as motions rather than words — plus that command's argument, and one Traits table answers every question the scattered code used to ask: what it is called, whether n/N steps it, whether it is a doc for layout, whether Esc dismisses it, whether it saves. The name is derived FROM the command instead of being the thing that identifies it. Adding a kind of output pane is adding a row. No golden moved. --- next-steps.txt | 2 +- src/builtins.zig | 2 +- src/config.zig | 6 +- src/dump.zig | 9 ++- src/lsp/lsp.zig | 26 ++----- src/lsp/lsp_zls.zig | 2 +- src/output_pane.zig | 195 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/pardes.zig | 103 ++++++++++++++++----------- 8 files changed, 260 insertions(+), 85 deletions(-) diff --git a/next-steps.txt b/next-steps.txt index e2211e7b..cd745f17 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -1,5 +1,5 @@ + All syntatic choices must be centralized in single .zig file: keybindings, syntax for Look, etc; the motivation for this is that those can easilly be changed and tweaked if they're centralized like this and in the process we may cut down some repetition of logic. -- Carve out and isolate the Output pane as its own semantic entity, right now we are doing specific stuff on the output pane based on the command that opened it, let's make this part of the data structure that represents it, the heuristics for special handling of the output panes will be done by reading the Command that originated it field from itself. Make this centralized on the code so its clear to see and tweak those heuristics. ++ Carve out and isolate the Output pane as its own semantic entity, right now we are doing specific stuff on the output pane based on the command that opened it, let's make this part of the data structure that represents it, the heuristics for special handling of the output panes will be done by reading the Command that originated it field from itself. Make this centralized on the code so its clear to see and tweak those heuristics. - File panes need to split the latest column which a file pane was being used. - Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch. - Add a new builtin that will list all builtins and their respective shortcuts if they have any (note that shortcuts may be spc+... or just keyboard or mouse chords/movements) diff --git a/src/builtins.zig b/src/builtins.zig index d32e4ed3..500ce264 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -198,7 +198,7 @@ pub const Ascii = struct { pub const Save = struct { pub fn run(c: Ctx) void { // an output buffer has no file behind it — nothing to write - if (c.pane.file) |f| if (!f.output) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } }); + if (c.pane.file) |f| if (output_pane.fileTraits(f.output).saves) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } }); } }; diff --git a/src/config.zig b/src/config.zig index 141d8376..65ac397b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -308,8 +308,10 @@ pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; -/// Output-buffer names (acme's +Errors). Load-bearing beyond cosmetics: n/N -/// finds its results buffer by name, so the writer and the stepper must agree. +/// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a +/// buffer is DERIVED from the command that opened it (output_pane.traits), and +/// nothing identifies one by matching this text any more — renaming any of +/// these changes only what you read in a tag. pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; pub const hover_buffer = "+Hover"; diff --git a/src/dump.zig b/src/dump.zig index 51221197..0f8cb810 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -68,8 +68,13 @@ pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", - /// an output buffer (no file behind the name) — see pardes.File.output - output: bool = false, + /// non-empty = an output buffer (no file behind the name): the WORD of the + /// command that opened it, plus that command's argument — see + /// output_pane.Origin. A word rather than an integer for the reason every + /// other command in here is a word: reordering builtins.zig stays free, + /// and a dump stays something a person can read. + origin: []const u8 = "", + origin_arg: []const u8 = "", }; pub const Image = struct { diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 610c5604..abb91874 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -17,7 +17,6 @@ //! implementations are measured against each other: same core, same harness, //! same rows, different `query`. const std = @import("std"); -const config = @import("../config.zig"); /// What the caller wants to know. The helix command each one backs is named /// alongside, because the keymap is helix's and these are its verbs. @@ -62,27 +61,10 @@ pub const Kind = enum { /// away from what `gd` actually did. explain, - /// Whether an answer of exactly one row should JUMP rather than open a - /// results buffer. Helix: the five gotos jump on a single location and - /// show a picker on several; a symbol list is always a picker. - pub fn jumpsWhenSingle(k: Kind) bool { - return switch (k) { - .definition, .declaration, .type_definition, .implementation, .references => true, - else => false, - }; - } - - /// The buffer an answer opens. Kept distinct from `+Search` only where the - /// content is not a list of locations — n/N over prose is nonsense. - pub fn bufferName(k: Kind) []const u8 { - return switch (k) { - .hover => config.hover_buffer, - .code_action, .format, .rename => config.lsp_buffer, - // prose about the backend, never a list of locations - .status, .explain => config.lsp_buffer, - else => config.search_buffer, - }; - } + // What an ANSWER becomes — which buffer it opens, whether a single row + // jumps instead, whether n/N walk it — is not here: it is one row per Kind + // in output_pane.traits, beside the same questions asked of `/`, Find, + // Grep and Help. A Kind added above will not compile until it has one. }; /// One question. `source` is a snapshot of the buffer taken by the shell diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index 6f5bbcdd..8a36a151 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -681,7 +681,7 @@ fn documentSymbols( } /// Depth-first so the rows read in source order, with the parent's name as a -/// qualifier — `Kind.jumpsWhenSingle` rather than a bare `jumpsWhenSingle`, +/// qualifier — `Pardes.runBuiltin` rather than a bare `runBuiltin`, /// because a flat list of method names is not navigable. fn emitSymbols( gpa: std.mem.Allocator, diff --git a/src/output_pane.zig b/src/output_pane.zig index 64bfc630..45eb0931 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -1,28 +1,189 @@ //! Output panes: acme's +Errors, a file pane with no file behind it, holding //! text the core produced itself (+Search results, +Help, the LSP answer -//! buffers). The ONLY thing that makes one different from a file pane is that -//! it cannot be saved — every mode, motion, chord and look works because it IS -//! a file pane, and that reuse is the point. +//! buffers). It IS a file pane — every mode, motion, chord and look works for +//! free — and that reuse is the point. //! -//! Thin on purpose, for now. The `f.output` tests still scattered through -//! pardes.zig each ask a one-line question inside kind-agnostic code (does -//! this pane claim a column? can it be saved? is it a doc?) and pulling them -//! here would trade one line for a call. What belongs here is what an output -//! pane KNOWS about itself, and today that is just how one is opened. +//! What it is NOT any more is a file pane with a bool on it. An output buffer +//! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it +//! gets is one `traits` lookup on that field. Before this, a dozen places +//! re-derived what a pane was from the outside, each asking a different wrong +//! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS, +//! which is backwards — a name is a consequence), a `search_kind` field on the +//! pane that ran the search rather than on the buffer that answered it, and +//! `f.output` booleans sprinkled through kind-agnostic layout code. Now the +//! buffer knows, and the table below is the whole answer: one screen you read +//! top to bottom to see every way an output pane differs from a file, and one +//! row to add to introduce another kind. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; +const File = pardes.File; const file_pane = @import("file_pane.zig"); +const builtins = @import("builtins.zig"); +const Builtin = builtins.Builtin(); const config = @import("config.zig"); +const lsp = @import("lsp/lsp.zig"); -/// `content` is gpa-owned and adopted. The name carries `dir` so looks inside -/// the buffer resolve like anywhere else. -pub fn open(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { - const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); +/// What opened this buffer — THE field, and the only input to `traits`. +/// +/// Two vocabularies because the core has exactly two: a `Builtin` is a word +/// you can execute anywhere, and that covers Find, Grep, Help and every +/// language query that has a name (Hover, Diagnostics...). The rest are KEYS — +/// helix binds the five gotos and `=` as motions and `/` as a search input, +/// and a motion has no word to click. Recording the key's `lsp.Kind` (or +/// `.search` for the bare `/`) is not a parallel tag enum: both are the values +/// the caller already holds when it opens the buffer. +pub const Origin = union(enum) { + cmd: Builtin, + query: lsp.Kind, + /// the bare `/` — the pane's own text, searched in core + search, +}; + +/// The cap on a recorded argument, matching the one lspRequest already puts on +/// an effect payload. ponytail: a longer pattern is TRUNCATED here, because +/// `arg` is a record of what was asked and never the text anything re-runs; if +/// something ever re-runs it, this becomes gpa-owned like `content`. +pub const max_arg = 128; + +/// An output buffer's own state, hung off `File.output`. +pub const Output = struct { + from: Origin, + /// the command's ARGUMENT: the pattern a Grep matched, the new name a + /// Rename took, the SPC prefix a Help lists. Inline rather than allocated + /// — it is a fixed-size record, and an owned slice would buy a free in + /// deinitPane and an errdefer at every open site for nothing. + arg_buf: [max_arg]u8 = undefined, + arg_len: u8 = 0, + + pub fn arg(o: *const Output) []const u8 { + return o.arg_buf[0..o.arg_len]; + } +}; + +pub fn setArg(o: *Output, text: []const u8) void { + o.arg_len = @intCast(@min(text.len, max_arg)); + @memcpy(o.arg_buf[0..o.arg_len], text[0..o.arg_len]); +} + +/// Every way an output pane differs from a file pane. One struct so the +/// QUESTIONS are visible even where today every buffer answers alike: a column +/// that never varies still says "this is decided here", which is what makes +/// the next kind of buffer a row rather than a hunt. +pub const Traits = struct { + /// the buffer's name (`+Search`...). DERIVED from the command, never the + /// thing that identifies it — that inversion is what this file undoes. + name: []const u8, + /// n/N walk the rows: each is a `path:LINE:COL text` location the ordinary + /// look path resolves, so the buffer IS helix's picker. Prose (a hover + /// blurb, a rename diff) has nowhere to step to. + steps: bool = false, + /// an answer of exactly ONE row jumps straight there instead of opening + /// this buffer at all — helix: the gotos jump on a single location and + /// show a picker on several, a symbol list is always a picker. + jumps: bool = false, + /// a DOCUMENT for layout purposes: claims a column of its own, is a split + /// parent, pays for a split. A result list is not — it belongs to the pane + /// that asked for it, lands directly below it and takes its rows from + /// there, so opening or closing one never resizes a bystander. + doc: bool = false, + /// Esc dismisses it: there is no file behind the pane and nothing worth + /// keeping, so "never mind" means close it — Esc IS the Del its tag offers. + dismiss: bool = true, + /// there is a file behind it to write. Also what its tag says: no Save to + /// offer means the plain pane tail rather than the file one. + saves: bool = false, +}; + +/// A REAL file pane, as a row of the same table — so kind-agnostic code asks +/// one question and gets one answer whichever it is holding. `name` is unused: +/// a file already has a path. +const file_row: Traits = .{ .name = "", .doc = true, .dismiss = false, .saves = true }; + +/// THE TABLE. Everything above, answered from the command that opened the +/// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening +/// builtin should not compile until someone has said what its buffer does. +pub fn traits(o: Origin) Traits { + return switch (o) { + // rows are `location text`, so n/N walk them + .search => .{ .name = config.search_buffer, .steps = true }, + .cmd => |b| switch (b) { + .Help => .{ .name = config.help_buffer }, + // 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. + else => .{ .name = config.search_buffer, .steps = true }, + }, + .query => |k| switch (k) { + .hover => .{ .name = config.hover_buffer }, + // prose: an action list, a diff, a report about the backend + .code_action, .format, .rename, .status, .explain => .{ .name = config.lsp_buffer }, + .definition, .declaration, .type_definition, .implementation, .references => .{ + .name = config.search_buffer, + .steps = true, + .jumps = true, + }, + .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs => .{ + .name = config.search_buffer, + .steps = true, + }, + }, + }; +} + +/// The same table asked of a file pane's `output` field, null (a real file) +/// included. This is what the kind-agnostic code in pardes.zig calls. +pub fn fileTraits(out: ?Output) Traits { + return traits((out orelse return file_row).from); +} + +/// How the dump spells an origin. A WORD, never an integer, for the reason the +/// dump already stores tag words: reordering builtins.zig stays free. Nothing +/// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is +/// neither. +pub fn word(o: Origin) []const u8 { + return switch (o) { + .cmd => |b| @tagName(b), + .query => |k| @tagName(k), + .search => "/", + }; +} + +/// the inverse; null for "" (a real file) and for a word this build no longer +/// has, which is a dump from another version and not a reason to fail a load +pub fn fromWord(w: []const u8) ?Origin { + if (w.len == 0) return null; + if (std.mem.eql(u8, w, "/")) return .search; + if (std.meta.stringToEnum(Builtin, w)) |b| return .{ .cmd = b }; + if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; + return null; +} + +/// Is the results buffer `pane`'s n/N is armed on the one `from` filled? +/// `]d`/`[d` are the only keys that care WHICH search is showing — they step +/// the diagnostics list when it is up and ask for one when it is not — and +/// this is how they ask now that the buffer remembers: `search_pane` is a +/// SLOT, so this doubles as the check that the slot is still ours. +pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { + const rp = p.panes[pane.search_pane orelse return false] orelse return false; + const f = rp.file orelse return false; + const o = f.output orelse return false; + return std.meta.eql(o.from, from); +} + +/// Open one. `content` is gpa-owned and adopted; the NAME comes from the table +/// (the caller says what ran, not what to call it) and carries `dir` so looks +/// inside the buffer resolve like anywhere else. +pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ + std.mem.trimEnd(u8, dir, "/"), traits(from).name, + }); errdefer p.gpa.free(path); const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = true }; + var out: Output = .{ .from = from }; + setArg(&out, arg); + pane.file = .{ .path = path, .content = content, .output = out }; pane.cur_pinned = true; return pane; } @@ -49,11 +210,15 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { out.append(arena, '\n') catch return; } const content = p.gpa.dupe(u8, out.items) catch return; + // the buffer says what made it, so finding the open one is asking that and + // not matching its name for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; - if (!hf.output or !std.mem.endsWith(u8, hf.path, "/" ++ config.help_buffer)) continue; + const ho = if (hf.output) |*o| o else continue; + if (!std.meta.eql(ho.from, Origin{ .cmd = .Help })) continue; file_pane.setContent(p, hf, content); + setArg(ho, prefix); hf.scroll = 0; hp.cur_row = 0; hp.msel.active = false; @@ -65,7 +230,7 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { p.gpa.free(content); return; }; - const np = open(p, free, dir, config.help_buffer, content) catch { + const np = open(p, free, dir, .{ .cmd = .Help }, prefix, content) catch { p.gpa.free(content); return; }; diff --git a/src/pardes.zig b/src/pardes.zig index 5412569b..01026347 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -600,12 +600,13 @@ pub const FileSnap = struct { pub const File = struct { path: []u8, content: []u8, - /// an OUTPUT buffer (acme's +Errors): a file pane with no file behind it, - /// where the core shows text it produced itself (search results). The ONLY - /// difference is that it cannot be saved — every mode, motion, chord and - /// look works because it IS a file pane. `path` still names it (and gives - /// its directory) so looks inside it resolve like anywhere else. - output: bool = false, + /// 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 + /// every way it differs from a file — ask it, never this field's shape. + /// `path` still names it (and gives its directory) so looks inside it + /// resolve like anywhere else. + output: ?output_pane.Output = null, scroll: usize = 0, /// one syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, @@ -678,15 +679,13 @@ pub const Pane = struct { /// (submit/cancel restore it, and the marker itself says WHICH search this /// is). Enter fills the output buffer in pane search_pane with one result /// per row; n/N walk it, search_row being the row they last landed on - /// (null = fresh results, nothing stepped to yet). + /// (null = fresh results, nothing stepped to yet). WHICH command filled + /// that buffer is the BUFFER's own record, never a second field here — + /// output_pane.resultsFrom is how `]d` asks. search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, search_row: ?usize = null, - /// which language query filled search_pane, or null when a plain `/`, - /// Find or Grep did. Read only by `]d`/`[d`, which step the diagnostics - /// list when it is already up and ask for one when it is not. - search_kind: ?lsp.Kind = null, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -928,8 +927,11 @@ pub const Pardes = struct { /// this one" — the id bump makes the older answer stale and lspResponse /// drops it. A queue would only buy the right to render an answer nobody /// is waiting for any more. + /// `arg` rides along only so the buffer the answer opens can record what + /// was asked (a rename's new name, a symbol query) — the query itself has + /// it in the effect already. lsp_seq: u32 = 0, - lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize } = null, + lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = null, /// pending effects, drained by the shell after each update. Growable: one /// update can emit unbounded writes (a big selection executed is chunked @@ -1217,10 +1219,10 @@ pub const Pardes = struct { } /// the editable tail: the user's edited buffer once touched, else defaults - /// (an output buffer has no file to Save, so it gets the plain tail) + /// (a buffer with nothing to Save gets the plain tail — the table decides) fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tag_tail.items; - if (pane.file) |f| if (!f.output) return file_pane_tail; + if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail; return pane_tail; } @@ -2252,11 +2254,11 @@ pub const Pardes = struct { // Esc: exit select mode / drop pending prefixes + count. The // selection itself SURVIVES (helix; `;` collapses it) — motions - // replace it anyway. On an OUTPUT BUFFER (+Search, +Help) there is no - // file behind the pane and nothing worth keeping, so "never mind" - // means dismiss it: Esc IS Del, the same builtin its tag offers. + // replace it anyway. A buffer the table marks `dismiss` (every output + // buffer today) instead takes Esc as "never mind" and closes: Esc IS + // Del, the same builtin its tag offers. if (hit(key, config.escape)) { - if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, "", null); + if (pane.file) |f| if (output_pane.fileTraits(f.output).dismiss) return p.runBuiltin(.Del, p.active, "", null); pane.select = false; pane.pending = 0; pane.pending2 = 0; @@ -2419,11 +2421,11 @@ pub const Pardes = struct { // n/N stepper — `d` differs from `n` only in what it guarantees is // showing. ]D / [D jump to the last/first, helix's pair. if (hit(key, config.goto_diagnostic)) { - if (pane.search_kind == .diagnostics and p.searchStep(p.active, if (fwd) 1 else -1)) return; + if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (hit(key, config.goto_diagnostic_end)) { - if (pane.search_kind != .diagnostics) return p.lspRequest(p.active, .diagnostics, ""); + if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { // [D = first pane.search_row = null; _ = p.searchStep(p.active, 1); @@ -2706,7 +2708,7 @@ pub const Pardes = struct { } else if (kind == .find) look.find(arena, dir, pat, &out) else { const pl = p.paneCursorLines(pane) catch return; // a real file names itself; a terminal or an output buffer has no path - const has_path = if (pane.file) |f| !f.output else false; + const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; // relative to `dir`, like Find's rows — and `dir` IS this file's // own directory, so that is exactly its basename @@ -2732,7 +2734,14 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = output_pane.open(p, free, dir, config.search_buffer, content) catch { + // the buffer records WHICH search filled it, pattern and all: Find and + // Grep are builtins (words you can execute), the bare `/` is a key + const from: output_pane.Origin = switch (kind) { + .text => .search, + .find => .{ .cmd = .Find }, + .grep => .{ .cmd = .Grep }, + }; + const np = output_pane.open(p, free, dir, from, pat, content) catch { p.gpa.free(content); return; }; @@ -2740,7 +2749,6 @@ pub const Pardes = struct { p.active = id; pane.search_pane = free; pane.search_row = null; - pane.search_kind = null; } /// n/N: step to the next/previous row of this pane's results buffer and @@ -2753,8 +2761,9 @@ pub const Pardes = struct { const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; - // a freed slot can be reused by an unrelated pane: the name says ours - if (!rf.output or !std.mem.endsWith(u8, rf.path, "/" ++ config.search_buffer)) return false; + // one question covers both hazards: a freed slot can be reused by an + // unrelated pane, and a buffer of PROSE has nowhere to step to + if (!output_pane.fileTraits(rf.output).steps) return false; // fresh results: n starts at the first row, N has nothing behind it const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; @@ -2795,7 +2804,7 @@ pub const Pardes = struct { // program's output and an output buffer is our own text. if (kind != .status) { const f = pane.file orelse return; - if (f.output) return; + if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) const off = if (pane.file) |f| modal.hxOff(f.content, .{ @@ -2803,7 +2812,7 @@ pub const Pardes = struct { .col = @intCast(@max(0, pane.cur_col)), }) else 0; p.lsp_seq +%= 1; - p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id }; + p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .arg = .from(arg) }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, @@ -2826,8 +2835,9 @@ pub const Pardes = struct { const pane = p.panes[w.pane] orelse return; if (rows.len == 0) return; + const from: output_pane.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); - if (w.kind.jumpsWhenSingle() and nrows == 1) { + if (output_pane.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; @@ -2845,18 +2855,18 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = output_pane.open(p, free, dir, w.kind.bufferName(), content) catch { + const np = output_pane.open(p, free, dir, from, w.arg.slice(), content) catch { p.gpa.free(content); return; }; p.placeDoc(w.pane, free, np); p.active = w.pane; // prose is not a list of locations: n/N over a hover blurb would step - // to nowhere, so only location buffers arm the stepper. - if (std.mem.eql(u8, w.kind.bufferName(), config.search_buffer)) { + // to nowhere, so only stepping buffers arm the stepper — and WHICH + // query filled it is now the buffer's own record, not a field here. + if (output_pane.traits(from).steps) { pane.search_pane = free; pane.search_row = null; - pane.search_kind = w.kind; } } @@ -4677,7 +4687,7 @@ pub const Pardes = struct { var k = f.idx; while (k > 0) : (k -= 1) { sib = p.col_terms[f.col][k - 1]; - if (p.panes[sib]) |pp| if (if (pp.file) |ff| !ff.output else true) break; + if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; } if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } @@ -4722,10 +4732,10 @@ pub const Pardes = struct { }; src.vweight = @floatFromInt(BOX_H + keep); nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); - // an output buffer is worth exactly its own text: a three-hit +Search + // a non-doc buffer is worth exactly its own text: a three-hit +Search // is four rows, not half the source. Nothing else can want the rows, // so they go straight back to the pane they were taken from. - if (nw.file) |f| if (f.output) { + if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { // trimmed: every row ends in a newline, and the empty line after // the last one is not a result const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); @@ -5069,13 +5079,13 @@ pub const Pardes = struct { /// right) — nothing is displaced, the other columns just narrow. A full /// column bar, or a result list whose source died, stacks into the leftmost. pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - const doc = if (nt.file) |f| !f.output else true; // an image is a doc + const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| !f.output else src.image != null) { + if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null) { src_id = from_id; }; if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| !f.output else pp.image != null)) { + if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null)) { src_id = i; break; }; @@ -5118,7 +5128,10 @@ pub const Pardes = struct { const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); - const dp: dump.Pane = if (pane.file) |f| .{ + // BY POINTER: `origin_arg` is a slice into the File's own inline + // buffer, and a by-value capture would leave it pointing at a + // stack copy that dies before the ZON is written. + const dp: dump.Pane = if (pane.file) |*f| .{ .kind = .file, .tag = tag, .body = body, @@ -5130,7 +5143,8 @@ pub const Pardes = struct { .path = f.path, .content = f.content, .content_b64 = try dump.encodeBytes(arena, f.content), - .output = f.output, + .origin = if (f.output) |o| output_pane.word(o.from) else "", + .origin_arg = if (f.output) |*o| o.arg() else "", }, } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; @@ -5276,7 +5290,14 @@ pub const Pardes = struct { const path = try gpa.dupe(u8, f.path); errdefer gpa.free(path); const pane = try p.newDocPane(i); - pane.file = .{ .path = path, .content = content, .output = f.output, .scroll = src.scroll }; + // an unknown origin word is a dump from another build, not + // a reason to refuse the load: it comes back as a file + const out: ?output_pane.Output = if (output_pane.fromWord(f.origin)) |o| blk: { + var ov: output_pane.Output = .{ .from = o }; + output_pane.setArg(&ov, f.origin_arg); + break :blk ov; + } else null; + pane.file = .{ .path = path, .content = content, .output = out, .scroll = src.scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); -- cgit v1.3