feat: add line_blame static command

_
This commit is contained in:
Nik Revenco 2025-03-19 23:44:36 +00:00
parent f830c76a76
commit c8eb9f2899
9 changed files with 77 additions and 29 deletions
book/src/generated
helix-term/src
helix-vcs/src
helix-view/src

View file

@ -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 `` |

View file

@ -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)
}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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) {

View file

@ -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,