aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--book/src/keymap.md4
-rw-r--r--book/src/usage.md1
-rw-r--r--helix-term/src/commands.rs121
-rw-r--r--helix-term/src/keymap/default.rs4
-rw-r--r--helix-vcs/src/diff.rs81
5 files changed, 211 insertions, 0 deletions
diff --git a/book/src/keymap.md b/book/src/keymap.md
index c3c09f4c..139e8fdd 100644
--- a/book/src/keymap.md
+++ b/book/src/keymap.md
@@ -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` |
diff --git a/book/src/usage.md b/book/src/usage.md
index 646bf926..a6eb9ec1 100644
--- a/book/src/usage.md
+++ b/book/src/usage.md
@@ -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
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 26389026..1843e7a2 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -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::*;
@@ -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",
@@ -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>;
@@ -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),
@@ -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)
diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs
index b6d9ea10..ebcd125a 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -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,
@@ -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,
diff --git a/helix-vcs/src/diff.rs b/helix-vcs/src/diff.rs
index b1acd1f2..9c6a362f 100644
--- a/helix-vcs/src/diff.rs
+++ b/helix-vcs/src/diff.rs
@@ -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
+ }
+ }
+ }
+ }
}