From c1dab7a0d3016478b5976f8a9f3ca596a65eefbc Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 14 Aug 2026 20:24:13 +0000 Subject: [PATCH] Support blaming parent revisions (#62614) Closes https://github.com/zed-industries/zed/discussions/42583 Adds more tooltip entries and `editor::BlameRevision`, `editor::BlamePreviousRevision` actions to use. Started to highlight gutter blame entries that belong to currently annotated commit. https://github.com/user-attachments/assets/ba754e0b-6431-407c-8d79-2f8b0324fde1 Release Notes: - Supported blaming parent revisions --- crates/collab/src/rpc.rs | 1 + .../collab/tests/integration/editor_tests.rs | 64 +++- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 11 +- crates/editor/src/element.rs | 6 +- crates/editor/src/git.rs | 133 ++++++- crates/editor/src/git/blame.rs | 327 ++++++++++++++++-- crates/fs/src/fake_git_repo.rs | 25 +- crates/git/src/blame.rs | 157 ++++++++- crates/git/src/git.rs | 2 +- crates/git/src/repository.rs | 94 +++++ crates/git_ui/src/blame_ui.rs | 228 +++++++++++- crates/git_ui/src/commit_view.rs | 62 ++-- crates/project/src/git_store.rs | 249 ++++++++++--- crates/proto/proto/git.proto | 20 ++ crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 4 + 17 files changed, 1251 insertions(+), 140 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 9bbca16a39..96e051a6bc 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -372,6 +372,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index bb20e88d6b..3b16f57d04 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -4214,7 +4214,10 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA .into_iter() .map(|(sha, message)| (sha.parse().unwrap(), message.into())) .collect(), - tag_names: Default::default(), + tag_names: [("1b1b1b", vec!["v1.0.0".to_string()])] + .into_iter() + .map(|(sha, tag_names)| (sha.parse().unwrap(), tag_names)) + .collect(), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), @@ -4300,6 +4303,10 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA let details = blame.details_for_entry(*buffer, entry).unwrap(); assert_eq!(details.message, format!("message for idx-{}", idx)); } + assert_eq!( + blame.tag_names_for_entry(buffer_id_b, &blame_entry("1b1b1b", 0..1)), + vec![SharedString::from("v1.0.0")] + ); }); }); @@ -4375,6 +4382,61 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA ] ); }); + + let revision = "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d" + .parse::() + .unwrap(); + let content_at_revision = "old line1\nold line2\n"; + let blame_at_revision = git::blame::Blame { + entries: vec![blame_entry("1b1b1b", 0..1), blame_entry("0d0d0d", 1..2)], + messages: [ + ("1b1b1b", "message for idx-0"), + ("0d0d0d", "message for idx-1"), + ] + .into_iter() + .map(|(sha, message)| (sha.parse().unwrap(), message.into())) + .collect(), + tag_names: [("0d0d0d", vec!["v1.0.0".to_string()])] + .into_iter() + .map(|(sha, tag_names)| (sha.parse().unwrap(), tag_names)) + .collect(), + }; + client_a + .fs() + .with_git_state(Path::new(path!("/my-repo/.git")), true, |state| { + state.refs.insert("HEAD".into(), revision.to_string()); + state + .head_contents + .insert(repo_path("file.txt"), content_at_revision.to_string()); + state + .blames_at_revision + .insert((repo_path("file.txt"), revision), blame_at_revision.clone()); + }) + .unwrap(); + + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + let repository_b = project_b.read_with(cx_b, |project, cx| { + project + .git_store() + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }); + + let (content, forwarded_blame) = repository_b + .update(cx_b, |repository, cx| { + repository.blame_buffer_at_revision(repo_path("file.txt"), revision, cx) + }) + .await + .unwrap(); + + assert_eq!(content, content_at_revision); + assert_eq!(forwarded_blame, blame_at_revision); } #[gpui::test(iterations = 30)] diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index d88ca2200f..7002e337f1 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -876,6 +876,10 @@ actions!( ToggleGitBlameInline, /// Opens the git commit for the blame at cursor. OpenGitBlameCommit, + /// Opens a blame of the file at the revision of the blame entry at cursor. + BlameRevision, + /// Opens a blame of the file at the revision preceding the blame entry at cursor. + BlamePreviousRevision, /// Toggles the diagnostics panel. ToggleDiagnostics, /// Toggles indent guides display. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5e4d6551c8..a720ce5151 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -106,7 +106,7 @@ pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, file_status_label_color, render_breadcrumb_text, }; -pub use git::blame::BlameRenderer; +pub use git::blame::{BlameRenderer, GitBlame}; pub use git::{ DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate, RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls, @@ -158,7 +158,7 @@ use futures::{ future::{self, Shared}, }; use fuzzy::{StringMatch, StringMatchCandidate}; -use git::blame::{GitBlame, GlobalBlameRenderer}; +use git::blame::GlobalBlameRenderer; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, @@ -3330,7 +3330,12 @@ impl Editor { cx.notify(); return; } - if self.show_git_blame_gutter { + if self.show_git_blame_gutter + && !self + .blame + .as_ref() + .is_some_and(|blame| blame.read(cx).is_static()) + { self.show_git_blame_gutter = false; cx.notify(); return; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 71c2ce7b53..07e81acc82 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -509,7 +509,11 @@ impl EditorElement { register_action(editor, window, Editor::copy_file_location); register_action(editor, window, Editor::toggle_git_blame); register_action(editor, window, Editor::toggle_git_blame_inline); - register_action(editor, window, Editor::open_git_blame_commit); + if editor.read(cx).blame().is_some() { + register_action(editor, window, Editor::open_git_blame_commit); + register_action(editor, window, Editor::blame_revision); + register_action(editor, window, Editor::blame_previous_revision); + } register_action(editor, window, Editor::toggle_selected_diff_hunks); register_action(editor, window, Editor::toggle_staged_selected_diff_hunks); register_action(editor, window, Editor::stage_and_next); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index aa36ce648d..e6552a1d33 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1,8 +1,12 @@ pub(super) mod blame; use super::*; -use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; +use ::git::{ + Oid, Restore, blame::BlameEntry, commit::ParsedCommitMessage, repository::RepoPath, + status::FileStatus, +}; use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind}; +use project::git_store::Repository; #[derive(Clone)] pub struct ResolvedDiffHunk { @@ -2145,15 +2149,36 @@ impl Editor { }) } + pub fn set_blame( + &mut self, + blame: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.blame_subscription = Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); + self.blame = Some(blame); + self.show_git_blame_gutter = true; + cx.notify(); + } + fn start_git_blame( &mut self, user_triggered: bool, window: &mut Window, cx: &mut Context, ) { + if self + .blame + .as_ref() + .is_some_and(|blame| blame.read(cx).is_static()) + { + return; + } if let Some(project) = self.project() { if let Some(buffer) = self.buffer().read(cx).as_singleton() - && buffer.read(cx).file().is_none() + && buffer.read(cx).file().is_none_or(|file| { + matches!(file.disk_state(), language::DiskState::Historic { .. }) + }) { return; } @@ -2248,7 +2273,19 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option<()> { - let blame = self.blame.as_ref()?; + let (blame_entry, repo) = self.blame_entry_at_cursor(window, cx)?; + let renderer = cx.global::().0.clone(); + let workspace = self.workspace()?.downgrade(); + renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); + None + } + + fn blame_entry_at_cursor( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(BlameEntry, Entity)> { + let blame = self.blame.clone()?; let snapshot = self.snapshot(window, cx); let cursor = self .selections @@ -2269,11 +2306,93 @@ impl Editor { .next() }) .flatten()?; + let repository = blame.read(cx).repository(cx, buffer.remote_id())?; + Some((blame_entry, repository)) + } + + pub(super) fn blame_revision( + &mut self, + _: &BlameRevision, + window: &mut Window, + cx: &mut Context, + ) { + let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else { + self.show_blame_revision_toast("No blame entry for this line", cx); + return; + }; + if blame_entry.sha.is_zero() { + self.show_blame_revision_toast("Cannot blame revision: the line is not committed", cx); + return; + } + if self + .blame + .as_ref() + .is_some_and(|blame| blame.read(cx).highlighted_sha() == Some(blame_entry.sha)) + { + self.show_blame_revision_toast("Already blaming at this revision", cx); + return; + } + let Some(path) = RepoPath::new(&blame_entry.filename).log_err() else { + return; + }; + self.open_blame_revision(path, blame_entry.sha, repository, window, cx); + } + + pub(super) fn blame_previous_revision( + &mut self, + _: &BlamePreviousRevision, + window: &mut Window, + cx: &mut Context, + ) { + let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else { + self.show_blame_revision_toast("No blame entry for this line", cx); + return; + }; + let Some((revision, filename)) = blame_entry.previous_sha_and_filename() else { + self.show_blame_revision_toast("No previous revision for this line", cx); + return; + }; + let Some(path) = RepoPath::new(filename).log_err() else { + return; + }; + self.open_blame_revision(path, revision, repository, window, cx); + } + + fn open_blame_revision( + &mut self, + path: RepoPath, + revision: Oid, + repository: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace() else { + return; + }; let renderer = cx.global::().0.clone(); - let repo = blame.read(cx).repository(cx, buffer.remote_id())?; - let workspace = self.workspace()?.downgrade(); - renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); - None + renderer.open_blame_revision( + path, + revision, + repository, + workspace.downgrade(), + window, + cx, + ); + } + + fn show_blame_revision_toast(&self, message: &str, cx: &mut Context) { + struct BlameRevisionToast; + if let Some(workspace) = self.workspace() { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + message.to_owned(), + ), + cx, + ); + }); + } } fn has_blame_entries(&self, cx: &App) -> bool { diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 347a1e8daf..7a4b097759 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -6,13 +6,14 @@ use git::{ GitHostingProviderRegistry, Oid, blame::{Blame, BlameEntry}, commit::ParsedCommitMessage, + repository::RepoPath, }; use gpui::{ AnyElement, App, AppContext as _, Context, Entity, Hsla, Pixels, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window, }; use itertools::Itertools; -use language::{Bias, BufferSnapshot, Edit}; +use language::{Bias, Buffer, BufferSnapshot, Edit}; use markdown::Markdown; use multi_buffer::{MultiBuffer, RowInfo}; use project::{ @@ -72,6 +73,26 @@ struct GitBlameBuffer { commit_tag_names: HashMap>, } +/// Determines where the blame data comes from and whether it is kept up to date. +enum GitBlameKind { + /// Blame of the working copy of every buffer in the multibuffer, computed + /// through the project's git state and regenerated on edits, saves, and + /// repository changes. The repository is resolved per buffer through the + /// project. + Live, + /// Precomputed per-buffer blames at a fixed revision, created via + /// [`GitBlame::new_static`]. They are never regenerated. The annotated + /// buffers are synthetic read-only buffers that do not belong to the + /// project, so the repository cannot be resolved through them and is + /// stored explicitly; all buffers must belong to the same repository. + Static { + repository: Entity, + /// The revision being viewed; gutter entries introduced by this commit + /// are highlighted. + highlighted_sha: Option, + }, +} + pub struct GitBlame { project: Entity, multi_buffer: WeakEntity, @@ -82,6 +103,7 @@ pub struct GitBlame { user_triggered: bool, regenerate_on_edit_task: Task>, _regenerate_subscriptions: Vec, + kind: GitBlameKind, } pub trait BlameRenderer { @@ -134,6 +156,17 @@ pub trait BlameRenderer { _: &mut Window, _: &mut App, ); + + fn open_blame_revision( + &self, + _: RepoPath, + _: Oid, + _: Entity, + _: WeakEntity, + _: &mut Window, + _: &mut App, + ) { + } } impl BlameRenderer for () { @@ -268,12 +301,80 @@ impl GitBlame { project_subscription, git_store_subscription, ], + kind: GitBlameKind::Live, }; this.generate(cx); this } + pub fn new_static( + multi_buffer: Entity, + project: Entity, + repository: Entity, + blames: impl IntoIterator, Blame)>, + highlighted_sha: Option, + cx: &mut Context, + ) -> Self { + let remote_url = repository.read(cx).default_remote_url(); + let provider_registry = GitHostingProviderRegistry::default_global(cx); + + let mut buffers = HashMap::default(); + for (buffer, blame) in blames { + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + let (commit_details, commit_tag_names) = parse_commit_details( + blame.messages, + blame.tag_names, + remote_url.as_deref(), + &provider_registry, + ); + let entries = build_blame_entry_sum_tree(blame.entries, snapshot.max_point().row); + buffers.insert( + snapshot.remote_id(), + GitBlameBuffer { + entries, + buffer_snapshot: snapshot, + buffer_edits, + commit_details, + commit_tag_names, + }, + ); + } + + Self { + project, + multi_buffer: multi_buffer.downgrade(), + buffers, + task: Task::ready(Ok(())), + focused: true, + changed_while_blurred: false, + user_triggered: true, + regenerate_on_edit_task: Task::ready(Ok(())), + _regenerate_subscriptions: Vec::new(), + kind: GitBlameKind::Static { + repository, + highlighted_sha, + }, + } + } + + pub fn is_static(&self) -> bool { + matches!(self.kind, GitBlameKind::Static { .. }) + } + + pub fn highlighted_sha(&self) -> Option { + match &self.kind { + GitBlameKind::Live => None, + GitBlameKind::Static { + highlighted_sha, .. + } => *highlighted_sha, + } + } + pub fn repository(&self, cx: &App, id: BufferId) -> Option> { + if let GitBlameKind::Static { repository, .. } = &self.kind { + return Some(repository.clone()); + } self.project .read(cx) .git_store() @@ -507,6 +608,9 @@ impl GitBlame { #[ztracing::instrument(skip_all)] fn generate(&mut self, cx: &mut Context) { + if self.is_static() { + return; + } if !self.focused { self.changed_while_blurred = true; return; @@ -583,31 +687,13 @@ impl GitBlame { entries, snapshot.max_point().row, ); - let commit_details = messages - .into_iter() - .map(|(oid, message)| { - let parsed_commit_message = - ParsedCommitMessage::parse( - oid.to_string(), - message, - remote_url.as_deref(), - Some(provider_registry.clone()), - ); - (oid, parsed_commit_message) - }) - .collect(); - let commit_tag_names = tag_names - .into_iter() - .map(|(oid, tag_names)| { - ( - oid, - tag_names - .into_iter() - .map(SharedString::from) - .collect(), - ) - }) - .collect(); + let (commit_details, commit_tag_names) = + parse_commit_details( + messages, + tag_names, + remote_url.as_deref(), + &provider_registry, + ); res.push(( id, snapshot, @@ -698,6 +784,42 @@ impl GitBlame { const REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL: Duration = Duration::from_secs(2); +fn parse_commit_details( + messages: HashMap, + tag_names: HashMap>, + remote_url: Option<&str>, + provider_registry: &Arc, +) -> ( + HashMap, + HashMap>, +) { + let commit_details = messages + .into_iter() + .map(|(oid, message)| { + let parsed_commit_message = ParsedCommitMessage::parse( + oid.to_string(), + message, + remote_url, + Some(provider_registry.clone()), + ); + (oid, parsed_commit_message) + }) + .collect(); + let commit_tag_names = tag_names + .into_iter() + .map(|(oid, tag_names)| { + ( + oid, + tag_names + .into_iter() + .map(SharedString::from) + .collect::>(), + ) + }) + .collect(); + (commit_details, commit_tag_names) +} + fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree { let mut current_row = 0; let mut entries = SumTree::from_iter( @@ -740,7 +862,7 @@ mod tests { use super::*; use git::repository::repo_path; use gpui::Context; - use language::{Point, Rope}; + use language::{Capability, Point, Rope}; use project::FakeFs; use rand::prelude::*; use serde_json::json; @@ -1049,6 +1171,157 @@ mod tests { }); } + #[gpui::test] + async fn test_static_blame(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/my-repo"), + json!({ + ".git": {}, + "file_one.txt": "Line 1\nLine 2\nLine 3\n", + "file_two.txt": "Other 1\nOther 2\n", + }), + ) + .await; + + let project = Project::test(fs, [path!("/my-repo").as_ref()], cx).await; + let buffer_one = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/my-repo/file_one.txt"), cx) + }) + .await + .unwrap(); + let buffer_two = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/my-repo/file_two.txt"), cx) + }) + .await + .unwrap(); + let buffer_one_id = buffer_one.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer_two_id = buffer_two.read_with(cx, |buffer, _| buffer.remote_id()); + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_buffer( + buffer_one.clone(), + [Point::zero()..buffer_one.read(cx).max_point()], + 0, + cx, + ); + multi_buffer.set_excerpts_for_buffer( + buffer_two.clone(), + [Point::zero()..buffer_two.read(cx).max_point()], + 0, + cx, + ); + multi_buffer + }); + cx.executor().run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .git_store() + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }); + + let highlighted_sha = "0d0d0d".parse::().unwrap(); + let blame_one = Blame { + entries: vec![blame_entry("1b1b1b", 0..1), blame_entry("0d0d0d", 1..2)], + messages: [ + ("1b1b1b".parse::().unwrap(), "commit one".to_string()), + ("0d0d0d".parse::().unwrap(), "commit two".to_string()), + ] + .into_iter() + .collect(), + tag_names: [("0d0d0d".parse::().unwrap(), vec!["v1.0.0".to_string()])] + .into_iter() + .collect(), + }; + let blame_two = Blame { + entries: vec![blame_entry("0d0d0d", 0..2)], + messages: [("0d0d0d".parse::().unwrap(), "commit two".to_string())] + .into_iter() + .collect(), + tag_names: HashMap::default(), + }; + + let git_blame = cx.new(|cx| { + GitBlame::new_static( + multi_buffer, + project.clone(), + repository.clone(), + [ + (buffer_one.clone(), blame_one), + (buffer_two.clone(), blame_two), + ], + Some(highlighted_sha), + cx, + ) + }); + + git_blame.update(cx, |blame, cx| { + assert!(blame.is_static()); + assert_eq!(blame.highlighted_sha(), Some(highlighted_sha)); + assert_eq!( + blame.repository(cx, buffer_one_id), + Some(repository.clone()) + ); + assert_eq!( + blame.repository(cx, buffer_two_id), + Some(repository.clone()) + ); + assert_blame_rows( + blame, + buffer_one_id, + 0..3, + vec![ + Some(blame_entry("1b1b1b", 0..1)), + Some(blame_entry("0d0d0d", 1..2)), + None, + ], + cx, + ); + assert_blame_rows( + blame, + buffer_two_id, + 0..2, + vec![ + Some(blame_entry("0d0d0d", 0..2)), + Some(blame_entry("0d0d0d", 0..2)), + ], + cx, + ); + assert_eq!( + blame + .details_for_entry(buffer_one_id, &blame_entry("1b1b1b", 0..1)) + .unwrap() + .message, + "commit one" + ); + assert_eq!( + blame + .details_for_entry(buffer_two_id, &blame_entry("0d0d0d", 0..2)) + .unwrap() + .message, + "commit two" + ); + assert_eq!( + blame.tag_names_for_entry(buffer_one_id, &blame_entry("0d0d0d", 1..2)), + vec![SharedString::from("v1.0.0")] + ); + assert_eq!( + blame.tag_names_for_entry(buffer_two_id, &blame_entry("0d0d0d", 0..2)), + Vec::::new() + ); + }); + } + #[gpui::test] async fn test_blame_for_rows_with_edits(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 0a87c4584d..543a11a9c2 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -66,6 +66,7 @@ pub struct FakeGitRepositoryState { pub merge_base_contents: HashMap, pub oids: HashMap, pub blames: HashMap, + pub blames_at_revision: HashMap<(RepoPath, Oid), Blame>, pub current_branch_name: Option, pub branches: HashSet, /// List of remotes, keys are names and values are URLs @@ -90,6 +91,7 @@ impl FakeGitRepositoryState { index_contents: Default::default(), unmerged_paths: Default::default(), blames: Default::default(), + blames_at_revision: Default::default(), current_branch_name: Default::default(), branches: Default::default(), simulated_index_write_error_message: Default::default(), @@ -269,7 +271,14 @@ impl GitRepository for FakeGitRepository { match prefix { "" => state.index_contents.get(&repo_path).cloned(), "HEAD" => state.head_contents.get(&repo_path).cloned(), - _ => None, + _ if state.refs.get("HEAD").map(String::as_str) == Some(prefix) => { + state.head_contents.get(&repo_path).cloned() + } + _ => state + .commit_history + .iter() + .find(|snapshot| snapshot.sha == prefix) + .and_then(|snapshot| snapshot.head_contents.get(&repo_path).cloned()), } }) .collect()) @@ -989,6 +998,20 @@ impl GitRepository for FakeGitRepository { }) } + fn blame_at_revision( + &self, + path: RepoPath, + revision: Oid, + ) -> BoxFuture<'_, Result> { + self.with_state_async(false, move |state| { + state + .blames_at_revision + .get(&(path.clone(), revision)) + .with_context(|| format!("failed to get blame for {path:?} at {revision}")) + .cloned() + }) + } + fn stage_paths( &self, paths: Vec, diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 6036e112e4..85c8f8a4ac 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -13,7 +13,7 @@ use time::UtcOffset; use time::macros::format_description; use util::command::Stdio; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct Blame { pub entries: Vec, pub messages: HashMap, @@ -27,8 +27,20 @@ impl Blame { content: &Rope, line_ending: LineEnding, ) -> Result { - let mut entries = run_git_blame(git, path, content, line_ending).await?; + let entries = run_git_blame(git, path, BlameSource::Contents(content, line_ending)).await?; + Self::with_commit_details(git, entries).await + } + pub(crate) async fn for_path_at_revision( + git: &GitBinary, + path: &RepoPath, + revision: Oid, + ) -> Result { + let entries = run_git_blame(git, path, BlameSource::Revision(revision)).await?; + Self::with_commit_details(git, entries).await + } + + async fn with_commit_details(git: &GitBinary, mut entries: Vec) -> Result { let mut unique_shas = HashSet::default(); for entry in entries.iter_mut() { @@ -63,18 +75,37 @@ const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; const BLAME_PARSE_YIELD_INTERVAL: usize = 512; +#[derive(Clone, Copy)] +enum BlameSource<'a> { + Contents(&'a Rope, LineEnding), + Revision(Oid), +} + async fn run_git_blame( git: &GitBinary, path: &RepoPath, - contents: &Rope, - line_ending: LineEnding, + source: BlameSource<'_>, ) -> Result> { let mut child = { let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str()); let _enter = span.enter(); - git.build_command(&["blame", "--incremental", "--contents", "-", "--"]) + let mut args = vec!["blame", "--incremental"]; + let revision_string; + match source { + BlameSource::Contents(..) => args.extend(["--contents", "-"]), + BlameSource::Revision(revision) => { + revision_string = revision.to_string(); + args.push(&revision_string); + } + } + args.push("--"); + git.build_command(&args) .arg(path.as_unix_str()) - .stdin(Stdio::piped()) + .stdin(if matches!(source, BlameSource::Contents(..)) { + Stdio::piped() + } else { + Stdio::null() + }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -82,10 +113,7 @@ async fn run_git_blame( .context("starting git blame process")? }; - let mut stdin = child - .stdin - .take() - .context("failed to get pipe to stdin of git blame command")?; + let stdin = child.stdin.take(); let stdout = child .stdout .take() @@ -96,10 +124,14 @@ async fn run_git_blame( .context("failed to get stderr from git blame command")?; let write_stdin = async move { - for chunk in text::chunks_with_line_ending(contents, line_ending) { - stdin.write_all(chunk.as_bytes()).await?; + if let BlameSource::Contents(contents, line_ending) = source { + let mut stdin = stdin.context("failed to get pipe to stdin of git blame command")?; + for chunk in text::chunks_with_line_ending(contents, line_ending) { + stdin.write_all(chunk.as_bytes()).await?; + } + stdin.flush().await?; } - stdin.flush().await.map_err(Into::into) + anyhow::Ok(()) }; let read_stdout = async move { @@ -233,6 +265,11 @@ impl BlameEntry { }) } + pub fn previous_sha_and_filename(&self) -> Option<(Oid, &str)> { + let (sha, filename) = self.previous.as_deref()?.split_once(' ')?; + Some((sha.parse().ok()?, filename)) + } + pub fn author_offset_date_time(&self) -> Result { if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { let format = format_description!("[offset_hour][offset_minute]"); @@ -338,10 +375,17 @@ impl GitBlameParser { let is_committed = !entry.sha.is_zero(); match key { "filename" => { - entry.filename = value.into(); + entry.filename = unquote_git_path(value); done = true; } - "previous" => entry.previous = Some(value.into()), + "previous" => { + entry.previous = Some(match value.split_once(' ') { + Some((sha, filename)) => { + format!("{sha} {}", unquote_git_path(filename)) + } + None => value.into(), + }) + } "summary" if is_committed => entry.summary = Some(value.into()), "author" if is_committed => entry.author = Some(value.into()), @@ -383,13 +427,55 @@ impl GitBlameParser { } } +fn unquote_git_path(value: &str) -> String { + let Some(quoted) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + else { + return value.to_owned(); + }; + let mut bytes = Vec::with_capacity(quoted.len()); + let mut input = quoted.bytes().peekable(); + while let Some(byte) = input.next() { + if byte != b'\\' { + bytes.push(byte); + continue; + } + match input.next() { + Some(b'a') => bytes.push(0x07), + Some(b'b') => bytes.push(0x08), + Some(b'f') => bytes.push(0x0c), + Some(b'n') => bytes.push(b'\n'), + Some(b'r') => bytes.push(b'\r'), + Some(b't') => bytes.push(b'\t'), + Some(b'v') => bytes.push(0x0b), + Some(digit @ b'0'..=b'7') => { + let mut octal = u32::from(digit - b'0'); + for _ in 0..2 { + match input.peek() { + Some(&next @ b'0'..=b'7') if octal * 8 + u32::from(next - b'0') <= 0xff => { + octal = octal * 8 + u32::from(next - b'0'); + input.next(); + } + _ => break, + } + } + bytes.push(octal as u8); + } + Some(other) => bytes.push(other), + None => {} + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + #[cfg(test)] mod tests { use std::path::PathBuf; use crate::blame::GitBlameParser; - use super::BlameEntry; + use super::{BlameEntry, unquote_git_path}; fn parse_git_blame(output: &str) -> anyhow::Result> { let mut parser = GitBlameParser::new(); @@ -459,4 +545,43 @@ mod tests { let entries = parse_git_blame(&output).unwrap(); assert_eq_golden(&entries, "blame_incremental_complex"); } + + #[test] + fn test_parse_git_blame_quoted_filename() { + let output = r#"6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +author Joe Schmoe +author-mail +author-time 1709741400 +author-tz +0100 +committer Joe Schmoe +committer-mail +committer-time 1709741400 +committer-tz +0100 +summary Joe's cool commit +previous 486c2409237a2c627230589e567024a96751d475 "\303\274rlich \"file\".txt" +filename "\303\274rlich \"file\".txt" +"#; + let entries = parse_git_blame(output).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].filename, "ürlich \"file\".txt"); + assert_eq!( + entries[0].previous.as_deref(), + Some("486c2409237a2c627230589e567024a96751d475 ürlich \"file\".txt") + ); + assert_eq!( + entries[0].previous_sha_and_filename(), + Some(( + "486c2409237a2c627230589e567024a96751d475".parse().unwrap(), + "ürlich \"file\".txt" + )) + ); + } + + #[test] + fn test_unquote_git_path() { + assert_eq!(unquote_git_path("plain file.txt"), "plain file.txt"); + assert_eq!(unquote_git_path(r#""a\\b\"c\td""#), "a\\b\"c\td"); + assert_eq!(unquote_git_path(r#""\1\12\123""#), "\u{1}\nS"); + assert_eq!(unquote_git_path(r#""\777""#), "?7"); + } } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index bb74142139..9d8f658a6b 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -225,7 +225,7 @@ impl Oid { &self.bytes[..self.format.byte_len()] } - pub(crate) fn is_zero(&self) -> bool { + pub fn is_zero(&self) -> bool { self.as_bytes().iter().all(|byte| *byte == 0) } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 9d3e4086f3..7cddc70b1a 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -889,6 +889,12 @@ pub trait GitRepository: Send + Sync { line_ending: LineEnding, ) -> BoxFuture<'_, Result>; + fn blame_at_revision( + &self, + path: RepoPath, + revision: Oid, + ) -> BoxFuture<'_, Result>; + /// Returns the absolute path to the repository. For worktrees, this will be the path to the /// worktree's gitdir within the main repository (typically `.git/worktrees/`). fn path(&self) -> PathBuf; @@ -1723,6 +1729,11 @@ impl GitRepository for RealGitRepository { if revisions.is_empty() { return Ok(Vec::new()); } + if let Some(revision) = revisions.iter().find(|revision| revision.contains('\n')) { + anyhow::bail!( + "revision spec {revision:?} contains a newline and cannot be passed to git cat-file --batch" + ); + } let mut process = git .build_command(&["cat-file", "--batch"]) @@ -2373,6 +2384,21 @@ impl GitRepository for RealGitRepository { .boxed() } + fn blame_at_revision( + &self, + path: RepoPath, + revision: Oid, + ) -> BoxFuture<'_, Result> { + let git = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git = git?; + crate::blame::Blame::for_path_at_revision(&git, &path, revision).await + }) + .boxed() + } + fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); self.executor @@ -5449,6 +5475,74 @@ mod tests { ); } + #[gpui::test] + async fn test_blame_at_revision(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let file_name = "ürlich file1"; + fs::write(repo_dir.path().join(file_name), "line one\n").unwrap(); + git_command(repo_dir.path(), ["add", "-A"]); + git_command(repo_dir.path(), ["commit", "-m", "First commit"]); + let first_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]); + + fs::write(repo_dir.path().join(file_name), "line one\nline two\n").unwrap(); + git_command(repo_dir.path(), ["add", "-A"]); + git_command(repo_dir.path(), ["commit", "-m", "Second commit"]); + let second_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]); + + let blame_at_head = repo + .blame_at_revision(repo_path(file_name), second_sha.parse().unwrap()) + .await + .unwrap(); + assert_eq!( + blame_at_head + .entries + .iter() + .map(|entry| { + ( + entry.sha.to_string(), + entry.range.clone(), + entry.filename.clone(), + entry.previous.clone(), + ) + }) + .collect::>(), + vec![ + (first_sha.clone(), 0..1, file_name.to_owned(), None), + ( + second_sha.clone(), + 1..2, + file_name.to_owned(), + Some(format!("{first_sha} {file_name}")) + ), + ] + ); + + let blame_at_first = repo + .blame_at_revision(repo_path(file_name), first_sha.parse().unwrap()) + .await + .unwrap(); + assert_eq!( + blame_at_first + .entries + .iter() + .map(|entry| (entry.sha.to_string(), entry.range.clone())) + .collect::>(), + vec![(first_sha.clone(), 0..1)] + ); + } + #[gpui::test] async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) { disable_git_global_config(); diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index 07097e2109..7413f40c7f 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -1,9 +1,15 @@ use crate::{ commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips}, - commit_view::CommitView, + commit_view::{CommitView, GitBlob, build_buffer, worktree_id_for_repo_path}, +}; +use anyhow::Context as _; +use editor::{BlameRenderer, Editor, GitBlame, MultiBuffer, hover_markdown_style}; +use git::{ + Oid, + blame::BlameEntry, + commit::ParsedCommitMessage, + repository::{CommitSummary, RepoPath}, }; -use editor::{BlameRenderer, Editor, hover_markdown_style}; -use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary}; use gpui::{ ClipboardItem, Entity, Hsla, MouseButton, Pixels, Rems, ScrollHandle, Subscription, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, @@ -14,10 +20,12 @@ use project::{ project_settings::{InlineBlameLocation, ProjectSettings}, }; use settings::Settings as _; +use std::sync::Arc; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container}; -use workspace::Workspace; +use util::paths::PathStyle; +use workspace::{Workspace, notifications::NotifyTaskExt as _}; const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; const GIT_BLAME_GUTTER_MARGIN: Rems = rems(0.5); @@ -160,6 +168,10 @@ impl BlameRenderer for GitBlameRenderer { let short_commit_id = blame_entry.sha.display_short(); let author_name = blame_entry.author.as_deref().unwrap_or(""); let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); + let is_highlighted = editor + .read(cx) + .blame() + .is_some_and(|blame| blame.read(cx).highlighted_sha() == Some(blame_entry.sha)); let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar { let author_email = blame_entry.author_mail.as_ref().map(|email| { @@ -195,6 +207,9 @@ impl BlameRenderer for GitBlameRenderer { .font(style.font()) .line_height(style.line_height) .text_color(cx.theme().status().hint) + .when(is_highlighted, |this| { + this.bg(cx.theme().colors().element_selected) + }) .child( h_flex() .gap_2() @@ -209,6 +224,8 @@ impl BlameRenderer for GitBlameRenderer { let blame_entry = blame_entry.clone(); let details = details.clone(); let editor = editor.clone(); + let repository = repository.clone(); + let workspace = workspace.clone(); move |event, window, cx| { cx.stop_propagation(); @@ -216,6 +233,8 @@ impl BlameRenderer for GitBlameRenderer { &blame_entry, details.as_ref(), editor.clone(), + repository.clone(), + workspace.clone(), event.position, window, cx, @@ -506,18 +525,44 @@ impl BlameRenderer for GitBlameRenderer { cx, ) } + + fn open_blame_revision( + &self, + path: RepoPath, + revision: Oid, + repository: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + open_buffer_blame_at_revision(repository, workspace, path, revision, window, cx); + } } fn deploy_blame_entry_context_menu( blame_entry: &BlameEntry, details: Option<&ParsedCommitMessage>, editor: Entity, + repository: Entity, + workspace: WeakEntity, position: gpui::Point, window: &mut Window, cx: &mut App, ) { + let highlighted_sha = editor + .read(cx) + .blame() + .and_then(|blame| blame.read(cx).highlighted_sha()); let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { let sha = format!("{}", blame_entry.sha); + let blame_revision = (!blame_entry.sha.is_zero() + && Some(blame_entry.sha) != highlighted_sha) + .then_some(blame_entry.sha) + .zip(RepoPath::new(&blame_entry.filename).ok()); + let blame_previous_revision = blame_entry + .previous_sha_and_filename() + .and_then(|(sha, filename)| Some((sha, RepoPath::new(filename).ok()?))); + let has_blame_targets = blame_revision.is_some() || blame_previous_revision.is_some(); menu.on_blur_subscription(Subscription::new(|| {})) .entry("Copy Commit SHA", None, move |_, cx| { cx.write_to_clipboard(ClipboardItem::new_string(sha.clone())); @@ -530,6 +575,35 @@ fn deploy_blame_entry_context_menu( }) }, ) + .when(has_blame_targets, |this| this.separator()) + .when_some(blame_revision, |this, (revision, path)| { + let repository = repository.clone(); + let workspace = workspace.clone(); + this.entry("Blame Revision", None, move |window, cx| { + open_buffer_blame_at_revision( + repository.clone(), + workspace.clone(), + path.clone(), + revision, + window, + cx, + ); + }) + }) + .when_some(blame_previous_revision, |this, (revision, path)| { + let repository = repository.clone(); + let workspace = workspace.clone(); + this.entry("Blame Previous Revision", None, move |window, cx| { + open_buffer_blame_at_revision( + repository.clone(), + workspace.clone(), + path.clone(), + revision, + window, + cx, + ); + }) + }) }); editor.update(cx, move |editor, cx| { @@ -539,6 +613,152 @@ fn deploy_blame_entry_context_menu( }); } +fn open_buffer_blame_at_revision( + repository: Entity, + workspace: WeakEntity, + path: RepoPath, + revision: Oid, + window: &mut Window, + cx: &mut App, +) { + window + .spawn(cx, { + let workspace = workspace.clone(); + async move |cx| { + let (language_registry, worktree_id) = + workspace.read_with(cx, |workspace, cx| { + let project = workspace.project().read(cx); + ( + project.languages().clone(), + worktree_id_for_repo_path(repository.read(cx), project, &path, cx), + ) + })?; + let worktree_id = worktree_id.context("project has no worktrees")?; + + let file_name = path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_else(|| path.display(PathStyle::local()).to_string()); + let display_name = format!("{file_name} @ {}", revision.display_short()); + + let activated_existing = workspace.update_in(cx, |workspace, window, cx| { + activate_existing_blame_editor( + workspace, + &repository, + &path, + revision, + window, + cx, + ) + })?; + if activated_existing { + return Ok(()); + } + + let (content, blame) = repository + .update(cx, |repository, cx| { + repository.blame_buffer_at_revision(path.clone(), revision, cx) + }) + .await?; + + let file = Arc::new(GitBlob { + path: path.clone(), + worktree_id, + is_deleted: false, + is_binary: false, + display_name: display_name.clone(), + }) as Arc; + + let buffer = build_buffer(content, file, &language_registry, cx).await?; + + workspace.update_in(cx, |workspace, window, cx| { + if activate_existing_blame_editor( + workspace, + &repository, + &path, + revision, + window, + cx, + ) { + return; + } + + let project = workspace.project().clone(); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new(|cx| { + let mut editor = Editor::for_multibuffer( + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ); + editor.set_read_only(true); + editor.set_should_serialize(false, cx); + editor + }); + let git_blame = cx.new(|cx| { + GitBlame::new_static( + multi_buffer, + project, + repository, + [(buffer, blame)], + Some(revision), + cx, + ) + }); + editor.update(cx, |editor, cx| editor.set_blame(git_blame, window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + }) + } + }) + .detach_and_notify_err(workspace, window, cx); +} + +fn activate_existing_blame_editor( + workspace: &mut Workspace, + repository: &Entity, + path: &RepoPath, + revision: Oid, + window: &mut Window, + cx: &mut Context, +) -> bool { + match existing_blame_editor(workspace, repository, path, revision, cx) { + Some(existing) => { + workspace.activate_item(&existing, true, true, window, cx); + true + } + None => false, + } +} + +fn existing_blame_editor( + workspace: &Workspace, + repository: &Entity, + path: &RepoPath, + revision: Oid, + cx: &App, +) -> Option> { + workspace + .panes() + .iter() + .flat_map(|pane| pane.read(cx).items()) + .find_map(|item| { + let editor = item.downcast::()?; + let blame = editor.read(cx).blame()?; + if blame.read(cx).highlighted_sha() != Some(revision) { + return None; + } + let buffer = editor.read(cx).buffer().read(cx).as_singleton()?; + let buffer_id = buffer.read(cx).remote_id(); + if blame.read(cx).repository(cx, buffer_id).as_ref() != Some(repository) { + return None; + } + let file = buffer.read(cx).file()?; + let blob = (file.as_ref() as &dyn std::any::Any).downcast_ref::()?; + (blob.path == *path).then_some(editor) + }) +} + fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String { match blame_entry.author_offset_date_time() { Ok(timestamp) => { diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index a595af730b..009aa368ad 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -85,14 +85,36 @@ pub struct CommitView { project: Entity, workspace: WeakEntity, remote: Option, + _load_diff_task: Task>, } -struct GitBlob { - path: RepoPath, - worktree_id: WorktreeId, - is_deleted: bool, - is_binary: bool, - display_name: String, +pub(crate) struct GitBlob { + pub(crate) path: RepoPath, + pub(crate) worktree_id: WorktreeId, + pub(crate) is_deleted: bool, + pub(crate) is_binary: bool, + pub(crate) display_name: String, +} + +pub(crate) fn worktree_id_for_repo_path( + repository: &Repository, + project: &Project, + path: &RepoPath, + cx: &App, +) -> Option { + repository + .repo_path_to_project_path(path, cx) + .map(|project_path| project_path.worktree_id) + .or_else(|| { + let (worktree, _) = project.find_worktree(&repository.work_directory_abs_path, cx)?; + Some(worktree.read(cx).id()) + }) + .or_else(|| { + project + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).id()) + }) } struct CommitDiffAddon { @@ -287,15 +309,10 @@ impl CommitView { }); let commit_sha = Arc::::from(commit.sha.as_ref()); - let first_worktree_id = project - .read(cx) - .worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).id()); - let repository_clone = repository.clone(); + let project_clone = project.clone(); - cx.spawn_in(window, async move |this, cx| { + let load_diff_task = cx.spawn_in(window, async move |this, cx| { let mut binary_buffer_ids: HashSet = HashSet::default(); let mut file_statuses: HashMap = HashMap::default(); @@ -318,11 +335,13 @@ impl CommitView { }; let old_text = if is_binary { None } else { raw_old_text }; let worktree_id = repository_clone - .update(cx, |repository, cx| { - repository - .repo_path_to_project_path(&file.path, cx) - .map(|path| path.worktree_id) - .or(first_worktree_id) + .read_with(cx, |repository, cx| { + worktree_id_for_repo_path( + repository, + project_clone.read(cx), + &file.path, + cx, + ) }) .context("project has no worktrees")?; let short_sha = commit_sha @@ -453,8 +472,7 @@ impl CommitView { })?; anyhow::Ok(()) - }) - .detach(); + }); let snapshot = repository.read(cx).snapshot(); let remote_url = snapshot @@ -483,6 +501,7 @@ impl CommitView { project, workspace, remote, + _load_diff_task: load_diff_task, } } @@ -957,7 +976,7 @@ impl language::File for GitBlob { } } -async fn build_buffer( +pub(crate) async fn build_buffer( mut text: String, blob: Arc, language_registry: &Arc, @@ -1235,6 +1254,7 @@ impl Item for CommitView { project: self.project.clone(), workspace: self.workspace.clone(), remote: self.remote.clone(), + _load_diff_task: Task::ready(Ok(())), } }))) } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5338528a55..c4f77c7878 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -39,7 +39,7 @@ use git::{ FileHistoryChangedFileSets, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, - delete_branch_flag, + delete_branch_flag, is_binary_content, }, stash::{GitStash, StashEntry}, status::{ @@ -860,6 +860,7 @@ impl GitStore { client.add_entity_message_handler(Self::handle_update_diff_bases); client.add_entity_request_handler(Self::handle_get_permalink_to_line); client.add_entity_request_handler(Self::handle_blame_buffer); + client.add_entity_request_handler(Self::handle_blame_buffer_at_revision); client.add_entity_message_handler(Self::handle_update_repository); client.add_entity_message_handler(Self::handle_remove_repository); client.add_entity_request_handler(Self::handle_git_clone); @@ -4628,6 +4629,39 @@ impl GitStore { Ok(serialize_blame_buffer_response(blame)) } + async fn handle_blame_buffer_at_revision( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId(envelope.payload.repository_id); + let path = RepoPath::from_proto(&envelope.payload.path)?; + let revision = envelope + .payload + .revision + .parse::() + .with_context(|| format!("invalid revision {:?}", envelope.payload.revision))?; + let (content, blame) = this + .update(&mut cx, |this, cx| { + let repository = this.repositories().get(&repository_id)?; + Some(repository.update(cx, |repository, cx| { + repository.blame_buffer_at_revision(path, revision, cx) + })) + }) + .context("missing repository")? + .await?; + Ok(proto::BlameBufferAtRevisionResponse { + content, + entries: blame + .entries + .into_iter() + .map(serialize_blame_entry) + .collect(), + messages: serialize_commit_messages(blame.messages), + tag_names: serialize_commit_tag_names(blame.tag_names), + }) + } + async fn handle_get_permalink_to_line( this: Entity, envelope: TypedEnvelope, @@ -9810,6 +9844,60 @@ impl Repository { ) } + pub fn blame_buffer_at_revision( + &mut self, + path: RepoPath, + revision: Oid, + cx: &App, + ) -> Task> { + let repository_id = self.snapshot.id; + let rx = self.send_job("blame_buffer_at_revision", None, { + let path = path.clone(); + move |state, _| async move { + match state { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + let content_task = backend + .load_revisions(vec![format!("{revision}:{}", path.as_unix_str())]); + let blame_task = backend.blame_at_revision(path.clone(), revision); + let (mut contents, blame) = futures::try_join!(content_task, blame_task)?; + let content = contents.pop().flatten().with_context(|| { + format!( + "cannot load {:?} at revision {revision}: the file is missing or binary", + path.as_ref() + ) + })?; + anyhow::Ok((content, blame)) + } + RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => { + let response = client + .request(proto::BlameBufferAtRevision { + project_id: project_id.to_proto(), + repository_id: repository_id.0, + path: path.as_unix_str().to_owned(), + revision: revision.to_string(), + }) + .await?; + let blame = blame_from_proto( + response.entries, + response.messages, + response.tag_names, + ); + Ok((response.content, blame)) + } + } + } + }); + cx.spawn(move |_: &mut AsyncApp| async move { + let (content, blame) = rx.await??; + anyhow::ensure!( + !is_binary_content(content.as_bytes()), + "cannot blame binary file {:?} at revision {revision}", + path.as_ref() + ); + Ok((content, blame)) + }) + } + fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task> { let repository_id = self.snapshot.id; let rx = self.send_job("load_blob_content", None, move |state, _| async move { @@ -10373,36 +10461,112 @@ fn serialize_blame_buffer_response(blame: Option) -> proto::B let entries = blame .entries .into_iter() - .map(|entry| proto::BlameEntry { - sha: entry.sha.as_bytes().into(), - start_line: entry.range.start, - end_line: entry.range.end, - original_line_number: entry.original_line_number, - author: entry.author, - author_mail: entry.author_mail, - author_time: entry.author_time, - author_tz: entry.author_tz, - committer: entry.committer_name, - committer_mail: entry.committer_email, - committer_time: entry.committer_time, - committer_tz: entry.committer_tz, - summary: entry.summary, - previous: entry.previous, - filename: entry.filename, - }) + .map(serialize_blame_entry) .collect::>(); + let messages = serialize_commit_messages(blame.messages); + let tag_names = serialize_commit_tag_names(blame.tag_names); - let messages = blame - .messages + proto::BlameBufferResponse { + blame_response: Some(proto::blame_buffer_response::BlameResponse { + entries, + messages, + tag_names, + }), + } +} + +fn serialize_blame_entry(entry: git::blame::BlameEntry) -> proto::BlameEntry { + proto::BlameEntry { + sha: entry.sha.as_bytes().into(), + start_line: entry.range.start, + end_line: entry.range.end, + original_line_number: entry.original_line_number, + author: entry.author, + author_mail: entry.author_mail, + author_time: entry.author_time, + author_tz: entry.author_tz, + committer: entry.committer_name, + committer_mail: entry.committer_email, + committer_time: entry.committer_time, + committer_tz: entry.committer_tz, + summary: entry.summary, + previous: entry.previous, + filename: entry.filename, + } +} + +fn deserialize_blame_entry(entry: proto::BlameEntry) -> Option { + Some(git::blame::BlameEntry { + sha: git::Oid::from_bytes(&entry.sha).ok()?, + range: entry.start_line..entry.end_line, + original_line_number: entry.original_line_number, + committer_name: entry.committer, + committer_time: entry.committer_time, + committer_tz: entry.committer_tz, + committer_email: entry.committer_mail, + author: entry.author, + author_mail: entry.author_mail, + author_time: entry.author_time, + author_tz: entry.author_tz, + summary: entry.summary, + previous: entry.previous, + filename: entry.filename, + }) +} + +fn serialize_commit_messages(messages: HashMap) -> Vec { + messages .into_iter() .map(|(oid, message)| proto::CommitMessage { oid: oid.as_bytes().into(), message, }) - .collect::>(); + .collect() +} - proto::BlameBufferResponse { - blame_response: Some(proto::blame_buffer_response::BlameResponse { entries, messages }), +fn deserialize_commit_message(message: proto::CommitMessage) -> Option<(git::Oid, String)> { + Some((git::Oid::from_bytes(&message.oid).ok()?, message.message)) +} + +fn serialize_commit_tag_names( + tag_names: HashMap>, +) -> Vec { + tag_names + .into_iter() + .map(|(oid, tag_names)| proto::CommitTagNames { + oid: oid.as_bytes().into(), + tag_names, + }) + .collect() +} + +fn deserialize_commit_tag_names( + tag_names: proto::CommitTagNames, +) -> Option<(git::Oid, Vec)> { + Some(( + git::Oid::from_bytes(&tag_names.oid).ok()?, + tag_names.tag_names, + )) +} + +fn blame_from_proto( + entries: Vec, + messages: Vec, + tag_names: Vec, +) -> git::blame::Blame { + git::blame::Blame { + entries: entries + .into_iter() + .filter_map(deserialize_blame_entry) + .collect(), + messages: messages + .into_iter() + .filter_map(deserialize_commit_message) + .collect(), + tag_names: tag_names + .into_iter() + .filter_map(deserialize_commit_tag_names) + .collect(), } } @@ -10410,40 +10574,11 @@ fn deserialize_blame_buffer_response( response: proto::BlameBufferResponse, ) -> Option { let response = response.blame_response?; - let entries = response - .entries - .into_iter() - .filter_map(|entry| { - Some(git::blame::BlameEntry { - sha: git::Oid::from_bytes(&entry.sha).ok()?, - range: entry.start_line..entry.end_line, - original_line_number: entry.original_line_number, - committer_name: entry.committer, - committer_time: entry.committer_time, - committer_tz: entry.committer_tz, - committer_email: entry.committer_mail, - author: entry.author, - author_mail: entry.author_mail, - author_time: entry.author_time, - author_tz: entry.author_tz, - summary: entry.summary, - previous: entry.previous, - filename: entry.filename, - }) - }) - .collect::>(); - - let messages = response - .messages - .into_iter() - .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) - .collect::>(); - - Some(Blame { - entries, - messages, - tag_names: Default::default(), - }) + Some(blame_from_proto( + response.entries, + response.messages, + response.tag_names, + )) } fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource { diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index eb5bb54323..1d0ff18b5d 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -525,6 +525,7 @@ message BlameBufferResponse { repeated CommitMessage messages = 2; reserved 3; reserved 4; + repeated CommitTagNames tag_names = 5; } optional BlameResponse blame_response = 5; @@ -532,6 +533,25 @@ message BlameBufferResponse { reserved 1 to 4; } +message BlameBufferAtRevision { + uint64 project_id = 1; + uint64 repository_id = 2; + string path = 3; + string revision = 4; +} + +message BlameBufferAtRevisionResponse { + string content = 1; + repeated BlameEntry entries = 2; + repeated CommitMessage messages = 3; + repeated CommitTagNames tag_names = 4; +} + +message CommitTagNames { + bytes oid = 1; + repeated string tag_names = 2; +} + message GetDefaultBranch { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 47895a18d2..077602d479 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -505,7 +505,9 @@ message Envelope { RefreshDocumentColors refresh_document_colors = 474; RefreshDocumentLinks refresh_document_links = 475; RefreshFoldingRanges refresh_folding_ranges = 476; - RefreshDocumentSymbols refresh_document_symbols = 477; // current max + RefreshDocumentSymbols refresh_document_symbols = 477; + BlameBufferAtRevision blame_buffer_at_revision = 478; + BlameBufferAtRevisionResponse blame_buffer_at_revision_response = 479; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 37aaa42504..dc735c2aab 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -358,6 +358,8 @@ messages!( (GetTreeDiffResponse, Background), (GetBlobContent, Background), (GetBlobContentResponse, Background), + (BlameBufferAtRevision, Background), + (BlameBufferAtRevisionResponse, Background), (LoadCommitTemplate, Background), (LoadCommitTemplateResponse, Background), (GitClone, Background), @@ -603,6 +605,7 @@ request_messages!( (PullWorkspaceDiagnostics, Ack), (GetDefaultBranch, GetDefaultBranchResponse), (GetBlobContent, GetBlobContentResponse), + (BlameBufferAtRevision, BlameBufferAtRevisionResponse), (LoadCommitTemplate, LoadCommitTemplateResponse), (GetTreeDiff, GetTreeDiffResponse), (GitClone, GitCloneResponse), @@ -827,6 +830,7 @@ entity_messages!( GetDefaultBranch, GetTreeDiff, GetBlobContent, + BlameBufferAtRevision, LoadCommitTemplate, GitClone, GetAgentServerCommand,