editor: Move blame popover from `hover_tooltip` to editor prepaint (#29320)

Smit Barmase created

WIP!

In light of having more control over blame popover from editor.

This fixes: https://github.com/zed-industries/zed/issues/28645,
https://github.com/zed-industries/zed/issues/26304

- [x] Initial rendering
- [x] Handle smart positioning (edge detection, etc)
- [x] Delayed hovering, release, etc
- [x] Test blame message selection
- [x] Fix tagged issues

Release Notes:

- Git inline blame popover now dismisses when the cursor is moved, the
editor is scrolled, or the command palette is opened.

Change summary

crates/editor/src/editor.rs         | 118 ++++++++++++-
crates/editor/src/element.rs        | 176 +++++++++++++++++---
crates/editor/src/git/blame.rs      |  27 ++
crates/editor/src/items.rs          |   1 
crates/git_ui/src/blame_ui.rs       | 252 +++++++++++++++++++++++++++---
crates/git_ui/src/commit_tooltip.rs |  10 
6 files changed, 501 insertions(+), 83 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -78,18 +78,19 @@ use futures::{
 };
 use fuzzy::StringMatchCandidate;
 
-use ::git::Restore;
+use ::git::blame::BlameEntry;
+use ::git::{Restore, blame::ParsedCommitMessage};
 use code_context_menus::{
     AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
     CompletionsMenu, ContextMenuOrigin,
 };
 use git::blame::{GitBlame, GlobalBlameRenderer};
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, AnyWeakEntity, App, AppContext,
-    AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry,
-    ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter,
-    FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla,
-    KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render,
+    Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext,
+    AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context,
+    DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
+    Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers,
+    MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, ScrollHandle,
     SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, TextStyleRefinement,
     UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
     div, impl_actions, point, prelude::*, pulsating_between, px, relative, size,
@@ -117,6 +118,7 @@ use language::{
 };
 use language::{BufferRow, CharClassifier, Runnable, RunnableRange, point_to_lsp};
 use linked_editing_ranges::refresh_linked_ranges;
+use markdown::Markdown;
 use mouse_context_menu::MouseContextMenu;
 use persistence::DB;
 use project::{
@@ -798,6 +800,21 @@ impl ChangeList {
     }
 }
 
+#[derive(Clone)]
+struct InlineBlamePopoverState {
+    scroll_handle: ScrollHandle,
+    commit_message: Option<ParsedCommitMessage>,
+    markdown: Entity<Markdown>,
+}
+
+struct InlineBlamePopover {
+    position: gpui::Point<Pixels>,
+    show_task: Option<Task<()>>,
+    hide_task: Option<Task<()>>,
+    popover_bounds: Option<Bounds<Pixels>>,
+    popover_state: InlineBlamePopoverState,
+}
+
 /// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`].
 ///
 /// See the [module level documentation](self) for more information.
@@ -866,6 +883,7 @@ pub struct Editor {
     context_menu_options: Option<ContextMenuOptions>,
     mouse_context_menu: Option<MouseContextMenu>,
     completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
+    inline_blame_popover: Option<InlineBlamePopover>,
     signature_help_state: SignatureHelpState,
     auto_signature_help: Option<bool>,
     find_all_references_task_sources: Vec<Anchor>,
@@ -922,7 +940,6 @@ pub struct Editor {
     show_git_blame_gutter: bool,
     show_git_blame_inline: bool,
     show_git_blame_inline_delay_task: Option<Task<()>>,
-    pub git_blame_inline_tooltip: Option<AnyWeakEntity>,
     git_blame_inline_enabled: bool,
     render_diff_hunk_controls: RenderDiffHunkControlsFn,
     serialize_dirty_buffers: bool,
@@ -1665,6 +1682,7 @@ impl Editor {
             context_menu_options: None,
             mouse_context_menu: None,
             completion_tasks: Default::default(),
+            inline_blame_popover: Default::default(),
             signature_help_state: SignatureHelpState::default(),
             auto_signature_help: None,
             find_all_references_task_sources: Vec::new(),
@@ -1730,7 +1748,6 @@ impl Editor {
             show_git_blame_inline: false,
             show_selection_menu: None,
             show_git_blame_inline_delay_task: None,
-            git_blame_inline_tooltip: None,
             git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
             render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
             serialize_dirty_buffers: ProjectSettings::get_global(cx)
@@ -1806,6 +1823,7 @@ impl Editor {
                             );
                         });
                         editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape);
+                        editor.inline_blame_popover.take();
                     }
                 }
                 EditorEvent::Edited { .. } => {
@@ -2603,6 +2621,7 @@ impl Editor {
             self.update_visible_inline_completion(window, cx);
             self.edit_prediction_requires_modifier_in_indent_conflict = true;
             linked_editing_ranges::refresh_linked_ranges(self, window, cx);
+            self.inline_blame_popover.take();
             if self.git_blame_inline_enabled {
                 self.start_inline_blame_timer(window, cx);
             }
@@ -5483,6 +5502,82 @@ impl Editor {
         }
     }
 
+    fn show_blame_popover(
+        &mut self,
+        blame_entry: &BlameEntry,
+        position: gpui::Point<Pixels>,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(state) = &mut self.inline_blame_popover {
+            state.hide_task.take();
+            cx.notify();
+        } else {
+            let delay = EditorSettings::get_global(cx).hover_popover_delay;
+            let show_task = cx.spawn(async move |editor, cx| {
+                cx.background_executor()
+                    .timer(std::time::Duration::from_millis(delay))
+                    .await;
+                editor
+                    .update(cx, |editor, cx| {
+                        if let Some(state) = &mut editor.inline_blame_popover {
+                            state.show_task = None;
+                            cx.notify();
+                        }
+                    })
+                    .ok();
+            });
+            let Some(blame) = self.blame.as_ref() else {
+                return;
+            };
+            let blame = blame.read(cx);
+            let details = blame.details_for_entry(&blame_entry);
+            let markdown = cx.new(|cx| {
+                Markdown::new(
+                    details
+                        .as_ref()
+                        .map(|message| message.message.clone())
+                        .unwrap_or_default(),
+                    None,
+                    None,
+                    cx,
+                )
+            });
+            self.inline_blame_popover = Some(InlineBlamePopover {
+                position,
+                show_task: Some(show_task),
+                hide_task: None,
+                popover_bounds: None,
+                popover_state: InlineBlamePopoverState {
+                    scroll_handle: ScrollHandle::new(),
+                    commit_message: details,
+                    markdown,
+                },
+            });
+        }
+    }
+
+    fn hide_blame_popover(&mut self, cx: &mut Context<Self>) {
+        if let Some(state) = &mut self.inline_blame_popover {
+            if state.show_task.is_some() {
+                self.inline_blame_popover.take();
+                cx.notify();
+            } else {
+                let hide_task = cx.spawn(async move |editor, cx| {
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(100))
+                        .await;
+                    editor
+                        .update(cx, |editor, cx| {
+                            editor.inline_blame_popover.take();
+                            cx.notify();
+                        })
+                        .ok();
+                });
+                state.hide_task = Some(hide_task);
+            }
+        }
+    }
+
     fn refresh_document_highlights(&mut self, cx: &mut Context<Self>) -> Option<()> {
         if self.pending_rename.is_some() {
             return None;
@@ -16657,12 +16752,7 @@ impl Editor {
 
     pub fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool {
         self.show_git_blame_inline
-            && (self.focus_handle.is_focused(window)
-                || self
-                    .git_blame_inline_tooltip
-                    .as_ref()
-                    .and_then(|t| t.upgrade())
-                    .is_some())
+            && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some())
             && !self.newest_selection_head_on_empty_line(cx)
             && self.has_blame_entries(cx)
     }

crates/editor/src/element.rs 🔗

@@ -32,15 +32,19 @@ use client::ParticipantIndex;
 use collections::{BTreeMap, HashMap};
 use feature_flags::{Debugger, FeatureFlagAppExt};
 use file_icons::FileIcons;
-use git::{Oid, blame::BlameEntry, status::FileStatus};
+use git::{
+    Oid,
+    blame::{BlameEntry, ParsedCommitMessage},
+    status::FileStatus,
+};
 use gpui::{
-    Action, Along, AnyElement, App, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds,
-    ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element,
-    ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
+    Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
+    Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
+    Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
     InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton,
     MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
-    ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
-    TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
+    ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
+    Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
     linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
     transparent_black,
 };
@@ -49,6 +53,7 @@ use language::language_settings::{
     IndentGuideBackgroundColoring, IndentGuideColoring, IndentGuideSettings, ShowWhitespaceSetting,
 };
 use lsp::DiagnosticSeverity;
+use markdown::Markdown;
 use multi_buffer::{
     Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint,
     MultiBufferRow, RowInfo,
@@ -1749,6 +1754,7 @@ impl EditorElement {
         content_origin: gpui::Point<Pixels>,
         scroll_pixel_position: gpui::Point<Pixels>,
         line_height: Pixels,
+        text_hitbox: &Hitbox,
         window: &mut Window,
         cx: &mut App,
     ) -> Option<AnyElement> {
@@ -1780,21 +1786,13 @@ impl EditorElement {
             padding * em_width
         };
 
-        let workspace = editor.workspace()?.downgrade();
         let blame_entry = blame
             .update(cx, |blame, cx| {
                 blame.blame_for_rows(&[*row_info], cx).next()
             })
             .flatten()?;
 
-        let mut element = render_inline_blame_entry(
-            self.editor.clone(),
-            workspace,
-            &blame,
-            blame_entry,
-            &self.style,
-            cx,
-        )?;
+        let mut element = render_inline_blame_entry(blame_entry.clone(), &self.style, cx)?;
 
         let start_y = content_origin.y
             + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height);
@@ -1820,11 +1818,122 @@ impl EditorElement {
         };
 
         let absolute_offset = point(start_x, start_y);
+        let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+        let bounds = Bounds::new(absolute_offset, size);
+
+        self.layout_blame_entry_popover(
+            bounds,
+            blame_entry,
+            blame,
+            line_height,
+            text_hitbox,
+            window,
+            cx,
+        );
+
         element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx);
 
         Some(element)
     }
 
+    fn layout_blame_entry_popover(
+        &self,
+        parent_bounds: Bounds<Pixels>,
+        blame_entry: BlameEntry,
+        blame: Entity<GitBlame>,
+        line_height: Pixels,
+        text_hitbox: &Hitbox,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let mouse_position = window.mouse_position();
+        let mouse_over_inline_blame = parent_bounds.contains(&mouse_position);
+        let mouse_over_popover = self.editor.update(cx, |editor, _| {
+            editor
+                .inline_blame_popover
+                .as_ref()
+                .and_then(|state| state.popover_bounds)
+                .map_or(false, |bounds| bounds.contains(&mouse_position))
+        });
+
+        self.editor.update(cx, |editor, cx| {
+            if mouse_over_inline_blame || mouse_over_popover {
+                editor.show_blame_popover(&blame_entry, mouse_position, cx);
+            } else {
+                editor.hide_blame_popover(cx);
+            }
+        });
+
+        let should_draw = self.editor.update(cx, |editor, _| {
+            editor
+                .inline_blame_popover
+                .as_ref()
+                .map_or(false, |state| state.show_task.is_none())
+        });
+
+        if should_draw {
+            let maybe_element = self.editor.update(cx, |editor, cx| {
+                editor
+                    .workspace()
+                    .map(|workspace| workspace.downgrade())
+                    .zip(
+                        editor
+                            .inline_blame_popover
+                            .as_ref()
+                            .map(|p| p.popover_state.clone()),
+                    )
+                    .and_then(|(workspace, popover_state)| {
+                        render_blame_entry_popover(
+                            blame_entry,
+                            popover_state.scroll_handle,
+                            popover_state.commit_message,
+                            popover_state.markdown,
+                            workspace,
+                            &blame,
+                            window,
+                            cx,
+                        )
+                    })
+            });
+
+            if let Some(mut element) = maybe_element {
+                let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+                let origin = self.editor.update(cx, |editor, _| {
+                    let target_point = editor
+                        .inline_blame_popover
+                        .as_ref()
+                        .map_or(mouse_position, |state| state.position);
+
+                    let overall_height = size.height + HOVER_POPOVER_GAP;
+                    let popover_origin = if target_point.y > overall_height {
+                        point(target_point.x, target_point.y - size.height)
+                    } else {
+                        point(
+                            target_point.x,
+                            target_point.y + line_height + HOVER_POPOVER_GAP,
+                        )
+                    };
+
+                    let horizontal_offset = (text_hitbox.top_right().x
+                        - POPOVER_RIGHT_OFFSET
+                        - (popover_origin.x + size.width))
+                        .min(Pixels::ZERO);
+
+                    point(popover_origin.x + horizontal_offset, popover_origin.y)
+                });
+
+                let popover_bounds = Bounds::new(origin, size);
+                self.editor.update(cx, |editor, _| {
+                    if let Some(state) = &mut editor.inline_blame_popover {
+                        state.popover_bounds = Some(popover_bounds);
+                    }
+                });
+
+                window.defer_draw(element, origin, 2);
+            }
+        }
+    }
+
     fn layout_blame_entries(
         &self,
         buffer_rows: &[RowInfo],
@@ -5851,24 +5960,35 @@ fn prepaint_gutter_button(
 }
 
 fn render_inline_blame_entry(
-    editor: Entity<Editor>,
-    workspace: WeakEntity<Workspace>,
-    blame: &Entity<GitBlame>,
     blame_entry: BlameEntry,
     style: &EditorStyle,
     cx: &mut App,
+) -> Option<AnyElement> {
+    let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
+    renderer.render_inline_blame_entry(&style.text, blame_entry, cx)
+}
+
+fn render_blame_entry_popover(
+    blame_entry: BlameEntry,
+    scroll_handle: ScrollHandle,
+    commit_message: Option<ParsedCommitMessage>,
+    markdown: Entity<Markdown>,
+    workspace: WeakEntity<Workspace>,
+    blame: &Entity<GitBlame>,
+    window: &mut Window,
+    cx: &mut App,
 ) -> Option<AnyElement> {
     let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
     let blame = blame.read(cx);
-    let details = blame.details_for_entry(&blame_entry);
     let repository = blame.repository(cx)?.clone();
-    renderer.render_inline_blame_entry(
-        &style.text,
+    renderer.render_blame_entry_popover(
         blame_entry,
-        details,
+        scroll_handle,
+        commit_message,
+        markdown,
         repository,
         workspace,
-        editor,
+        window,
         cx,
     )
 }
@@ -7046,14 +7166,7 @@ impl Element for EditorElement {
                                     blame.blame_for_rows(&[row_infos], cx).next()
                                 })
                                 .flatten()?;
-                            let mut element = render_inline_blame_entry(
-                                self.editor.clone(),
-                                editor.workspace()?.downgrade(),
-                                blame,
-                                blame_entry,
-                                &style,
-                                cx,
-                            )?;
+                            let mut element = render_inline_blame_entry(blame_entry, &style, cx)?;
                             let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance;
                             Some(
                                 element
@@ -7262,6 +7375,7 @@ impl Element for EditorElement {
                                 content_origin,
                                 scroll_pixel_position,
                                 line_height,
+                                &text_hitbox,
                                 window,
                                 cx,
                             );

crates/editor/src/git/blame.rs 🔗

@@ -7,10 +7,11 @@ use git::{
     parse_git_remote_url,
 };
 use gpui::{
-    AnyElement, App, AppContext as _, Context, Entity, Hsla, Subscription, Task, TextStyle,
-    WeakEntity, Window,
+    AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
+    TextStyle, WeakEntity, Window,
 };
 use language::{Bias, Buffer, BufferSnapshot, Edit};
+use markdown::Markdown;
 use multi_buffer::RowInfo;
 use project::{
     Project, ProjectItem,
@@ -98,10 +99,18 @@ pub trait BlameRenderer {
         &self,
         _: &TextStyle,
         _: BlameEntry,
+        _: &mut App,
+    ) -> Option<AnyElement>;
+
+    fn render_blame_entry_popover(
+        &self,
+        _: BlameEntry,
+        _: ScrollHandle,
         _: Option<ParsedCommitMessage>,
+        _: Entity<Markdown>,
         _: Entity<Repository>,
         _: WeakEntity<Workspace>,
-        _: Entity<Editor>,
+        _: &mut Window,
         _: &mut App,
     ) -> Option<AnyElement>;
 
@@ -139,10 +148,20 @@ impl BlameRenderer for () {
         &self,
         _: &TextStyle,
         _: BlameEntry,
+        _: &mut App,
+    ) -> Option<AnyElement> {
+        None
+    }
+
+    fn render_blame_entry_popover(
+        &self,
+        _: BlameEntry,
+        _: ScrollHandle,
         _: Option<ParsedCommitMessage>,
+        _: Entity<Markdown>,
         _: Entity<Repository>,
         _: WeakEntity<Workspace>,
-        _: Entity<Editor>,
+        _: &mut Window,
         _: &mut App,
     ) -> Option<AnyElement> {
         None

crates/editor/src/items.rs 🔗

@@ -957,6 +957,7 @@ impl Item for Editor {
             cx.subscribe(&workspace, |editor, _, event: &workspace::Event, _cx| {
                 if matches!(event, workspace::Event::ModalOpened) {
                     editor.mouse_context_menu.take();
+                    editor.inline_blame_popover.take();
                 }
             })
             .detach();

crates/git_ui/src/blame_ui.rs 🔗

@@ -1,19 +1,23 @@
-use crate::{commit_tooltip::CommitTooltip, commit_view::CommitView};
-use editor::{BlameRenderer, Editor};
+use crate::{
+    commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip},
+    commit_view::CommitView,
+};
+use editor::{BlameRenderer, Editor, hover_markdown_style};
 use git::{
     blame::{BlameEntry, ParsedCommitMessage},
     repository::CommitSummary,
 };
 use gpui::{
-    AnyElement, App, AppContext as _, ClipboardItem, Element as _, Entity, Hsla,
-    InteractiveElement as _, MouseButton, Pixels, StatefulInteractiveElement as _, Styled as _,
-    Subscription, TextStyle, WeakEntity, Window, div,
+    ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, WeakEntity,
+    prelude::*,
 };
+use markdown::{Markdown, MarkdownElement};
 use project::{git_store::Repository, project_settings::ProjectSettings};
 use settings::Settings as _;
-use ui::{
-    ActiveTheme, Color, ContextMenu, FluentBuilder as _, Icon, IconName, ParentElement as _, h_flex,
-};
+use theme::ThemeSettings;
+use time::OffsetDateTime;
+use time_format::format_local_timestamp;
+use ui::{ContextMenu, Divider, IconButtonShape, prelude::*};
 use workspace::Workspace;
 
 const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
@@ -115,10 +119,6 @@ impl BlameRenderer for GitBlameRenderer {
         &self,
         style: &TextStyle,
         blame_entry: BlameEntry,
-        details: Option<ParsedCommitMessage>,
-        repository: Entity<Repository>,
-        workspace: WeakEntity<Workspace>,
-        editor: Entity<Editor>,
         cx: &mut App,
     ) -> Option<AnyElement> {
         let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
@@ -144,25 +144,223 @@ impl BlameRenderer for GitBlameRenderer {
                 .child(Icon::new(IconName::FileGit).color(Color::Hint))
                 .child(text)
                 .gap_2()
-                .hoverable_tooltip(move |_window, cx| {
-                    let tooltip = cx.new(|cx| {
-                        CommitTooltip::blame_entry(
-                            &blame_entry,
-                            details.clone(),
-                            repository.clone(),
-                            workspace.clone(),
-                            cx,
-                        )
-                    });
-                    editor.update(cx, |editor, _| {
-                        editor.git_blame_inline_tooltip = Some(tooltip.downgrade().into())
-                    });
-                    tooltip.into()
-                })
                 .into_any(),
         )
     }
 
+    fn render_blame_entry_popover(
+        &self,
+        blame: BlameEntry,
+        scroll_handle: ScrollHandle,
+        details: Option<ParsedCommitMessage>,
+        markdown: Entity<Markdown>,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<AnyElement> {
+        let commit_time = blame
+            .committer_time
+            .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
+            .unwrap_or(OffsetDateTime::now_utc());
+
+        let commit_details = CommitDetails {
+            sha: blame.sha.to_string().into(),
+            commit_time,
+            author_name: blame
+                .author
+                .clone()
+                .unwrap_or("<no name>".to_string())
+                .into(),
+            author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
+            message: details,
+        };
+
+        let avatar = CommitAvatar::new(&commit_details).render(window, cx);
+
+        let author = commit_details.author_name.clone();
+        let author_email = commit_details.author_email.clone();
+
+        let short_commit_id = commit_details
+            .sha
+            .get(0..8)
+            .map(|sha| sha.to_string().into())
+            .unwrap_or_else(|| commit_details.sha.clone());
+        let full_sha = commit_details.sha.to_string().clone();
+        let absolute_timestamp = format_local_timestamp(
+            commit_details.commit_time,
+            OffsetDateTime::now_utc(),
+            time_format::TimestampFormat::MediumAbsolute,
+        );
+        let markdown_style = {
+            let mut style = hover_markdown_style(window, cx);
+            if let Some(code_block) = &style.code_block.text {
+                style.base_text_style.refine(code_block);
+            }
+            style
+        };
+
+        let message = commit_details
+            .message
+            .as_ref()
+            .map(|_| MarkdownElement::new(markdown.clone(), markdown_style).into_any())
+            .unwrap_or("<no commit message>".into_any());
+
+        let pull_request = commit_details
+            .message
+            .as_ref()
+            .and_then(|details| details.pull_request.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);
+        let commit_summary = CommitSummary {
+            sha: commit_details.sha.clone(),
+            subject: commit_details
+                .message
+                .as_ref()
+                .map_or(Default::default(), |message| {
+                    message
+                        .message
+                        .split('\n')
+                        .next()
+                        .unwrap()
+                        .trim_end()
+                        .to_string()
+                        .into()
+                }),
+            commit_timestamp: commit_details.commit_time.unix_timestamp(),
+            has_parent: false,
+        };
+
+        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
+
+        // padding to avoid tooltip appearing right below the mouse cursor
+        // TODO: use tooltip_container here
+        Some(
+            div()
+                .pl_2()
+                .pt_2p5()
+                .child(
+                    v_flex()
+                        .elevation_2(cx)
+                        .font(ui_font)
+                        .text_ui(cx)
+                        .text_color(cx.theme().colors().text)
+                        .py_1()
+                        .px_2()
+                        .map(|el| {
+                            el.occlude()
+                                .on_mouse_move(|_, _, cx| cx.stop_propagation())
+                                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+                                .child(
+                                    v_flex()
+                                        .w(gpui::rems(30.))
+                                        .gap_4()
+                                        .child(
+                                            h_flex()
+                                                .pb_1p5()
+                                                .gap_x_2()
+                                                .overflow_x_hidden()
+                                                .flex_wrap()
+                                                .children(avatar)
+                                                .child(author)
+                                                .when(!author_email.is_empty(), |this| {
+                                                    this.child(
+                                                        div()
+                                                            .text_color(
+                                                                cx.theme().colors().text_muted,
+                                                            )
+                                                            .child(author_email),
+                                                    )
+                                                })
+                                                .border_b_1()
+                                                .border_color(cx.theme().colors().border_variant),
+                                        )
+                                        .child(
+                                            div()
+                                                .id("inline-blame-commit-message")
+                                                .child(message)
+                                                .max_h(message_max_height)
+                                                .overflow_y_scroll()
+                                                .track_scroll(&scroll_handle),
+                                        )
+                                        .child(
+                                            h_flex()
+                                                .text_color(cx.theme().colors().text_muted)
+                                                .w_full()
+                                                .justify_between()
+                                                .pt_1p5()
+                                                .border_t_1()
+                                                .border_color(cx.theme().colors().border_variant)
+                                                .child(absolute_timestamp)
+                                                .child(
+                                                    h_flex()
+                                                        .gap_1p5()
+                                                        .when_some(pull_request, |this, pr| {
+                                                            this.child(
+                                                                Button::new(
+                                                                    "pull-request-button",
+                                                                    format!("#{}", pr.number),
+                                                                )
+                                                                .color(Color::Muted)
+                                                                .icon(IconName::PullRequest)
+                                                                .icon_color(Color::Muted)
+                                                                .icon_position(IconPosition::Start)
+                                                                .style(ButtonStyle::Subtle)
+                                                                .on_click(move |_, _, cx| {
+                                                                    cx.stop_propagation();
+                                                                    cx.open_url(pr.url.as_str())
+                                                                }),
+                                                            )
+                                                        })
+                                                        .child(Divider::vertical())
+                                                        .child(
+                                                            Button::new(
+                                                                "commit-sha-button",
+                                                                short_commit_id.clone(),
+                                                            )
+                                                            .style(ButtonStyle::Subtle)
+                                                            .color(Color::Muted)
+                                                            .icon(IconName::FileGit)
+                                                            .icon_color(Color::Muted)
+                                                            .icon_position(IconPosition::Start)
+                                                            .on_click(move |_, window, cx| {
+                                                                CommitView::open(
+                                                                    commit_summary.clone(),
+                                                                    repository.downgrade(),
+                                                                    workspace.clone(),
+                                                                    window,
+                                                                    cx,
+                                                                );
+                                                                cx.stop_propagation();
+                                                            }),
+                                                        )
+                                                        .child(
+                                                            IconButton::new(
+                                                                "copy-sha-button",
+                                                                IconName::Copy,
+                                                            )
+                                                            .shape(IconButtonShape::Square)
+                                                            .icon_size(IconSize::Small)
+                                                            .icon_color(Color::Muted)
+                                                            .on_click(move |_, _, cx| {
+                                                                cx.stop_propagation();
+                                                                cx.write_to_clipboard(
+                                                                    ClipboardItem::new_string(
+                                                                        full_sha.clone(),
+                                                                    ),
+                                                                )
+                                                            }),
+                                                        ),
+                                                ),
+                                        ),
+                                )
+                        }),
+                )
+                .into_any_element(),
+        )
+    }
+
     fn open_blame_commit(
         &self,
         blame_entry: BlameEntry,

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -27,22 +27,18 @@ pub struct CommitDetails {
     pub message: Option<ParsedCommitMessage>,
 }
 
-struct CommitAvatar<'a> {
+pub struct CommitAvatar<'a> {
     commit: &'a CommitDetails,
 }
 
 impl<'a> CommitAvatar<'a> {
-    fn new(details: &'a CommitDetails) -> Self {
+    pub fn new(details: &'a CommitDetails) -> Self {
         Self { commit: details }
     }
 }
 
 impl<'a> CommitAvatar<'a> {
-    fn render(
-        &'a self,
-        window: &mut Window,
-        cx: &mut Context<CommitTooltip>,
-    ) -> Option<impl IntoElement + use<>> {
+    pub fn render(&'a self, window: &mut Window, cx: &mut App) -> Option<impl IntoElement + use<>> {
         let remote = self
             .commit
             .message