diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 28f9cd1575..bb20e88d6b 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -4214,6 +4214,7 @@ 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(), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 83e8f702b4..d5ca045a5d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -6950,10 +6950,12 @@ fn render_blame_entry_popover( let renderer = cx.global::().0.clone(); let blame = blame.read(cx); let repository = blame.repository(cx, buffer)?; + let tag_names = blame.tag_names_for_entry(buffer, &blame_entry); renderer.render_blame_entry_popover( blame_entry, scroll_handle, commit_message, + tag_names, markdown, repository, workspace, @@ -6990,11 +6992,13 @@ fn render_blame_entry( let blame = blame.read(cx); let details = blame.details_for_entry(buffer, &blame_entry); + let tag_names = blame.tag_names_for_entry(buffer, &blame_entry); let repository = blame.repository(cx, buffer)?; renderer.render_blame_entry( &style.text, blame_entry, details, + tag_names, repository, workspace.downgrade(), editor, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index e96a1087bd..6423182d50 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -8,8 +8,8 @@ use git::{ commit::ParsedCommitMessage, }; use gpui::{ - AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task, - TextStyle, WeakEntity, Window, + AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, SharedString, + Subscription, Task, TextStyle, WeakEntity, Window, }; use itertools::Itertools; use language::{Bias, BufferSnapshot, Edit}; @@ -69,6 +69,7 @@ struct GitBlameBuffer { buffer_snapshot: BufferSnapshot, buffer_edits: text::Subscription, commit_details: HashMap, + commit_tag_names: HashMap>, } pub struct GitBlame { @@ -91,6 +92,7 @@ pub trait BlameRenderer { _: &TextStyle, _: BlameEntry, _: Option, + _: Vec, _: Entity, _: WeakEntity, _: Entity, @@ -112,6 +114,7 @@ pub trait BlameRenderer { _: BlameEntry, _: ScrollHandle, _: Option, + _: Vec, _: Entity, _: Entity, _: WeakEntity, @@ -139,6 +142,7 @@ impl BlameRenderer for () { _: &TextStyle, _: BlameEntry, _: Option, + _: Vec, _: Entity, _: WeakEntity, _: Entity, @@ -164,6 +168,7 @@ impl BlameRenderer for () { _: BlameEntry, _: ScrollHandle, _: Option, + _: Vec, _: Entity, _: Entity, _: WeakEntity, @@ -289,6 +294,14 @@ impl GitBlame { .cloned() } + pub fn tag_names_for_entry(&self, buffer: BufferId, entry: &BlameEntry) -> Vec { + self.buffers + .get(&buffer) + .and_then(|buffer| buffer.commit_tag_names.get(&entry.sha)) + .cloned() + .unwrap_or_default() + } + pub fn blame_for_rows<'a>( &'a mut self, rows: &'a [RowInfo], @@ -557,7 +570,11 @@ impl GitBlame { let mut errors = vec![]; for (id, snapshot, buffer_edits, blame, remote_url) in blame { match blame { - Ok(Some(Blame { entries, messages })) => { + Ok(Some(Blame { + entries, + messages, + tag_names, + })) => { let entries = build_blame_entry_sum_tree( entries, snapshot.max_point().row, @@ -575,12 +592,25 @@ impl GitBlame { (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(); res.push(( id, snapshot, buffer_edits, Some(entries), commit_details, + commit_tag_names, )); } Ok(None) => res.push(( @@ -589,6 +619,7 @@ impl GitBlame { buffer_edits, None, Default::default(), + Default::default(), )), Err(e) => errors.push(e), } @@ -603,7 +634,9 @@ impl GitBlame { this.update(cx, |this, cx| { this.buffers.clear(); - for (id, snapshot, buffer_edits, entries, commit_details) in all_results { + for (id, snapshot, buffer_edits, entries, commit_details, commit_tag_names) in + all_results + { let Some(entries) = entries else { continue; }; @@ -614,6 +647,7 @@ impl GitBlame { buffer_snapshot: snapshot, entries, commit_details, + commit_tag_names, }, ); } diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index dd905f521f..6036e112e4 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,9 +1,9 @@ use crate::Oid; -use crate::commit::get_messages; +use crate::commit::{get_messages, get_tag_names}; use crate::repository::{GitBinary, RepoPath}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; -use futures::{AsyncWriteExt, try_join}; +use futures::{AsyncWriteExt, TryFutureExt, try_join}; use serde::{Deserialize, Serialize}; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use std::ops::Range; @@ -17,6 +17,7 @@ use util::command::Stdio; pub struct Blame { pub entries: Vec, pub messages: HashMap, + pub tag_names: HashMap>, } impl Blame { @@ -35,12 +36,26 @@ impl Blame { } let shas = unique_shas.into_iter().collect::>(); - let messages = get_messages(git, &shas) - .await - .context("failed to get commit messages")?; + let (messages, tag_names) = try_join!( + get_messages(git, &shas) + .map_err(|error| error.context("failed to get commit messages")), + async { + match get_tag_names(git, &shas).await { + Ok(tag_names) => Ok(tag_names), + Err(error) => { + log::warn!("failed to get commit tag names: {error:#}"); + Ok(HashMap::default()) + } + } + }, + )?; entries.sort_unstable_by_key(|entry| entry.range.start); - Ok(Self { entries, messages }) + Ok(Self { + entries, + messages, + tag_names, + }) } } diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 62bf8b4a10..e7f367fb22 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -3,9 +3,9 @@ use crate::{ repository::GitBinary, status::StatusCode, }; use anyhow::{Context as _, Result}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use gpui::SharedString; -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; #[derive(Clone, Debug, Default)] pub struct ParsedCommitMessage { @@ -78,6 +78,60 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result>()) } +pub(crate) async fn get_tag_names( + git: &GitBinary, + shas: &[Oid], +) -> Result>> { + if shas.is_empty() { + return Ok(HashMap::default()); + } + + let output = git + .build_command(&[ + "for-each-ref", + "refs/tags", + "--sort=-creatordate", + "--format=%(objectname)%00%(*objectname)%00%(refname:short)", + ]) + .output() + .await + .context("starting git for-each-ref process")?; + anyhow::ensure!( + output.status.success(), + "'git for-each-ref' failed with error {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + Ok(parse_tag_names( + &String::from_utf8_lossy(&output.stdout), + shas, + )) +} + +fn parse_tag_names(output: &str, shas: &[Oid]) -> HashMap> { + let shas = shas.iter().copied().collect::>(); + let mut result = HashMap::>::default(); + + for line in output.lines() { + let mut fields = line.split('\0'); + let object_sha = fields.next(); + let peeled_sha = fields.next().filter(|sha| !sha.is_empty()); + let Some(sha) = peeled_sha + .or(object_sha) + .and_then(|sha| Oid::from_str(sha).ok()) + else { + continue; + }; + let Some(tag_name) = fields.next().filter(|tag_name| !tag_name.is_empty()) else { + continue; + }; + result.entry(sha).or_default().push(tag_name.to_string()); + } + + result.retain(|sha, _| shas.contains(sha)); + result +} + async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result> { const MARKER: &str = ""; let output = git @@ -246,4 +300,27 @@ mod tests { .expect_err("expected malformed metadata to fail"); assert!(error.to_string().contains("new mode")); } + + #[test] + fn test_parse_tag_names_for_lightweight_and_annotated_tags() -> Result<()> { + let tagged_commit = Oid::from_str("1111111111111111111111111111111111111111")?; + let tag_object = Oid::from_str("2222222222222222222222222222222222222222")?; + let other_commit = Oid::from_str("3333333333333333333333333333333333333333")?; + let output = format!( + "{tagged_commit}\0\0v1.0.0\n\ + {tag_object}\0{tagged_commit}\0v1.1.0\n\ + {other_commit}\0\0ignored\n" + ); + + let parsed = parse_tag_names(&output, &[tagged_commit]); + + assert_eq!( + parsed, + HashMap::from_iter([( + tagged_commit, + vec![String::from("v1.0.0"), String::from("v1.1.0")] + )]) + ); + Ok(()) + } } diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index 90d5948de5..ec1386e54d 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -1,5 +1,5 @@ use crate::{ - commit_tooltip::{CommitAvatar, CommitTooltip}, + commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips}, commit_view::CommitView, }; use editor::{BlameRenderer, Editor, hover_markdown_style}; @@ -131,6 +131,7 @@ impl BlameRenderer for GitBlameRenderer { style: &TextStyle, blame_entry: BlameEntry, details: Option, + tag_names: Vec, repository: Entity, workspace: WeakEntity, editor: Entity, @@ -226,6 +227,7 @@ impl BlameRenderer for GitBlameRenderer { CommitTooltip::blame_entry( &blame_entry, details.clone(), + tag_names.clone(), repository.clone(), workspace.clone(), cx, @@ -266,6 +268,7 @@ impl BlameRenderer for GitBlameRenderer { blame: BlameEntry, scroll_handle: ScrollHandle, details: Option, + tag_names: Vec, markdown: Entity, repository: Entity, workspace: WeakEntity, @@ -402,12 +405,16 @@ impl BlameRenderer for GitBlameRenderer { .w_full() .justify_between() .pt_1() + .gap_1() + .flex_wrap() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(absolute_timestamp) .child( h_flex() .gap_1() + .min_w_0() + .children(commit_tag_chips(&tag_names)) .when_some(pull_request, |this, pr| { this.child( Button::new( diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 9d4c896606..2c0e81d0e3 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -14,7 +14,7 @@ use settings::Settings; use std::hash::Hash; use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; -use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container}; +use ui::{Avatar, Chip, CopyButton, Divider, Tooltip, prelude::*, tooltip_container}; use workspace::Workspace; #[derive(Clone, Debug)] @@ -24,6 +24,51 @@ pub struct CommitDetails { pub author_email: SharedString, pub commit_time: OffsetDateTime, pub message: Option, + pub tag_names: Vec, +} + +const MAX_COMMIT_TOOLTIP_TAG_CHIPS: usize = 2; + +pub(crate) fn commit_tag_chips(tag_names: &[SharedString]) -> Option { + if tag_names.is_empty() { + return None; + } + + let (visible_tags, hidden_tags) = + tag_names.split_at(tag_names.len().min(MAX_COMMIT_TOOLTIP_TAG_CHIPS)); + + Some( + h_flex().max_w(relative(0.6)).gap_1().child( + h_flex() + .gap_1() + .min_w_0() + .children( + visible_tags + .iter() + .map(|tag_name| Chip::new(tag_name.clone()).truncate()), + ) + .when(!hidden_tags.is_empty(), |this| { + let hidden_tags = hidden_tags.to_vec(); + this.child(Chip::new(format!("+{}", hidden_tags.len())).tooltip( + Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .children(itertools::Itertools::intersperse_with( + hidden_tags.iter().map(|tag_name| { + Label::new(tag_name.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .into_any_element() + }), + || Divider::horizontal().into_any_element(), + )) + .into_any_element() + }), + )) + }) + .child(Divider::vertical()), + ), + ) } pub struct CommitAvatar<'a> { @@ -172,6 +217,7 @@ impl CommitTooltip { pub fn blame_entry( blame: &BlameEntry, details: Option, + tag_names: Vec, repository: Entity, workspace: WeakEntity, cx: &mut Context, @@ -192,6 +238,7 @@ impl CommitTooltip { .into(), author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(), message: details, + tag_names, }, repository, workspace, @@ -270,6 +317,7 @@ impl Render for CommitTooltip { .message .as_ref() .and_then(|details| details.pull_request.clone()); + let tag_names = self.commit.tag_names.clone(); let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4); @@ -336,12 +384,16 @@ impl Render for CommitTooltip { .w_full() .justify_between() .pt_1() + .gap_1() + .flex_wrap() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(absolute_timestamp) .child( h_flex() .gap_1() + .min_w_0() + .children(commit_tag_chips(&tag_names)) .when_some(pull_request, |this, pr| { this.child( Button::new( diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index c28b764431..343cf2abbd 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1911,6 +1911,12 @@ impl GitGraph { let author_email = commit_data.author_email.clone(); let message = commit_data.message.clone(); let commit_timestamp = commit_data.commit_timestamp; + let tag_names = commit + .data + .tag_names() + .into_iter() + .map(SharedString::from) + .collect::>(); let workspace = self.workspace.clone(); let repository = repository.clone(); this.hoverable_tooltip(move |_window, cx| { @@ -1931,6 +1937,7 @@ impl GitGraph { remote_url.as_deref(), Some(provider_registry), )), + tag_names: tag_names.clone(), }; cx.new(|cx| { CommitTooltip::new( diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b5b1a541b2..53dbe3e74b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -8034,6 +8034,7 @@ impl GitPanelMessageTooltip { remote_url.as_deref(), provider_registry, )), + tag_names: Vec::new(), }; this.update(cx, |this: &mut GitPanelMessageTooltip, cx| { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index f2aa24d51a..8594904a17 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -9649,7 +9649,11 @@ fn deserialize_blame_buffer_response( .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) .collect::>(); - Some(Blame { entries, messages }) + Some(Blame { + entries, + messages, + tag_names: Default::default(), + }) } fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource {