From af532147c97987d6170dc06a52aa3434ebf1b9d4 Mon Sep 17 00:00:00 2001
From: Pascal Kuthe <pascal.kuthe@semimod.de>
Date: Tue, 6 Dec 2022 15:18:33 +0100
Subject: [PATCH] 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 <mcarsondavis@gmail.com>

* select entire hunk instead of first char

* fix selection range

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
---
 book/src/keymap.md               |   4 +
 book/src/usage.md                |   1 +
 helix-term/src/commands.rs       | 121 +++++++++++++++++++++++++++++++
 helix-term/src/keymap/default.rs |   4 +
 helix-vcs/src/diff.rs            |  81 +++++++++++++++++++++
 5 files changed, 211 insertions(+)

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
+                }
+            }
+        }
+    }
 }