on master help sign in
Add command/keybinding to jump between hunks (#4650)
af53214Pascal Kuthe
book/src/keymap.md+4
@@ -320,6 +320,10 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
320| `]T` | Go to previous test (**TS**) | `goto_prev_test` |
320| `]T` | Go to previous test (**TS**) | `goto_prev_test` |
321| `]p` | Go to next paragraph | `goto_next_paragraph` |
321| `]p` | Go to next paragraph | `goto_next_paragraph` |
322| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
322| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
323+| `]g` | Go to next change | `goto_next_change` |
324+| `[g` | Go to previous change | `goto_prev_change` |
325+| `]G` | Go to first change | `goto_first_change` |
326+| `[G` | Go to last change | `goto_last_change` |
323| `[Space` | Add newline above | `add_newline_above` |
327| `[Space` | Add newline above | `add_newline_above` |
324| `]Space` | Add newline below | `add_newline_below` |
328| `]Space` | Add newline below | `add_newline_below` |
325
329
book/src/usage.md+1
@@ -143,6 +143,7 @@ though, we climb the syntax tree and then take the previous selection. So
143| `a` | Argument/parameter |
143| `a` | Argument/parameter |
144| `o` | Comment |
144| `o` | Comment |
145| `t` | Test |
145| `t` | Test |
146+| `g` | Change |
146
147
147> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current
148> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current
148document and a special tree-sitter query file to work properly. [Only
149document and a special tree-sitter query file to work properly. [Only
helix-term/src/commands.rs+121
@@ -3,6 +3,7 @@ pub(crate) mod lsp;
3pub(crate) mod typed;
3pub(crate) mod typed;
4
4
5pub use dap::*;
5pub use dap::*;
6+use helix_vcs::Hunk;
6pub use lsp::*;
7pub use lsp::*;
7use tui::text::Spans;
8use tui::text::Spans;
8pub use typed::*;
9pub use typed::*;
@@ -308,6 +309,10 @@ impl MappableCommand {
308 goto_last_diag, "Goto last diagnostic",
309 goto_last_diag, "Goto last diagnostic",
309 goto_next_diag, "Goto next diagnostic",
310 goto_next_diag, "Goto next diagnostic",
310 goto_prev_diag, "Goto previous diagnostic",
311 goto_prev_diag, "Goto previous diagnostic",
312+ goto_next_change, "Goto next change",
313+ goto_prev_change, "Goto previous change",
314+ goto_first_change, "Goto first change",
315+ goto_last_change, "Goto last change",
311 goto_line_start, "Goto line start",
316 goto_line_start, "Goto line start",
312 goto_line_end, "Goto line end",
317 goto_line_end, "Goto line end",
313 goto_next_buffer, "Goto next buffer",
318 goto_next_buffer, "Goto next buffer",
@@ -2912,6 +2917,100 @@ fn goto_prev_diag(cx: &mut Context) {
2912 goto_pos(editor, pos);
2917 goto_pos(editor, pos);
2913}
2918}
2914
2919
2920+fn goto_first_change(cx: &mut Context) {
2921+ goto_first_change_impl(cx, false);
2922+}
2923+
2924+fn goto_last_change(cx: &mut Context) {
2925+ goto_first_change_impl(cx, true);
2926+}
2927+
2928+fn goto_first_change_impl(cx: &mut Context, reverse: bool) {
2929+ let editor = &mut cx.editor;
2930+ let (_, doc) = current!(editor);
2931+ if let Some(handle) = doc.diff_handle() {
2932+ let hunk = {
2933+ let hunks = handle.hunks();
2934+ let idx = if reverse {
2935+ hunks.len().saturating_sub(1)
2936+ } else {
2937+ 0
2938+ };
2939+ hunks.nth_hunk(idx)
2940+ };
2941+ if hunk != Hunk::NONE {
2942+ let pos = doc.text().line_to_char(hunk.after.start as usize);
2943+ goto_pos(editor, pos)
2944+ }
2945+ }
2946+}
2947+
2948+fn goto_next_change(cx: &mut Context) {
2949+ goto_next_change_impl(cx, Direction::Forward)
2950+}
2951+
2952+fn goto_prev_change(cx: &mut Context) {
2953+ goto_next_change_impl(cx, Direction::Backward)
2954+}
2955+
2956+fn goto_next_change_impl(cx: &mut Context, direction: Direction) {
2957+ let count = cx.count() as u32 - 1;
2958+ let motion = move |editor: &mut Editor| {
2959+ let (view, doc) = current!(editor);
2960+ let doc_text = doc.text().slice(..);
2961+ let diff_handle = if let Some(diff_handle) = doc.diff_handle() {
2962+ diff_handle
2963+ } else {
2964+ editor.set_status("Diff is not available in current buffer");
2965+ return;
2966+ };
2967+
2968+ let selection = doc.selection(view.id).clone().transform(|range| {
2969+ let cursor_line = range.cursor_line(doc_text) as u32;
2970+
2971+ let hunks = diff_handle.hunks();
2972+ let hunk_idx = match direction {
2973+ Direction::Forward => hunks
2974+ .next_hunk(cursor_line)
2975+ .map(|idx| (idx + count).min(hunks.len() - 1)),
2976+ Direction::Backward => hunks
2977+ .prev_hunk(cursor_line)
2978+ .map(|idx| idx.saturating_sub(count)),
2979+ };
2980+ // TODO refactor with let..else once MSRV reaches 1.65
2981+ let hunk_idx = if let Some(hunk_idx) = hunk_idx {
2982+ hunk_idx
2983+ } else {
2984+ return range;
2985+ };
2986+ let hunk = hunks.nth_hunk(hunk_idx);
2987+
2988+ let hunk_start = doc_text.line_to_char(hunk.after.start as usize);
2989+ let hunk_end = if hunk.after.is_empty() {
2990+ hunk_start + 1
2991+ } else {
2992+ doc_text.line_to_char(hunk.after.end as usize)
2993+ };
2994+ let new_range = Range::new(hunk_start, hunk_end);
2995+ if editor.mode == Mode::Select {
2996+ let head = if new_range.head < range.anchor {
2997+ new_range.anchor
2998+ } else {
2999+ new_range.head
3000+ };
3001+
3002+ Range::new(range.anchor, head)
3003+ } else {
3004+ new_range.with_direction(direction)
3005+ }
3006+ });
3007+
3008+ doc.set_selection(view.id, selection)
3009+ };
3010+ motion(cx.editor);
3011+ cx.editor.last_motion = Some(Motion(Box::new(motion)));
3012+}
3013+
2915pub mod insert {
3014pub mod insert {
2916 use super::*;
3015 use super::*;
2917 pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
3016 pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
@@ -4515,6 +4614,27 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
4515 )
4614 )
4516 };
4615 };
4517
4616
4617+ if ch == 'g' && doc.diff_handle().is_none() {
4618+ editor.set_status("Diff is not available in current buffer");
4619+ return;
4620+ }
4621+
4622+ let textobject_change = |range: Range| -> Range {
4623+ let diff_handle = doc.diff_handle().unwrap();
4624+ let hunks = diff_handle.hunks();
4625+ let line = range.cursor_line(text);
4626+ let hunk_idx = if let Some(hunk_idx) = hunks.hunk_at(line as u32, false) {
4627+ hunk_idx
4628+ } else {
4629+ return range;
4630+ };
4631+ let hunk = hunks.nth_hunk(hunk_idx).after;
4632+
4633+ let start = text.line_to_char(hunk.start as usize);
4634+ let end = text.line_to_char(hunk.end as usize);
4635+ Range::new(start, end).with_direction(range.direction())
4636+ };
4637+
4518 let selection = doc.selection(view.id).clone().transform(|range| {
4638 let selection = doc.selection(view.id).clone().transform(|range| {
4519 match ch {
4639 match ch {
4520 'w' => textobject::textobject_word(text, range, objtype, count, false),
4640 'w' => textobject::textobject_word(text, range, objtype, count, false),
@@ -4528,6 +4648,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
4528 'm' => textobject::textobject_pair_surround_closest(
4648 'm' => textobject::textobject_pair_surround_closest(
4529 text, range, objtype, count,
4649 text, range, objtype, count,
4530 ),
4650 ),
4651+ 'g' => textobject_change(range),
4531 // TODO: cancel new ranges if inconsistent surround matches across lines
4652 // TODO: cancel new ranges if inconsistent surround matches across lines
4532 ch if !ch.is_ascii_alphanumeric() => {
4653 ch if !ch.is_ascii_alphanumeric() => {
4533 textobject::textobject_pair_surround(text, range, objtype, ch, count)
4654 textobject::textobject_pair_surround(text, range, objtype, ch, count)
helix-term/src/keymap/default.rs+4
@@ -100,6 +100,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
100 "[" => { "Left bracket"
100 "[" => { "Left bracket"
101 "d" => goto_prev_diag,
101 "d" => goto_prev_diag,
102 "D" => goto_first_diag,
102 "D" => goto_first_diag,
103+ "g" => goto_prev_change,
104+ "G" => goto_first_change,
103 "f" => goto_prev_function,
105 "f" => goto_prev_function,
104 "t" => goto_prev_class,
106 "t" => goto_prev_class,
105 "a" => goto_prev_parameter,
107 "a" => goto_prev_parameter,
@@ -111,6 +113,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
111 "]" => { "Right bracket"
113 "]" => { "Right bracket"
112 "d" => goto_next_diag,
114 "d" => goto_next_diag,
113 "D" => goto_last_diag,
115 "D" => goto_last_diag,
116+ "g" => goto_next_change,
117+ "G" => goto_last_change,
114 "f" => goto_next_function,
118 "f" => goto_next_function,
115 "t" => goto_next_class,
119 "t" => goto_next_class,
116 "a" => goto_next_parameter,
120 "a" => goto_next_parameter,
helix-vcs/src/diff.rs+81
@@ -195,4 +195,85 @@ impl FileHunks<'_> {
195 pub fn is_empty(&self) -> bool {
195 pub fn is_empty(&self) -> bool {
196 self.len() == 0
196 self.len() == 0
197 }
197 }
198+
199+ pub fn next_hunk(&self, line: u32) -> Option<u32> {
200+ let hunk_range = if self.inverted {
201+ |hunk: &Hunk| hunk.before.clone()
202+ } else {
203+ |hunk: &Hunk| hunk.after.clone()
204+ };
205+
206+ let res = self
207+ .hunks
208+ .binary_search_by_key(&line, |hunk| hunk_range(hunk).start);
209+
210+ match res {
211+ // Search found a hunk that starts exactly at this line, return the next hunk if it exists.
212+ Ok(pos) if pos + 1 == self.hunks.len() => None,
213+ Ok(pos) => Some(pos as u32 + 1),
214+
215+ // No hunk starts exactly at this line, so the search returns
216+ // the position where a hunk starting at this line should be inserted.
217+ // That position is exactly the position of the next hunk or the end
218+ // of the list if no such hunk exists
219+ Err(pos) if pos == self.hunks.len() => None,
220+ Err(pos) => Some(pos as u32),
221+ }
222+ }
223+
224+ pub fn prev_hunk(&self, line: u32) -> Option<u32> {
225+ let hunk_range = if self.inverted {
226+ |hunk: &Hunk| hunk.before.clone()
227+ } else {
228+ |hunk: &Hunk| hunk.after.clone()
229+ };
230+ let res = self
231+ .hunks
232+ .binary_search_by_key(&line, |hunk| hunk_range(hunk).end);
233+
234+ match res {
235+ // Search found a hunk that ends exactly at this line (so it does not include the current line).
236+ // We can usually just return that hunk, however a special case for empty hunk is necessary
237+ // which represents a pure removal.
238+ // Removals are technically empty but are still shown as single line hunks
239+ // and as such we must jump to the previous hunk (if it exists) if we are already inside the removal
240+ Ok(pos) if !hunk_range(&self.hunks[pos]).is_empty() => Some(pos as u32),
241+
242+ // No hunk ends exactly at this line, so the search returns
243+ // the position where a hunk ending at this line should be inserted.
244+ // That position before this one is exactly the position of the previous hunk
245+ Err(0) | Ok(0) => None,
246+ Err(pos) | Ok(pos) => Some(pos as u32 - 1),
247+ }
248+ }
249+
250+ pub fn hunk_at(&self, line: u32, include_removal: bool) -> Option<u32> {
251+ let hunk_range = if self.inverted {
252+ |hunk: &Hunk| hunk.before.clone()
253+ } else {
254+ |hunk: &Hunk| hunk.after.clone()
255+ };
256+
257+ let res = self
258+ .hunks
259+ .binary_search_by_key(&line, |hunk| hunk_range(hunk).start);
260+
261+ match res {
262+ // Search found a hunk that starts exactly at this line, return it
263+ Ok(pos) => Some(pos as u32),
264+
265+ // No hunk starts exactly at this line, so the search returns
266+ // the position where a hunk starting at this line should be inserted.
267+ // The previous hunk contains this hunk if it exists and doesn't end before this line
268+ Err(0) => None,
269+ Err(pos) => {
270+ let hunk = hunk_range(&self.hunks[pos - 1]);
271+ if hunk.end > line || include_removal && hunk.start == line && hunk.is_empty() {
272+ Some(pos as u32 - 1)
273+ } else {
274+ None
275+ }
276+ }
277+ }
278+ }
198}
279}