Skip to content

Commit af53214

Browse files
Add command/keybinding to jump between hunks (#4650)
* add command and keybding to jump to next/prev hunk * add textobject for change * Update helix-vcs/src/diff.rs Co-authored-by: Michael Davis <[email protected]> * select entire hunk instead of first char * fix selection range Co-authored-by: Michael Davis <[email protected]>
1 parent 453a75a commit af53214

File tree

5 files changed

+211
-0
lines changed

5 files changed

+211
-0
lines changed

book/src/keymap.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
320320
| `]T` | Go to previous test (**TS**) | `goto_prev_test` |
321321
| `]p` | Go to next paragraph | `goto_next_paragraph` |
322322
| `[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` |
323327
| `[Space` | Add newline above | `add_newline_above` |
324328
| `]Space` | Add newline below | `add_newline_below` |
325329

book/src/usage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ though, we climb the syntax tree and then take the previous selection. So
143143
| `a` | Argument/parameter |
144144
| `o` | Comment |
145145
| `t` | Test |
146+
| `g` | Change |
146147

147148
> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current
148149
document and a special tree-sitter query file to work properly. [Only

helix-term/src/commands.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub(crate) mod lsp;
33
pub(crate) mod typed;
44

55
pub use dap::*;
6+
use helix_vcs::Hunk;
67
pub use lsp::*;
78
use tui::text::Spans;
89
pub use typed::*;
@@ -308,6 +309,10 @@ impl MappableCommand {
308309
goto_last_diag, "Goto last diagnostic",
309310
goto_next_diag, "Goto next diagnostic",
310311
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",
311316
goto_line_start, "Goto line start",
312317
goto_line_end, "Goto line end",
313318
goto_next_buffer, "Goto next buffer",
@@ -2912,6 +2917,100 @@ fn goto_prev_diag(cx: &mut Context) {
29122917
goto_pos(editor, pos);
29132918
}
29142919

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+
29153014
pub mod insert {
29163015
use super::*;
29173016
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
@@ -4515,6 +4614,27 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
45154614
)
45164615
};
45174616

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+
45184638
let selection = doc.selection(view.id).clone().transform(|range| {
45194639
match ch {
45204640
'w' => textobject::textobject_word(text, range, objtype, count, false),
@@ -4528,6 +4648,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
45284648
'm' => textobject::textobject_pair_surround_closest(
45294649
text, range, objtype, count,
45304650
),
4651+
'g' => textobject_change(range),
45314652
// TODO: cancel new ranges if inconsistent surround matches across lines
45324653
ch if !ch.is_ascii_alphanumeric() => {
45334654
textobject::textobject_pair_surround(text, range, objtype, ch, count)

helix-term/src/keymap/default.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
100100
"[" => { "Left bracket"
101101
"d" => goto_prev_diag,
102102
"D" => goto_first_diag,
103+
"g" => goto_prev_change,
104+
"G" => goto_first_change,
103105
"f" => goto_prev_function,
104106
"t" => goto_prev_class,
105107
"a" => goto_prev_parameter,
@@ -111,6 +113,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
111113
"]" => { "Right bracket"
112114
"d" => goto_next_diag,
113115
"D" => goto_last_diag,
116+
"g" => goto_next_change,
117+
"G" => goto_last_change,
114118
"f" => goto_next_function,
115119
"t" => goto_next_class,
116120
"a" => goto_next_parameter,

helix-vcs/src/diff.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,85 @@ impl FileHunks<'_> {
195195
pub fn is_empty(&self) -> bool {
196196
self.len() == 0
197197
}
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+
}
198279
}

0 commit comments

Comments
 (0)