summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-26 23:25:53 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitd9da3ce9e87cf4c43bf34664031f090e6dde2410 (patch)
tree1ab9627da3c8d8066a282f20fe4796e7001c3f94
parentfc3b5633cb00f2c0495b940c3f1fb07000502819 (diff)
downloadpardes-d9da3ce9e87cf4c43bf34664031f090e6dde2410.tar.gz
pardes-d9da3ce9e87cf4c43bf34664031f090e6dde2410.zip
Find builtin (SPC f f): fd in-core, results into +Search, n/N opens each hit
Save moves w -> f s to free the w group for window navigation.
-rw-r--r--docs/helix-keys.md2
-rw-r--r--src/look.zig62
-rw-r--r--src/pardes.zig134
-rw-r--r--src/tutor.txt9
-rw-r--r--test/snapshots/find.golden492
-rw-r--r--test/snapshots/find.snap75
-rw-r--r--test/snapshots/leader.golden44
7 files changed, 743 insertions, 75 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 444bc822..c109fc48 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -90,7 +90,7 @@ with a reason in `test/hxcases/waivers.jsonl`.
| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Insert mode owns `Ctrl-w` = delete-word-back; `Ctrl-w` while editing a tag (tags are always insert) is a no-op | pardes-specific |
| `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
-| `Space` (normal, body) | the pardes LEADER: a key path from here runs a BUILTIN with no arguments — the same builtins the topbar and the tags hold. `SPC k` Kill, `SPC d` Del, `SPC w` Save, `SPC h t` Tutor, `SPC c n`/`SPC c d` Newcol/Delcol, `SPC t d/c/n/r` Debug/Colors/NextColor/Crt, `SPC s d`/`SPC s r` Dump/Restore. `?` at ANY depth opens the Help builtin listing what the prefix can still reach (`SPC ?` = all, `SPC h ?` = the docs group), into a `+Help` output buffer. The pending path shows at the right edge of the active pane's tag; Esc — or any unmapped key — abandons it | helix spends Space on pickers/LSP (section C); pardes has neither, and acme's builtins are what a leader is for. Enum-derived: the paths, the Help lines and the execute dispatch all fold out of one `Builtin` enum at comptime. Normal mode on a BODY only — tags are always insert, tty keys belong to the program | pardes-specific |
+| `Space` (normal, body) | the pardes LEADER: a key path from here runs a BUILTIN with no arguments — the same builtins the topbar and the tags hold. `SPC k` Kill, `SPC d` Del, `SPC f s` Save, `SPC f f` Find (fd over the pane's directory, results into `+Search`), `SPC h t` Tutor, `SPC c n`/`SPC c d` Newcol/Delcol, `SPC t d/c/n/r` Debug/Colors/NextColor/Crt, `SPC s d`/`SPC s r` Dump/Restore. `?` at ANY depth opens the Help builtin listing what the prefix can still reach (`SPC ?` = all, `SPC h ?` = the docs group), into a `+Help` output buffer. The pending path shows at the right edge of the active pane's tag; Esc — or any unmapped key — abandons it | helix spends Space on pickers/LSP (section C); pardes has neither, and acme's builtins are what a leader is for. Enum-derived: the paths, the Help lines and the execute dispatch all fold out of one `Builtin` enum at comptime. Normal mode on a BODY only — tags are always insert, tty keys belong to the program | pardes-specific |
| `/` then `n`/`N` (any pane) | pardes' own plain-substring search into a `+Search` output buffer, n/N walk the result rows and look them (EXPLICIT selections) | KEEP, do not touch; not in the corpus (helix `/` is regex search) | pardes-specific |
| `n`/`N` (terminal panes, no search armed) | lookable-token motion over scrollback (EXPLICIT selections) | KEEP, do not touch | pardes-specific |
| insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) |
diff --git a/src/look.zig b/src/look.zig
index f34fa367..61970de1 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -170,6 +170,68 @@ const platform_has_fs = switch (pardes.platform) {
.web => false,
};
+// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the
+// keystroke that asked for it — so the walk must end whatever it is pointed at.
+// Three caps, because each alone leaks: hits bound the results buffer, depth
+// bounds a deep tree, and steps bound a wide shallow one (a pattern that never
+// matches would otherwise walk the whole disk without ever filling `hits`).
+const find_max_hits = 512;
+const find_max_depth = 16;
+const find_max_steps = 100_000;
+
+/// `fd`, in-core: every path under `dir` whose NAME contains `pat` (plain
+/// case-insensitive substring — fd's default is a regex and pardes has no
+/// regex engine to spend on one), one ABSOLUTE path per line into `out`.
+/// Absolute because each line is an ordinary look target that outlives the
+/// walk: a terminal's cwd can move out from under its own results buffer.
+/// Only real directories are entered, so a symlink can never close a cycle,
+/// and `.git` is skipped whole (fd's default too).
+pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void {
+ var hits: std.ArrayList([]const u8) = .empty;
+ if (platform_has_fs) {
+ // Zig 0.16 moved the filesystem behind std.Io; the blocking
+ // single-threaded implementation (the one std.debug itself holds) IS
+ // the synchronous walk a sans-IO core wants — no pool, no cancelation.
+ 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);
+ // walkSelectively, not walk: descending is opt-in, which is the only
+ // way to express the depth cap and the .git skip at all.
+ var w = root.walkSelectively(arena) catch return;
+ defer w.deinit();
+ var steps: usize = 0;
+ while (steps < find_max_steps and hits.items.len < find_max_hits) {
+ steps += 1; // an unreadable dir burns a step too, so it cannot spin
+ const e = (w.next(io) catch continue) orelse break;
+ if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null)
+ hits.append(arena, std.fmt.allocPrint(arena, "{s}/{s}", .{
+ std.mem.trimEnd(u8, dir, "/"), e.path,
+ }) catch break) catch break;
+ if (e.kind == .directory and e.depth() < find_max_depth and !std.mem.eql(u8, e.basename, ".git"))
+ w.enter(io, e) catch {};
+ }
+ } else {
+ // web: the build-generated source archive IS the filesystem, and it is
+ // already a flat list of paths — the whole walk is the match.
+ for (embedded_sources.all) |s| {
+ if (hits.items.len >= find_max_hits) break;
+ if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null)
+ hits.append(arena, s.path) catch break;
+ }
+ }
+ // readdir order is undefined; sort so the same tree gives the same buffer
+ // twice running and n/N walks it in a sane order.
+ std.mem.sort([]const u8, hits.items, {}, struct {
+ fn lt(_: void, a: []const u8, b: []const u8) bool {
+ return std.mem.lessThan(u8, a, b);
+ }
+ }.lt);
+ for (hits.items) |h| {
+ out.appendSlice(arena, h) catch return;
+ out.append(arena, '\n') catch return;
+ }
+}
+
/// 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 89f295cc..fadaf3cc 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -67,6 +67,7 @@ const Builtin = enum {
Dump,
Restore,
Save,
+ Find,
Del,
Delcol,
Help,
@@ -74,14 +75,17 @@ const Builtin = enum {
/// SPC leader: ONE key path per builtin, the whole remapping surface. An
/// EnumArray, so a new enum field is a compile error until it has a path.
-/// Groups are just shared first letters (h docs, c columns, t toggles, s
-/// session); `?` is Help's path and is honored at ANY depth — it lists what
-/// the prefix typed so far can still reach.
+/// Groups are just shared first letters (f files, h docs, c columns, t
+/// toggles, s session); `?` is Help's path and is honored at ANY depth — it
+/// lists what the prefix typed so far can still reach.
const leader_path = std.EnumArray(Builtin, []const u8).init(.{
.Help = "?",
.Del = "d",
.Kill = "k",
- .Save = "w", // vim's :w — `s` is the session group
+ // the `f` file group (spacemacs): Save left vim's `w` to join Find here,
+ // which frees `w` for the window group (SPC w h/j/k/l) to move into.
+ .Save = "fs",
+ .Find = "ff",
.Tutor = "ht",
.Newcol = "cn",
.Delcol = "cd",
@@ -594,11 +598,12 @@ pub const Pane = struct {
/// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an
/// op waiting for its char argument (`f` `F` `t` `T` `r`)
pending: u21 = 0,
- /// `/` search: the pattern is typed into the tag tail (no popup);
- /// search_at is the tail length before " /" was appended (submit/cancel
- /// restore it). 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).
+ /// `/` search and the Find builtin: the pattern is typed into the tag tail
+ /// (no popup); search_at is the tail length before the marker was appended
+ /// (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).
search_edit: bool = false,
search_at: u16 = 0,
search_pane: ?usize = null,
@@ -1148,7 +1153,7 @@ pub const Pardes = struct {
} else pane.mode = .normal;
pane.tag_edit = false;
pane.tag_sel = false;
- pane.search_edit = false; // an abandoned `/` input stays as tag text
+ pane.search_edit = false; // an abandoned search input stays as tag text
}
fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
@@ -1421,8 +1426,8 @@ pub const Pardes = struct {
} else p.enterTty(p.active);
return;
}
- // `/` search input in flight: Enter searches, Esc abandons; both
- // restore the tag tail and hand focus back to the body.
+ // a search input in flight (`/` or Find): Enter searches, Esc abandons;
+ // both restore the tag tail and hand focus back to the body.
if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) {
if (is(key, Key.enter)) p.submitSearch(p.active);
pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len));
@@ -2305,7 +2310,7 @@ pub const Pardes = struct {
// search: `/` types a pattern into the tag, n/N walk the results. Same
// keys on every kind of pane; a terminal with no search armed falls
// back to n/N as a motion over the lookable tokens in its output.
- if (is(key, '/')) return p.startSearch(pane);
+ if (is(key, '/')) return p.startSearch(pane, search_marker);
if (is(key, 'n') or is(key, 'N')) {
const delta: i32 = if (is(key, 'n')) 1 else -1;
if (p.searchStep(p.active, delta)) return;
@@ -2314,18 +2319,25 @@ pub const Pardes = struct {
}
}
- // ---- search (`/` + n/N): results into an output buffer, look the rows ----
+ // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
- /// `/` on any pane: type the pattern into the tag tail after a " /"
- /// marker — the existing modal one-line editor, visible while typing,
- /// nothing that disappears. Enter/Esc are intercepted in handleKey.
- fn startSearch(p: *Pardes, pane: *Pane) void {
+ /// What an armed search writes into the tag tail — and the ONLY record of
+ /// which search it is: Enter reads the marker back (submitSearch) instead
+ /// of pardes carrying a second piece of pane state for two commands that
+ /// differ in nothing but where the rows come from.
+ const search_marker = " /";
+ const find_marker = " Find /";
+
+ /// `/` (and the Find builtin) on any pane: type the pattern into the tag
+ /// tail after `marker` — the existing modal one-line editor, visible while
+ /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey.
+ fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void {
if (!pane.tag_init) {
pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
pane.tag_init = true;
}
pane.search_at = @intCast(pane.tag_tail.items.len);
- pane.tag_tail.appendSlice(p.gpa, " /") catch return;
+ pane.tag_tail.appendSlice(p.gpa, marker) catch return;
pane.search_edit = true;
pane.tag_edit = true;
pane.tag_sel = false;
@@ -2334,47 +2346,61 @@ pub const Pardes = struct {
pane.tag_col = @intCast(pane.tag_tail.items.len);
}
- /// Enter on a `/` input: peel the typed pattern off the tag tail (past the
- /// " /" marker) and run it through the shared search.
+ /// 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.
fn submitSearch(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
const tail = pane.tag_tail.items;
- p.runSearch(id, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..]);
+ 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));
}
- /// Search a pane's own text for `pat_raw` (plain substring, case-insensitive
- /// — the same flat view Look and Execute read, so a terminal searches its
- /// scrollback exactly as a file searches its content) and write the hits
- /// into this pane's output buffer, one `location text` row per hit, the
- /// location a bare `LINE:COL` look target ended by a SPACE (a trailing `:`
- /// would read as part of it). It is the pane's path when it has a real
- /// file, else `@pN` — either way an ordinary look target, so n/N is just
- /// "step a row and look it". No matches = an empty buffer.
- fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8) void {
+ /// Fill this pane's results buffer with everything matching `pat_raw`
+ /// (plain substring, case-insensitive) — ONE function for both searches,
+ /// because they differ only in where the rows come from and agree on every
+ /// row being a LOOK TARGET, which is what makes n/N work:
+ /// text (`/`) — the pane's own flat text, the same view Look and Execute
+ /// read, so a terminal searches its scrollback exactly as a file
+ /// searches its content. Rows are `location text`, the location ended
+ /// 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.
+ /// No matches = an empty buffer.
+ fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, find: bool) void {
const pane = p.panes[id] orelse return;
const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
if (pat.len == 0) return;
const arena = p.scratch.allocator();
- 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;
- var idbuf: [16]u8 = undefined;
- const loc: []const u8 = if (has_path)
- pane.file.?.path
- else
- std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return;
+ // where the pane lives: a file's directory, a shell's cwd — the walk
+ // 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;
- for (pl.lines, 0..) |ln, i| {
- const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"),
- }) catch return;
- out.appendSlice(arena, row) catch return;
+ if (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;
+ var idbuf: [16]u8 = undefined;
+ const loc: []const u8 = if (has_path)
+ pane.file.?.path
+ else
+ std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return;
+ for (pl.lines, 0..) |ln, i| {
+ const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
+ const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"),
+ }) catch return;
+ out.appendSlice(arena, row) catch return;
+ }
}
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.
- if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output) {
+ // 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;
@@ -2382,7 +2408,6 @@ pub const Pardes = struct {
pane.search_row = null;
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;
@@ -2407,7 +2432,8 @@ 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;
- if (!rf.output) 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, "/+Search")) 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;
@@ -2425,6 +2451,11 @@ pub const Pardes = struct {
var hi: usize = 0;
while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1;
p.actOnSelection(.right, rid, ln[0..hi], null);
+ // the look may focus what it opened — a Find row opens a whole new
+ // file pane every time — so focus comes back to the pane that owns the
+ // search and the next n keeps stepping. A `/` row looks at the
+ // searching pane itself, so this is what already happened there.
+ p.active = id;
return true;
}
@@ -4566,7 +4597,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);
+ if (pane.image == null and bmode != .tty) p.runSearch(id, txt, false);
},
// `@p7:10:5`: pane 7, line 10, column 5 — how a search result
// points at a terminal or an output buffer, neither of which
@@ -4676,6 +4707,9 @@ 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),
.Newcol => {
const free = p.freeSlot() orelse return;
if (p.ncol >= MAX_COLS) return;
diff --git a/src/tutor.txt b/src/tutor.txt
index 65788c70..f2954667 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -141,6 +141,11 @@
— pane N, line LINE, column COL. Looking either one goes there; the column
is optional (`main.zig:100`, `@p3:12`) and you can type one yourself
anywhere text lives.
+ FIND: the "Find" builtin (SPC f f) arms the same tag input, but Enter
+ walks the pane's DIRECTORY instead of its text — `fd`, in-core — and writes
+ one matching PATH per row into the same "+Search" buffer. Rows are look
+ targets like any other, so n/N step them and each found file opens; the
+ match is on the name, plain and case-insensitive, ".git" is skipped.
LAYOUT: columns split the screen; windows stack within a column. Panes
abut with no wasted gap — a pane's own trailing edge (its last column, or
@@ -464,7 +469,7 @@
SPC ? Help: every builtin and the keys that run it
SPC k Kill (quit) SPC d Del (close this pane)
- SPC w Save (vim's :w) SPC h t Tutor (this file)
+ SPC f s / f f Save / Find SPC h t Tutor (this file)
SPC c n / c d Newcol / Delcol
SPC t d/c/n/r Debug / Colors / NextColor / Crt toggles
SPC s d / s r Dump / Restore the session
@@ -499,7 +504,7 @@
: = the tag as a command line (motions, Enter, back)
zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c
SPC = the leader: a key path runs a builtin (SPC ? lists
- them; SPC k Kill, SPC d Del, SPC w Save, SPC h t Tutor)
+ them; SPC k Kill, SPC d Del, SPC f s Save, SPC f f Find)
vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty.
diff --git a/test/snapshots/find.golden b/test/snapshots/find.golden
new file mode 100644
index 00000000..d489c1c2
--- /dev/null
+++ b/test/snapshots/find.golden
@@ -0,0 +1,492 @@
+== snap group grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del SPC f NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap armed grid=120x40 cursor=56,1
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| INS /tmp/pardes-snap/find/cwd/here.txt Save Del Find / NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap typing grid=120x40 cursor=59,1
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| INS /tmp/pardes-snap/find/cwd/here.txt Save Del Find /zqm NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap results grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 1 /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| 3 /tmp/pardes-snap/find/cwd/zqmark-alpha.txt
+| 4
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap first grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 1 /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| 3 /tmp/pardes-snap/find/cwd/zqmark-alpha.txt
+| 4
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== style first
+|0: 0-48 #969696,#2c2a3e, 49-119 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-48 #969696,#2c2a3e, 49-59 d,#2c2a3e, 60-61 d,#383c54, 62-94 #969696,#2c2a3e, 95-119 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #969696,#2c2a3e, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|11: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|12: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|13: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|14: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|15: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|16: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|17: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|18: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|19: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|20: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|21: 0-1 d,#383c54, 2-42 #969696,#2c2a3e, 43-59 d,#2c2a3e, 60-61 d,#525252, 62-119 d,d,
+|22: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 #d8c8a8,#4a3a1e, 60-61 d,#525252, 62-119 d,d,
+|23: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|24: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|25: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|26: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|27: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|28: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|29: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|30: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|31: 0-1 d,#383c54, 2-59 #969696,#2c2a3e, 60-61 d,#525252, 62-119 d,d,
+|32: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|33: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|34: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|35: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|36: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|37: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|38: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+|39: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d,
+== snap second grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 1 /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| 3 /tmp/pardes-snap/find/cwd/zqmark-alpha.txt
+| 4
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== snap third grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 1 /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt
+| NOR /tmp/pardes-snap/find/cwd/zqmark-alpha.txt Save Del
+| 1 alpha body
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== snap back grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| NOR /tmp/pardes-snap/find/cwd/zqmark-alpha.txt Save Del
+| 1 alpha body
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== snap word grid=120x40 cursor=56,1
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| INS /tmp/pardes-snap/find/cwd/here.txt Save Del Find / NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| NOR /tmp/pardes-snap/find/cwd/zqmark-alpha.txt Save Del
+| 1 alpha body
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== snap cancel grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 2 /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt
+| NOR /tmp/pardes-snap/find/cwd/zqmark-alpha.txt Save Del
+| 1 alpha body
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
+== snap nomatch grid=120x40 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Crt Dump
+| NOR /tmp/pardes-snap/find/cwd/here.txt Save Del NOR /tmp/pardes-snap/find/cwd Del
+| 1 Find
+| here.txt other.txt sub zqmark-alpha.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/+Search Del
+| 1
+| NOR /tmp/pardes-snap/find/cwd/zqmark-alpha.txt Save Del
+| 1 alpha body
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/nest/zqmark-gamma.txt Sa
+| 1 gamma body
+|
+|
+|
+| NOR /tmp/pardes-snap/find/cwd/sub/ZQMARK-beta.txt Save Del
+| 1 beta body
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap
new file mode 100644
index 00000000..28d5ebb2
--- /dev/null
+++ b/test/snapshots/find.snap
@@ -0,0 +1,75 @@
+# The Find builtin (SPC f f) = `fd`, in-core: it arms the SAME tag input `/`
+# does, but Enter walks the pane's DIRECTORY instead of its text and writes one
+# matching path per row into a "+Search" OUTPUT BUFFER. Every row is therefore
+# an ordinary look target, which is the whole point: n/N step the rows and look
+# them with no code of their own, opening each found file, and focus returns to
+# the pane that owns the search so the next n keeps stepping. The walk skips
+# .git, matches the NAME case-insensitively, and sorts (readdir order is not
+# stable). `SPC f s` is Save, which moved out of `w` to make this group.
+dirmk sub
+dirmk sub/nest
+dirmk .git
+file here.txt Find
+file zqmark-alpha.txt alpha body
+file sub/ZQMARK-beta.txt beta body
+file sub/nest/zqmark-gamma.txt gamma body
+file other.txt nothing to match here
+file .git/zqmark-hidden.txt hidden body
+start 40 120 here.txt
+wait 8000 here.txt
+stable 700 20000
+# SPC f is a GROUP now (Find + Save): the indicator stays on the tag
+key space
+key f
+stable 400 5000
+snap group
+# ... and the second f arms the input, marker visible in the tag
+key f
+stable 400 5000
+snap armed
+text zqm
+stable 400 5000
+snap typing
+# Enter walks: three hits, sorted, .git's copy skipped, other.txt not matched
+key enter
+wait 10000 zqmark-alpha.txt
+stable 700 15000
+snap results
+# n looks row 1 — a path, so the file OPENS — and focus comes back here
+key n
+wait 10000 beta body
+stable 700 15000
+snap first
+snapstyle first
+key n
+wait 10000 gamma body
+stable 700 15000
+snap second
+key n
+wait 10000 alpha body
+stable 700 15000
+snap third
+# N walks back up: the file is already open, so the look just focuses it
+key N
+stable 700 15000
+snap back
+# executing the WORD Find (middle-click in a body) arms the same input — the
+# enum IS the dispatch — and Esc abandons it, restoring the tag tail
+press middle 8 3
+release middle 8 3
+stable 700 10000
+snap word
+key esc
+settle 700
+stable 400 5000
+snap cancel
+# a no-match walk empties the buffer; n then has nowhere to go
+key space
+key f
+key f
+text zzqq
+key enter
+stable 700 10000
+key n
+stable 400 5000
+snap nomatch
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index d89997f1..201888ec 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -192,17 +192,17 @@
| 5 SPC c d Delcol
| 6 SPC c n Newcol
| 7 SPC d Del
-| 8 SPC h t Tutor
-| 9 SPC k Kill
-| 10 SPC s d Dump
-| 11 SPC s r Restore
-| 12 SPC t c Colors
-| 13 SPC t d Debug
-| 14 SPC t n NextColor
-| 15 SPC t r Crt
-| 16 SPC w Save
-| 17
-|
+| 8 SPC f f Find
+| 9 SPC f s Save
+| 10 SPC h t Tutor
+| 11 SPC k Kill
+| 12 SPC s d Dump
+| 13 SPC s r Restore
+| 14 SPC t c Colors
+| 15 SPC t d Debug
+| 16 SPC t n NextColor
+| 17 SPC t r Crt
+| 18
== snap help-group grid=100x40 cursor=7,22
|Kill Newcol Tutor Debug Colors NextColor Crt Dump
| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del
@@ -356,17 +356,17 @@
| 5 SPC c d Delcol
| 6 SPC c n Newcol
| 7 SPC d Del
-| 8 SPC h t Tutor
-| 9 SPC k Kill
-| 10 SPC s d Dump
-| 11 SPC s r Restore
-| 12 SPC t c Colors
-| 13 SPC t d Debug
-| 14 SPC t n NextColor
-| 15 SPC t r Crt
-| 16 SPC w Save
-| 17
-|
+| 8 SPC f f Find
+| 9 SPC f s Save
+| 10 SPC h t Tutor
+| 11 SPC k Kill
+| 12 SPC s d Dump
+| 13 SPC s r Restore
+| 14 SPC t c Colors
+| 15 SPC t d Debug
+| 16 SPC t n NextColor
+| 17 SPC t r Crt
+| 18
== snap esc-del grid=100x40 cursor=7,2
|Kill Newcol Tutor Debug Colors NextColor Crt Dump
| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del