Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ impl App {
Rc::clone(&repo),
size,
reference.clone(),
None,
)?]
}
None => vec![screen::status::create(
Expand Down
11 changes: 11 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
4 changes: 4 additions & 0 deletions src/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub enum Error {
GetBranchName(git2::Error),
BaseCommitOid,
UpstreamCommitOid,
GitBlame(io::Error),
}

impl std::error::Error for Error {}
Expand Down Expand Up @@ -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}")),
}
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/git/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(crate) struct Diff {
pub text: String,
pub diff_type: DiffType,
pub file_diffs: Vec<FileDiff>,
pub commit: Option<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -104,6 +105,36 @@ 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;
Expand Down
95 changes: 95 additions & 0 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub(crate) fn diff_unstaged(repo: &Repository) -> Res<Diff> {
file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(),
diff_type: DiffType::WorkdirToIndex,
text,
commit: None,
})
}

Expand All @@ -186,6 +187,7 @@ pub(crate) fn diff_staged(repo: &Repository) -> Res<Diff> {
file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(),
diff_type: DiffType::IndexToTree,
text,
commit: None,
})
}

Expand Down Expand Up @@ -218,6 +220,7 @@ pub(crate) fn show(repo: &Repository, reference: &str) -> Res<Diff> {
file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(),
diff_type: DiffType::TreeToTree,
text,
commit: Some(reference.to_string()),
})
}

Expand Down Expand Up @@ -254,6 +257,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res<StashDiffs>
file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(),
diff_type: DiffType::TreeToTree,
text,
commit: None,
})
};

Expand All @@ -272,6 +276,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res<StashDiffs>
file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(),
diff_type: DiffType::TreeToTree,
text,
commit: None,
})
};

Expand All @@ -280,6 +285,7 @@ pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res<StashDiffs>
text: String::new(),
diff_type: DiffType::TreeToTree,
file_diffs: vec![],
commit: None,
};
return Ok(StashDiffs {
staged: empty,
Expand Down Expand Up @@ -428,6 +434,95 @@ pub(crate) fn does_branch_exist(repo: &git2::Repository, name: &str) -> Res<bool
}
}

#[derive(Debug, Clone)]
pub(crate) struct BlameLine {
pub commit_hash: String,
pub short_hash: String,
pub author: String,
pub author_time: i64,
pub summary: String,
pub line_num: u32,
pub orig_line_num: u32,
pub content: String,
}

pub(crate) fn blame(
repo: &Repository,
file_path: &str,
commit: Option<&str>,
) -> Res<Vec<BlameLine>> {
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<BlameLine> {
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();

for line in lines.by_ref() {
if let Some(rest) = line.strip_prefix('\t') {
content = rest.to_string();
break;
} else if let Some(rest) = line.strip_prefix("author ") {
author = rest.to_string();
} else if let Some(rest) = line.strip_prefix("author-time ") {
author_time = rest.parse().unwrap_or(0);
} else if let Some(rest) = line.strip_prefix("summary ") {
summary = rest.to_string();
}
}

result.push(BlameLine {
commit_hash,
short_hash,
author,
author_time,
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"]);
Expand Down
37 changes: 37 additions & 0 deletions src/highlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>, Style)>,
line_index: Vec<Range<usize>>,
}

impl BlameHighlights {
pub fn get_line_highlights(&self, line: usize) -> &[(Range<usize>, Style)] {
if line >= self.line_index.len() {
return &[];
}
&self.spans[self.line_index[line].clone()]
}
}

pub(crate) fn fill_gaps<T: Clone + Default>(
full_range: Range<usize>,
ranges: impl Iterator<Item = (Range<usize>, T)>,
Expand Down
30 changes: 29 additions & 1 deletion src/item_data.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,6 +22,7 @@ pub(crate) enum ItemData {
Delta {
diff: Rc<Diff>,
file_i: usize,
commit: Option<String>,
},
Hunk {
diff: Rc<Diff>,
Expand All @@ -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,
_author_time: i64,
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<BlameFile>,
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 {
Expand Down Expand Up @@ -72,6 +97,8 @@ 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,
}
}
Expand Down Expand Up @@ -157,4 +184,5 @@ pub(crate) enum SectionHeader {
StagedChanges(usize),
UnstagedChanges(usize),
UntrackedFiles(usize),
Blame(String, String),
}
Loading