diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-28 16:49:51 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 65b207c3392c75eac3f2b18a266a6482d8345df1 (patch) | |
| tree | 640f449b6e603d8cd5a01e94e40b12b8551d52d4 /src | |
| parent | 0b5ad7a30ba9e7c6eedcb426be294bd94219310f (diff) | |
| download | pardes-65b207c3392c75eac3f2b18a266a6482d8345df1.tar.gz pardes-65b207c3392c75eac3f2b18a266a6482d8345df1.zip | |
Builtins (Find, Grep) qol improvements and ui fixes
Diffstat (limited to 'src')
| -rw-r--r-- | src/look.zig | 117 | ||||
| -rw-r--r-- | src/pardes.zig | 250 |
2 files changed, 287 insertions, 80 deletions
diff --git a/src/look.zig b/src/look.zig index 2b788bde..a3d6ab30 100644 --- a/src/look.zig +++ b/src/look.zig @@ -243,6 +243,123 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *st } } +/// how much of one file Grep reads. The core is synchronous, so a tree with a +/// core dump in it must not stall the keystroke: past this the tail of the file +/// is simply not searched (`grep -R` would read it all). +const grep_max_bytes = 256 * 1024; +const grep_max_files = 20_000; + +/// every line of `text` holding `pat`, as `path:LINE:COL text` rows — the +/// shared half of grep(), and the shape every result row in pardes has: the +/// leading word is a look target, so n/N walk the hits. Returns the rows +/// written, at most `budget`. +fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: []const u8, out: *std.ArrayList(u8), budget: usize) usize { + var n: usize = 0; + var line: usize = 0; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + line += 1; + if (n >= budget) break; + const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; + // one minified line can be the whole file: cut it, but never mid + // codepoint — a partial UTF-8 sequence reaches the renderer as a hit + // row and there is nothing sane for it to draw. + const ln = std.mem.trimEnd(u8, raw, " \t\r"); + var cut = @min(ln.len, 200); + while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; + const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ path, line, at + 1, ln[0..cut] }) catch break; + out.appendSlice(arena, row) catch break; + n += 1; + } + return n; +} + +/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat` +/// (plain case-insensitive substring, like every other search here), one row +/// per hit into `out`. A row's path is RELATIVE to `base` — the directory of +/// the pane that asked, which is also the one its results buffer is named in, +/// so a row reads as the short name that pane would have typed and still looks +/// up. A hit `base` does not contain (another pane's tree) keeps its absolute +/// path, which resolves from anywhere. Same walk, same skip list and same three +/// caps as find(), plus grep_max_bytes and a NUL sniff so a binary never lands +/// in the results. +pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void { + var hits: usize = 0; + if (!platform_has_fs) { + // web: the build-generated source archive IS the filesystem + for (embedded_sources.all) |s| { + if (hits >= find_max_hits) break; + hits += grepText(arena, s.path, s.contents, pat, out, find_max_hits - hits); + } + return; + } + const root_path = std.mem.trimEnd(u8, dir, "/"); + const home = std.mem.trimEnd(u8, base, "/"); + // the walk COLLECTS, then the read scans in sorted order: readdir order is + // undefined, and rows the same tree hands back in a different order twice + // running are rows n/N cannot be trusted to walk (find() sorts for the + // same reason). e.path dies at the next next(), so these are copies. + var files: std.ArrayList([]const u8) = .empty; + { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return; + defer root.close(io); + var w = root.walkSelectively(arena) catch return; + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and files.items.len < grep_max_files) { + steps += 1; + const e = (w.next(io) catch continue) orelse break; + if (e.kind == .directory) { + if (e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + w.enter(io, e) catch {}; + continue; + } + if (e.kind != .file) continue; + const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }) catch break; + files.append(arena, path) catch break; + } + } + std.mem.sort([]const u8, files.items, {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + // ONE buffer for every file: readFile is unbounded, and a synchronous + // search must not swallow a file it cannot afford to hold + const buf = gpa.alloc(u8, grep_max_bytes) catch return; + defer gpa.free(buf); + for (files.items) |path| { + if (hits >= find_max_hits) break; + var pathbuf: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch continue; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }); + if (fd < 0) continue; + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + break; + } + if (n == 0) break; + len += @intCast(n); + } + _ = libc.close(fd); + const text = buf[0..len]; + if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; // binary + // per PATH, not per root: one root can straddle the asking pane's + // directory (a shell at `/a` searching for a file pane at `/a/b`), and + // the rows inside it are the ones worth shortening + const shown = if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/') + path[home.len + 1 ..] + else + path; + hits += grepText(arena, shown, text, pat, out, find_max_hits - hits); + } +} + /// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) fn isDir(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); diff --git a/src/pardes.zig b/src/pardes.zig index 731dfe0a..e29c3662 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -41,7 +41,11 @@ const scroll_off = 3; /// them for the vim reading. pub const swap_enter_tab = false; -pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump"; +// The two searches go LAST (before the optional `Restore <path>`), and next to +// each other: they are one pair — Find matches file NAMES, Grep their CONTENTS +// — and every word's start column is what a topbar click dispatches on, so +// appending is the edit that leaves the bar people already know where it was. +pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep"; const pane_builtins_str = "Del"; const file_pane_builtins_str = "Save Del"; const pane_tail = " " ++ pane_builtins_str; @@ -78,6 +82,9 @@ const Builtin = enum { Restore, Save, Find, + // Find's sibling: Find matches file NAMES under this pane's directory, + // Grep matches file CONTENTS under every pane's directory at once. + Grep, Del, Delcol, Help, @@ -107,6 +114,7 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{ // which frees `w` for the window group (SPC w h/j/k/l) to move into. .Save = "fs", .Find = "ff", + .Grep = "fg", .Tutor = "ht", .Newcol = "cn", .Delcol = "cd", @@ -198,6 +206,14 @@ fn fileLineCount(content: []const u8) usize { return std.mem.count(u8, content, "\n") + 1; } +/// `a` IS `b` or lives under it — how Grep drops a pane's directory when +/// another pane's directory already covers it +fn pathUnder(a: []const u8, b: []const u8) bool { + const root = std.mem.trimEnd(u8, b, "/"); + if (!std.mem.startsWith(u8, a, root)) return false; + return a.len == root.len or a[root.len] == '/'; +} + const tutor_text = @embedFile("tutor.txt"); const rc_path = "/tmp/pardes-osc133.bash"; pub const bash_rc = @@ -1423,7 +1439,7 @@ pub const Pardes = struct { null; if (dir) |d| { const from = p.active; - p.runBuiltin(d, from, ""); + p.runBuiltin(d, from, "", null); if (p.active == from) { // above the topmost tagline is the TOPBAR — row 0, the global // one. It is not a pane, so focusDir can never reach it; this @@ -1612,6 +1628,32 @@ pub const Pardes = struct { return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } + /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — + /// the chord argument is whatever is selected, in any window), searched + /// `first` (the pane the execute happened in), then the active pane (making + /// a selection focuses its pane, so it holds the most recent one), then + /// slot order. Per pane a kept left selection wins, else an explicit modal + /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller + /// that keeps it (the 2-1 chord) copies. + fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { + var k: usize = 0; + while (k < p.panes.len + 2) : (k += 1) { + const i = switch (k) { + 0 => first, + 1 => p.active, + else => k - 2, + }; + if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; + const t = p.panes[i] orelse continue; + if (t.sel[0].state == .done) { + if (p.selectionText(t, t.sel[0]) catch null) |tx| return tx; + } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { + if (p.currentSelText(t)) |tx| return tx; + } + } + return null; + } + fn handleKey(p: *Pardes, key: Key) void { // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is // its own one-line normal mode and owns every key until Esc or a chord. @@ -1629,7 +1671,7 @@ pub const Pardes = struct { // so this is the only keyboard way out of one. if (p.ctrl_w_pending) { p.ctrl_w_pending = false; - if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "") else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "") else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "") else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, ""); + if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "", null) else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "", null) else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "", null) else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, "", null); return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus @@ -1772,7 +1814,7 @@ pub const Pardes = struct { p.leader_n += 1; const pfx = p.leader_keys[0..p.leader_n]; for (leader_rows) |row| { - if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, ""); + if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, "", null); } for (leader_rows) |row| { if (std.mem.startsWith(u8, row.path, pfx)) { @@ -2268,7 +2310,7 @@ pub const Pardes = struct { // file behind the pane and nothing worth keeping, so "never mind" // means dismiss it: Esc IS Del, the same builtin its tag offers. if (is(key, Key.escape)) { - if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, ""); + if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, "", null); pane.select = false; pane.pending = 0; pane.pending2 = 0; @@ -2581,6 +2623,8 @@ pub const Pardes = struct { /// differ in nothing but where the rows come from. const search_marker = " /"; const find_marker = " Find /"; + const grep_marker = " Grep /"; + const Search = enum { text, find, grep }; /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while @@ -2604,14 +2648,21 @@ pub const Pardes = struct { /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the - /// search — " Find /" walks the filesystem, " /" reads the pane's text. - /// No `/` left means the editor ate the marker: nothing to run. + /// search — " Find /" walks the filesystem for NAMES, " Grep /" for + /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor + /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_at, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; - p.runSearch(id, armed[slash + 1 ..], std.mem.startsWith(u8, armed, find_marker)); + const kind: Search = if (std.mem.startsWith(u8, armed, find_marker)) + .find + else if (std.mem.startsWith(u8, armed, grep_marker)) + .grep + else + .text; + p.runSearch(id, armed[slash + 1 ..], kind); } /// Fill this pane's results buffer with everything matching `pat_raw` @@ -2624,8 +2675,11 @@ pub const Pardes = struct { /// by a SPACE (a trailing `:` would read as part of it): the pane's /// path when it has a real file, else `@pN`. /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. + /// grep — the CONTENTS of every file under every pane's directory, + /// walked like `grep -R`. Rows are `path:LINE:COL text`, the path + /// relative to THIS pane's directory (absolute for a hit outside it). /// No matches = an empty buffer. - fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, find: bool) void { + fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; @@ -2634,7 +2688,37 @@ pub const Pardes = struct { // root, and the directory the results buffer is named in. const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); var out: std.ArrayList(u8) = .empty; - if (find) look.find(arena, dir, pat, &out) else { + if (kind == .grep) { + // One walk per PLACE the session is open on: every pane's + // directory, minus the ones another pane's already contains, so a + // tree two panes sit in is greped once and a pane deep inside + // another's tree adds nothing. Slot order, so the same session + // gives the same buffer twice running. + var roots: [MAX_PANES][]const u8 = undefined; + var nroots: usize = 0; + for (p.panes) |slot| { + const pp = slot orelse continue; + const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice(); + var covered = false; + var k: usize = 0; + while (k < nroots) { + if (pathUnder(d, roots[k])) { + covered = true; + break; + } + if (pathUnder(roots[k], d)) { // this one swallows a root already kept + nroots -= 1; + roots[k] = roots[nroots]; + continue; + } + k += 1; + } + if (covered) continue; + roots[nroots] = d; + nroots += 1; + } + for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); + } 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; @@ -2654,31 +2738,11 @@ pub const Pardes = struct { } } const content = p.gpa.dupe(u8, out.items) catch return; - // reuse this pane's own results buffer while it lives, else open one - // next to the docs; focus stays here so n/N keeps stepping. search_pane - // is a raw slot index and a deleted buffer frees its slot, so the NAME - // is the proof the pane found there is still the one we made. - if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output and std.mem.endsWith(u8, sf.path, "/+Search")) { - p.setFileContent(sf, content); - sf.scroll = 0; - sp.cur_row = 0; - sp.msel.active = false; - pane.search_row = null; - // a new result count is a new height (splitBelow does this for a - // fresh one): trade rows with the neighbour it was split off, in - // BOTH directions so the pane grows back for a wider search. The - // neighbour keeps a tag + 2 rows whatever the hit count. - if (p.layoutFindTerm(sid)) |loc| if (p.col_n[loc.col] > 1) { - p.snapColWeights(loc.col); - if (p.panes[p.col_terms[loc.col][if (loc.idx > 0) loc.idx - 1 else 1]]) |sib| { - const pair = sp.vweight + sib.vweight; - const hi = @max(@as(f32, BOX_H + 1), pair - @as(f32, BOX_H + 2)); - sp.vweight = @min(hi, @as(f32, @floatFromInt(BOX_H + fileLineCount(std.mem.trimEnd(u8, content, "\n"))))); - sib.vweight = pair - sp.vweight; - } - }; - return; - }; + // every search opens its OWN buffer, even from the same pane: the + // earlier lists stay open at their sizes and the new one stacks + // directly below this pane, taking its rows from here (placeDoc). + // search_pane is the newest, so n/N step the search just run; focus + // stays here. const free = p.freeSlot() orelse { p.gpa.free(content); return; @@ -4081,32 +4145,11 @@ pub const Pardes = struct { .left => switch (m.kind) { .press => { // middle+left chord: a left press during a middle select- - // drag captures a selection as the execute's argument; the - // middle drag keeps running to its release. The drag pane's - // own selection wins; a pane with none borrows from the - // OTHER panes (acme: the chord argument is whatever text is - // selected, in any window) — the ACTIVE pane first (making - // a selection focuses its pane, so it holds the most - // recently made one), then slot order. Per pane a kept left - // selection wins, else an explicit modal (v/x, n/N) one. + // drag captures a selection (heldSelection) as the + // execute's argument; the middle drag keeps running to its + // release, which consumes it. if (p.drag == .select and p.drag.select.button == .middle) { - const did = p.drag.select.id; - var txt: ?[]const u8 = null; - var k: usize = 0; - while (txt == null and k < p.panes.len + 2) : (k += 1) { - const i = switch (k) { - 0 => did, - 1 => p.active, - else => k - 2, - }; - if (k > 0 and (i == did or (k > 1 and i == p.active))) continue; - const t = p.panes[i] orelse continue; - if (t.sel[0].state == .done) - txt = p.selectionText(t, t.sel[0]) catch null - else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) - txt = p.currentSelText(t); - } - if (txt) |tx| { + if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch null; } @@ -4198,7 +4241,11 @@ pub const Pardes = struct { if (m.button == .middle) { var tb_buf: [1200]u8 = undefined; const word = wordAtCol(p.topbar(&tb_buf), mcol); - if (word.len > 0) p.actOnSelection(.middle, p.active, word, null); + // a topbar word runs on the PRESS — there is no + // drag to chord into, so the argument is simply + // whatever is selected right now: select a word, + // then click Grep, and it greps that word. + if (word.len > 0) p.actOnSelection(.middle, p.active, word, p.heldSelection(p.active)); } return; } @@ -4586,7 +4633,15 @@ pub const Pardes = struct { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); - const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; + // the pane above, but never a result list: walking past a stack of them + // lands on the pane that spawned it, so those keep their heights and + // only the spawner grows. The topmost pane has only what is below it. + var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; + 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]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } @@ -4900,7 +4955,7 @@ pub const Pardes = struct { // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (pane.image == null and bmode != .tty) p.runSearch(id, txt, false); + if (pane.image == null and bmode != .tty) p.runSearch(id, txt, .text); }, // `@p7:10:5`: pane 7, line 10, column 5 — how a search result // points at a terminal or an output buffer, neither of which @@ -4953,12 +5008,24 @@ pub const Pardes = struct { if (button != .middle) return; const cmd = std.mem.trim(u8, txt, " \t\r\n"); if (cmd.len == 0) return; - const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse - // Restore is the ONE builtin that takes an argument (`Restore - // <path>`); every other name must match whole, so `Kill foo` is a - // shell command and not the Kill builtin. - if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) .Restore else null; - if (bi) |b| return p.runBuiltin(b, id, cmd); + // three builtins take an ARGUMENT — `Restore <path>`, `Find <pat>`, + // `Grep <pat>` — so their name matches with a tail; every other name + // must match whole, so `Kill foo` is a shell command, not Kill. + const sp = std.mem.indexOfAny(u8, cmd, " \t"); + const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { + const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; + break :blk switch (head) { + .Restore, .Find, .Grep => head, + else => null, + }; + }; + if (bi) |b| { + // the executed text's own argument if it has one, else the chord's + // kept selection: `Grep foo` and select-foo-then-click-Grep are the + // same command reached two ways + const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; + return p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else arg); + } // terminal: run in itself. file pane: run in a shell in the file's // directory (found by cwd, else freshly forked). const dst: ?usize = if (pane.file) |f| @@ -4981,12 +5048,15 @@ pub const Pardes = struct { } } - /// Run a builtin on pane `id`. `txt` is the executed text — only Restore - /// reads it (for its optional path argument), so the leader passes "" and - /// every builtin runs with zero arguments. The topbar ones are global; the - /// pane-scoped ones (Save, Del, Delcol, the image toggles, and the window - /// group, which moves focus relative to `id`) act on `id`. - fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8) void { + /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads + /// its path back out of it) and `arg` the execute's ARGUMENT — text typed + /// after the name, or the selection a mouse chord kept, which is why Grep + /// and Find run straight away when there is one instead of asking. The + /// leader passes "" and null: a key path names a builtin, never an + /// argument. The topbar builtins are global; the pane-scoped ones (Save, + /// Del, Delcol, the image toggles, and the window group, which moves focus + /// relative to `id`) act on `id`. + fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { const pane = p.panes[id] orelse return; switch (b) { .Debug => p.show_debug = !p.show_debug, @@ -5024,9 +5094,18 @@ pub const Pardes = struct { // an output buffer has no file behind it — nothing to write if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); }, - // the one builtin that ASKS for something: it arms the same tag - // input `/` does, and Enter there runs the walk (submitSearch). - .Find => p.startSearch(pane, find_marker), + // The two builtins that ASK for something — Find walks file NAMES + // under this pane's directory, Grep file CONTENTS under every + // pane's. With an argument there is nothing to ask: it IS the + // pattern, so the walk runs now (this is what a `Grep` executed + // with a selection chorded to it means). Without one they arm the + // same tag input `/` does, and Enter runs it (submitSearch). + .Find, .Grep => { + const kind: Search = if (b == .Find) .find else .grep; + const pat = std.mem.trim(u8, arg orelse "", " \t\r\n"); + if (pat.len > 0) return p.runSearch(id, pat, kind); + p.startSearch(pane, if (b == .Find) find_marker else grep_marker); + }, .Newcol => { const free = p.freeSlot() orelse return; if (p.ncol >= MAX_COLS) return; @@ -5157,7 +5236,8 @@ pub const Pardes = struct { /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a /// document: it is the result list belonging to the pane that asked for it, /// so it never claims a column and is never anyone else's split parent — it - /// lands right below `from_id`, be that a shell, a file or another list. + /// lands right below `from_id`, be that a shell, a file or another list, + /// and `from_id` alone pays the rows (several lists just stack there). /// A real doc joins the docs: any doc already open is the split parent (the /// source if it IS one, else the first found) and the newcomer lands right /// below it, so all docs share one column. The FIRST doc of the session @@ -5167,7 +5247,7 @@ pub const Pardes = struct { 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 var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (!doc or (if (src.file) |f| !f.output else src.image != null)) { + if (p.panes[from_id]) |src| if (if (src.file) |f| !f.output else src.image != null) { src_id = from_id; }; if (doc and src_id == null) for (p.panes, 0..) |sl, i| { @@ -5176,6 +5256,16 @@ pub const Pardes = struct { break; }; }; + // A result list belongs to its spawner: it lands directly BELOW it — + // on top of the lists already there — and its rows come out of the + // SPAWNER, never a bystander, so opening another list (or deleting + // one, see absorbVWeight) leaves every other pane's height untouched. + if (!doc) if (p.layoutFindTerm(from_id)) |sf| { + p.layoutInsert(sf.col, sf.idx + 1, free); + p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays + p.active = free; + return; + }; if (src_id) |sid| { const src = p.splitParent(sid); const sf = p.layoutFindTerm(src).?; |
