Inline git blame (#10398)

This adds so-called "inline git blame" to the editor that, when turned
on, shows `git blame` information about the current line inline:


![screenshot-2024-04-15-11 29
35@2x](https://github.com/zed-industries/zed/assets/1185253/21cef7be-3283-4556-a9f0-cc349c4e1d75)


When the inline information is hovered, a new tooltip appears that
contains more information on the current commit:


![screenshot-2024-04-15-11 28
24@2x](https://github.com/zed-industries/zed/assets/1185253/ee128460-f6a2-48c2-a70d-e03ff90a737f)

The commit message in this tooltip is rendered as Markdown, is
scrollable and clickable.

The tooltip is now also the tooltip used in the gutter:

![screenshot-2024-04-15-11 28
51@2x](https://github.com/zed-industries/zed/assets/1185253/42be3d63-91d0-4936-8183-570e024beabe)


## Settings

1. The inline git blame information can be turned on and off via
settings:
```json
{
  "git": {
    "inline_blame": {
      "enabled": true
    }
  }
}
```
2. Optionally, a delay can be configured. When a delay is set, the
inline blame information will only show up `x milliseconds` after a
cursor movement:
```json
{
  "git": {
    "inline_blame": {
      "enabled": true,
      "delay_ms": 600
    }
  }
}
```
3. It can also be turned on/off for the current buffer with `editor:
toggle git blame inline`.

## To be done in follow-up PRs

- [ ] Add link to pull request in tooltip
- [ ] Add avatars of users if possible

## Release notes

Release Notes:

- Added inline `git blame` information the editor. It can be turned on
in the settings with `{"git": { "inline_blame": "on" } }` for every
buffer or, temporarily for the current buffer, with `editor: toggle git
blame inline`.
This commit is contained in:
Thorsten Ball 2024-04-15 14:21:52 +02:00 committed by GitHub
parent 573ba83034
commit faebce8cd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 655 additions and 237 deletions

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use anyhow::Result;
use collections::HashMap;
use git::{
@ -5,7 +7,7 @@ use git::{
Oid,
};
use gpui::{Model, ModelContext, Subscription, Task};
use language::{Bias, Buffer, BufferSnapshot, Edit};
use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
use project::{Item, Project};
use smallvec::SmallVec;
use sum_tree::SumTree;
@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
}
}
#[derive(Clone, Debug)]
pub struct CommitDetails {
pub message: String,
pub parsed_message: ParsedMarkdown,
pub permalink: Option<Url>,
}
pub struct GitBlame {
project: Model<Project>,
buffer: Model<Buffer>,
entries: SumTree<GitBlameEntry>,
permalinks: HashMap<Oid, Url>,
messages: HashMap<Oid, String>,
commit_details: HashMap<Oid, CommitDetails>,
buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription,
task: Task<Result<()>>,
generated: bool,
user_triggered: bool,
_refresh_subscription: Subscription,
}
@ -61,6 +70,7 @@ impl GitBlame {
pub fn new(
buffer: Model<Buffer>,
project: Model<Project>,
user_triggered: bool,
cx: &mut ModelContext<Self>,
) -> Self {
let entries = SumTree::from_item(
@ -102,8 +112,8 @@ impl GitBlame {
buffer_snapshot,
entries,
buffer_edits,
permalinks: HashMap::default(),
messages: HashMap::default(),
user_triggered,
commit_details: HashMap::default(),
task: Task::ready(Ok(())),
generated: false,
_refresh_subscription: refresh_subscription,
@ -116,12 +126,8 @@ impl GitBlame {
self.generated
}
pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {
self.permalinks.get(&entry.sha).cloned()
}
pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
self.messages.get(&entry.sha).cloned()
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
self.commit_details.get(&entry.sha).cloned()
}
pub fn blame_for_rows<'a>(
@ -254,6 +260,7 @@ impl GitBlame {
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
let snapshot = self.buffer.read(cx).snapshot();
let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
let languages = self.project.read(cx).languages().clone();
self.task = cx.spawn(|this, mut cx| async move {
let result = cx
@ -267,65 +274,121 @@ impl GitBlame {
messages,
} = blame.await?;
let mut current_row = 0;
let mut entries = SumTree::from_iter(
entries.into_iter().flat_map(|entry| {
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
let commit_details =
parse_commit_messages(messages, &permalinks, &languages).await;
if entry.range.start > current_row {
let skipped_rows = entry.range.start - current_row;
entries.push(GitBlameEntry {
rows: skipped_rows,
blame: None,
});
}
entries.push(GitBlameEntry {
rows: entry.range.len() as u32,
blame: Some(entry.clone()),
});
current_row = entry.range.end;
entries
}),
&(),
);
let max_row = snapshot.max_point().row;
if max_row >= current_row {
entries.push(
GitBlameEntry {
rows: (max_row + 1) - current_row,
blame: None,
},
&(),
);
}
anyhow::Ok((entries, permalinks, messages))
anyhow::Ok((entries, commit_details))
}
})
.await;
this.update(&mut cx, |this, cx| match result {
Ok((entries, permalinks, messages)) => {
Ok((entries, commit_details)) => {
this.buffer_edits = buffer_edits;
this.buffer_snapshot = snapshot;
this.entries = entries;
this.permalinks = permalinks;
this.messages = messages;
this.commit_details = commit_details;
this.generated = true;
cx.notify();
}
Err(error) => this.project.update(cx, |_, cx| {
log::error!("failed to get git blame data: {error:?}");
let notification = format!("{:#}", error).trim().to_string();
cx.emit(project::Event::Notification(notification));
if this.user_triggered {
log::error!("failed to get git blame data: {error:?}");
let notification = format!("{:#}", error).trim().to_string();
cx.emit(project::Event::Notification(notification));
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
// Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
// and opens a non-git file.
if error.downcast_ref::<project::NoRepositoryError>().is_none() {
log::error!("failed to get git blame data: {error:?}");
}
}
}),
})
});
}
}
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(
entries.into_iter().flat_map(|entry| {
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
if entry.range.start > current_row {
let skipped_rows = entry.range.start - current_row;
entries.push(GitBlameEntry {
rows: skipped_rows,
blame: None,
});
}
entries.push(GitBlameEntry {
rows: entry.range.len() as u32,
blame: Some(entry.clone()),
});
current_row = entry.range.end;
entries
}),
&(),
);
if max_row >= current_row {
entries.push(
GitBlameEntry {
rows: (max_row + 1) - current_row,
blame: None,
},
&(),
);
}
entries
}
async fn parse_commit_messages(
messages: impl IntoIterator<Item = (Oid, String)>,
permalinks: &HashMap<Oid, Url>,
languages: &Arc<LanguageRegistry>,
) -> HashMap<Oid, CommitDetails> {
let mut commit_details = HashMap::default();
for (oid, message) in messages {
let parsed_message = parse_markdown(&message, &languages).await;
let permalink = permalinks.get(&oid).cloned();
commit_details.insert(
oid,
CommitDetails {
message,
parsed_message,
permalink,
},
);
}
commit_details
}
async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
let mut parsed_message = ParsedMarkdown::default();
markdown::parse_markdown_block(
text,
language_registry,
None,
&mut parsed_message.text,
&mut parsed_message.highlights,
&mut parsed_message.region_ranges,
&mut parsed_message.regions,
)
.await;
parsed_message
}
#[cfg(test)]
mod tests {
use super::*;
@ -394,7 +457,7 @@ mod tests {
.await
.unwrap();
let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), cx));
let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
let event = project.next_event(cx).await;
assert_eq!(
@ -463,7 +526,7 @@ mod tests {
.await
.unwrap();
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
cx.executor().run_until_parked();
@ -543,7 +606,7 @@ mod tests {
.await
.unwrap();
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
cx.executor().run_until_parked();
@ -692,7 +755,7 @@ mod tests {
.await
.unwrap();
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
cx.executor().run_until_parked();
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));