From b220d078bf447bd977a3f41037c02f8ff4bcf871 Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Tue, 14 Jul 2026 04:21:38 +0300 Subject: [PATCH] Show tag names in git blame tooltips (#60757) Fetch tag names for commits included in blame data and pass them through the blame rendering path. Render tag names as chips in the blame hover tooltip and expanded blame popover. Tag lookup is best-effort, so blame still renders if tag lookup fails. > Note: Tag names are currently only fetched for local repositories. For remote projects (SSH) and collab sessions, blame data travels over the `BlameBufferResponse` proto message, which doesn't carry tag names yet. ## Testing - `cargo test -p git test_parse_tag_names_for_lightweight_and_annotated_tags` - `cargo check -p git_ui` - `cargo check -p editor` - `cargo check -p project` Manual testing: - Opened `git: blame` on a file with a tagged commit. - Verified tag chips are shown in the blame hover tooltip and expanded popover. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon] (https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Before: before After: after --- Release Notes: - Git: Made tags visible in Git blame tooltips. --------- Co-authored-by: Danilo Leal --- .../collab/tests/integration/editor_tests.rs | 1 + crates/editor/src/element.rs | 4 + crates/editor/src/git/blame.rs | 42 +++++++++- crates/git/src/blame.rs | 27 +++++-- crates/git/src/commit.rs | 81 ++++++++++++++++++- crates/git_ui/src/blame_ui.rs | 9 ++- crates/git_ui/src/commit_tooltip.rs | 54 ++++++++++++- crates/git_ui/src/git_graph.rs | 7 ++ crates/git_ui/src/git_panel.rs | 1 + crates/project/src/git_store.rs | 6 +- 10 files changed, 217 insertions(+), 15 deletions(-) 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 {