summaryrefslogtreecommitdiff
path: root/src/pardes.zig
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 /src/pardes.zig
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.
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig134
1 files changed, 84 insertions, 50 deletions
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;