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

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.
This commit is contained in:
Smit Barmase 2025-04-25 01:52:24 +05:30 committed by GitHub
parent 87f85f1863
commit d3911e34de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 501 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,23 @@
use crate::{commit_tooltip::CommitTooltip, commit_view::CommitView}; use crate::{
use editor::{BlameRenderer, Editor}; commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip},
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
use git::{ use git::{
blame::{BlameEntry, ParsedCommitMessage}, blame::{BlameEntry, ParsedCommitMessage},
repository::CommitSummary, repository::CommitSummary,
}; };
use gpui::{ use gpui::{
AnyElement, App, AppContext as _, ClipboardItem, Element as _, Entity, Hsla, ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, WeakEntity,
InteractiveElement as _, MouseButton, Pixels, StatefulInteractiveElement as _, Styled as _, prelude::*,
Subscription, TextStyle, WeakEntity, Window, div,
}; };
use markdown::{Markdown, MarkdownElement};
use project::{git_store::Repository, project_settings::ProjectSettings}; use project::{git_store::Repository, project_settings::ProjectSettings};
use settings::Settings as _; use settings::Settings as _;
use ui::{ use theme::ThemeSettings;
ActiveTheme, Color, ContextMenu, FluentBuilder as _, Icon, IconName, ParentElement as _, h_flex, use time::OffsetDateTime;
}; use time_format::format_local_timestamp;
use ui::{ContextMenu, Divider, IconButtonShape, prelude::*};
use workspace::Workspace; use workspace::Workspace;
const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
@ -115,10 +119,6 @@ impl BlameRenderer for GitBlameRenderer {
&self, &self,
style: &TextStyle, style: &TextStyle,
blame_entry: BlameEntry, blame_entry: BlameEntry,
details: Option<ParsedCommitMessage>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
editor: Entity<Editor>,
cx: &mut App, cx: &mut App,
) -> Option<AnyElement> { ) -> Option<AnyElement> {
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); 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(Icon::new(IconName::FileGit).color(Color::Hint))
.child(text) .child(text)
.gap_2() .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(), .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( fn open_blame_commit(
&self, &self,
blame_entry: BlameEntry, blame_entry: BlameEntry,

View file

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