History

Inline git blame (#10398)

This adds so-called "inline git blame" to the editor that, when turned on, shows `git blame` information about the current line inline: ![screenshot-2024-04-15-11 29 35@2x](https://github.com/zed-industries/zed/assets/1185253/21cef7be-3283-4556-a9f0-cc349c4e1d75) When the inline information is hovered, a new tooltip appears that contains more information on the current commit: ![screenshot-2024-04-15-11 28 24@2x](https://github.com/zed-industries/zed/assets/1185253/ee128460-f6a2-48c2-a70d-e03ff90a737f) The commit message in this tooltip is rendered as Markdown, is scrollable and clickable. The tooltip is now also the tooltip used in the gutter: ![screenshot-2024-04-15-11 28 51@2x](https://github.com/zed-industries/zed/assets/1185253/42be3d63-91d0-4936-8183-570e024beabe) ## Settings 1. The inline git blame information can be turned on and off via settings: ```json { "git": { "inline_blame": { "enabled": true } } } ``` 2. Optionally, a delay can be configured. When a delay is set, the inline blame information will only show up `x milliseconds` after a cursor movement: ```json { "git": { "inline_blame": { "enabled": true, "delay_ms": 600 } } } ``` 3. It can also be turned on/off for the current buffer with `editor: toggle git blame inline`. ## To be done in follow-up PRs - [ ] Add link to pull request in tooltip - [ ] Add avatars of users if possible ## Release notes Release Notes: - Added inline `git blame` information the editor. It can be turned on in the settings with `{"git": { "inline_blame": "on" } }` for every buffer or, temporarily for the current buffer, with `editor: toggle git blame inline`.

Thorsten Ball committed 2 years ago

Commit faebce81 parent 573ba8313 files · +654 −236Browse files

Unified · Split

13 files
  1. assets/settings/default.json +9 −1
  2. crates/collab/src/tests/editor_tests.rs +3 −5
  3. crates/collab_ui/src/chat_panel/message_editor.rs +2 −0
  4. crates/editor/src/actions.rs +1 −0
  5. crates/editor/src/editor.rs +96 −23
  6. crates/editor/src/element.rs +321 −143
  7. crates/editor/src/git/blame.rs +118 −55
  8. crates/language/src/markdown.rs +1 −1
  9. crates/project/src/project.rs +23 −6
  10. crates/project/src/project_settings.rs +40 −1
  11. crates/search/src/buffer_search.rs +2 −0
  12. crates/time_format/src/time_format.rs +36 −1
  13. crates/ui/src/styles/color.rs +2 −0
assets/settings/default.json+9−1
@@ -390,7 +390,15 @@ · 389 unmodified lines
390390 // "git_gutter": "tracked_files"
391391 // 2. Hide the gutter
392392 // "git_gutter": "hide"
393− "git_gutter": "tracked_files"
393+ "git_gutter": "tracked_files",
394+ // Control whether the git blame information is shown inline,
395+ // in the currently focused line.
396+ "inline_blame": {
397+ "enabled": false
398+ // Sets a delay after which the inline blame information is shown.
399+ // Delay is restarted with every cursor movement.
400+ // "delay_ms": 600
401+ }
394402 },
395403 "copilot": {
396404 // The set of glob patterns for which copilot should be disabled
crates/collab/src/tests/editor_tests.rs+3−5
@@ -2100,14 +2100,12 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA · 2099 unmodified lines
21002100
21012101 blame.update(cx, |blame, _| {
21022102 for (idx, entry) in entries.iter().flatten().enumerate() {
2103+ let details = blame.details_for_entry(entry).unwrap();
2104+ assert_eq!(details.message, format!("message for idx-{}", idx));
21032105 assert_eq!(
2104− blame.permalink_for_entry(entry).unwrap().to_string(),
2106+ details.permalink.unwrap().to_string(),
21052107 format!("http://example.com/codehost/idx-{}", idx)
21062108 );
2107− assert_eq!(
2108− blame.message_for_entry(entry).unwrap(),
2109− format!("message for idx-{}", idx)
2110− );
21112109 }
21122110 });
21132111 });
crates/collab_ui/src/chat_panel/message_editor.rs+2−0
@@ -557,6 +557,7 @@ mod tests { · 556 unmodified lines
557557 use clock::FakeSystemClock;
558558 use gpui::TestAppContext;
559559 use language::{Language, LanguageConfig};
560+ use project::Project;
560561 use rpc::proto;
561562 use settings::SettingsStore;
562563 use util::{http::FakeHttpClient, test::marked_text_ranges};
@@ -630,6 +631,7 @@ mod tests { · 67 unmodified lines
630631 let client = Client::new(clock, http.clone(), cx);
631632 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
632633 theme::init(theme::LoadThemes::JustBase, cx);
634+ Project::init_settings(cx);
633635 language::init(cx);
634636 editor::init(cx);
635637 client::init(&client, cx);
crates/editor/src/actions.rs+1−0
@@ -245,6 +245,7 @@ gpui::actions!( · 244 unmodified lines
245245 Tab,
246246 TabPrev,
247247 ToggleGitBlame,
248+ ToggleGitBlameInline,
248249 ToggleInlayHints,
249250 ToggleLineNumbers,
250251 ToggleSoftWrap,
crates/editor/src/editor.rs+96−23
@@ -155,7 +155,7 @@ pub fn render_parsed_markdown( · 154 unmodified lines
155155 parsed: &language::ParsedMarkdown,
156156 editor_style: &EditorStyle,
157157 workspace: Option<WeakView<Workspace>>,
158− cx: &mut ViewContext<Editor>,
158+ cx: &mut WindowContext,
159159 ) -> InteractiveText {
160160 let code_span_background_color = cx
161161 .theme()
@@ -463,7 +463,9 @@ pub struct Editor { · 301 unmodified lines
463463 editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
464464 use_autoclose: bool,
465465 auto_replace_emoji_shortcode: bool,
466− show_git_blame: bool,
466+ show_git_blame_gutter: bool,
467+ show_git_blame_inline: bool,
468+ show_git_blame_inline_delay_task: Option<Task<()>>,
467469 blame: Option<Model<GitBlame>>,
468470 blame_subscription: Option<Subscription>,
469471 custom_context_menu: Option<
@@ -480,7 +482,7 @@ pub struct Editor { · 10 unmodified lines
480482 pub struct EditorSnapshot {
481483 pub mode: EditorMode,
482484 show_gutter: bool,
483− show_git_blame: bool,
485+ render_git_blame_gutter: bool,
484486 pub display_snapshot: DisplaySnapshot,
485487 pub placeholder_text: Option<Arc<str>>,
486488 is_focused: bool,
@@ -1498,7 +1500,9 @@ impl Editor { · 1011 unmodified lines
14981500 vim_replace_map: Default::default(),
14991501 show_inline_completions: mode == EditorMode::Full,
15001502 custom_context_menu: None,
1501− show_git_blame: false,
1503+ show_git_blame_gutter: false,
1504+ show_git_blame_inline: false,
1505+ show_git_blame_inline_delay_task: None,
15021506 blame: None,
15031507 blame_subscription: None,
15041508 _subscriptions: vec![
@@ -1530,6 +1534,10 @@ impl Editor { · 25 unmodified lines
15301534 if mode == EditorMode::Full {
15311535 let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
15321536 cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
1537+
1538+ if ProjectSettings::get_global(cx).git.inline_blame_enabled() {
1539+ this.start_git_blame_inline(false, cx);
1540+ }
15331541 }
15341542
15351543 this.report_editor_event("open", None, cx);
@@ -1646,10 +1654,7 @@ impl Editor { · 110 unmodified lines
16461654 EditorSnapshot {
16471655 mode: self.mode,
16481656 show_gutter: self.show_gutter,
1649− show_git_blame: self
1650− .blame
1651− .as_ref()
1652− .map_or(false, |blame| blame.read(cx).has_generated_entries()),
1657+ render_git_blame_gutter: self.render_git_blame_gutter(cx),
16531658 display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
16541659 scroll_anchor: self.scroll_manager.anchor(),
16551660 ongoing_scroll: self.scroll_manager.ongoing_scroll(),
@@ -1915,6 +1920,7 @@ impl Editor { · 259 unmodified lines
19151920 self.refresh_document_highlights(cx);
19161921 refresh_matching_bracket_highlights(self, cx);
19171922 self.discard_inline_completion(cx);
1923+ self.start_inline_blame_timer(cx);
19181924 }
19191925
19201926 self.blink_manager.update(cx, BlinkManager::pause_blinking);
@@ -3794,6 +3800,22 @@ impl Editor { · 1873 unmodified lines
37943800 None
37953801 }
37963802
3803+ fn start_inline_blame_timer(&mut self, cx: &mut ViewContext<Self>) {
3804+ if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() {
3805+ self.show_git_blame_inline = false;
3806+
3807+ self.show_git_blame_inline_delay_task = Some(cx.spawn(|this, mut cx| async move {
3808+ cx.background_executor().timer(delay).await;
3809+
3810+ this.update(&mut cx, |this, cx| {
3811+ this.show_git_blame_inline = true;
3812+ cx.notify();
3813+ })
3814+ .log_err();
3815+ }));
3816+ }
3817+ }
3818+
37973819 fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
37983820 if self.pending_rename.is_some() {
37993821 return None;
@@ -8843,40 +8865,83 @@ impl Editor { · 5043 unmodified lines
88438865 }
88448866
88458867 pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {
8846− if self.show_git_blame {
8847− self.blame_subscription.take();
8848− self.blame.take();
8849− self.show_git_blame = false
8850− } else {
8851− if let Err(error) = self.show_git_blame_internal(cx) {
8852− log::error!("failed to toggle on 'git blame': {}", error);
8853− return;
8854− }
8855− self.show_git_blame = true
8868+ self.show_git_blame_gutter = !self.show_git_blame_gutter;
8869+
8870+ if self.show_git_blame_gutter && !self.has_blame_entries(cx) {
8871+ self.start_git_blame(true, cx);
88568872 }
88578873
88588874 cx.notify();
88598875 }
88608876
8861− fn show_git_blame_internal(&mut self, cx: &mut ViewContext<Self>) -> Result<()> {
8877+ pub fn toggle_git_blame_inline(
8878+ &mut self,
8879+ _: &ToggleGitBlameInline,
8880+ cx: &mut ViewContext<Self>,
8881+ ) {
8882+ self.toggle_git_blame_inline_internal(true, cx);
8883+ cx.notify();
8884+ }
8885+
8886+ fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
88628887 if let Some(project) = self.project.as_ref() {
88638888 let Some(buffer) = self.buffer().read(cx).as_singleton() else {
8864− anyhow::bail!("git blame not available in multi buffers")
8889+ return;
88658890 };
88668891
88678892 let project = project.clone();
8868− let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx));
8893+ let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx));
88698894 self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));
88708895 self.blame = Some(blame);
88718896 }
8897+ }
88728898
8873− Ok(())
8899+ fn toggle_git_blame_inline_internal(
8900+ &mut self,
8901+ user_triggered: bool,
8902+ cx: &mut ViewContext<Self>,
8903+ ) {
8904+ if self.show_git_blame_inline || self.show_git_blame_inline_delay_task.is_some() {
8905+ self.show_git_blame_inline = false;
8906+ self.show_git_blame_inline_delay_task.take();
8907+ } else {
8908+ self.start_git_blame_inline(user_triggered, cx);
8909+ }
8910+
8911+ cx.notify();
8912+ }
8913+
8914+ fn start_git_blame_inline(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
8915+ if let Some(inline_blame_settings) = ProjectSettings::get_global(cx).git.inline_blame {
8916+ if inline_blame_settings.enabled {
8917+ self.start_git_blame(user_triggered, cx);
8918+
8919+ if inline_blame_settings.delay_ms.is_some() {
8920+ self.start_inline_blame_timer(cx);
8921+ } else {
8922+ self.show_git_blame_inline = true
8923+ }
8924+ }
8925+ }
88748926 }
88758927
88768928 pub fn blame(&self) -> Option<&Model<GitBlame>> {
88778929 self.blame.as_ref()
88788930 }
88798931
8932+ pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool {
8933+ self.show_git_blame_gutter && self.has_blame_entries(cx)
8934+ }
8935+
8936+ pub fn render_git_blame_inline(&mut self, cx: &mut WindowContext) -> bool {
8937+ self.show_git_blame_inline && self.has_blame_entries(cx)
8938+ }
8939+
8940+ fn has_blame_entries(&self, cx: &mut WindowContext) -> bool {
8941+ self.blame()
8942+ .map_or(false, |blame| blame.read(cx).has_generated_entries())
8943+ }
8944+
88808945 fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
88818946 let (path, repo) = maybe!({
88828947 let project_handle = self.project.as_ref()?.clone();
@@ -9446,6 +9511,14 @@ impl Editor { · 563 unmodified lines
94469511 let editor_settings = EditorSettings::get_global(cx);
94479512 self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
94489513 self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
9514+
9515+ if self.mode == EditorMode::Full {
9516+ let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
9517+ if self.show_git_blame_inline != inline_blame_enabled {
9518+ self.toggle_git_blame_inline_internal(false, cx);
9519+ }
9520+ }
9521+
94499522 cx.notify();
94509523 }
94519524
@@ -10058,7 +10131,7 @@ impl EditorSnapshot { · 606 unmodified lines
1005810131 };
1005910132
1006010133 let git_blame_entries_width = self
10061− .show_git_blame
10134+ .render_git_blame_gutter
1006210135 .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
1006310136
1006410137 let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
crates/editor/src/element.rs+321−143
@@ -4,7 +4,10 @@ use crate::{ · 3 unmodified lines
44 TransformBlock,
55 },
66 editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},
7− git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk},
7+ git::{
8+ blame::{CommitDetails, GitBlame},
9+ diff_hunk_to_display, DisplayDiffHunk,
10+ },
811 hover_popover::{
912 self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
1013 },
@@ -21,13 +24,13 @@ use collections::{BTreeMap, HashMap}; · 10 unmodified lines
2124 use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
2225 use gpui::{
2326 anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
24− transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds,
25− ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,
26− ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
27+ transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
28+ ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext,
29+ ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
2730 ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
28− ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful,
29− StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
30− ViewContext, WindowContext,
31+ ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
32+ Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle,
33+ TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
3134 };
3235 use itertools::Itertools;
3336 use language::language_settings::ShowWhitespaceSetting;
@@ -49,11 +52,11 @@ use std::{ · 15 unmodified lines
4952 sync::Arc,
5053 };
5154 use sum_tree::Bias;
52−use theme::{ActiveTheme, PlayerColor};
55+use theme::{ActiveTheme, PlayerColor, ThemeSettings};
5356 use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
5457 use ui::{prelude::*, tooltip_container};
5558 use util::ResultExt;
56−use workspace::item::Item;
59+use workspace::{item::Item, Workspace};
5760
5861 struct SelectionLayout {
5962 head: DisplayPoint,
@@ -303,6 +306,7 @@ impl EditorElement { · 243 unmodified lines
303306 register_action(view, cx, Editor::copy_permalink_to_line);
304307 register_action(view, cx, Editor::open_permalink_to_line);
305308 register_action(view, cx, Editor::toggle_git_blame);
309+ register_action(view, cx, Editor::toggle_git_blame_inline);
306310 register_action(view, cx, |editor, action, cx| {
307311 if let Some(task) = editor.format(action, cx) {
308312 task.detach_and_log_err(cx);
@@ -1092,6 +1096,58 @@ impl EditorElement { · 783 unmodified lines
10921096 .collect()
10931097 }
10941098
1099+ #[allow(clippy::too_many_arguments)]
1100+ fn layout_inline_blame(
1101+ &self,
1102+ start_row: u32,
1103+ row: u32,
1104+ line_layouts: &[LineWithInvisibles],
1105+ em_width: Pixels,
1106+ content_origin: gpui::Point<Pixels>,
1107+ scroll_pixel_position: gpui::Point<Pixels>,
1108+ line_height: Pixels,
1109+ cx: &mut ElementContext,
1110+ ) -> Option<AnyElement> {
1111+ if !self
1112+ .editor
1113+ .update(cx, |editor, cx| editor.render_git_blame_inline(cx))
1114+ {
1115+ return None;
1116+ }
1117+
1118+ let blame = self.editor.read(cx).blame.clone()?;
1119+ let workspace = self
1120+ .editor
1121+ .read(cx)
1122+ .workspace
1123+ .as_ref()
1124+ .map(|(w, _)| w.clone());
1125+ let blame_entry = blame
1126+ .update(cx, |blame, cx| blame.blame_for_rows([Some(row)], cx).next())
1127+ .flatten()?;
1128+
1129+ let mut element =
1130+ render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx);
1131+
1132+ let start_y =
1133+ content_origin.y + line_height * (row as f32 - scroll_pixel_position.y / line_height);
1134+
1135+ let start_x = {
1136+ let line_layout = &line_layouts[(row - start_row) as usize];
1137+ let line_width = line_layout.line.width;
1138+
1139+ // TODO: define the padding as a constant
1140+ content_origin.x + line_width + (em_width * 6.)
1141+ };
1142+
1143+ let absolute_offset = point(start_x, start_y);
1144+ let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
1145+
1146+ element.layout(absolute_offset, available_space, cx);
1147+
1148+ Some(element)
1149+ }
1150+
10951151 #[allow(clippy::too_many_arguments)]
10961152 fn layout_blame_entries(
10971153 &self,
@@ -1103,10 +1159,14 @@ impl EditorElement { · 5 unmodified lines
11031159 max_width: Option<Pixels>,
11041160 cx: &mut ElementContext,
11051161 ) -> Option<Vec<AnyElement>> {
1106− let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else {
1162+ if !self
1163+ .editor
1164+ .update(cx, |editor, cx| editor.render_git_blame_gutter(cx))
1165+ {
11071166 return None;
1108− };
1167+ }
11091168
1169+ let blame = self.editor.read(cx).blame.clone()?;
11101170 let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
11111171 blame.blame_for_rows(buffer_rows, cx).collect()
11121172 });
@@ -1120,7 +1180,6 @@ impl EditorElement { · 7 unmodified lines
11201180 let start_x = em_width * 1;
11211181
11221182 let mut last_used_color: Option<(PlayerColor, Oid)> = None;
1123− let text_style = &self.style.text;
11241183
11251184 let shaped_lines = blamed_rows
11261185 .into_iter()
@@ -1131,7 +1190,7 @@ impl EditorElement { · 4 unmodified lines
11311190 ix,
11321191 &blame,
11331192 blame_entry,
1134− text_style,
1193+ &self.style,
11351194 &mut last_used_color,
11361195 self.editor.clone(),
11371196 cx,
@@ -2256,6 +2315,7 @@ impl EditorElement { · 1118 unmodified lines
22562315 self.paint_lines(&invisible_display_ranges, layout, cx);
22572316 self.paint_redactions(layout, cx);
22582317 self.paint_cursors(layout, cx);
2318+ self.paint_inline_blame(layout, cx);
22592319 },
22602320 )
22612321 }
@@ -2730,6 +2790,14 @@ impl EditorElement { · 468 unmodified lines
27302790 })
27312791 }
27322792
2793+ fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
2794+ if let Some(mut inline_blame) = layout.inline_blame.take() {
2795+ cx.paint_layer(layout.text_hitbox.bounds, |cx| {
2796+ inline_blame.paint(cx);
2797+ })
2798+ }
2799+ }
2800+
27332801 fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
27342802 for mut block in layout.blocks.drain(..) {
27352803 block.element.paint(cx);
@@ -2894,11 +2962,192 @@ impl EditorElement { · 158 unmodified lines
28942962 }
28952963 }
28962964
2965+fn render_inline_blame_entry(
2966+ blame: &gpui::Model<GitBlame>,
2967+ blame_entry: BlameEntry,
2968+ style: &EditorStyle,
2969+ workspace: Option<WeakView<Workspace>>,
2970+ cx: &mut ElementContext<'_>,
2971+) -> AnyElement {
2972+ let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
2973+
2974+ let author = blame_entry.author.as_deref().unwrap_or_default();
2975+ let text = format!("{}, {}", author, relative_timestamp);
2976+
2977+ let details = blame.read(cx).details_for_entry(&blame_entry);
2978+
2979+ let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
2980+
2981+ h_flex()
2982+ .id("inline-blame")
2983+ .w_full()
2984+ .font(style.text.font().family)
2985+ .text_color(cx.theme().status().hint)
2986+ .line_height(style.text.line_height)
2987+ .child(Icon::new(IconName::FileGit).color(Color::Hint))
2988+ .child(text)
2989+ .gap_2()
2990+ .hoverable_tooltip(move |_| tooltip.clone().into())
2991+ .into_any()
2992+}
2993+
2994+fn blame_entry_timestamp(
2995+ blame_entry: &BlameEntry,
2996+ format: time_format::TimestampFormat,
2997+ cx: &WindowContext,
2998+) -> String {
2999+ match blame_entry.author_offset_date_time() {
3000+ Ok(timestamp) => time_format::format_localized_timestamp(
3001+ timestamp,
3002+ time::OffsetDateTime::now_utc(),
3003+ cx.local_timezone(),
3004+ format,
3005+ ),
3006+ Err(_) => "Error parsing date".to_string(),
3007+ }
3008+}
3009+
3010+fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
3011+ blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx)
3012+}
3013+
3014+fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
3015+ blame_entry_timestamp(
3016+ blame_entry,
3017+ time_format::TimestampFormat::MediumAbsolute,
3018+ cx,
3019+ )
3020+}
3021+
3022+struct BlameEntryTooltip {
3023+ blame_entry: BlameEntry,
3024+ details: Option<CommitDetails>,
3025+ style: EditorStyle,
3026+ workspace: Option<WeakView<Workspace>>,
3027+ scroll_handle: ScrollHandle,
3028+}
3029+
3030+impl BlameEntryTooltip {
3031+ fn new(
3032+ blame_entry: BlameEntry,
3033+ details: Option<CommitDetails>,
3034+ style: &EditorStyle,
3035+ workspace: Option<WeakView<Workspace>>,
3036+ ) -> Self {
3037+ Self {
3038+ style: style.clone(),
3039+ blame_entry,
3040+ details,
3041+ workspace,
3042+ scroll_handle: ScrollHandle::new(),
3043+ }
3044+ }
3045+}
3046+
3047+impl Render for BlameEntryTooltip {
3048+ fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3049+ let author = self
3050+ .blame_entry
3051+ .author
3052+ .clone()
3053+ .unwrap_or("<no name>".to_string());
3054+
3055+ let author_email = self.blame_entry.author_mail.clone();
3056+
3057+ let pretty_commit_id = format!("{}", self.blame_entry.sha);
3058+ let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
3059+ let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx);
3060+
3061+ let message = self
3062+ .details
3063+ .as_ref()
3064+ .map(|details| {
3065+ crate::render_parsed_markdown(
3066+ "blame-message",
3067+ &details.parsed_message,
3068+ &self.style,
3069+ self.workspace.clone(),
3070+ cx,
3071+ )
3072+ .into_any()
3073+ })
3074+ .unwrap_or("<no commit message>".into_any());
3075+
3076+ let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
3077+ let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
3078+
3079+ tooltip_container(cx, move |this, cx| {
3080+ this.occlude()
3081+ .on_mouse_move(|_, cx| cx.stop_propagation())
3082+ .child(
3083+ v_flex()
3084+ .w(gpui::rems(30.))
3085+ .gap_4()
3086+ .child(
3087+ h_flex()
3088+ .gap_2()
3089+ .child(author)
3090+ .when_some(author_email, |this, author_email| {
3091+ this.child(
3092+ div()
3093+ .text_color(cx.theme().colors().text_muted)
3094+ .child(author_email),
3095+ )
3096+ })
3097+ .pb_1()
3098+ .border_b_1()
3099+ .border_color(cx.theme().colors().border),
3100+ )
3101+ .child(
3102+ div()
3103+ .id("inline-blame-commit-message")
3104+ .occlude()
3105+ .child(message)
3106+ .max_h(message_max_height)
3107+ .overflow_y_scroll()
3108+ .track_scroll(&self.scroll_handle),
3109+ )
3110+ .child(
3111+ h_flex()
3112+ .text_color(cx.theme().colors().text_muted)
3113+ .w_full()
3114+ .justify_between()
3115+ .child(absolute_timestamp)
3116+ .child(
3117+ Button::new("commit-sha-button", short_commit_id.clone())
3118+ .style(ButtonStyle::Transparent)
3119+ .color(Color::Muted)
3120+ .icon(IconName::FileGit)
3121+ .icon_color(Color::Muted)
3122+ .icon_position(IconPosition::Start)
3123+ .disabled(
3124+ self.details.as_ref().map_or(true, |details| {
3125+ details.permalink.is_none()
3126+ }),
3127+ )
3128+ .when_some(
3129+ self.details
3130+ .as_ref()
3131+ .and_then(|details| details.permalink.clone()),
3132+ |this, url| {
3133+ this.on_click(move |_, cx| {
3134+ cx.stop_propagation();
3135+ cx.open_url(url.as_str())
3136+ })
3137+ },
3138+ ),
3139+ ),
3140+ ),
3141+ )
3142+ })
3143+ }
3144+}
3145+
28973146 fn render_blame_entry(
28983147 ix: usize,
28993148 blame: &gpui::Model<GitBlame>,
29003149 blame_entry: BlameEntry,
2901− text_style: &TextStyle,
3150+ style: &EditorStyle,
29023151 last_used_color: &mut Option<(PlayerColor, Oid)>,
29033152 editor: View<Editor>,
29043153 cx: &mut ElementContext<'_>,
@@ -2918,29 +3167,26 @@ fn render_blame_entry( · 13 unmodified lines
29183167 };
29193168 last_used_color.replace((sha_color, blame_entry.sha));
29203169
2921− let relative_timestamp = match blame_entry.author_offset_date_time() {
2922− Ok(timestamp) => time_format::format_localized_timestamp(
2923− timestamp,
2924− time::OffsetDateTime::now_utc(),
2925− cx.local_timezone(),
2926− time_format::TimestampFormat::Relative,
2927− ),
2928− Err(_) => "Error parsing date".to_string(),
2929− };
3170+ let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
29303171
29313172 let pretty_commit_id = format!("{}", blame_entry.sha);
2932− let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::<String>();
3173+ let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
29333174
29343175 let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
29353176 let name = util::truncate_and_trailoff(author_name, 20);
29363177
2937− let permalink = blame.read(cx).permalink_for_entry(&blame_entry);
2938− let commit_message = blame.read(cx).message_for_entry(&blame_entry);
3178+ let details = blame.read(cx).details_for_entry(&blame_entry);
3179+
3180+ let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
3181+
3182+ let tooltip = cx.new_view(|_| {
3183+ BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)
3184+ });
29393185
29403186 h_flex()
29413187 .w_full()
2942− .font(text_style.font().family)
2943− .line_height(text_style.line_height)
3188+ .font(style.text.font().family)
3189+ .line_height(style.text.line_height)
29443190 .id(("blame", ix))
29453191 .children([
29463192 div()
@@ -2962,21 +3208,17 @@ fn render_blame_entry( · 15 unmodified lines
29623208 }
29633209 })
29643210 .hover(|style| style.bg(cx.theme().colors().element_hover))
2965− .when_some(permalink, |this, url| {
2966− let url = url.clone();
2967− this.cursor_pointer().on_click(move |_, cx| {
2968− cx.stop_propagation();
2969− cx.open_url(url.as_str())
2970− })
2971− })
2972− .hoverable_tooltip(move |cx| {
2973− BlameEntryTooltip::new(
2974− sha_color.cursor,
2975− commit_message.clone(),
2976− blame_entry.clone(),
2977− cx,
2978− )
2979− })
3211+ .when_some(
3212+ details.and_then(|details| details.permalink),
3213+ |this, url| {
3214+ let url = url.clone();
3215+ this.cursor_pointer().on_click(move |_, cx| {
3216+ cx.stop_propagation();
3217+ cx.open_url(url.as_str())
3218+ })
3219+ },
3220+ )
3221+ .hoverable_tooltip(move |_| tooltip.clone().into())
29803222 .into_any()
29813223 }
29823224
@@ -2999,84 +3241,6 @@ fn deploy_blame_entry_context_menu( · 16 unmodified lines
29993241 });
30003242 }
30013243
3002−struct BlameEntryTooltip {
3003− color: Hsla,
3004− commit_message: Option<String>,
3005− blame_entry: BlameEntry,
3006−}
3007−
3008−impl BlameEntryTooltip {
3009− fn new(
3010− color: Hsla,
3011− commit_message: Option<String>,
3012− blame_entry: BlameEntry,
3013− cx: &mut WindowContext,
3014− ) -> AnyView {
3015− cx.new_view(|_cx| Self {
3016− color,
3017− commit_message,
3018− blame_entry,
3019− })
3020− .into()
3021− }
3022−}
3023−
3024−impl Render for BlameEntryTooltip {
3025− fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3026− let author = self
3027− .blame_entry
3028− .author
3029− .clone()
3030− .unwrap_or("<no name>".to_string());
3031− let author_email = self.blame_entry.author_mail.clone().unwrap_or_default();
3032− let absolute_timestamp = match self.blame_entry.author_offset_date_time() {
3033− Ok(timestamp) => time_format::format_localized_timestamp(
3034− timestamp,
3035− time::OffsetDateTime::now_utc(),
3036− cx.local_timezone(),
3037− time_format::TimestampFormat::Absolute,
3038− ),
3039− Err(_) => "Error parsing date".to_string(),
3040− };
3041−
3042− let message = match &self.commit_message {
3043− Some(message) => util::truncate_lines_and_trailoff(message, 15),
3044− None => self.blame_entry.summary.clone().unwrap_or_default(),
3045− };
3046−
3047− let pretty_commit_id = format!("{}", self.blame_entry.sha);
3048−
3049− tooltip_container(cx, move |this, cx| {
3050− this.occlude()
3051− .on_mouse_move(|_, cx| cx.stop_propagation())
3052− .child(
3053− v_flex()
3054− .child(
3055− h_flex()
3056− .child(
3057− div()
3058− .text_color(cx.theme().colors().text_muted)
3059− .child("Commit")
3060− .pr_2(),
3061− )
3062− .child(
3063− div().text_color(self.color).child(pretty_commit_id.clone()),
3064− ),
3065− )
3066− .child(
3067− div()
3068− .child(format!(
3069− "{} {} - {}",
3070− author, author_email, absolute_timestamp
3071− ))
3072− .text_color(cx.theme().colors().text_muted),
3073− )
3074− .child(div().child(message)),
3075− )
3076− })
3077− }
3078−}
3079−
30803244 #[derive(Debug)]
30813245 pub(crate) struct LineWithInvisibles {
30823246 pub line: ShapedLine,
@@ -3205,13 +3369,9 @@ impl LineWithInvisibles { · 122 unmodified lines
32053369 let line_y =
32063370 line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);
32073371
3208− self.line
3209− .paint(
3210− content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y),
3211− line_height,
3212− cx,
3213− )
3214− .log_err();
3372+ let line_origin =
3373+ content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y);
3374+ self.line.paint(line_origin, line_height, cx).log_err();
32153375
32163376 self.draw_invisibles(
32173377 &selection_ranges,
@@ -3490,16 +3650,6 @@ impl Element for EditorElement { · 272 unmodified lines
34903650
34913651 let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
34923652
3493− let blamed_display_rows = self.layout_blame_entries(
3494− buffer_rows,
3495− em_width,
3496− scroll_position,
3497− line_height,
3498− &gutter_hitbox,
3499− gutter_dimensions.git_blame_entries_width,
3500− cx,
3501− );
3502−
35033653 let mut max_visible_line_width = Pixels::ZERO;
35043654 let line_layouts =
35053655 self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
@@ -3528,6 +3678,37 @@ impl Element for EditorElement { · 22 unmodified lines
35283678 cx,
35293679 );
35303680
3681+ let scroll_pixel_position = point(
3682+ scroll_position.x * em_width,
3683+ scroll_position.y * line_height,
3684+ );
3685+
3686+ let mut inline_blame = None;
3687+ if let Some(newest_selection_head) = newest_selection_head {
3688+ if (start_row..end_row).contains(&newest_selection_head.row()) {
3689+ inline_blame = self.layout_inline_blame(
3690+ start_row,
3691+ newest_selection_head.row(),
3692+ &line_layouts,
3693+ em_width,
3694+ content_origin,
3695+ scroll_pixel_position,
3696+ line_height,
3697+ cx,
3698+ );
3699+ }
3700+ }
3701+
3702+ let blamed_display_rows = self.layout_blame_entries(
3703+ buffer_rows,
3704+ em_width,
3705+ scroll_position,
3706+ line_height,
3707+ &gutter_hitbox,
3708+ gutter_dimensions.git_blame_entries_width,
3709+ cx,
3710+ );
3711+
35313712 let scroll_max = point(
35323713 ((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
35333714 max_row as f32,
@@ -3555,11 +3736,6 @@ impl Element for EditorElement { · 21 unmodified lines
35553736 }
35563737 });
35573738
3558− let scroll_pixel_position = point(
3559− scroll_position.x * em_width,
3560− scroll_position.y * line_height,
3561− );
3562−
35633739 cx.with_element_id(Some("blocks"), |cx| {
35643740 self.layout_blocks(
35653741 &mut blocks,
@@ -3728,6 +3904,7 @@ impl Element for EditorElement { · 162 unmodified lines
37283904 line_numbers,
37293905 display_hunks,
37303906 blamed_display_rows,
3907+ inline_blame,
37313908 folds,
37323909 blocks,
37333910 cursors,
@@ -3815,6 +3992,7 @@ pub struct EditorLayout { · 81 unmodified lines
38153992 line_numbers: Vec<Option<ShapedLine>>,
38163993 display_hunks: Vec<DisplayDiffHunk>,
38173994 blamed_display_rows: Option<Vec<AnyElement>>,
3995+ inline_blame: Option<AnyElement>,
38183996 folds: Vec<FoldLayout>,
38193997 blocks: Vec<BlockLayout>,
38203998 highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,
crates/editor/src/git/blame.rs+118−55
@@ -1,3 +1,5 @@
1+use std::sync::Arc;
2+
13 use anyhow::Result;
24 use collections::HashMap;
35 use git::{
@@ -5,7 +7,7 @@ use git::{ · 1 unmodified line
57 Oid,
68 };
79 use gpui::{Model, ModelContext, Subscription, Task};
8−use language::{Bias, Buffer, BufferSnapshot, Edit};
10+use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
911 use project::{Item, Project};
1012 use smallvec::SmallVec;
1113 use sum_tree::SumTree;
@@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { · 32 unmodified lines
4446 }
4547 }
4648
49+#[derive(Clone, Debug)]
50+pub struct CommitDetails {
51+ pub message: String,
52+ pub parsed_message: ParsedMarkdown,
53+ pub permalink: Option<Url>,
54+}
55+
4756 pub struct GitBlame {
4857 project: Model<Project>,
4958 buffer: Model<Buffer>,
5059 entries: SumTree<GitBlameEntry>,
51− permalinks: HashMap<Oid, Url>,
52− messages: HashMap<Oid, String>,
60+ commit_details: HashMap<Oid, CommitDetails>,
5361 buffer_snapshot: BufferSnapshot,
5462 buffer_edits: text::Subscription,
5563 task: Task<Result<()>>,
5664 generated: bool,
65+ user_triggered: bool,
5766 _refresh_subscription: Subscription,
5867 }
5968
@@ -61,6 +70,7 @@ impl GitBlame { · 1 unmodified line
6170 pub fn new(
6271 buffer: Model<Buffer>,
6372 project: Model<Project>,
73+ user_triggered: bool,
6474 cx: &mut ModelContext<Self>,
6575 ) -> Self {
6676 let entries = SumTree::from_item(
@@ -102,8 +112,8 @@ impl GitBlame { · 35 unmodified lines
102112 buffer_snapshot,
103113 entries,
104114 buffer_edits,
105− permalinks: HashMap::default(),
106− messages: HashMap::default(),
115+ user_triggered,
116+ commit_details: HashMap::default(),
107117 task: Task::ready(Ok(())),
108118 generated: false,
109119 _refresh_subscription: refresh_subscription,
@@ -116,12 +126,8 @@ impl GitBlame { · 6 unmodified lines
116126 self.generated
117127 }
118128
119− pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {
120− self.permalinks.get(&entry.sha).cloned()
121− }
122−
123− pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
124− self.messages.get(&entry.sha).cloned()
129+ pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
130+ self.commit_details.get(&entry.sha).cloned()
125131 }
126132
127133 pub fn blame_for_rows<'a>(
@@ -254,6 +260,7 @@ impl GitBlame { · 126 unmodified lines
254260 let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
255261 let snapshot = self.buffer.read(cx).snapshot();
256262 let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
263+ let languages = self.project.read(cx).languages().clone();
257264
258265 self.task = cx.spawn(|this, mut cx| async move {
259266 let result = cx
@@ -267,65 +274,121 @@ impl GitBlame { · 7 unmodified lines
267274 messages,
268275 } = blame.await?;
269276
270− let mut current_row = 0;
271− let mut entries = SumTree::from_iter(
272− entries.into_iter().flat_map(|entry| {
273− let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
274−
275− if entry.range.start > current_row {
276− let skipped_rows = entry.range.start - current_row;
277− entries.push(GitBlameEntry {
278− rows: skipped_rows,
279− blame: None,
280− });
281− }
282− entries.push(GitBlameEntry {
283− rows: entry.range.len() as u32,
284− blame: Some(entry.clone()),
285− });
286−
287− current_row = entry.range.end;
288− entries
289− }),
290− &(),
291− );
277+ let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
278+ let commit_details =
279+ parse_commit_messages(messages, &permalinks, &languages).await;
292280
293− let max_row = snapshot.max_point().row;
294− if max_row >= current_row {
295− entries.push(
296− GitBlameEntry {
297− rows: (max_row + 1) - current_row,
298− blame: None,
299− },
300− &(),
301− );
302− }
303−
304− anyhow::Ok((entries, permalinks, messages))
281+ anyhow::Ok((entries, commit_details))
305282 }
306283 })
307284 .await;
308285
309286 this.update(&mut cx, |this, cx| match result {
310− Ok((entries, permalinks, messages)) => {
287+ Ok((entries, commit_details)) => {
311288 this.buffer_edits = buffer_edits;
312289 this.buffer_snapshot = snapshot;
313290 this.entries = entries;
314− this.permalinks = permalinks;
315− this.messages = messages;
291+ this.commit_details = commit_details;
316292 this.generated = true;
317293 cx.notify();
318294 }
319295 Err(error) => this.project.update(cx, |_, cx| {
320− log::error!("failed to get git blame data: {error:?}");
321− let notification = format!("{:#}", error).trim().to_string();
322− cx.emit(project::Event::Notification(notification));
296+ if this.user_triggered {
297+ log::error!("failed to get git blame data: {error:?}");
298+ let notification = format!("{:#}", error).trim().to_string();
299+ cx.emit(project::Event::Notification(notification));
300+ } else {
301+ // If we weren't triggered by a user, we just log errors in the background, instead of sending
302+ // notifications.
303+ // Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
304+ // and opens a non-git file.
305+ if error.downcast_ref::<project::NoRepositoryError>().is_none() {
306+ log::error!("failed to get git blame data: {error:?}");
307+ }
308+ }
323309 }),
324310 })
325311 });
326312 }
327313 }
328314
315+fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
316+ let mut current_row = 0;
317+ let mut entries = SumTree::from_iter(
318+ entries.into_iter().flat_map(|entry| {
319+ let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
320+
321+ if entry.range.start > current_row {
322+ let skipped_rows = entry.range.start - current_row;
323+ entries.push(GitBlameEntry {
324+ rows: skipped_rows,
325+ blame: None,
326+ });
327+ }
328+ entries.push(GitBlameEntry {
329+ rows: entry.range.len() as u32,
330+ blame: Some(entry.clone()),
331+ });
332+
333+ current_row = entry.range.end;
334+ entries
335+ }),
336+ &(),
337+ );
338+
339+ if max_row >= current_row {
340+ entries.push(
341+ GitBlameEntry {
342+ rows: (max_row + 1) - current_row,
343+ blame: None,
344+ },
345+ &(),
346+ );
347+ }
348+
349+ entries
350+}
351+
352+async fn parse_commit_messages(
353+ messages: impl IntoIterator<Item = (Oid, String)>,
354+ permalinks: &HashMap<Oid, Url>,
355+ languages: &Arc<LanguageRegistry>,
356+) -> HashMap<Oid, CommitDetails> {
357+ let mut commit_details = HashMap::default();
358+ for (oid, message) in messages {
359+ let parsed_message = parse_markdown(&message, &languages).await;
360+ let permalink = permalinks.get(&oid).cloned();
361+
362+ commit_details.insert(
363+ oid,
364+ CommitDetails {
365+ message,
366+ parsed_message,
367+ permalink,
368+ },
369+ );
370+ }
371+
372+ commit_details
373+}
374+
375+async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
376+ let mut parsed_message = ParsedMarkdown::default();
377+
378+ markdown::parse_markdown_block(
379+ text,
380+ language_registry,
381+ None,
382+ &mut parsed_message.text,
383+ &mut parsed_message.highlights,
384+ &mut parsed_message.region_ranges,
385+ &mut parsed_message.regions,
386+ )
387+ .await;
388+
389+ parsed_message
390+}
391+
329392 #[cfg(test)]
330393 mod tests {
331394 use super::*;
@@ -394,7 +457,7 @@ mod tests { · 62 unmodified lines
394457 .await
395458 .unwrap();
396459
397− let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), cx));
460+ let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
398461
399462 let event = project.next_event(cx).await;
400463 assert_eq!(
@@ -463,7 +526,7 @@ mod tests { · 62 unmodified lines
463526 .await
464527 .unwrap();
465528
466− let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
529+ let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
467530
468531 cx.executor().run_until_parked();
469532
@@ -543,7 +606,7 @@ mod tests { · 73 unmodified lines
543606 .await
544607 .unwrap();
545608
546− let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
609+ let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
547610
548611 cx.executor().run_until_parked();
549612
@@ -692,7 +755,7 @@ mod tests { · 142 unmodified lines
692755 .await
693756 .unwrap();
694757
695− let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
758+ let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
696759 cx.executor().run_until_parked();
697760 git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
698761
crates/language/src/markdown.rs+1−1
@@ -8,7 +8,7 @@ use gpui::{px, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Underl · 7 unmodified lines
88 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
99
1010 /// Parsed Markdown content.
11−#[derive(Debug, Clone)]
11+#[derive(Debug, Clone, Default)]
1212 pub struct ParsedMarkdown {
1313 /// The Markdown text.
1414 pub text: String,
crates/project/src/project.rs+23−6
@@ -7756,13 +7756,20 @@ impl Project { · 7755 unmodified lines
77567756 .as_local()
77577757 .context("worktree was not local")?
77587758 .snapshot();
7759− let (work_directory, repo) = worktree
7759+
7760+ let (work_directory, repo) = match worktree
77607761 .repository_and_work_directory_for_path(&buffer_project_path.path)
7761− .context("failed to get repo for blamed buffer")?;
7762+ {
7763+ Some(work_dir_repo) => work_dir_repo,
7764+ None => anyhow::bail!(NoRepositoryError {}),
7765+ };
77627766
7763− let repo_entry = worktree
7764− .get_local_repo(&repo)
7765− .context("failed to get repo for blamed buffer")?;
7767+ let repo_entry = match worktree.get_local_repo(&repo) {
7768+ Some(repo_entry) => repo_entry,
7769+ None => anyhow::bail!(NoRepositoryError {}),
7770+ };
7771+
7772+ let repo = repo_entry.repo().clone();
77667773
77677774 let relative_path = buffer_project_path
77687775 .path
@@ -7773,7 +7780,6 @@ impl Project { · 4 unmodified lines
77737780 Some(version) => buffer.rope_for_version(&version).clone(),
77747781 None => buffer.as_rope().clone(),
77757782 };
7776− let repo = repo_entry.repo().clone();
77777783
77787784 anyhow::Ok((repo, relative_path, content))
77797785 });
@@ -10782,3 +10788,14 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> { · 3002 unmodified lines
1078210788 Some(hover)
1078310789 }
1078410790 }
10791+
10792+#[derive(Debug)]
10793+pub struct NoRepositoryError {}
10794+
10795+impl std::fmt::Display for NoRepositoryError {
10796+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10797+ write!(f, "no git repository for worktree found")
10798+ }
10799+}
10800+
10801+impl std::error::Error for NoRepositoryError {}
crates/project/src/project_settings.rs+40−1
@@ -3,7 +3,7 @@ use gpui::AppContext; · 2 unmodified lines
33 use schemars::JsonSchema;
44 use serde::{Deserialize, Serialize};
55 use settings::{Settings, SettingsSources};
6−use std::sync::Arc;
6+use std::{sync::Arc, time::Duration};
77
88 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
99 pub struct ProjectSettings {
@@ -29,6 +29,30 @@ pub struct GitSettings { · 19 unmodified lines
2929 /// Default: tracked_files
3030 pub git_gutter: Option<GitGutterSetting>,
3131 pub gutter_debounce: Option<u64>,
32+ /// Whether or not to show git blame data inline in
33+ /// the currently focused line.
34+ ///
35+ /// Default: off
36+ pub inline_blame: Option<InlineBlameSettings>,
37+}
38+
39+impl GitSettings {
40+ pub fn inline_blame_enabled(&self) -> bool {
41+ match self.inline_blame {
42+ Some(InlineBlameSettings { enabled, .. }) => enabled,
43+ _ => false,
44+ }
45+ }
46+
47+ pub fn inline_blame_delay(&self) -> Option<Duration> {
48+ match self.inline_blame {
49+ Some(InlineBlameSettings {
50+ delay_ms: Some(delay_ms),
51+ ..
52+ }) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
53+ _ => None,
54+ }
55+ }
3256 }
3357
3458 #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -41,6 +65,21 @@ pub enum GitGutterSetting { · 6 unmodified lines
4165 Hide,
4266 }
4367
68+#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
69+#[serde(rename_all = "snake_case")]
70+pub struct InlineBlameSettings {
71+ /// Whether or not to show git blame data inline in
72+ /// the currently focused line.
73+ ///
74+ /// Default: false
75+ pub enabled: bool,
76+ /// Whether to only show the inline blame information
77+ /// after a delay once the cursor stops moving.
78+ ///
79+ /// Default: 0
80+ pub delay_ms: Option<u64>,
81+}
82+
4483 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
4584 pub struct BinarySettings {
4685 pub path: Option<String>,
crates/search/src/buffer_search.rs+2−0
@@ -1096,6 +1096,7 @@ mod tests { · 1095 unmodified lines
10961096 use editor::{DisplayPoint, Editor};
10971097 use gpui::{Context, Hsla, TestAppContext, VisualTestContext};
10981098 use language::Buffer;
1099+ use project::Project;
10991100 use smol::stream::StreamExt as _;
11001101 use unindent::Unindent as _;
11011102
@@ -1106,6 +1107,7 @@ mod tests { · 4 unmodified lines
11061107 editor::init(cx);
11071108
11081109 language::init(cx);
1110+ Project::init_settings(cx);
11091111 theme::init(theme::LoadThemes::JustBase, cx);
11101112 });
11111113 }
crates/time_format/src/time_format.rs+36−1
@@ -9,6 +9,8 @@ pub enum TimestampFormat { · 8 unmodified lines
99 /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
1010 /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
1111 EnhancedAbsolute,
12+ /// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
13+ MediumAbsolute,
1214 /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
1315 Relative,
1416 }
@@ -30,6 +32,9 @@ pub fn format_localized_timestamp( · 15 unmodified lines
3032 TimestampFormat::EnhancedAbsolute => {
3133 format_absolute_timestamp(timestamp_local, reference_local, true)
3234 }
35+ TimestampFormat::MediumAbsolute => {
36+ format_absolute_timestamp_medium(timestamp_local, reference_local)
37+ }
3338 TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
3439 .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
3540 }
@@ -72,6 +77,22 @@ fn format_absolute_timestamp( · 36 unmodified lines
7277 }
7378 }
7479
80+fn format_absolute_timestamp_medium(
81+ timestamp: OffsetDateTime,
82+ #[allow(unused_variables)] reference: OffsetDateTime,
83+) -> String {
84+ #[cfg(target_os = "macos")]
85+ {
86+ macos::format_date_medium(&timestamp)
87+ }
88+ #[cfg(not(target_os = "macos"))]
89+ {
90+ // todo(linux) respect user's date/time preferences
91+ // todo(windows) respect user's date/time preferences
92+ format_timestamp_fallback(timestamp, reference)
93+ }
94+}
95+
7596 fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
7697 let difference = reference - timestamp;
7798 let minutes = difference.whole_minutes();
@@ -253,7 +274,8 @@ mod macos { · 175 unmodified lines
253274 use core_foundation_sys::{
254275 base::kCFAllocatorDefault,
255276 date_formatter::{
256− kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
277+ kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle,
278+ CFDateFormatterCreate,
257279 },
258280 locale::CFLocaleCopyCurrent,
259281 };
@@ -266,6 +288,10 @@ mod macos { · 6 unmodified lines
266288 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
267289 }
268290
291+ pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
292+ format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
293+ }
294+
269295 fn format_with_date_formatter(
270296 timestamp: &time::OffsetDateTime,
271297 fmt: CFDateFormatterRef,
@@ -302,6 +328,15 @@ mod macos { · 30 unmodified lines
302328 kCFDateFormatterNoStyle,
303329 )
304330 };
331+
332+ static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
333+ CFDateFormatterCreate(
334+ kCFAllocatorDefault,
335+ CURRENT_LOCALE.with(|locale| *locale),
336+ kCFDateFormatterMediumStyle,
337+ kCFDateFormatterNoStyle,
338+ )
339+ };
305340 }
306341 }
307342
crates/ui/src/styles/color.rs+2−0
@@ -12,6 +12,7 @@ pub enum Color { · 11 unmodified lines
1212 Disabled,
1313 Error,
1414 Hidden,
15+ Hint,
1516 Info,
1617 Modified,
1718 Conflict,
@@ -36,6 +37,7 @@ impl Color { · 18 unmodified lines
3637 Color::Deleted => cx.theme().status().deleted,
3738 Color::Disabled => cx.theme().colors().text_disabled,
3839 Color::Hidden => cx.theme().status().hidden,
40+ Color::Hint => cx.theme().status().hint,
3941 Color::Info => cx.theme().status().info,
4042 Color::Placeholder => cx.theme().colors().text_placeholder,
4143 Color::Accent => cx.theme().colors().text_accent,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t