From c8eb9f289977837827303b2d5ec4afccc3a495a2 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 23:44:36 +0000
Subject: [PATCH] feat: add `line_blame` static command

_
---
 book/src/generated/static-cmd.md |  1 +
 helix-term/src/commands.rs       | 37 ++++++++++++++++++++++++++++++++
 helix-term/src/handlers/blame.rs | 30 +++++++-------------------
 helix-term/src/keymap/default.rs |  1 +
 helix-vcs/src/diff.rs            | 14 ++++++++++++
 helix-vcs/src/git/blame.rs       |  4 +++-
 helix-vcs/src/lib.rs             |  8 +++----
 helix-view/src/document.rs       |  7 ++++++
 helix-view/src/handlers.rs       |  4 ++--
 9 files changed, 77 insertions(+), 29 deletions(-)

diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md
index af7515b8..f1cba01d 100644
--- a/book/src/generated/static-cmd.md
+++ b/book/src/generated/static-cmd.md
@@ -173,6 +173,7 @@
 | `redo` | Redo change | normal: `` U ``, select: `` U `` |
 | `earlier` | Move backward in history | normal: `` <A-u> ``, select: `` <A-u> `` |
 | `later` | Move forward in history | normal: `` <A-U> ``, select: `` <A-U> `` |
+| `line_blame` | Blame for the current line | normal: `` <space>B ``, select: `` <space>B `` |
 | `commit_undo_checkpoint` | Commit changes to new checkpoint | insert: `` <C-s> `` |
 | `yank` | Yank selection | normal: `` y ``, select: `` y `` |
 | `yank_to_clipboard` | Yank selections to clipboard | normal: `` <space>y ``, select: `` <space>y `` |
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index a197792e..09c66961 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -472,6 +472,7 @@ impl MappableCommand {
         redo, "Redo change",
         earlier, "Move backward in history",
         later, "Move forward in history",
+        line_blame, "Blame for the current line",
         commit_undo_checkpoint, "Commit changes to new checkpoint",
         yank, "Yank selection",
         yank_to_clipboard, "Yank selections to clipboard",
@@ -6559,6 +6560,42 @@ fn goto_word(cx: &mut Context) {
     jump_to_word(cx, Movement::Move)
 }
 
+fn line_blame(cx: &mut Context) {
+    let (view, doc) = current!(cx.editor);
+    const BLAME_ERROR: &str = "No blame available for the current file";
+    let Some(diff) = doc.diff_handle() else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let Some(path) = doc.path() else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let cursor_line = doc.cursor_line(view.id);
+    let (inserted_lines_count, deleted_lines_count) =
+        diff.load().inserted_and_deleted_before_line(cursor_line);
+
+    let Ok(cursor_line) = u32::try_from(doc.cursor_line(view.id)) else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let Ok(output) = cx.editor.diff_providers.blame_line(
+        path,
+        cursor_line,
+        inserted_lines_count,
+        deleted_lines_count,
+    ) else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    cx.editor
+        .set_status(output.parse_format(&cx.editor.config().version_control.inline_blame_format));
+}
+
 fn extend_to_word(cx: &mut Context) {
     jump_to_word(cx, Movement::Extend)
 }
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 0be7815b..923ae74c 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -32,19 +32,16 @@ impl helix_event::AsyncHook for BlameHandler {
             file,
             cursor_line,
             diff_providers,
-            removed_lines_count,
-            added_lines_count,
+            deleted_lines_count: removed_lines_count,
+            inserted_lines_count: added_lines_count,
             blame_format,
         } = event;
 
         self.cursor_line = cursor_line;
 
-        // convert 0-based line numbers into 1-based line numbers
-        let cursor_line = cursor_line + 1;
-
         let worker = tokio::spawn(async move {
             diff_providers
-                .blame(&file, cursor_line, added_lines_count, removed_lines_count)
+                .blame_line(&file, cursor_line, added_lines_count, removed_lines_count)
                 .map(|s| s.parse_format(&blame_format))
         });
         self.worker = Some(worker);
@@ -87,38 +84,27 @@ pub(super) fn register_hooks(handlers: &Handlers) {
         }
 
         let (view, doc) = current!(event.cx.editor);
-        let text = doc.text();
-        let selection = doc.selection(view.id);
         let Some(file) = doc.path() else {
             return Ok(());
         };
         let file = file.to_path_buf();
 
-        let Ok(cursor_line) =
-            u32::try_from(text.char_to_line(selection.primary().cursor(doc.text().slice(..))))
-        else {
+        let Ok(cursor_line) = u32::try_from(doc.cursor_line(view.id)) else {
             return Ok(());
         };
 
         let hunks = doc.diff_handle().unwrap().load();
 
-        let mut removed_lines_count: u32 = 0;
-        let mut added_lines_count: u32 = 0;
-        for hunk in hunks.hunks_intersecting_line_ranges(std::iter::once((0, cursor_line as usize)))
-        {
-            let lines_inserted = hunk.after.end - hunk.after.start;
-            let lines_removed = hunk.before.end - hunk.before.start;
-            added_lines_count += lines_inserted;
-            removed_lines_count += lines_removed;
-        }
+        let (inserted_lines_count, deleted_lines_count) =
+            hunks.inserted_and_deleted_before_line(cursor_line as usize);
 
         send_blocking(
             &tx,
             BlameEvent::PostCommand {
                 file,
                 cursor_line,
-                removed_lines_count,
-                added_lines_count,
+                deleted_lines_count,
+                inserted_lines_count,
                 // ok to clone because diff_providers is very small
                 diff_providers: event.cx.editor.diff_providers.clone(),
                 // ok to clone because blame_format is likely to be about 30 characters or less
diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs
index e160b224..4e24624c 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -232,6 +232,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
             "D" => workspace_diagnostics_picker,
             "g" => changed_file_picker,
             "a" => code_action,
+            "B" => line_blame,
             "'" => last_picker,
             "G" => { "Debug (experimental)" sticky=true
                 "l" => dap_launch,
diff --git a/helix-vcs/src/diff.rs b/helix-vcs/src/diff.rs
index e49e171d..80c861d0 100644
--- a/helix-vcs/src/diff.rs
+++ b/helix-vcs/src/diff.rs
@@ -177,6 +177,20 @@ impl Diff<'_> {
         }
     }
 
+    /// Get the amount of lines inserted and deleted before a given line
+    pub fn inserted_and_deleted_before_line(&self, cursor_line: usize) -> (u32, u32) {
+        let mut inserted_lines_count: u32 = 0;
+        let mut deleted_lines_count: u32 = 0;
+        for hunk in self.hunks_intersecting_line_ranges(std::iter::once((0, cursor_line))) {
+            let lines_inserted = hunk.after.end - hunk.after.start;
+            let lines_removed = hunk.before.end - hunk.before.start;
+            inserted_lines_count += lines_inserted;
+            deleted_lines_count += lines_removed;
+        }
+
+        (inserted_lines_count, deleted_lines_count)
+    }
+
     pub fn doc(&self) -> &Rope {
         if self.inverted {
             &self.diff.diff_base
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 6e8ac480..a56b361c 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -134,7 +134,9 @@ pub fn blame_line(
     // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
     // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
     // we can show to the user nothing at all. This is detected in the editor
-    let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count;
+    //
+    // Add 1 to convert 0-based line numbers into 1-based
+    let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count + 1;
 
     let repo_dir = get_repo_dir(file)?;
     let repo = open_repo(repo_dir)
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index c63558c0..6f8e3b0f 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -50,8 +50,8 @@ impl DiffProviderRegistry {
             })
     }
 
-    /// Blame range of lines in a file. Lines are 1-indexed
-    pub fn blame(
+    /// Blame a line in a file
+    pub fn blame_line(
         &self,
         file: &Path,
         line: u32,
@@ -60,7 +60,7 @@ impl DiffProviderRegistry {
     ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .map(|provider| provider.blame(file, line, added_lines_count, removed_lines_count))
+            .map(|provider| provider.blame_line(file, line, added_lines_count, removed_lines_count))
             .next()
             .context("No provider found")?
     }
@@ -125,7 +125,7 @@ impl DiffProvider {
         }
     }
 
-    fn blame(
+    fn blame_line(
         &self,
         file: &Path,
         line: u32,
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 482cd1df..0ed23bad 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -1262,6 +1262,13 @@ impl Document {
         Range::new(0, 1).grapheme_aligned(self.text().slice(..))
     }
 
+    /// Get the line of cursor for the primary selection
+    pub fn cursor_line(&self, view_id: ViewId) -> usize {
+        let text = self.text();
+        let selection = self.selection(view_id);
+        text.char_to_line(selection.primary().cursor(text.slice(..)))
+    }
+
     /// Reset the view's selection on this document to the
     /// [origin](Document::origin) cursor.
     pub fn reset_selection(&mut self, view_id: ViewId) {
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index 99789102..7926ec81 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -25,9 +25,9 @@ pub enum BlameEvent {
         file: PathBuf,
         cursor_line: u32,
         /// How many lines were removed before cursor_line
-        removed_lines_count: u32,
+        deleted_lines_count: u32,
         /// How many lines were added before cursor_line
-        added_lines_count: u32,
+        inserted_lines_count: u32,
         diff_providers: DiffProviderRegistry,
         /// Format of the blame
         blame_format: String,