Skip to content

Commit

Permalink
Add command/keybinding to jump between hunks (helix-editor#4650)
Browse files Browse the repository at this point in the history
* 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 <mcarsondavis@gmail.com>

* select entire hunk instead of first char

* fix selection range

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
  • Loading branch information
2 people authored and Frederik Vestre committed Feb 6, 2023
1 parent 56264cd commit 25f928c
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 0 deletions.
4 changes: 4 additions & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
| `]T` | Go to previous test (**TS**) | `goto_prev_test` |
| `]p` | Go to next paragraph | `goto_next_paragraph` |
| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `]g` | Go to next change | `goto_next_change` |
| `[g` | Go to previous change | `goto_prev_change` |
| `]G` | Go to first change | `goto_first_change` |
| `[G` | Go to last change | `goto_last_change` |
| `[Space` | Add newline above | `add_newline_above` |
| `]Space` | Add newline below | `add_newline_below` |

Expand Down
1 change: 1 addition & 0 deletions book/src/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ though, we climb the syntax tree and then take the previous selection. So
| `a` | Argument/parameter |
| `o` | Comment |
| `t` | Test |
| `g` | Change |

> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current
document and a special tree-sitter query file to work properly. [Only
Expand Down
121 changes: 121 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub(crate) mod lsp;
pub(crate) mod typed;

pub use dap::*;
use helix_vcs::Hunk;
pub use lsp::*;
use tui::text::Spans;
pub use typed::*;
Expand Down Expand Up @@ -308,6 +309,10 @@ impl MappableCommand {
goto_last_diag, "Goto last diagnostic",
goto_next_diag, "Goto next diagnostic",
goto_prev_diag, "Goto previous diagnostic",
goto_next_change, "Goto next change",
goto_prev_change, "Goto previous change",
goto_first_change, "Goto first change",
goto_last_change, "Goto last change",
goto_line_start, "Goto line start",
goto_line_end, "Goto line end",
goto_next_buffer, "Goto next buffer",
Expand Down Expand Up @@ -2912,6 +2917,100 @@ fn goto_prev_diag(cx: &mut Context) {
goto_pos(editor, pos);
}

fn goto_first_change(cx: &mut Context) {
goto_first_change_impl(cx, false);
}

fn goto_last_change(cx: &mut Context) {
goto_first_change_impl(cx, true);
}

fn goto_first_change_impl(cx: &mut Context, reverse: bool) {
let editor = &mut cx.editor;
let (_, doc) = current!(editor);
if let Some(handle) = doc.diff_handle() {
let hunk = {
let hunks = handle.hunks();
let idx = if reverse {
hunks.len().saturating_sub(1)
} else {
0
};
hunks.nth_hunk(idx)
};
if hunk != Hunk::NONE {
let pos = doc.text().line_to_char(hunk.after.start as usize);
goto_pos(editor, pos)
}
}
}

fn goto_next_change(cx: &mut Context) {
goto_next_change_impl(cx, Direction::Forward)
}

fn goto_prev_change(cx: &mut Context) {
goto_next_change_impl(cx, Direction::Backward)
}

fn goto_next_change_impl(cx: &mut Context, direction: Direction) {
let count = cx.count() as u32 - 1;
let motion = move |editor: &mut Editor| {
let (view, doc) = current!(editor);
let doc_text = doc.text().slice(..);
let diff_handle = if let Some(diff_handle) = doc.diff_handle() {
diff_handle
} else {
editor.set_status("Diff is not available in current buffer");
return;
};

let selection = doc.selection(view.id).clone().transform(|range| {
let cursor_line = range.cursor_line(doc_text) as u32;

let hunks = diff_handle.hunks();
let hunk_idx = match direction {
Direction::Forward => hunks
.next_hunk(cursor_line)
.map(|idx| (idx + count).min(hunks.len() - 1)),
Direction::Backward => hunks
.prev_hunk(cursor_line)
.map(|idx| idx.saturating_sub(count)),
};
// TODO refactor with let..else once MSRV reaches 1.65
let hunk_idx = if let Some(hunk_idx) = hunk_idx {
hunk_idx
} else {
return range;
};
let hunk = hunks.nth_hunk(hunk_idx);

let hunk_start = doc_text.line_to_char(hunk.after.start as usize);
let hunk_end = if hunk.after.is_empty() {
hunk_start + 1
} else {
doc_text.line_to_char(hunk.after.end as usize)
};
let new_range = Range::new(hunk_start, hunk_end);
if editor.mode == Mode::Select {
let head = if new_range.head < range.anchor {
new_range.anchor
} else {
new_range.head
};

Range::new(range.anchor, head)
} else {
new_range.with_direction(direction)
}
});

doc.set_selection(view.id, selection)
};
motion(cx.editor);
cx.editor.last_motion = Some(Motion(Box::new(motion)));
}

pub mod insert {
use super::*;
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
Expand Down Expand Up @@ -4515,6 +4614,27 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
)
};

if ch == 'g' && doc.diff_handle().is_none() {
editor.set_status("Diff is not available in current buffer");
return;
}

let textobject_change = |range: Range| -> Range {
let diff_handle = doc.diff_handle().unwrap();
let hunks = diff_handle.hunks();
let line = range.cursor_line(text);
let hunk_idx = if let Some(hunk_idx) = hunks.hunk_at(line as u32, false) {
hunk_idx
} else {
return range;
};
let hunk = hunks.nth_hunk(hunk_idx).after;

let start = text.line_to_char(hunk.start as usize);
let end = text.line_to_char(hunk.end as usize);
Range::new(start, end).with_direction(range.direction())
};

let selection = doc.selection(view.id).clone().transform(|range| {
match ch {
'w' => textobject::textobject_word(text, range, objtype, count, false),
Expand All @@ -4528,6 +4648,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'm' => textobject::textobject_pair_surround_closest(
text, range, objtype, count,
),
'g' => textobject_change(range),
// TODO: cancel new ranges if inconsistent surround matches across lines
ch if !ch.is_ascii_alphanumeric() => {
textobject::textobject_pair_surround(text, range, objtype, ch, count)
Expand Down
4 changes: 4 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
"[" => { "Left bracket"
"d" => goto_prev_diag,
"D" => goto_first_diag,
"g" => goto_prev_change,
"G" => goto_first_change,
"f" => goto_prev_function,
"t" => goto_prev_class,
"a" => goto_prev_parameter,
Expand All @@ -111,6 +113,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
"]" => { "Right bracket"
"d" => goto_next_diag,
"D" => goto_last_diag,
"g" => goto_next_change,
"G" => goto_last_change,
"f" => goto_next_function,
"t" => goto_next_class,
"a" => goto_next_parameter,
Expand Down
81 changes: 81 additions & 0 deletions helix-vcs/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,85 @@ impl FileHunks<'_> {
pub fn is_empty(&self) -> bool {
self.len() == 0
}

pub fn next_hunk(&self, line: u32) -> Option<u32> {
let hunk_range = if self.inverted {
|hunk: &Hunk| hunk.before.clone()
} else {
|hunk: &Hunk| hunk.after.clone()
};

let res = self
.hunks
.binary_search_by_key(&line, |hunk| hunk_range(hunk).start);

match res {
// Search found a hunk that starts exactly at this line, return the next hunk if it exists.
Ok(pos) if pos + 1 == self.hunks.len() => None,
Ok(pos) => Some(pos as u32 + 1),

// No hunk starts exactly at this line, so the search returns
// the position where a hunk starting at this line should be inserted.
// That position is exactly the position of the next hunk or the end
// of the list if no such hunk exists
Err(pos) if pos == self.hunks.len() => None,
Err(pos) => Some(pos as u32),
}
}

pub fn prev_hunk(&self, line: u32) -> Option<u32> {
let hunk_range = if self.inverted {
|hunk: &Hunk| hunk.before.clone()
} else {
|hunk: &Hunk| hunk.after.clone()
};
let res = self
.hunks
.binary_search_by_key(&line, |hunk| hunk_range(hunk).end);

match res {
// Search found a hunk that ends exactly at this line (so it does not include the current line).
// We can usually just return that hunk, however a special case for empty hunk is necessary
// which represents a pure removal.
// Removals are technically empty but are still shown as single line hunks
// and as such we must jump to the previous hunk (if it exists) if we are already inside the removal
Ok(pos) if !hunk_range(&self.hunks[pos]).is_empty() => Some(pos as u32),

// No hunk ends exactly at this line, so the search returns
// the position where a hunk ending at this line should be inserted.
// That position before this one is exactly the position of the previous hunk
Err(0) | Ok(0) => None,
Err(pos) | Ok(pos) => Some(pos as u32 - 1),
}
}

pub fn hunk_at(&self, line: u32, include_removal: bool) -> Option<u32> {
let hunk_range = if self.inverted {
|hunk: &Hunk| hunk.before.clone()
} else {
|hunk: &Hunk| hunk.after.clone()
};

let res = self
.hunks
.binary_search_by_key(&line, |hunk| hunk_range(hunk).start);

match res {
// Search found a hunk that starts exactly at this line, return it
Ok(pos) => Some(pos as u32),

// No hunk starts exactly at this line, so the search returns
// the position where a hunk starting at this line should be inserted.
// The previous hunk contains this hunk if it exists and doesn't end before this line
Err(0) => None,
Err(pos) => {
let hunk = hunk_range(&self.hunks[pos - 1]);
if hunk.end > line || include_removal && hunk.start == line && hunk.is_empty() {
Some(pos as u32 - 1)
} else {
None
}
}
}
}
}

0 comments on commit 25f928c

Please sign in to comment.