feat(blame): add file blame dialog with git porcelain parsing
This change introduces a new backend command to fetch file blame using git's line-porcelain output and returns structured per-line metadata. The UI adds a BlameDialog that groups lines by commit, highlights uncommitted changes, and styles the dialog to match the updated theme. - Add get_file_blame command and parsing with uncommitted detection - Wire BlameDialog into the explorer file node actions - Add blame UI component and supporting types and styles
This commit is contained in:
@@ -117,6 +117,25 @@ pub struct GitCommitComparison {
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameLine {
|
||||
pub line_number: u32,
|
||||
pub content: String,
|
||||
pub commit_hash: String,
|
||||
pub short_hash: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_time: i64,
|
||||
pub summary: String,
|
||||
pub is_uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameResult {
|
||||
pub path: String,
|
||||
pub lines: Vec<GitBlameLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
pub path: String,
|
||||
@@ -1607,6 +1626,107 @@ pub fn cancel_file_history(
|
||||
state.cancel(request_id)
|
||||
}
|
||||
|
||||
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
return Err("Repository has no commits yet.".to_string());
|
||||
}
|
||||
|
||||
let args = vec![
|
||||
OsString::from("blame"),
|
||||
OsString::from("--line-porcelain"),
|
||||
OsString::from("--"),
|
||||
OsString::from(file.clone()),
|
||||
];
|
||||
let output =
|
||||
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
|
||||
|
||||
Ok(GitBlameResult {
|
||||
path: file,
|
||||
lines: parse_blame_porcelain(&output),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
|
||||
#[derive(Default, Clone)]
|
||||
struct BlameMeta {
|
||||
author_name: String,
|
||||
author_email: String,
|
||||
author_time: i64,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(output);
|
||||
let mut lines_out: Vec<GitBlameLine> = Vec::new();
|
||||
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
|
||||
let mut current_hash = String::new();
|
||||
let mut current_final_line: u32 = 0;
|
||||
|
||||
for line in text.split('\n') {
|
||||
if let Some(content) = line.strip_prefix('\t') {
|
||||
let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default();
|
||||
lines_out.push(GitBlameLine {
|
||||
line_number: current_final_line,
|
||||
content: content.to_string(),
|
||||
commit_hash: current_hash.clone(),
|
||||
short_hash: short_hash(¤t_hash),
|
||||
author_name: meta.author_name,
|
||||
author_email: meta.author_email,
|
||||
author_time: meta.author_time,
|
||||
summary: meta.summary,
|
||||
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parts = line.splitn(2, ' ');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let tail = parts.next().unwrap_or("");
|
||||
|
||||
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if let Some(final_line) = tail.split_whitespace().nth(1) {
|
||||
current_final_line = final_line.parse().unwrap_or(current_final_line);
|
||||
}
|
||||
current_hash = head.to_string();
|
||||
commit_meta.entry(current_hash.clone()).or_default();
|
||||
continue;
|
||||
}
|
||||
|
||||
match head {
|
||||
"author" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_name = tail.to_string()
|
||||
}
|
||||
"author-mail" => {
|
||||
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_email = email;
|
||||
}
|
||||
"author-time" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_time = tail.parse().unwrap_or(0);
|
||||
}
|
||||
"summary" => {
|
||||
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
lines_out
|
||||
}
|
||||
|
||||
fn list_file_history_core(
|
||||
repo: &Path,
|
||||
file: String,
|
||||
@@ -5419,4 +5539,44 @@ mod tests {
|
||||
|
||||
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
||||
let repo = init_temp_repo("file_blame");
|
||||
commit_initial_file(&repo.path);
|
||||
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change");
|
||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.path, "old.txt");
|
||||
assert_eq!(result.lines.len(), 2);
|
||||
assert_eq!(result.lines[0].content, "original");
|
||||
assert_eq!(result.lines[0].summary, "init");
|
||||
assert_eq!(result.lines[1].content, "two");
|
||||
assert_eq!(result.lines[1].summary, "add second line");
|
||||
assert!(!result.lines[1].author_name.is_empty());
|
||||
assert!(!result.lines[1].is_uncommitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_marks_uncommitted_working_tree_changes() {
|
||||
let repo = init_temp_repo("file_blame_uncommitted");
|
||||
commit_initial_file(&repo.path);
|
||||
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.lines.len(), 1);
|
||||
assert!(result.lines[0].is_uncommitted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use git::{
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
|
||||
get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||
@@ -76,6 +76,7 @@ fn main() {
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
cancel_file_history,
|
||||
get_file_blame,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
|
||||
Reference in New Issue
Block a user