summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design.typ2
-rw-r--r--docs/helix-keys.md4
-rw-r--r--src/Pipe.zig63
-rw-r--r--src/config.zig4
-rw-r--r--src/modal.zig7
-rw-r--r--src/tutor.txt2
-rw-r--r--test/panes.zig6
-rw-r--r--test/pdf.zig3
-rw-r--r--test/snapshots/badutf.snap2
-rw-r--r--test/snapshots/hscroll.snap4
-rw-r--r--test/snapshots/tagnav.snap4
11 files changed, 86 insertions, 15 deletions
diff --git a/docs/design.typ b/docs/design.typ
index 234c3692..4765fabd 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -1916,7 +1916,7 @@ delayed, theme-derived highlight of the exact side-effect-free selection that
Look would expand; it neither focuses nor installs that selection, and pointer
leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched.
-*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and
+*Modes.* normal: helix motions (`h j k l w b e W B E 0 gl ^`, `f F t T` and
`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth
mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 14a39239..cd33ccbe 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -100,7 +100,7 @@ language-backend queries, and the shell pipe.
| `w` `b` `e` | select to next word start / prev word start / next word end | full helix model incl. landing conventions (block cursor one before the next word after `w`) and newline/punct/EOF edges | helix-verified |
| `W` `B` `E` | long-word (WORD) variants | same | helix-verified |
| `Home` / `End` | line start / line end | matches `goto_line_start` / `goto_line_end` | helix-verified |
-| `0` / `$` / `^` | line start / line end / first non-ws | `0` and `^` are pardes extras — helix leaves those two unbound (it spells them `gh`/`gs`). `$` is NOT unbound in helix: it is `shell_keep_pipe` (`keymap/default.rs`), and taking it for line-end is a deliberate divergence — see the `$` row in C. It is the one shell command still unimplemented: it needs a per-selection verdict, where the runner's answer is atomic. `0` is a count digit while a count is pending | pardes-specific (`$`: deliberate) |
+| `0` / `^` | line start / first non-ws | pardes extras: helix leaves both unbound (it spells them `gh`/`gs`), so they collide with nothing. Line end is helix's own `gl` and End. `0` is a count digit while a count is pending | pardes-specific |
| `G` | bare `G` is a **no-op**; `<n>G` = goto line n | vim-ism removed (phase 5): helix `goto_line` only acts with a count; `ge` is goto-last-line | helix-verified |
| `gg` / `<n>gg` | goto first line / line n | | helix-verified |
| `ge` | goto last content line | matches `goto_last_line` (ignores the trailing empty line) | helix-verified |
@@ -146,6 +146,7 @@ language-backend queries, and the shell pipe.
| `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus |
| `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus |
| `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus |
+| `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus |
## B. To implement
@@ -355,7 +356,6 @@ text.
| `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped |
| `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes |
| `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") |
-| `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) |
| `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) |
| `gf` | goto file under selection | covered by pardes Enter-look |
| `Ctrl-z` | suspend | pardes IS the terminal multiplexer |
diff --git a/src/Pipe.zig b/src/Pipe.zig
index 1ad7b826..37145edf 100644
--- a/src/Pipe.zig
+++ b/src/Pipe.zig
@@ -35,6 +35,7 @@ pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 {
.ignore => config.pipe_marker_to,
.insert => config.pipe_marker_insert,
.append => config.pipe_marker_append,
+ .keep => config.pipe_marker_keep,
};
}
@@ -117,7 +118,13 @@ pub fn submitPipe(p: *Pardes, id: usize) void {
p.gpa.free(inputs);
return;
}
- const command_copy = p.gpa.dupe(u8, command) catch {
+ // `$` wants a verdict per selection where the runner's is all or
+ // nothing: the command runs in a subshell whose exit status becomes the
+ // output, so the runner always succeeds and the answer says which kept
+ const command_copy = (if (how == .keep)
+ std.fmt.allocPrint(p.gpa, "(\n{s}\n) >/dev/null; echo $?", .{command})
+ else
+ p.gpa.dupe(u8, command)) catch {
for (inputs) |input| p.gpa.free(@constCast(input.bytes));
p.gpa.free(inputs);
return;
@@ -202,7 +209,7 @@ fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo
const hi = @max(range.anchor, range.head);
if (hi > content.len) return null;
return switch (wait.how) {
- .replace, .ignore => .{ .lo = lo, .hi = hi },
+ .replace, .ignore, .keep => .{ .lo = lo, .hi = hi },
.insert => .{ .lo = lo, .hi = lo },
.append => .{ .lo = hi, .hi = hi },
};
@@ -260,6 +267,21 @@ pub fn pipeResponse(
if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering);
if (wait.how == .ignore) return;
+ if (wait.how == .keep) {
+ // helix shell_keep_pipe: the primary stays if kept, else the last
+ // kept range takes over; keeping none changes nothing
+ var kept: [panes.Text.max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ var pri: ?usize = null;
+ for (0..wait.nranges) |i| {
+ if (!std.mem.eql(u8, std.mem.trim(u8, outputs[i], " \n"), "0")) continue;
+ if (i == wait.primary) pri = m;
+ kept[m] = wait.ranges[i];
+ m += 1;
+ }
+ if (m == 0) return;
+ return t.setRanges(content, kept[0..m], &.{}, pri orelse m - 1, wait.explicit);
+ }
const n = wait.nranges;
var total_output: usize = 0;
@@ -366,6 +388,43 @@ test "| in a tag pipes the tag's own text, and undo there brings it back" {
try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save "));
}
+test "$ keeps the selections the command exits 0 on, and a real shell gives that verdict" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("aa bb cc\n");
+ const ranges = [_]modal.Selection{
+ .{ .anchor = 0, .head = 2 },
+ .{ .anchor = 3, .head = 5 },
+ .{ .anchor = 6, .head = 8 },
+ };
+ pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 1, true);
+ p.update(.{ .key = .{ .cp = '$' } });
+ try std.testing.expectEqualStrings(config.pipe_marker_keep, pane.promptText().?);
+ p.update(.{ .key = .{ .cp = 'g', .text = "grep -v b" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest;
+ try std.testing.expectEqual(@as(usize, 3), request.inputs.len);
+ // the wrapper turns each run's status into its output, as the runner sees it
+ const job = try selection_pipe.Job.copy(gpa, request);
+ defer job.deinit(gpa);
+ var response = selection_pipe.runJob(gpa, std.testing.io, job);
+ defer response.deinit(gpa);
+ try std.testing.expect(response.success);
+ try std.testing.expectEqualStrings("0\n", response.outputs[0]);
+ try std.testing.expectEqualStrings("1\n", response.outputs[1]);
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = response.outputs } });
+ try std.testing.expectEqualStrings("aa bb cc\n", pane.file.?.content);
+ var out: [panes.Text.max_selections]modal.Selection = undefined;
+ const got = pane.body.ranges(pane.file.?.content, 0, &out);
+ try std.testing.expectEqual(@as(usize, 2), got.n);
+ try std.testing.expectEqual(@as(usize, 6), out[1].anchor);
+ // the primary was dropped: the last kept range takes over
+ try std.testing.expectEqual(@as(usize, 1), got.pri);
+}
+
test "selection pipe replaces all ranges atomically and undo restores them" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
diff --git a/src/config.zig b/src/config.zig
index 4412ef0d..91665b14 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -266,10 +266,12 @@ pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }};
pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }};
pub const insert_output: []const Chord = &.{.{ .cp = '!' }};
pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }};
+pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }};
// Armed inputs are parsed from their distinct, nonempty tag markers.
pub const pipe_marker_to = " |-";
pub const pipe_marker_insert = " !";
pub const pipe_marker_append = " !+";
+pub const pipe_marker_keep = " $";
pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }};
// Del from the keyboard on a pane with open panes both above and below it
@@ -454,7 +456,7 @@ pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }};
pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }};
pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }};
pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } };
-pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } };
+pub const line_end: []const Chord = &.{.{ .cp = Key.end }};
pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }};
// Bare G does nothing; ge reaches the last line.
pub const goto_line: []const Chord = &.{.{ .cp = 'G' }};
diff --git a/src/modal.zig b/src/modal.zig
index aec1c579..bce941d5 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -128,6 +128,7 @@ pub const Normal = struct {
pipe_selection_to,
insert_output,
append_output,
+ keep_pipe,
search,
search_next,
search_prev,
@@ -196,10 +197,13 @@ pub const Normal = struct {
insert,
/// `A-!` — no stdin, and the output is appended AFTER each selection.
append,
+ /// `$` — stdin is the selection; the output is discarded, and only the
+ /// selections the command exits 0 on are kept.
+ keep,
/// Do the selections become stdin? helix's `pipe` flag.
pub fn pipes(b: PipeBehavior) bool {
- return b == .replace or b == .ignore;
+ return b == .replace or b == .ignore or b == .keep;
}
};
@@ -597,6 +601,7 @@ pub const Normal = struct {
if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore });
if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert });
if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append });
+ if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep });
if (key.has(.search)) return resultAction(.search);
if (key.has(.search_next)) return resultAction(.{ .search_step = .forward });
if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward });
diff --git a/src/tutor.txt b/src/tutor.txt
index 507ce40e..0893415e 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -461,7 +461,7 @@ typed
N frontends share ONE screen at the smallest common grid
the default 9P socket works in a daemon too
- KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p
+ KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p
i a I A o O u undo U redo
motions SELECT, so `i` types at the selection's start and
`;` collapses first
diff --git a/test/panes.zig b/test/panes.zig
index 0b9909ad..f0e1ba3d 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -1954,7 +1954,11 @@ test "a prompt owns the pipe behavior through cancellation and submission" {
p.update(.{ .key = .{ .cp = Key.enter } });
const pending = p.pipe.wait orelse return error.MissingPipe;
try std.testing.expectEqual(how, pending.how);
- try std.testing.expectEqualStrings("cat", pending.command);
+ // `$` runs the command in a subshell that reports its status
+ if (how == .keep)
+ try std.testing.expect(std.mem.indexOf(u8, pending.command, "\ncat\n") != null)
+ else
+ try std.testing.expectEqualStrings("cat", pending.command);
try std.testing.expectEqual(@as(usize, 1), pending.inputs.len);
try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes);
try std.testing.expect(pane.prompt == .none);
diff --git a/test/pdf.zig b/test/pdf.zig
index 3a592451..8b94c1ae 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -859,7 +859,8 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
p.update(.{ .key = .{ .cp = '2' } });
p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expect(pane.pdf.?.pan_x > 0);
- p.update(.{ .key = .{ .cp = '$' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
p.update(.{ .key = .{ .cp = '2' } });
p.update(.{ .key = .{ .cp = 'h' } });
diff --git a/test/snapshots/badutf.snap b/test/snapshots/badutf.snap
index ddd1588b..7171df00 100644
--- a/test/snapshots/badutf.snap
+++ b/test/snapshots/badutf.snap
@@ -19,6 +19,6 @@ stable 700 20000
# first visible CJK glyph intact instead of starting inside its UTF-8 bytes.
key space t w
stable 400 5000
-key $
+key g l
stable 700 10000
snap hscroll-midglyph
diff --git a/test/snapshots/hscroll.snap b/test/snapshots/hscroll.snap
index 6f73154f..05366323 100644
--- a/test/snapshots/hscroll.snap
+++ b/test/snapshots/hscroll.snap
@@ -36,10 +36,10 @@ send \x1b[<67;10;8M
send \x1b[<67;10;8M
stable 400 5000
snap clamped
-# $ walks the cursor to the line end: the view follows with the margin
+# gl walks the cursor to the line end: the view follows with the margin
key g g
stable 400 5000
-key $
+key g l
stable 400 5000
snap dollar
# 0 back to column zero: the view snaps home
diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap
index 95c4b751..f145480e 100644
--- a/test/snapshots/tagnav.snap
+++ b/test/snapshots/tagnav.snap
@@ -98,7 +98,7 @@ snap workspace-tag
# lives up here too, so a builtin that frees the session must never return
# into a focused header. Dump grows a `Restore <path>` word, which is also the
# case where the line gets longer than the column you were parked on.
-key $
+key g l
stable 400 5000
snap topbar-dollar
key B B B B
@@ -114,7 +114,7 @@ snap topbar-exec
key c-w k
key c-w k
stable 400 5000
-key $ B B
+key g l B B
stable 400 5000
snap topbar-restore-word
key tab