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
This commit is contained in:
1 parent
232eaba704
commit
c1dab7a0d3
17 files changed
+1251
-140
No files matched your search
@@ -372,6 +372,7 @@ impl Server {
|
||||
.add_request_handler(forward_read_only_project_request::<proto::DownloadFileByPath>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::BlameBufferAtRevision>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
|
||||
|
||||
@@ -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::<git::Oid>()
|
||||
.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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+126
-7
@@ -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<GitBlame>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) -> Option<()> {
|
||||
let blame = self.blame.as_ref()?;
|
||||
let (blame_entry, repo) = self.blame_entry_at_cursor(window, cx)?;
|
||||
let renderer = cx.global::<GlobalBlameRenderer>().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<Self>,
|
||||
) -> Option<(BlameEntry, Entity<Repository>)> {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Repository>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
return;
|
||||
};
|
||||
let renderer = cx.global::<GlobalBlameRenderer>().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<Self>) {
|
||||
struct BlameRevisionToast;
|
||||
if let Some(workspace) = self.workspace() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<BlameRevisionToast>(),
|
||||
message.to_owned(),
|
||||
),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn has_blame_entries(&self, cx: &App) -> bool {
|
||||
|
||||
+300
-27
@@ -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<Oid, Vec<SharedString>>,
|
||||
}
|
||||
|
||||
/// 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<Repository>,
|
||||
/// The revision being viewed; gutter entries introduced by this commit
|
||||
/// are highlighted.
|
||||
highlighted_sha: Option<Oid>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Entity<Project>,
|
||||
multi_buffer: WeakEntity<MultiBuffer>,
|
||||
@@ -82,6 +103,7 @@ pub struct GitBlame {
|
||||
user_triggered: bool,
|
||||
regenerate_on_edit_task: Task<Result<()>>,
|
||||
_regenerate_subscriptions: Vec<Subscription>,
|
||||
kind: GitBlameKind,
|
||||
}
|
||||
|
||||
pub trait BlameRenderer {
|
||||
@@ -134,6 +156,17 @@ pub trait BlameRenderer {
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
);
|
||||
|
||||
fn open_blame_revision(
|
||||
&self,
|
||||
_: RepoPath,
|
||||
_: Oid,
|
||||
_: Entity<Repository>,
|
||||
_: WeakEntity<Workspace>,
|
||||
_: &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<MultiBuffer>,
|
||||
project: Entity<Project>,
|
||||
repository: Entity<Repository>,
|
||||
blames: impl IntoIterator<Item = (Entity<Buffer>, Blame)>,
|
||||
highlighted_sha: Option<Oid>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Oid> {
|
||||
match &self.kind {
|
||||
GitBlameKind::Live => None,
|
||||
GitBlameKind::Static {
|
||||
highlighted_sha, ..
|
||||
} => *highlighted_sha,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
|
||||
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<Self>) {
|
||||
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<Oid, String>,
|
||||
tag_names: HashMap<Oid, Vec<String>>,
|
||||
remote_url: Option<&str>,
|
||||
provider_registry: &Arc<GitHostingProviderRegistry>,
|
||||
) -> (
|
||||
HashMap<Oid, ParsedCommitMessage>,
|
||||
HashMap<Oid, Vec<SharedString>>,
|
||||
) {
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(commit_details, commit_tag_names)
|
||||
}
|
||||
|
||||
fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
|
||||
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::<Oid>().unwrap();
|
||||
let blame_one = Blame {
|
||||
entries: vec![blame_entry("1b1b1b", 0..1), blame_entry("0d0d0d", 1..2)],
|
||||
messages: [
|
||||
("1b1b1b".parse::<Oid>().unwrap(), "commit one".to_string()),
|
||||
("0d0d0d".parse::<Oid>().unwrap(), "commit two".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
tag_names: [("0d0d0d".parse::<Oid>().unwrap(), vec!["v1.0.0".to_string()])]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
};
|
||||
let blame_two = Blame {
|
||||
entries: vec![blame_entry("0d0d0d", 0..2)],
|
||||
messages: [("0d0d0d".parse::<Oid>().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::<SharedString>::new()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_blame_for_rows_with_edits(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -66,6 +66,7 @@ pub struct FakeGitRepositoryState {
|
||||
pub merge_base_contents: HashMap<RepoPath, Oid>,
|
||||
pub oids: HashMap<Oid, String>,
|
||||
pub blames: HashMap<RepoPath, Blame>,
|
||||
pub blames_at_revision: HashMap<(RepoPath, Oid), Blame>,
|
||||
pub current_branch_name: Option<String>,
|
||||
pub branches: HashSet<String>,
|
||||
/// 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<git::blame::Blame>> {
|
||||
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<RepoPath>,
|
||||
|
||||
+141
-16
@@ -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<BlameEntry>,
|
||||
pub messages: HashMap<Oid, String>,
|
||||
@@ -27,8 +27,20 @@ impl Blame {
|
||||
content: &Rope,
|
||||
line_ending: LineEnding,
|
||||
) -> Result<Self> {
|
||||
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<Self> {
|
||||
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<BlameEntry>) -> Result<Self> {
|
||||
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<Vec<BlameEntry>> {
|
||||
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<time::OffsetDateTime> {
|
||||
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<Vec<BlameEntry>> {
|
||||
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 <joe.schmoe@example.com>
|
||||
author-time 1709741400
|
||||
author-tz +0100
|
||||
committer Joe Schmoe
|
||||
committer-mail <joe.schmoe@example.com>
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -889,6 +889,12 @@ pub trait GitRepository: Send + Sync {
|
||||
line_ending: LineEnding,
|
||||
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
|
||||
|
||||
fn blame_at_revision(
|
||||
&self,
|
||||
path: RepoPath,
|
||||
revision: Oid,
|
||||
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
|
||||
|
||||
/// 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/<name>`).
|
||||
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<crate::blame::Blame>> {
|
||||
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<String>> {
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
vec![(first_sha.clone(), 0..1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
|
||||
disable_git_global_config();
|
||||
|
||||
@@ -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("<no name>");
|
||||
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<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
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<Editor>,
|
||||
repository: Entity<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
position: gpui::Point<Pixels>,
|
||||
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<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
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<dyn language::File>;
|
||||
|
||||
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<Repository>,
|
||||
path: &RepoPath,
|
||||
revision: Oid,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> 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<Repository>,
|
||||
path: &RepoPath,
|
||||
revision: Oid,
|
||||
cx: &App,
|
||||
) -> Option<Entity<Editor>> {
|
||||
workspace
|
||||
.panes()
|
||||
.iter()
|
||||
.flat_map(|pane| pane.read(cx).items())
|
||||
.find_map(|item| {
|
||||
let editor = item.downcast::<Editor>()?;
|
||||
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::<GitBlob>()?;
|
||||
(blob.path == *path).then_some(editor)
|
||||
})
|
||||
}
|
||||
|
||||
fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
|
||||
match blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => {
|
||||
|
||||
@@ -85,14 +85,36 @@ pub struct CommitView {
|
||||
project: Entity<Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
remote: Option<GitRemote>,
|
||||
_load_diff_task: Task<Result<()>>,
|
||||
}
|
||||
|
||||
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<WorktreeId> {
|
||||
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::<str>::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<language::BufferId> = HashSet::default();
|
||||
let mut file_statuses: HashMap<language::BufferId, FileStatus> = 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<dyn File>,
|
||||
language_registry: &Arc<language::LanguageRegistry>,
|
||||
@@ -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(())),
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
+192
-57
@@ -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<Self>,
|
||||
envelope: TypedEnvelope<proto::BlameBufferAtRevision>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::BlameBufferAtRevisionResponse> {
|
||||
let repository_id = RepositoryId(envelope.payload.repository_id);
|
||||
let path = RepoPath::from_proto(&envelope.payload.path)?;
|
||||
let revision = envelope
|
||||
.payload
|
||||
.revision
|
||||
.parse::<git::Oid>()
|
||||
.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<Self>,
|
||||
envelope: TypedEnvelope<proto::GetPermalinkToLine>,
|
||||
@@ -9810,6 +9844,60 @@ impl Repository {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn blame_buffer_at_revision(
|
||||
&mut self,
|
||||
path: RepoPath,
|
||||
revision: Oid,
|
||||
cx: &App,
|
||||
) -> Task<Result<(String, git::blame::Blame)>> {
|
||||
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<Result<String>> {
|
||||
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<git::blame::Blame>) -> 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::<Vec<_>>();
|
||||
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<git::blame::BlameEntry> {
|
||||
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<git::Oid, String>) -> Vec<proto::CommitMessage> {
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|(oid, message)| proto::CommitMessage {
|
||||
oid: oid.as_bytes().into(),
|
||||
message,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.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<git::Oid, Vec<String>>,
|
||||
) -> Vec<proto::CommitTagNames> {
|
||||
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<String>)> {
|
||||
Some((
|
||||
git::Oid::from_bytes(&tag_names.oid).ok()?,
|
||||
tag_names.tag_names,
|
||||
))
|
||||
}
|
||||
|
||||
fn blame_from_proto(
|
||||
entries: Vec<proto::BlameEntry>,
|
||||
messages: Vec<proto::CommitMessage>,
|
||||
tag_names: Vec<proto::CommitTagNames>,
|
||||
) -> 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<git::blame::Blame> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
let messages = response
|
||||
.messages
|
||||
.into_iter()
|
||||
.filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user