editor: Make blame and inline blame work for multibuffers (#37366)

Release Notes:

- Added blame view and inline blame support for multi buffer editors

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Lukas Wirth
2025-09-03 14:22:35 +00:00
committed by GitHub
co-authored by Kirill Bulatov
parent 92283285ae
commit c1ca7303a8
10 changed files with 429 additions and 325 deletions
+229 -137
View File
@@ -10,16 +10,18 @@ use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
TextStyle, WeakEntity, Window,
};
use language::{Bias, Buffer, BufferSnapshot, Edit};
use itertools::Itertools;
use language::{Bias, BufferSnapshot, Edit};
use markdown::Markdown;
use multi_buffer::RowInfo;
use multi_buffer::{MultiBuffer, RowInfo};
use project::{
Project, ProjectItem,
Project, ProjectItem as _,
git_store::{GitStoreEvent, Repository, RepositoryEvent},
};
use smallvec::SmallVec;
use std::{sync::Arc, time::Duration};
use sum_tree::SumTree;
use text::BufferId;
use workspace::Workspace;
#[derive(Clone, Debug, Default)]
@@ -63,16 +65,19 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
}
}
pub struct GitBlame {
project: Entity<Project>,
buffer: Entity<Buffer>,
struct GitBlameBuffer {
entries: SumTree<GitBlameEntry>,
commit_details: HashMap<Oid, ParsedCommitMessage>,
buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription,
commit_details: HashMap<Oid, ParsedCommitMessage>,
}
pub struct GitBlame {
project: Entity<Project>,
multi_buffer: WeakEntity<MultiBuffer>,
buffers: HashMap<BufferId, GitBlameBuffer>,
task: Task<Result<()>>,
focused: bool,
generated: bool,
changed_while_blurred: bool,
user_triggered: bool,
regenerate_on_edit_task: Task<Result<()>>,
@@ -184,44 +189,44 @@ impl gpui::Global for GlobalBlameRenderer {}
impl GitBlame {
pub fn new(
buffer: Entity<Buffer>,
multi_buffer: Entity<MultiBuffer>,
project: Entity<Project>,
user_triggered: bool,
focused: bool,
cx: &mut Context<Self>,
) -> Self {
let entries = SumTree::from_item(
GitBlameEntry {
rows: buffer.read(cx).max_point().row + 1,
blame: None,
let multi_buffer_subscription = cx.subscribe(
&multi_buffer,
|git_blame, multi_buffer, event, cx| match event {
multi_buffer::Event::DirtyChanged => {
if !multi_buffer.read(cx).is_dirty(cx) {
git_blame.generate(cx);
}
}
multi_buffer::Event::ExcerptsAdded { .. }
| multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx),
_ => {}
},
&(),
);
let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event {
language::BufferEvent::DirtyChanged => {
if !buffer.read(cx).is_dirty() {
this.generate(cx);
}
}
language::BufferEvent::Edited => {
this.regenerate_on_edit(cx);
}
_ => {}
});
let project_subscription = cx.subscribe(&project, {
let buffer = buffer.clone();
let multi_buffer = multi_buffer.downgrade();
move |this, _, event, cx| {
move |git_blame, _, event, cx| {
if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
let project_entry_id = buffer.read(cx).entry_id(cx);
let Some(multi_buffer) = multi_buffer.upgrade() else {
return;
};
let project_entry_id = multi_buffer
.read(cx)
.as_singleton()
.and_then(|it| it.read(cx).entry_id(cx));
if updated
.iter()
.any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
{
log::debug!("Updated buffers. Regenerating blame data...",);
this.generate(cx);
git_blame.generate(cx);
}
}
}
@@ -239,24 +244,17 @@ impl GitBlame {
_ => {}
});
let buffer_snapshot = buffer.read(cx).snapshot();
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let mut this = Self {
project,
buffer,
buffer_snapshot,
entries,
buffer_edits,
multi_buffer: multi_buffer.downgrade(),
buffers: HashMap::default(),
user_triggered,
focused,
changed_while_blurred: false,
commit_details: HashMap::default(),
task: Task::ready(Ok(())),
generated: false,
regenerate_on_edit_task: Task::ready(Ok(())),
_regenerate_subscriptions: vec![
buffer_subscriptions,
multi_buffer_subscription,
project_subscription,
git_store_subscription,
],
@@ -265,56 +263,63 @@ impl GitBlame {
this
}
pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
self.project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
.repository_and_path_for_buffer_id(id, cx)
.map(|(repo, _)| repo)
}
pub fn has_generated_entries(&self) -> bool {
self.generated
!self.buffers.is_empty()
}
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
self.commit_details.get(&entry.sha).cloned()
pub fn details_for_entry(
&self,
buffer: BufferId,
entry: &BlameEntry,
) -> Option<ParsedCommitMessage> {
self.buffers
.get(&buffer)?
.commit_details
.get(&entry.sha)
.cloned()
}
pub fn blame_for_rows<'a>(
&'a mut self,
rows: &'a [RowInfo],
cx: &App,
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
self.sync(cx);
let buffer_id = self.buffer_snapshot.remote_id();
let mut cursor = self.entries.cursor::<u32>(&());
cx: &'a mut App,
) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
rows.iter().map(move |info| {
let row = info
.buffer_row
.filter(|_| info.buffer_id == Some(buffer_id))?;
cursor.seek_forward(&row, Bias::Right);
cursor.item()?.blame.clone()
let buffer_id = info.buffer_id?;
self.sync(cx, buffer_id);
let buffer_row = info.buffer_row?;
let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(&());
cursor.seek_forward(&buffer_row, Bias::Right);
Some((buffer_id, cursor.item()?.blame.clone()?))
})
}
pub fn max_author_length(&mut self, cx: &App) -> usize {
self.sync(cx);
pub fn max_author_length(&mut self, cx: &mut App) -> usize {
let mut max_author_length = 0;
self.sync_all(cx);
for entry in self.entries.iter() {
let author_len = entry
.blame
.as_ref()
.and_then(|entry| entry.author.as_ref())
.map(|author| author.len());
if let Some(author_len) = author_len
&& author_len > max_author_length
{
max_author_length = author_len;
for buffer in self.buffers.values() {
for entry in buffer.entries.iter() {
let author_len = entry
.blame
.as_ref()
.and_then(|entry| entry.author.as_ref())
.map(|author| author.len());
if let Some(author_len) = author_len
&& author_len > max_author_length
{
max_author_length = author_len;
}
}
}
@@ -336,22 +341,48 @@ impl GitBlame {
}
}
fn sync(&mut self, cx: &App) {
let edits = self.buffer_edits.consume();
let new_snapshot = self.buffer.read(cx).snapshot();
fn sync_all(&mut self, cx: &mut App) {
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
return;
};
multi_buffer
.read(cx)
.excerpt_buffer_ids()
.into_iter()
.for_each(|id| self.sync(cx, id));
}
fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
return;
};
let Some(buffer) = self
.multi_buffer
.upgrade()
.and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
else {
return;
};
let edits = blame_buffer.buffer_edits.consume();
let new_snapshot = buffer.read(cx).snapshot();
let mut row_edits = edits
.into_iter()
.map(|edit| {
let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start)
..self.buffer_snapshot.offset_to_point(edit.old.end);
let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
let new_point_range = new_snapshot.offset_to_point(edit.new.start)
..new_snapshot.offset_to_point(edit.new.end);
if old_point_range.start.column
== self.buffer_snapshot.line_len(old_point_range.start.row)
== blame_buffer
.buffer_snapshot
.line_len(old_point_range.start.row)
&& (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
|| self.buffer_snapshot.line_len(old_point_range.end.row) == 0)
|| blame_buffer
.buffer_snapshot
.line_len(old_point_range.end.row)
== 0)
{
Edit {
old: old_point_range.start.row + 1..old_point_range.end.row + 1,
@@ -375,7 +406,7 @@ impl GitBlame {
.peekable();
let mut new_entries = SumTree::default();
let mut cursor = self.entries.cursor::<u32>(&());
let mut cursor = blame_buffer.entries.cursor::<u32>(&());
while let Some(mut edit) = row_edits.next() {
while let Some(next_edit) = row_edits.peek() {
@@ -433,17 +464,28 @@ impl GitBlame {
new_entries.append(cursor.suffix(), &());
drop(cursor);
self.buffer_snapshot = new_snapshot;
self.entries = new_entries;
blame_buffer.buffer_snapshot = new_snapshot;
blame_buffer.entries = new_entries;
}
#[cfg(test)]
fn check_invariants(&mut self, cx: &mut Context<Self>) {
self.sync(cx);
assert_eq!(
self.entries.summary().rows,
self.buffer.read(cx).max_point().row + 1
);
self.sync_all(cx);
for (&id, buffer) in &self.buffers {
assert_eq!(
buffer.entries.summary().rows,
self.multi_buffer
.upgrade()
.unwrap()
.read(cx)
.buffer(id)
.unwrap()
.read(cx)
.max_point()
.row
+ 1
);
}
}
fn generate(&mut self, cx: &mut Context<Self>) {
@@ -451,62 +493,105 @@ impl GitBlame {
self.changed_while_blurred = true;
return;
}
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
let snapshot = self.buffer.read(cx).snapshot();
let blame = self.project.update(cx, |project, cx| {
project.blame_buffer(&self.buffer, None, cx)
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
return Vec::new();
};
multi_buffer
.read(cx)
.all_buffer_ids()
.into_iter()
.filter_map(|id| {
let buffer = multi_buffer.read(cx).buffer(id)?;
let snapshot = buffer.read(cx).snapshot();
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let blame_buffer = project.blame_buffer(&buffer, None, cx);
Some((id, snapshot, buffer_edits, blame_buffer))
})
.collect::<Vec<_>>()
});
let provider_registry = GitHostingProviderRegistry::default_global(cx);
self.task = cx.spawn(async move |this, cx| {
let result = cx
let (result, errors) = cx
.background_spawn({
let snapshot = snapshot.clone();
async move {
let Some(Blame {
entries,
messages,
remote_url,
}) = blame.await?
else {
return Ok(None);
};
let mut res = vec![];
let mut errors = vec![];
for (id, snapshot, buffer_edits, blame) in blame {
match blame.await {
Ok(Some(Blame {
entries,
messages,
remote_url,
})) => {
let entries = build_blame_entry_sum_tree(
entries,
snapshot.max_point().row,
);
let commit_details = parse_commit_messages(
messages,
remote_url,
provider_registry.clone(),
)
.await;
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
let commit_details =
parse_commit_messages(messages, remote_url, provider_registry).await;
anyhow::Ok(Some((entries, commit_details)))
res.push((
id,
snapshot,
buffer_edits,
Some(entries),
commit_details,
));
}
Ok(None) => {
res.push((id, snapshot, buffer_edits, None, Default::default()))
}
Err(e) => errors.push(e),
}
}
(res, errors)
}
})
.await;
this.update(cx, |this, cx| match result {
Ok(None) => {
// Nothing to do, e.g. no repository found
this.update(cx, |this, cx| {
this.buffers.clear();
for (id, snapshot, buffer_edits, entries, commit_details) in result {
let Some(entries) = entries else {
continue;
};
this.buffers.insert(
id,
GitBlameBuffer {
buffer_edits,
buffer_snapshot: snapshot,
entries,
commit_details,
},
);
}
Ok(Some((entries, commit_details))) => {
this.buffer_edits = buffer_edits;
this.buffer_snapshot = snapshot;
this.entries = entries;
this.commit_details = commit_details;
this.generated = true;
cx.notify();
cx.notify();
if !errors.is_empty() {
this.project.update(cx, |_, cx| {
if this.user_triggered {
log::error!("failed to get git blame data: {errors:?}");
let notification = errors
.into_iter()
.format_with(",", |e, f| f(&format_args!("{:#}", e)))
.to_string();
cx.emit(project::Event::Toast {
notification_id: "git-blame".into(),
message: notification,
});
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
log::debug!("failed to get git blame data: {errors:?}");
}
})
}
Err(error) => this.project.update(cx, |_, cx| {
if this.user_triggered {
log::error!("failed to get git blame data: {error:?}");
let notification = format!("{:#}", error).trim().to_string();
cx.emit(project::Event::Toast {
notification_id: "git-blame".into(),
message: notification,
});
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
log::debug!("failed to get git blame data: {error:?}");
}
}),
})
});
}
@@ -520,7 +605,7 @@ impl GitBlame {
this.update(cx, |this, cx| {
this.generate(cx);
})
})
});
}
}
@@ -659,6 +744,9 @@ mod tests {
)
.collect::<Vec<_>>(),
expected
.into_iter()
.map(|it| Some((buffer_id, it?)))
.collect::<Vec<_>>()
);
}
@@ -705,6 +793,7 @@ mod tests {
})
.await
.unwrap();
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
@@ -785,6 +874,7 @@ mod tests {
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -806,14 +896,14 @@ mod tests {
)
.collect::<Vec<_>>(),
vec![
Some(blame_entry("1b1b1b", 0..1)),
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some((buffer_id, blame_entry("1b1b1b", 0..1))),
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
None,
None,
Some(blame_entry("3a3a3a", 5..6)),
Some(blame_entry("0d0d0d", 6..7)),
Some(blame_entry("3a3a3a", 7..8)),
Some((buffer_id, blame_entry("3a3a3a", 5..6))),
Some((buffer_id, blame_entry("0d0d0d", 6..7))),
Some((buffer_id, blame_entry("3a3a3a", 7..8))),
]
);
// Subset of lines
@@ -831,8 +921,8 @@ mod tests {
)
.collect::<Vec<_>>(),
vec![
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
None
]
);
@@ -852,7 +942,7 @@ mod tests {
cx
)
.collect::<Vec<_>>(),
vec![Some(blame_entry("0d0d0d", 1..2)), None, None]
vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
);
});
}
@@ -895,6 +985,7 @@ mod tests {
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -1061,8 +1152,9 @@ mod tests {
})
.await
.unwrap();
let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
cx.executor().run_until_parked();
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));