From bdbab17204efdb785b6ab3cc2022484485c0ded2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:38:06 -0300 Subject: g. goes to where the last edit ended helix goto_last_modification: every range moves to the end of the last change (the start of a deletion, the end of an insertion), extending in select mode. The position is kept on the Text in surface rows, set where setEditText hands a body its new text, so a terminal's typed text has one too and moves with its scrollback. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/Terminal.zig | 6 +++++- src/Text.zig | 3 +++ src/config.zig | 1 + src/edit.zig | 7 +++++++ src/modal.zig | 3 +++ src/normal.zig | 4 ++++ test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ 9 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index f2901b92..2928d78e 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -184,6 +184,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `Alt-.` | repeat last `f`/`t`/`F`/`T` motion (`Pane.find_op`/`find_ch`), takes a count | decision: repeats ONLY the find family, not `m`/`[`/`]` (helix extends it there; marginal) | helix-verified (phase 5) | | `g|`, `g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) | | `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) | +| `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -351,7 +352,6 @@ text. | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | `gw` | word-label jump | label-jump overlay machinery, not core editing | -| `g.` | goto last modification | jumplist/history position tracking | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | diff --git a/src/Terminal.zig b/src/Terminal.zig index 1ff561b1..4c29e151 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -720,7 +720,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -846,6 +846,10 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } + if (pane.body.last_edit) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } if (pane.look_at) |*at| { extra[nextra] = &at.row; nextra += 1; diff --git a/src/Text.zig b/src/Text.zig index 411c2ec8..70ec5369 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -69,6 +69,9 @@ sticky_col: i32 = -1, /// The insert session began with `a`: on Esc every range gives back the /// one character it was stretched by (helix restore_cursor). restore_cursor: bool = false, +/// Where the last edit ended, a deletion's start or an insertion's end, in +/// surface rows: for `g.` (helix last_edit_pos). +last_edit: ?struct { row: i32, col: i32 } = null, /// insert `Ctrl-r` was pressed: the next key names the register to insert reg_wait: bool = false, normal: modal.Normal.State = .{}, diff --git a/src/config.zig b/src/config.zig index 959f4c4a..993b6fe0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -487,6 +487,7 @@ pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/edit.zig b/src/edit.zig index 5e0d20a4..9398674a 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -484,6 +484,13 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { return; } const pane = t.pane() orelse return retireEditText(p, new); + // what changed ended where the texts agree again from their ends + const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + const end = modal.positionAt(new, new.len - back); + t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } diff --git a/src/modal.zig b/src/modal.zig index ae008f60..0171da93 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -28,6 +28,7 @@ pub const Normal = struct { goto_view_top, goto_view_center, goto_view_bottom, + goto_last_modification, goto_definition, goto_declaration, goto_type_definition, @@ -251,6 +252,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + last_modification, }; pub const View = enum { top, center, bottom, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; @@ -437,6 +439,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); diff --git a/src/normal.zig b/src/normal.zig index bc450581..228c9c39 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -460,6 +460,10 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .view_top => return t.gotoWindow(text, range, .top, go.count), .view_center => return t.gotoWindow(text, range, .center, go.count), .view_bottom => return t.gotoWindow(text, range, .bottom, go.count), + .last_modification => { + const at = t.last_edit orelse return; + return t.pointMove(text, range, modal.offsetAt(text, .{ .row = @intCast(@max(0, at.row)), .col = @intCast(@max(0, at.col)) })); + }, }, .view => |view| if (t.what == .body) switch (view) { .top => { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 8a8f8a94..03b20ff5 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -441,6 +441,11 @@ {"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} {"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} {"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w"} +{"name": "goto-last-mod", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYgg"} +{"name": "goto-last-mod-go", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYggg."} +{"name": "goto-last-mod-delete", "pane": "file", "text": "abc\ndef\n", "keys": "jlxdgg"} +{"name": "goto-last-mod-delete-go", "pane": "file", "text": "abc\ndef\n", "keys": "jldggg."} +{"name": "goto-last-mod-select", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjiXggvg."} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index f1df5c0a..8e642073 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -441,6 +441,11 @@ {"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} {"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-go","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"goto-last-mod-delete","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-delete-go","text":"abc\ndf\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"goto-last-mod-select","text":"ab\ncd\nXef\n","mode":"select","cursor":{"row":2,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3