diff --git a/src/app.rs b/src/app.rs index 9dfbc7df39..7f34e08cf5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -79,6 +79,7 @@ impl App { Rc::clone(&repo), size, reference.clone(), + None, )?] } None => vec![screen::status::create( diff --git a/src/config.rs b/src/config.rs index 1bb410d5cf..5b1ef96e15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -112,6 +112,17 @@ pub struct StyleConfig { pub branch: StyleConfigEntry, pub remote: StyleConfigEntry, pub tag: StyleConfigEntry, + + #[serde(default)] + pub blame: BlameStyleConfig, +} + +#[derive(Default, Debug, Deserialize)] +pub struct BlameStyleConfig { + #[serde(default)] + pub line_num: StyleConfigEntry, + #[serde(default)] + pub code_line: StyleConfigEntry, } #[derive(Default, Debug, Deserialize)] diff --git a/src/default_config.toml b/src/default_config.toml index a2aee60fb7..c19e354e6d 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -100,6 +100,9 @@ branch = { fg = "green" } remote = { fg = "red" } tag = { fg = "yellow" } +blame.line_num = { mods = "DIM" } +blame.code_line = { mods = "DIM" } + [bindings] root.quit = ["q", "esc"] root.refresh = ["g"] @@ -121,6 +124,7 @@ root.unstage = ["u"] root.apply = ["a"] root.reverse = ["v"] root.copy_hash = ["y"] +root.blame = ["B"] picker.next = ["down", "ctrl+n", "tab"] picker.previous = ["up", "ctrl+p", "backtab"] diff --git a/src/error.rs b/src/error.rs index 5f4ecdc560..890d984cae 100644 --- a/src/error.rs +++ b/src/error.rs @@ -58,6 +58,7 @@ pub enum Error { GetBranchName(git2::Error), BaseCommitOid, UpstreamCommitOid, + GitBlame(io::Error), } impl std::error::Error for Error {} @@ -164,6 +165,7 @@ impl Display for Error { Error::UpstreamCommitOid => { f.write_str("Could not resolve OID of upstream branch commit") } + Error::GitBlame(e) => f.write_fmt(format_args!("Git blame error: {e}")), } } } diff --git a/src/git/diff.rs b/src/git/diff.rs index 9b277df6fc..a95cdce389 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -6,6 +6,7 @@ pub(crate) struct Diff { pub text: String, pub diff_type: DiffType, pub file_diffs: Vec, + pub commit: Option, } #[derive(Debug, Clone)] @@ -104,6 +105,31 @@ impl Diff { format!("{file_header}{hunk_header}{modified_content}") } + pub(crate) fn hunk_first_changed_line_num(&self, file_i: usize, hunk_i: usize) -> u32 { + let new_line_start = self.file_diffs[file_i].hunks[hunk_i].header.new_line_start; + let mut new_line = new_line_start; + for line in self.hunk_content(file_i, hunk_i).lines() { + if line.starts_with('+') { + return new_line; + } + if !line.starts_with('-') { + new_line += 1; + } + } + new_line_start + } + + pub(crate) fn hunk_line_new_file_num(&self, file_i: usize, hunk_i: usize, line_i: usize) -> u32 { + let hunk = &self.file_diffs[file_i].hunks[hunk_i]; + let non_minus_before = self + .hunk_content(file_i, hunk_i) + .lines() + .take(line_i) + .filter(|l| l.starts_with(' ') || l.starts_with('+')) + .count() as u32; + hunk.header.new_line_start + non_minus_before + } + pub(crate) fn file_line_of_first_diff(&self, file_i: usize, hunk_i: usize) -> usize { let hunk = &self.file_diffs[file_i].hunks[hunk_i]; let line = hunk.header.new_line_start as usize; diff --git a/src/git/mod.rs b/src/git/mod.rs index 287c739b2c..26d205cc5e 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -168,6 +168,7 @@ pub(crate) fn diff_unstaged(repo: &Repository) -> Res { file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), diff_type: DiffType::WorkdirToIndex, text, + commit: None, }) } @@ -186,6 +187,7 @@ pub(crate) fn diff_staged(repo: &Repository) -> Res { file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), diff_type: DiffType::IndexToTree, text, + commit: None, }) } @@ -218,6 +220,7 @@ pub(crate) fn show(repo: &Repository, reference: &str) -> Res { file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), diff_type: DiffType::TreeToTree, text, + commit: Some(reference.to_string()), }) } @@ -254,6 +257,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), diff_type: DiffType::TreeToTree, text, + commit: None, }) }; @@ -272,6 +276,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), diff_type: DiffType::TreeToTree, text, + commit: None, }) }; @@ -280,6 +285,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res text: String::new(), diff_type: DiffType::TreeToTree, file_diffs: vec![], + commit: None, }; return Ok(StashDiffs { staged: empty, @@ -428,6 +434,102 @@ pub(crate) fn does_branch_exist(repo: &git2::Repository, name: &str) -> Res) -> Res> { + let dir = repo.workdir().expect("Bare repos unhandled"); + + let mut args = vec!["blame", "--line-porcelain"]; + let commit_owned; + if let Some(c) = commit { + commit_owned = c.to_string(); + args.push(commit_owned.as_str()); + } + args.extend_from_slice(&["--", file_path]); + + let output = Command::new("git") + .current_dir(dir) + .args(&args) + .output() + .map_err(Error::GitBlame)?; + + if !output.status.success() { + return Ok(vec![]); + } + + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + Ok(parse_blame_porcelain(&text)) +} + +fn parse_blame_porcelain(text: &str) -> Vec { + let mut lines = text.lines(); + let mut result = Vec::new(); + + while let Some(header) = lines.next() { + let parts: Vec<&str> = header.splitn(4, ' ').collect(); + if parts.len() < 3 || parts[0].len() < 8 { + continue; + } + + let commit_hash = parts[0].to_string(); + let short_hash = commit_hash[..8].to_string(); + let orig_line_num: u32 = parts[1].parse().unwrap_or(0); + let line_num: u32 = parts[2].parse().unwrap_or(0); + + let mut author = String::new(); + let mut author_time: i64 = 0; + let mut summary = String::new(); + let mut content = String::new(); + + loop { + match lines.next() { + Some(line) if line.starts_with('\t') => { + content = line[1..].to_string(); + break; + } + Some(line) if line.starts_with("author ") => { + author = line["author ".len()..].to_string(); + } + Some(line) if line.starts_with("author-time ") => { + author_time = line["author-time ".len()..].parse().unwrap_or(0); + } + Some(line) if line.starts_with("summary ") => { + summary = line["summary ".len()..].to_string(); + } + Some(_) => {} + None => break, + } + } + + let time_str = chrono::DateTime::from_timestamp(author_time, 0) + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_default(); + + result.push(BlameLine { + commit_hash, + short_hash, + author, + time_str, + summary, + line_num, + orig_line_num, + content, + }); + } + + result +} + pub(crate) fn restore_index(file: &Path) -> Command { let mut cmd = Command::new("git"); cmd.args(["restore", "--staged"]); diff --git a/src/highlight.rs b/src/highlight.rs index e87bc8adc8..d7af433ff2 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -225,6 +225,43 @@ pub(crate) fn iter_syntax_highlights<'a>( .peekable() } +pub(crate) fn highlight_blame_file( + config: &Config, + file_path: &str, + content: String, +) -> BlameHighlights { + let mut highlights_iter = + iter_syntax_highlights(&config.style.syntax_highlight, file_path, content.clone()); + + let mut result = BlameHighlights { + spans: vec![], + line_index: vec![], + }; + + for (line_range, _) in line_range_iterator(&content) { + let start = result.spans.len(); + collect_line_highlights(&mut highlights_iter, &line_range, &mut result.spans); + result.line_index.push(start..result.spans.len()); + } + + result +} + +#[derive(Debug, Clone)] +pub struct BlameHighlights { + spans: Vec<(Range, Style)>, + line_index: Vec>, +} + +impl BlameHighlights { + pub fn get_line_highlights(&self, line: usize) -> &[(Range, Style)] { + if line >= self.line_index.len() { + return &[]; + } + &self.spans[self.line_index[line].clone()] + } +} + pub(crate) fn fill_gaps( full_range: Range, ranges: impl Iterator, T)>, diff --git a/src/item_data.rs b/src/item_data.rs index 2dfd0a9dd1..2eca45f78b 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -1,6 +1,6 @@ use std::{ops::Range, path::PathBuf, rc::Rc}; -use crate::{Res, error::Error, git::diff::Diff}; +use crate::{Res, error::Error, git::diff::Diff, highlight::BlameHighlights}; #[derive(Clone, Debug)] pub(crate) enum ItemData { @@ -22,6 +22,7 @@ pub(crate) enum ItemData { Delta { diff: Rc, file_i: usize, + commit: Option, }, Hunk { diff: Rc, @@ -43,6 +44,30 @@ pub(crate) enum ItemData { Header(SectionHeader), BranchStatus(String, u32, u32), Error(String), + BlameHeader { + commit_hash: String, + short_hash: String, + author: String, + time_str: String, + summary: String, + file_path: String, + line_num: u32, // orig line in the introducing commit (for show-screen nav) + blamed_line_num: u32, // line number in the blamed file (for blame-view nav) + }, + BlameCodeLine { + blame_file: Rc, + line_i: usize, + line_num: u32, + orig_line_num: u32, + content: String, + commit_hash: String, + file_path: String, + }, +} + +#[derive(Debug)] +pub(crate) struct BlameFile { + pub highlights: BlameHighlights, } impl ItemData { @@ -72,6 +97,10 @@ impl ItemData { .cloned() .map(Rev::Ref) .or_else(|| Some(Rev::Commit(oid.to_owned()))), + ItemData::BlameHeader { commit_hash, .. } + | ItemData::BlameCodeLine { commit_hash, .. } => { + Some(Rev::Commit(commit_hash.clone())) + } _ => None, } } @@ -157,4 +186,5 @@ pub(crate) enum SectionHeader { StagedChanges(usize), UnstagedChanges(usize), UntrackedFiles(usize), + Blame(String, String), } diff --git a/src/items.rs b/src/items.rs index 47699d28ec..c4dbbc82bf 100644 --- a/src/items.rs +++ b/src/items.rs @@ -9,6 +9,7 @@ use crate::item_data::Ref; use crate::item_data::SectionHeader; use git2::Oid; use git2::Repository; +use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; use regex::Regex; @@ -77,7 +78,7 @@ impl Item { path.to_string_lossy().into_owned(), &config.style.file_header, ), - ItemData::Delta { diff, file_i } => { + ItemData::Delta { diff, file_i, .. } => { let file_diff = &diff.file_diffs[file_i]; let content = format!( @@ -153,6 +154,9 @@ impl Item { SectionHeader::StagedChanges(count) => format!("Staged changes ({count})"), SectionHeader::UnstagedChanges(count) => format!("Unstaged changes ({count})"), SectionHeader::UntrackedFiles(count) => format!("Untracked files ({count})"), + SectionHeader::Blame(file, commit) => { + format!("Blame {file} @ {commit}") + } }; Line::styled(content, &config.style.section_header) @@ -173,6 +177,38 @@ impl Item { Line::raw(content) } ItemData::Error(err) => Line::raw(err), + ItemData::BlameHeader { + short_hash, + summary, + .. + } => Line::from(vec![ + Span::styled(format!("{:<8}", short_hash), &config.style.hash), + Span::raw(" "), + Span::raw(summary.clone()), + ]), + ItemData::BlameCodeLine { + blame_file, + line_i, + line_num, + content, + .. + } => { + let mut spans = vec![Span::styled( + format!("{:>4} ", line_num), + &config.style.blame.line_num, + )]; + + for (range, style) in blame_file.highlights.get_line_highlights(line_i) { + if !range.is_empty() && range.end <= content.len() { + spans.push(Span::styled( + content[range.clone()].replace('\t', " "), + *style, + )); + } + } + + Line::from(spans).style(Style::from(&config.style.blame.code_line)) + } } } } @@ -181,6 +217,7 @@ pub(crate) fn create_diff_items( diff: &Rc, depth: usize, default_collapsed: bool, + commit: Option, ) -> impl Iterator + '_ { diff.file_diffs .iter() @@ -193,6 +230,7 @@ pub(crate) fn create_diff_items( data: ItemData::Delta { diff: Rc::clone(diff), file_i, + commit: commit.clone(), }, ..Default::default() }) diff --git a/src/ops/apply.rs b/src/ops/apply.rs index 3abd77b1e8..b0d23dd088 100644 --- a/src/ops/apply.rs +++ b/src/ops/apply.rs @@ -13,7 +13,7 @@ impl OpTrait for Apply { fn get_action(&self, target: &ItemData) -> Option { let action = match target { ItemData::Stash { stash_ref, .. } => apply_stash(stash_ref.clone()), - ItemData::Delta { diff, file_i } => apply_patch(diff.format_file_patch(*file_i)), + ItemData::Delta { diff, file_i, .. } => apply_patch(diff.format_file_patch(*file_i)), ItemData::Hunk { diff, file_i, diff --git a/src/ops/blame.rs b/src/ops/blame.rs new file mode 100644 index 0000000000..c280d70d8e --- /dev/null +++ b/src/ops/blame.rs @@ -0,0 +1,90 @@ +use super::OpTrait; +use crate::{Action, app::State, error::Error, item_data::ItemData, screen}; +use std::{rc::Rc, sync::Arc}; + +pub(crate) struct Blame; +impl OpTrait for Blame { + fn get_action(&self, target: &ItemData) -> Option { + match target { + ItemData::Delta { + diff, + file_i, + commit, + } => { + let file_path = diff.file_diffs[*file_i] + .header + .new_file + .fmt(&diff.text) + .to_string(); + open_blame(file_path, commit.clone(), None) + } + ItemData::Hunk { diff, file_i, hunk_i } => { + let file_path = diff.file_diffs[*file_i] + .header + .new_file + .fmt(&diff.text) + .to_string(); + let line = diff.hunk_first_changed_line_num(*file_i, *hunk_i); + open_blame(file_path, diff.commit.clone(), Some(line)) + } + ItemData::HunkLine { diff, file_i, hunk_i, line_i, line_range } => { + let hunk_content = diff.hunk_content(*file_i, *hunk_i); + let line_content = &hunk_content[line_range.clone()]; + if line_content.starts_with('-') { + return None; + } + let file_path = diff.file_diffs[*file_i] + .header + .new_file + .fmt(&diff.text) + .to_string(); + let line = diff.hunk_line_new_file_num(*file_i, *hunk_i, *line_i); + open_blame(file_path, diff.commit.clone(), Some(line)) + } + ItemData::BlameHeader { + commit_hash, + file_path, + line_num, + .. + } if !commit_hash.chars().all(|c| c == '0') => { + let parent = format!("{}^", commit_hash); + open_blame(file_path.clone(), Some(parent), Some(*line_num)) + } + ItemData::BlameCodeLine { + commit_hash, + file_path, + orig_line_num, + .. + } if !commit_hash.chars().all(|c| c == '0') => { + let parent = format!("{}^", commit_hash); + open_blame(file_path.clone(), Some(parent), Some(*orig_line_num)) + } + _ => None, + } + } + + fn is_target_op(&self) -> bool { + true + } + + fn display(&self, _state: &State) -> String { + "Blame".into() + } +} + +fn open_blame(file_path: String, commit: Option, target_line: Option) -> Option { + Some(Rc::new(move |app, term| { + app.state.screens.push( + screen::blame::create( + Arc::clone(&app.state.config), + Rc::clone(&app.state.repo), + term.size().map_err(Error::Term)?, + file_path.clone(), + commit.clone(), + target_line, + ) + .expect("Couldn't create blame screen"), + ); + Ok(()) + })) +} diff --git a/src/ops/discard.rs b/src/ops/discard.rs index 40f3808158..b12186a591 100644 --- a/src/ops/discard.rs +++ b/src/ops/discard.rs @@ -18,7 +18,7 @@ impl OpTrait for Discard { .. } => discard_branch(branch.clone()), ItemData::Untracked(file) => clean_file(file.clone()), - ItemData::Delta { diff, file_i } => { + ItemData::Delta { diff, file_i, .. } => { let patch = diff.format_file_patch(*file_i); match diff.diff_type { DiffType::WorkdirToIndex => reverse_worktree(patch), diff --git a/src/ops/editor.rs b/src/ops/editor.rs index 53dec3bca9..62d20e64bf 100644 --- a/src/ops/editor.rs +++ b/src/ops/editor.rs @@ -187,7 +187,7 @@ pub(crate) struct MoveDownLine; impl OpTrait for MoveDownLine { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { - app.screen_mut().select_next(NavMode::IncludeHunkLines); + app.screen_mut().select_next(NavMode::IncludeSubLines); Ok(()) })) } @@ -201,7 +201,7 @@ pub(crate) struct MoveUpLine; impl OpTrait for MoveUpLine { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { - app.screen_mut().select_previous(NavMode::IncludeHunkLines); + app.screen_mut().select_previous(NavMode::IncludeSubLines); Ok(()) })) } diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 1f392a0a2e..7e87e8fa49 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -10,6 +10,7 @@ use crate::{ use std::{fmt::Display, rc::Rc}; pub(crate) mod apply; +pub(crate) mod blame; pub(crate) mod branch; pub(crate) mod cherry_pick; pub(crate) mod commit; @@ -110,6 +111,7 @@ pub(crate) enum Op { Apply, Reverse, CopyHash, + Blame, ToggleSection, MoveUp, @@ -203,6 +205,7 @@ impl Op { Op::Apply => Box::new(apply::Apply), Op::Reverse => Box::new(reverse::Reverse), Op::CopyHash => Box::new(copy_hash::CopyHash), + Op::Blame => Box::new(blame::Blame), Op::AddRemote => Box::new(remote::AddRemote), Op::RemoveRemote => Box::new(remote::RemoveRemote), diff --git a/src/ops/reverse.rs b/src/ops/reverse.rs index e77c2ff102..4a8dd1d4cb 100644 --- a/src/ops/reverse.rs +++ b/src/ops/reverse.rs @@ -12,7 +12,7 @@ pub(crate) struct Reverse; impl OpTrait for Reverse { fn get_action(&self, target: &ItemData) -> Option { let action = match target { - ItemData::Delta { diff, file_i } => reverse_patch(diff.format_file_patch(*file_i)), + ItemData::Delta { diff, file_i, .. } => reverse_patch(diff.format_file_patch(*file_i)), ItemData::Hunk { diff, file_i, diff --git a/src/ops/show.rs b/src/ops/show.rs index 8b4e06d9fb..6370eed1ca 100644 --- a/src/ops/show.rs +++ b/src/ops/show.rs @@ -21,9 +21,25 @@ impl OpTrait for Show { | ItemData::Reference { kind: Ref::Head(oid), .. - } => goto_show_screen(oid.clone()), + } => goto_show_screen(oid.clone(), None), + ItemData::BlameHeader { + commit_hash, + file_path, + line_num, + .. + } if !is_null_oid(commit_hash) => { + goto_show_screen(commit_hash.clone(), Some((file_path.clone(), *line_num))) + } + ItemData::BlameCodeLine { + commit_hash, + file_path, + orig_line_num, + .. + } if !is_null_oid(commit_hash) => { + goto_show_screen(commit_hash.clone(), Some((file_path.clone(), *orig_line_num))) + } ItemData::Untracked(u) => editor(u.as_path(), None), - ItemData::Delta { diff, file_i } => { + ItemData::Delta { diff, file_i, .. } => { let file_path = &diff.file_diffs[*file_i].header.new_file; let path: &str = &file_path.fmt(&diff.text); editor(Path::new(path), None) @@ -40,6 +56,20 @@ impl OpTrait for Show { Some(diff.file_line_of_first_diff(*file_i, *hunk_i) as u32), ) } + ItemData::HunkLine { + diff, + file_i, + hunk_i, + line_i, + .. + } => { + let file_path = &diff.file_diffs[*file_i].header.new_file; + let path: &str = &file_path.fmt(&diff.text); + editor( + Path::new(path), + Some(diff.hunk_line_new_file_num(*file_i, *hunk_i, *line_i)), + ) + } ItemData::Stash { stash_ref, .. } => goto_show_stash_screen(stash_ref.clone()), _ => None, } @@ -53,7 +83,7 @@ impl OpTrait for Show { } } -fn goto_show_screen(r: String) -> Option { +fn goto_show_screen(r: String, target: Option<(String, u32)>) -> Option { Some(Rc::new(move |app, term| { app.state.screens.push( screen::show::create( @@ -61,6 +91,7 @@ fn goto_show_screen(r: String) -> Option { Rc::clone(&app.state.repo), term.size().map_err(Error::Term)?, r.clone(), + target.clone(), ) .expect("Couldn't create screen"), ); @@ -83,6 +114,10 @@ fn goto_show_stash_screen(stash_ref: String) -> Option { })) } +fn is_null_oid(hash: &str) -> bool { + hash.chars().all(|c| c == '0') +} + pub(crate) const EDITOR_VARS: [&str; 4] = ["GITU_SHOW_EDITOR", "VISUAL", "EDITOR", "GIT_EDITOR"]; fn editor(file: &Path, maybe_line: Option) -> Option { let file = file.to_str().unwrap().to_string(); diff --git a/src/ops/stage.rs b/src/ops/stage.rs index 92991f37fc..e8227d6fe7 100644 --- a/src/ops/stage.rs +++ b/src/ops/stage.rs @@ -16,7 +16,7 @@ impl OpTrait for Stage { ItemData::AllUnstaged(_) => stage_unstaged(), ItemData::AllUntracked(untracked) => stage_untracked(untracked.clone()), ItemData::Untracked(u) => stage_file(u.into()), - ItemData::Delta { diff, file_i } => { + ItemData::Delta { diff, file_i, .. } => { let diff_header = &diff.file_diffs[*file_i].header; let file_path = match diff_header.status { Status::Deleted => &diff_header.old_file, diff --git a/src/ops/unstage.rs b/src/ops/unstage.rs index d459cf7d8d..d926642e8d 100644 --- a/src/ops/unstage.rs +++ b/src/ops/unstage.rs @@ -14,7 +14,7 @@ impl OpTrait for Unstage { fn get_action(&self, target: &ItemData) -> Option { let action = match target { ItemData::AllStaged(_) => unstage_staged(), - ItemData::Delta { diff, file_i } => { + ItemData::Delta { diff, file_i, .. } => { let diff_header = &diff.file_diffs[*file_i].header; let file_path = match diff_header.status { Status::Deleted => &diff_header.old_file, diff --git a/src/screen/blame.rs b/src/screen/blame.rs new file mode 100644 index 0000000000..226fef7925 --- /dev/null +++ b/src/screen/blame.rs @@ -0,0 +1,111 @@ +use std::{rc::Rc, sync::Arc}; + +use crate::{ + Res, + config::Config, + git, highlight, + item_data::{BlameFile, ItemData, SectionHeader}, + items::{Item, hash}, +}; +use git2::Repository; +use ratatui::layout::Size; + +use super::Screen; + +pub(crate) fn create( + config: Arc, + repo: Rc, + size: Size, + file_path: String, + commit: Option, + target_line: Option, +) -> Res { + let mut screen = Screen::new( + Arc::clone(&config), + size, + Box::new(move || { + let commit_display = commit.as_deref().unwrap_or("HEAD").to_string(); + let blame_lines = git::blame(repo.as_ref(), &file_path, commit.as_deref())?; + + let full_content = blame_lines + .iter() + .map(|l| l.content.as_str()) + .collect::>() + .join("\n"); + + let highlights = highlight::highlight_blame_file(&config, &file_path, full_content); + + let blame_file = Rc::new(BlameFile { highlights }); + + let header = Item { + id: hash(("blame_header", file_path.as_str(), commit_display.as_str())), + depth: 0, + unselectable: true, + data: ItemData::Header(SectionHeader::Blame(file_path.clone(), commit_display)), + ..Default::default() + }; + + let entries = blame_lines + .into_iter() + .enumerate() + .scan(None::, |prev_hash, (line_i, line)| { + let is_new_chunk = prev_hash.as_deref() != Some(line.commit_hash.as_str()); + *prev_hash = Some(line.commit_hash.clone()); + Some((line_i, line, is_new_chunk)) + }) + .flat_map(|(line_i, line, is_new_chunk)| { + let mut items = Vec::new(); + + if is_new_chunk { + items.push(Item { + id: hash(("blame_chunk", line.commit_hash.as_str(), line.line_num)), + depth: 0, + data: ItemData::BlameHeader { + commit_hash: line.commit_hash.clone(), + short_hash: line.short_hash.clone(), + author: line.author.clone(), + time_str: line.time_str.clone(), + summary: line.summary.clone(), + file_path: file_path.clone(), + line_num: line.orig_line_num, + blamed_line_num: line.line_num, + }, + ..Default::default() + }); + } + + items.push(Item { + id: hash(("blame_code", line.commit_hash.as_str(), line.line_num)), + depth: 0, + data: ItemData::BlameCodeLine { + blame_file: Rc::clone(&blame_file), + line_i, + line_num: line.line_num, + orig_line_num: line.orig_line_num, + content: line.content, + commit_hash: line.commit_hash, + file_path: file_path.clone(), + }, + ..Default::default() + }); + + items + }); + + Ok(std::iter::once(header).chain(entries).collect()) + }), + )?; + + if let Some(line) = target_line { + if !screen.select_matching(|data| { + matches!(data, ItemData::BlameCodeLine { line_num, .. } if *line_num == line) + }) { + screen.select_last_matching(|data| { + matches!(data, ItemData::BlameHeader { blamed_line_num, .. } + if *blamed_line_num <= line) + }); + } + } + + Ok(screen) +} diff --git a/src/screen/mod.rs b/src/screen/mod.rs index f797e83453..1e6f947d42 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -12,6 +12,7 @@ use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; +pub(crate) mod blame; pub(crate) mod log; pub(crate) mod show; pub(crate) mod show_refs; @@ -24,7 +25,7 @@ const BOTTOM_CONTEXT_LINES: usize = 2; pub(crate) enum NavMode { Normal, Siblings { depth: usize }, - IncludeHunkLines, + IncludeSubLines, } pub(crate) struct Screen { @@ -145,14 +146,16 @@ impl Screen { let item = self.at_line(line_i); match nav_mode { NavMode::Normal => { - let is_hunk_line = matches!(item.data, ItemData::HunkLine { .. }); - - !item.unselectable && !is_hunk_line + let is_sub_line = matches!( + item.data, + ItemData::HunkLine { .. } | ItemData::BlameCodeLine { .. } + ); + !item.unselectable && !is_sub_line } NavMode::Siblings { depth } => { !item.unselectable && item.data.is_section() && item.depth <= depth } - NavMode::IncludeHunkLines => !item.unselectable, + NavMode::IncludeSubLines => !item.unselectable, } } @@ -246,7 +249,7 @@ impl Screen { } match self.get_selected_item().data { - ItemData::HunkLine { .. } => NavMode::IncludeHunkLines, + ItemData::HunkLine { .. } | ItemData::BlameCodeLine { .. } => NavMode::IncludeSubLines, _ => NavMode::Normal, } } @@ -331,12 +334,46 @@ impl Screen { &self.items[self.line_index[self.cursor]] } + pub(crate) fn select_matching bool>(&mut self, predicate: F) -> bool { + if let Some(line_i) = (0..self.line_index.len()).find(|&line_i| { + !self.at_line(line_i).unselectable && predicate(&self.at_line(line_i).data) + }) { + self.cursor = line_i; + let half_screen = self.size.height as usize / 2; + if self.cursor >= half_screen { + self.scroll = self.cursor - half_screen; + } + self.scroll_fit_end(); + self.scroll_fit_start(); + true + } else { + false + } + } + + pub(crate) fn select_last_matching bool>(&mut self, predicate: F) -> bool { + if let Some(line_i) = (0..self.line_index.len()).rev().find(|&line_i| { + !self.at_line(line_i).unselectable && predicate(&self.at_line(line_i).data) + }) { + self.cursor = line_i; + let half_screen = self.size.height as usize / 2; + if self.cursor >= half_screen { + self.scroll = self.cursor - half_screen; + } else { + self.scroll_fit_start(); + } + true + } else { + false + } + } + pub(crate) fn is_valid_screen_line(&self, screen_line: usize) -> bool { let target_line_i = screen_line + self.scroll; if self.line_index.is_empty() || target_line_i >= self.line_index.len() { return false; } - self.nav_filter(target_line_i, NavMode::IncludeHunkLines) + self.nav_filter(target_line_i, NavMode::IncludeSubLines) } fn line_views(&'_ self, area: Size) -> impl Iterator> { diff --git a/src/screen/show.rs b/src/screen/show.rs index 60f264d3ac..13751db929 100644 --- a/src/screen/show.rs +++ b/src/screen/show.rs @@ -17,8 +17,9 @@ pub(crate) fn create( repo: Rc, size: Size, reference: String, + target: Option<(String, u32)>, ) -> Res { - Screen::new( + let mut screen = Screen::new( Arc::clone(&config), size, Box::new(move || { @@ -40,8 +41,31 @@ pub(crate) fn create( ..Default::default() })) .chain([items::blank_line()]) - .chain(items::create_diff_items(&Rc::new(show), 0, false)) + .chain(items::create_diff_items(&Rc::new(show), 0, false, Some(commit.hash.clone()))) .collect()) }), - ) + )?; + + if let Some((file, line_num)) = target { + let found = screen.select_matching(|data| { + if let ItemData::HunkLine { diff, file_i, hunk_i, line_i, line_range } = data { + if diff.file_diffs[*file_i].header.new_file.fmt(&diff.text) != file { + return false; + } + let line = &diff.hunk_content(*file_i, *hunk_i)[line_range.clone()]; + !line.starts_with('-') + && diff.hunk_line_new_file_num(*file_i, *hunk_i, *line_i) == line_num + } else { + false + } + }); + if !found { + screen.select_matching(|data| { + matches!(data, ItemData::Delta { diff, file_i, .. } + if diff.file_diffs[*file_i].header.new_file.fmt(&diff.text) == file) + }); + } + } + + Ok(screen) } diff --git a/src/screen/show_stash.rs b/src/screen/show_stash.rs index a387715432..b0a138fa18 100644 --- a/src/screen/show_stash.rs +++ b/src/screen/show_stash.rs @@ -57,7 +57,7 @@ pub(crate) fn create( ..Default::default() }, ]); - out.extend(items::create_diff_items(&diff, 1, false)); + out.extend(items::create_diff_items(&diff, 1, false, None)); }; if !staged.file_diffs.is_empty() { diff --git a/src/screen/status.rs b/src/screen/status.rs index 72d24aa4d6..9968105ec0 100644 --- a/src/screen/status.rs +++ b/src/screen/status.rs @@ -197,7 +197,7 @@ fn create_status_section_items<'a>( ] } .into_iter() - .chain(items::create_diff_items(diff, 1, true)) + .chain(items::create_diff_items(diff, 1, true, None)) } fn create_stash_list_section_items<'a>( diff --git a/src/tests/blame.rs b/src/tests/blame.rs new file mode 100644 index 0000000000..081ffbd08f --- /dev/null +++ b/src/tests/blame.rs @@ -0,0 +1,65 @@ +use super::*; + +fn setup(ctx: TestContext) -> TestContext { + commit(&ctx.dir, "file", "unchanged-top\noriginal\nunchanged-bottom\n"); + commit(&ctx.dir, "file", "unchanged-top\nSELECT-THIS-LINE\nunchanged-bottom\n"); + ctx +} + +// Reproduces the bug: Hunk/HunkLine blame uses the commit's line number but blames HEAD. +// If later commits shifted lines, select_last_matching navigates to the wrong BlameHeader. +fn setup_shifted(ctx: TestContext) -> TestContext { + // commit A: file with target line at position 2 + commit(&ctx.dir, "file", "top\nSELECT-THIS-LINE\nbottom\n"); + // commit B: prepends 5 lines, pushing SELECT-THIS-LINE to position 7 in HEAD + commit( + &ctx.dir, + "file", + "added1\nadded2\nadded3\nadded4\nadded5\ntop\nSELECT-THIS-LINE\nbottom\n", + ); + ctx +} + +#[test] +fn blame_from_hunk() { + // log → show most recent commit → cursor lands on first Hunk → B blames at first changed line + snapshot!(setup(setup_clone!()), "llB"); +} + +#[test] +fn blame_navigate_code_line() { + snapshot!(setup(setup_clone!()), "llB"); +} + +#[test] +fn blame_enter_on_header() { + // Enter on BlameHeader → show screen navigated to that line in the diff + snapshot!(setup(setup_clone!()), "llB"); +} + +#[test] +fn blame_enter_on_code_line() { + // ctrl+j to first BlameCodeLine, Enter → show screen navigated to that line + snapshot!(setup(setup_clone!()), "llB"); +} + +#[test] +fn blame_re_blame_from_header() { + // B on BlameHeader → re-blame at parent commit, navigated to approx same line + snapshot!(setup(setup_clone!()), "llBB"); +} + +#[test] +fn blame_re_blame_from_code_line() { + // ctrl+j to BlameCodeLine, B → re-blame at parent + snapshot!(setup(setup_clone!()), "llBB"); +} + +#[test] +fn blame_from_hunk_shifted_lines() { + // Bug: hunk blame uses commit's line number but blames HEAD. + // jj selects the older commit (commit A) whose hunk has SELECT-THIS-LINE at line 2, + // but HEAD has it at line 7 due to commit B prepending lines. + // Should still navigate to SELECT-THIS-LINE's BlameHeader, not line 2 in HEAD. + snapshot!(setup_shifted(setup_clone!()), "lljB"); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 9df83e6eb1..d6b88fd156 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -14,6 +14,7 @@ use std::fs; #[macro_use] mod helpers; mod arg; +mod blame; mod branch; mod cherry_pick; mod commit; diff --git a/src/tests/snapshots/gitu__tests__blame__blame_enter_on_code_line.snap b/src/tests/snapshots/gitu__tests__blame__blame_enter_on_code_line.snap new file mode 100644 index 0000000000..af95326a4c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_enter_on_code_line.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/blame.rs +expression: ctx.redact_buffer() +--- + Date: Fri, 16 Feb 2024 11:11:00 +0100 | + | + modify file | + | + Commit body goes here | + | + modified file | + @@ -1,3 +1,3 @@ | + unchanged-top | + -original | +▌+SELECT-THIS-LINE | + unchanged-bottom | + | + | + | + | + | + | + | + | +styles_hash: 53ab6c4805700159 diff --git a/src/tests/snapshots/gitu__tests__blame__blame_enter_on_header.snap b/src/tests/snapshots/gitu__tests__blame__blame_enter_on_header.snap new file mode 100644 index 0000000000..af95326a4c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_enter_on_header.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/blame.rs +expression: ctx.redact_buffer() +--- + Date: Fri, 16 Feb 2024 11:11:00 +0100 | + | + modify file | + | + Commit body goes here | + | + modified file | + @@ -1,3 +1,3 @@ | + unchanged-top | + -original | +▌+SELECT-THIS-LINE | + unchanged-bottom | + | + | + | + | + | + | + | + | +styles_hash: 53ab6c4805700159 diff --git a/src/tests/snapshots/gitu__tests__blame__blame_from_hunk.snap b/src/tests/snapshots/gitu__tests__blame__blame_from_hunk.snap new file mode 100644 index 0000000000..278bccc9f7 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_from_hunk.snap @@ -0,0 +1,26 @@ +--- +source: src/tests/blame.rs +assertion_line: 26 +expression: ctx.redact_buffer() +--- + Blame file @ dc61526641c542a66eef0574499da9b6f12a5764 | + 1fcc61bc add file | + 1 unchanged-top | +▌dc615266 modify file | + 2 SELECT-THIS-LINE | + 1fcc61bc add file | + 3 unchanged-botto | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: e048f7f1c5d5515c diff --git a/src/tests/snapshots/gitu__tests__blame__blame_from_hunk_shifted_lines.snap b/src/tests/snapshots/gitu__tests__blame__blame_from_hunk_shifted_lines.snap new file mode 100644 index 0000000000..a7001779ae --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_from_hunk_shifted_lines.snap @@ -0,0 +1,26 @@ +--- +source: src/tests/blame.rs +assertion_line: 64 +expression: ctx.redact_buffer() +--- + Blame file @ fa5e4bd731a775eec7d161bd5e133baca888379d | +▌fa5e4bd7 add file | + 1 top | + 2 SELECT-THIS-LINE | + 3 botto | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 8307b2cb56709b02 diff --git a/src/tests/snapshots/gitu__tests__blame__blame_navigate_code_line.snap b/src/tests/snapshots/gitu__tests__blame__blame_navigate_code_line.snap new file mode 100644 index 0000000000..6ea83bfa97 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_navigate_code_line.snap @@ -0,0 +1,26 @@ +--- +source: src/tests/blame.rs +assertion_line: 31 +expression: ctx.redact_buffer() +--- + Blame file @ dc61526641c542a66eef0574499da9b6f12a5764 | + 1fcc61bc add file | + 1 unchanged-top | + dc615266 modify file | +▌ 2 SELECT-THIS-LINE | + 1fcc61bc add file | + 3 unchanged-botto | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 8f174603e5263fc7 diff --git a/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_code_line.snap b/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_code_line.snap new file mode 100644 index 0000000000..0c9dc8b3c5 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_code_line.snap @@ -0,0 +1,26 @@ +--- +source: src/tests/blame.rs +assertion_line: 55 +expression: ctx.redact_buffer() +--- + Blame file @ dc61526641c542a66eef0574499da9b6f12a5764^ | +▌1fcc61bc add file | + 1 unchanged-top | + 2 original | + 3 unchanged-botto | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 563e339255ab3c79 diff --git a/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_header.snap b/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_header.snap new file mode 100644 index 0000000000..fdb257b78f --- /dev/null +++ b/src/tests/snapshots/gitu__tests__blame__blame_re_blame_from_header.snap @@ -0,0 +1,26 @@ +--- +source: src/tests/blame.rs +assertion_line: 49 +expression: ctx.redact_buffer() +--- + Blame file @ dc61526641c542a66eef0574499da9b6f12a5764^ | +▌1fcc61bc add file | + 1 unchanged-top | + 2 original | + 3 unchanged-botto | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 563e339255ab3c79