Inline git blame (#10398)

Thorsten Ball created

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`.

Change summary

assets/settings/default.json                      |  10 
crates/collab/src/tests/editor_tests.rs           |   8 
crates/collab_ui/src/chat_panel/message_editor.rs |   2 
crates/editor/src/actions.rs                      |   1 
crates/editor/src/editor.rs                       | 119 +++
crates/editor/src/element.rs                      | 464 +++++++++++-----
crates/editor/src/git/blame.rs                    | 173 ++++--
crates/language/src/markdown.rs                   |   2 
crates/project/src/project.rs                     |  29 
crates/project/src/project_settings.rs            |  41 +
crates/search/src/buffer_search.rs                |   2 
crates/time_format/src/time_format.rs             |  37 +
crates/ui/src/styles/color.rs                     |   2 
13 files changed, 654 insertions(+), 236 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -390,7 +390,15 @@
     //      "git_gutter": "tracked_files"
     // 2. Hide the gutter
     //      "git_gutter": "hide"
-    "git_gutter": "tracked_files"
+    "git_gutter": "tracked_files",
+    // Control whether the git blame information is shown inline,
+    // in the currently focused line.
+    "inline_blame": {
+      "enabled": false
+      // Sets a delay after which the inline blame information is shown.
+      // Delay is restarted with every cursor movement.
+      // "delay_ms": 600
+    }
   },
   "copilot": {
     // The set of glob patterns for which copilot should be disabled

crates/collab/src/tests/editor_tests.rs 🔗

@@ -2100,14 +2100,12 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
 
         blame.update(cx, |blame, _| {
             for (idx, entry) in entries.iter().flatten().enumerate() {
+                let details = blame.details_for_entry(entry).unwrap();
+                assert_eq!(details.message, format!("message for idx-{}", idx));
                 assert_eq!(
-                    blame.permalink_for_entry(entry).unwrap().to_string(),
+                    details.permalink.unwrap().to_string(),
                     format!("http://example.com/codehost/idx-{}", idx)
                 );
-                assert_eq!(
-                    blame.message_for_entry(entry).unwrap(),
-                    format!("message for idx-{}", idx)
-                );
             }
         });
     });

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -557,6 +557,7 @@ mod tests {
     use clock::FakeSystemClock;
     use gpui::TestAppContext;
     use language::{Language, LanguageConfig};
+    use project::Project;
     use rpc::proto;
     use settings::SettingsStore;
     use util::{http::FakeHttpClient, test::marked_text_ranges};
@@ -630,6 +631,7 @@ mod tests {
             let client = Client::new(clock, http.clone(), cx);
             let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
             theme::init(theme::LoadThemes::JustBase, cx);
+            Project::init_settings(cx);
             language::init(cx);
             editor::init(cx);
             client::init(&client, cx);

crates/editor/src/actions.rs 🔗

@@ -245,6 +245,7 @@ gpui::actions!(
         Tab,
         TabPrev,
         ToggleGitBlame,
+        ToggleGitBlameInline,
         ToggleInlayHints,
         ToggleLineNumbers,
         ToggleSoftWrap,

crates/editor/src/editor.rs 🔗

@@ -155,7 +155,7 @@ pub fn render_parsed_markdown(
     parsed: &language::ParsedMarkdown,
     editor_style: &EditorStyle,
     workspace: Option<WeakView<Workspace>>,
-    cx: &mut ViewContext<Editor>,
+    cx: &mut WindowContext,
 ) -> InteractiveText {
     let code_span_background_color = cx
         .theme()
@@ -463,7 +463,9 @@ pub struct Editor {
     editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
     use_autoclose: bool,
     auto_replace_emoji_shortcode: bool,
-    show_git_blame: bool,
+    show_git_blame_gutter: bool,
+    show_git_blame_inline: bool,
+    show_git_blame_inline_delay_task: Option<Task<()>>,
     blame: Option<Model<GitBlame>>,
     blame_subscription: Option<Subscription>,
     custom_context_menu: Option<
@@ -480,7 +482,7 @@ pub struct Editor {
 pub struct EditorSnapshot {
     pub mode: EditorMode,
     show_gutter: bool,
-    show_git_blame: bool,
+    render_git_blame_gutter: bool,
     pub display_snapshot: DisplaySnapshot,
     pub placeholder_text: Option<Arc<str>>,
     is_focused: bool,
@@ -1498,7 +1500,9 @@ impl Editor {
             vim_replace_map: Default::default(),
             show_inline_completions: mode == EditorMode::Full,
             custom_context_menu: None,
-            show_git_blame: false,
+            show_git_blame_gutter: false,
+            show_git_blame_inline: false,
+            show_git_blame_inline_delay_task: None,
             blame: None,
             blame_subscription: None,
             _subscriptions: vec![
@@ -1530,6 +1534,10 @@ impl Editor {
         if mode == EditorMode::Full {
             let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
             cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
+
+            if ProjectSettings::get_global(cx).git.inline_blame_enabled() {
+                this.start_git_blame_inline(false, cx);
+            }
         }
 
         this.report_editor_event("open", None, cx);
@@ -1646,10 +1654,7 @@ impl Editor {
         EditorSnapshot {
             mode: self.mode,
             show_gutter: self.show_gutter,
-            show_git_blame: self
-                .blame
-                .as_ref()
-                .map_or(false, |blame| blame.read(cx).has_generated_entries()),
+            render_git_blame_gutter: self.render_git_blame_gutter(cx),
             display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
             scroll_anchor: self.scroll_manager.anchor(),
             ongoing_scroll: self.scroll_manager.ongoing_scroll(),
@@ -1915,6 +1920,7 @@ impl Editor {
             self.refresh_document_highlights(cx);
             refresh_matching_bracket_highlights(self, cx);
             self.discard_inline_completion(cx);
+            self.start_inline_blame_timer(cx);
         }
 
         self.blink_manager.update(cx, BlinkManager::pause_blinking);
@@ -3794,6 +3800,22 @@ impl Editor {
         None
     }
 
+    fn start_inline_blame_timer(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() {
+            self.show_git_blame_inline = false;
+
+            self.show_git_blame_inline_delay_task = Some(cx.spawn(|this, mut cx| async move {
+                cx.background_executor().timer(delay).await;
+
+                this.update(&mut cx, |this, cx| {
+                    this.show_git_blame_inline = true;
+                    cx.notify();
+                })
+                .log_err();
+            }));
+        }
+    }
+
     fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
         if self.pending_rename.is_some() {
             return None;
@@ -8843,40 +8865,83 @@ impl Editor {
     }
 
     pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {
-        if self.show_git_blame {
-            self.blame_subscription.take();
-            self.blame.take();
-            self.show_git_blame = false
-        } else {
-            if let Err(error) = self.show_git_blame_internal(cx) {
-                log::error!("failed to toggle on 'git blame': {}", error);
-                return;
-            }
-            self.show_git_blame = true
+        self.show_git_blame_gutter = !self.show_git_blame_gutter;
+
+        if self.show_git_blame_gutter && !self.has_blame_entries(cx) {
+            self.start_git_blame(true, cx);
         }
 
         cx.notify();
     }
 
-    fn show_git_blame_internal(&mut self, cx: &mut ViewContext<Self>) -> Result<()> {
+    pub fn toggle_git_blame_inline(
+        &mut self,
+        _: &ToggleGitBlameInline,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.toggle_git_blame_inline_internal(true, cx);
+        cx.notify();
+    }
+
+    fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
         if let Some(project) = self.project.as_ref() {
             let Some(buffer) = self.buffer().read(cx).as_singleton() else {
-                anyhow::bail!("git blame not available in multi buffers")
+                return;
             };
 
             let project = project.clone();
-            let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx));
+            let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx));
             self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));
             self.blame = Some(blame);
         }
+    }
 
-        Ok(())
+    fn toggle_git_blame_inline_internal(
+        &mut self,
+        user_triggered: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if self.show_git_blame_inline || self.show_git_blame_inline_delay_task.is_some() {
+            self.show_git_blame_inline = false;
+            self.show_git_blame_inline_delay_task.take();
+        } else {
+            self.start_git_blame_inline(user_triggered, cx);
+        }
+
+        cx.notify();
+    }
+
+    fn start_git_blame_inline(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
+        if let Some(inline_blame_settings) = ProjectSettings::get_global(cx).git.inline_blame {
+            if inline_blame_settings.enabled {
+                self.start_git_blame(user_triggered, cx);
+
+                if inline_blame_settings.delay_ms.is_some() {
+                    self.start_inline_blame_timer(cx);
+                } else {
+                    self.show_git_blame_inline = true
+                }
+            }
+        }
     }
 
     pub fn blame(&self) -> Option<&Model<GitBlame>> {
         self.blame.as_ref()
     }
 
+    pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool {
+        self.show_git_blame_gutter && self.has_blame_entries(cx)
+    }
+
+    pub fn render_git_blame_inline(&mut self, cx: &mut WindowContext) -> bool {
+        self.show_git_blame_inline && self.has_blame_entries(cx)
+    }
+
+    fn has_blame_entries(&self, cx: &mut WindowContext) -> bool {
+        self.blame()
+            .map_or(false, |blame| blame.read(cx).has_generated_entries())
+    }
+
     fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
         let (path, repo) = maybe!({
             let project_handle = self.project.as_ref()?.clone();
@@ -9446,6 +9511,14 @@ impl Editor {
         let editor_settings = EditorSettings::get_global(cx);
         self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
         self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
+
+        if self.mode == EditorMode::Full {
+            let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
+            if self.show_git_blame_inline != inline_blame_enabled {
+                self.toggle_git_blame_inline_internal(false, cx);
+            }
+        }
+
         cx.notify();
     }
 
@@ -10058,7 +10131,7 @@ impl EditorSnapshot {
         };
 
         let git_blame_entries_width = self
-            .show_git_blame
+            .render_git_blame_gutter
             .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
 
         let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);

crates/editor/src/element.rs 🔗

@@ -4,7 +4,10 @@ use crate::{
         TransformBlock,
     },
     editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},
-    git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk},
+    git::{
+        blame::{CommitDetails, GitBlame},
+        diff_hunk_to_display, DisplayDiffHunk,
+    },
     hover_popover::{
         self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
     },
@@ -21,13 +24,13 @@ use collections::{BTreeMap, HashMap};
 use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
 use gpui::{
     anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
-    transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds,
-    ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,
-    ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
+    transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
+    ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext,
+    ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
     ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
-    ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful,
-    StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
-    ViewContext, WindowContext,
+    ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
+    Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle,
+    TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use language::language_settings::ShowWhitespaceSetting;
@@ -49,11 +52,11 @@ use std::{
     sync::Arc,
 };
 use sum_tree::Bias;
-use theme::{ActiveTheme, PlayerColor};
+use theme::{ActiveTheme, PlayerColor, ThemeSettings};
 use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
 use ui::{prelude::*, tooltip_container};
 use util::ResultExt;
-use workspace::item::Item;
+use workspace::{item::Item, Workspace};
 
 struct SelectionLayout {
     head: DisplayPoint,
@@ -303,6 +306,7 @@ impl EditorElement {
         register_action(view, cx, Editor::copy_permalink_to_line);
         register_action(view, cx, Editor::open_permalink_to_line);
         register_action(view, cx, Editor::toggle_git_blame);
+        register_action(view, cx, Editor::toggle_git_blame_inline);
         register_action(view, cx, |editor, action, cx| {
             if let Some(task) = editor.format(action, cx) {
                 task.detach_and_log_err(cx);
@@ -1092,6 +1096,58 @@ impl EditorElement {
             .collect()
     }
 
+    #[allow(clippy::too_many_arguments)]
+    fn layout_inline_blame(
+        &self,
+        start_row: u32,
+        row: u32,
+        line_layouts: &[LineWithInvisibles],
+        em_width: Pixels,
+        content_origin: gpui::Point<Pixels>,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        cx: &mut ElementContext,
+    ) -> Option<AnyElement> {
+        if !self
+            .editor
+            .update(cx, |editor, cx| editor.render_git_blame_inline(cx))
+        {
+            return None;
+        }
+
+        let blame = self.editor.read(cx).blame.clone()?;
+        let workspace = self
+            .editor
+            .read(cx)
+            .workspace
+            .as_ref()
+            .map(|(w, _)| w.clone());
+        let blame_entry = blame
+            .update(cx, |blame, cx| blame.blame_for_rows([Some(row)], cx).next())
+            .flatten()?;
+
+        let mut element =
+            render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx);
+
+        let start_y =
+            content_origin.y + line_height * (row as f32 - scroll_pixel_position.y / line_height);
+
+        let start_x = {
+            let line_layout = &line_layouts[(row - start_row) as usize];
+            let line_width = line_layout.line.width;
+
+            // TODO: define the padding as a constant
+            content_origin.x + line_width + (em_width * 6.)
+        };
+
+        let absolute_offset = point(start_x, start_y);
+        let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
+
+        element.layout(absolute_offset, available_space, cx);
+
+        Some(element)
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn layout_blame_entries(
         &self,
@@ -1103,10 +1159,14 @@ impl EditorElement {
         max_width: Option<Pixels>,
         cx: &mut ElementContext,
     ) -> Option<Vec<AnyElement>> {
-        let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else {
+        if !self
+            .editor
+            .update(cx, |editor, cx| editor.render_git_blame_gutter(cx))
+        {
             return None;
-        };
+        }
 
+        let blame = self.editor.read(cx).blame.clone()?;
         let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
             blame.blame_for_rows(buffer_rows, cx).collect()
         });
@@ -1120,7 +1180,6 @@ impl EditorElement {
         let start_x = em_width * 1;
 
         let mut last_used_color: Option<(PlayerColor, Oid)> = None;
-        let text_style = &self.style.text;
 
         let shaped_lines = blamed_rows
             .into_iter()
@@ -1131,7 +1190,7 @@ impl EditorElement {
                         ix,
                         &blame,
                         blame_entry,
-                        text_style,
+                        &self.style,
                         &mut last_used_color,
                         self.editor.clone(),
                         cx,
@@ -2256,6 +2315,7 @@ impl EditorElement {
                 self.paint_lines(&invisible_display_ranges, layout, cx);
                 self.paint_redactions(layout, cx);
                 self.paint_cursors(layout, cx);
+                self.paint_inline_blame(layout, cx);
             },
         )
     }
@@ -2730,6 +2790,14 @@ impl EditorElement {
         })
     }
 
+    fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
+        if let Some(mut inline_blame) = layout.inline_blame.take() {
+            cx.paint_layer(layout.text_hitbox.bounds, |cx| {
+                inline_blame.paint(cx);
+            })
+        }
+    }
+
     fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
         for mut block in layout.blocks.drain(..) {
             block.element.paint(cx);
@@ -2894,11 +2962,192 @@ impl EditorElement {
     }
 }
 
+fn render_inline_blame_entry(
+    blame: &gpui::Model<GitBlame>,
+    blame_entry: BlameEntry,
+    style: &EditorStyle,
+    workspace: Option<WeakView<Workspace>>,
+    cx: &mut ElementContext<'_>,
+) -> AnyElement {
+    let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
+
+    let author = blame_entry.author.as_deref().unwrap_or_default();
+    let text = format!("{}, {}", author, relative_timestamp);
+
+    let details = blame.read(cx).details_for_entry(&blame_entry);
+
+    let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
+
+    h_flex()
+        .id("inline-blame")
+        .w_full()
+        .font(style.text.font().family)
+        .text_color(cx.theme().status().hint)
+        .line_height(style.text.line_height)
+        .child(Icon::new(IconName::FileGit).color(Color::Hint))
+        .child(text)
+        .gap_2()
+        .hoverable_tooltip(move |_| tooltip.clone().into())
+        .into_any()
+}
+
+fn blame_entry_timestamp(
+    blame_entry: &BlameEntry,
+    format: time_format::TimestampFormat,
+    cx: &WindowContext,
+) -> String {
+    match blame_entry.author_offset_date_time() {
+        Ok(timestamp) => time_format::format_localized_timestamp(
+            timestamp,
+            time::OffsetDateTime::now_utc(),
+            cx.local_timezone(),
+            format,
+        ),
+        Err(_) => "Error parsing date".to_string(),
+    }
+}
+
+fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
+    blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx)
+}
+
+fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
+    blame_entry_timestamp(
+        blame_entry,
+        time_format::TimestampFormat::MediumAbsolute,
+        cx,
+    )
+}
+
+struct BlameEntryTooltip {
+    blame_entry: BlameEntry,
+    details: Option<CommitDetails>,
+    style: EditorStyle,
+    workspace: Option<WeakView<Workspace>>,
+    scroll_handle: ScrollHandle,
+}
+
+impl BlameEntryTooltip {
+    fn new(
+        blame_entry: BlameEntry,
+        details: Option<CommitDetails>,
+        style: &EditorStyle,
+        workspace: Option<WeakView<Workspace>>,
+    ) -> Self {
+        Self {
+            style: style.clone(),
+            blame_entry,
+            details,
+            workspace,
+            scroll_handle: ScrollHandle::new(),
+        }
+    }
+}
+
+impl Render for BlameEntryTooltip {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let author = self
+            .blame_entry
+            .author
+            .clone()
+            .unwrap_or("<no name>".to_string());
+
+        let author_email = self.blame_entry.author_mail.clone();
+
+        let pretty_commit_id = format!("{}", self.blame_entry.sha);
+        let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
+        let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx);
+
+        let message = self
+            .details
+            .as_ref()
+            .map(|details| {
+                crate::render_parsed_markdown(
+                    "blame-message",
+                    &details.parsed_message,
+                    &self.style,
+                    self.workspace.clone(),
+                    cx,
+                )
+                .into_any()
+            })
+            .unwrap_or("<no commit message>".into_any());
+
+        let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
+        let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
+
+        tooltip_container(cx, move |this, cx| {
+            this.occlude()
+                .on_mouse_move(|_, cx| cx.stop_propagation())
+                .child(
+                    v_flex()
+                        .w(gpui::rems(30.))
+                        .gap_4()
+                        .child(
+                            h_flex()
+                                .gap_2()
+                                .child(author)
+                                .when_some(author_email, |this, author_email| {
+                                    this.child(
+                                        div()
+                                            .text_color(cx.theme().colors().text_muted)
+                                            .child(author_email),
+                                    )
+                                })
+                                .pb_1()
+                                .border_b_1()
+                                .border_color(cx.theme().colors().border),
+                        )
+                        .child(
+                            div()
+                                .id("inline-blame-commit-message")
+                                .occlude()
+                                .child(message)
+                                .max_h(message_max_height)
+                                .overflow_y_scroll()
+                                .track_scroll(&self.scroll_handle),
+                        )
+                        .child(
+                            h_flex()
+                                .text_color(cx.theme().colors().text_muted)
+                                .w_full()
+                                .justify_between()
+                                .child(absolute_timestamp)
+                                .child(
+                                    Button::new("commit-sha-button", short_commit_id.clone())
+                                        .style(ButtonStyle::Transparent)
+                                        .color(Color::Muted)
+                                        .icon(IconName::FileGit)
+                                        .icon_color(Color::Muted)
+                                        .icon_position(IconPosition::Start)
+                                        .disabled(
+                                            self.details.as_ref().map_or(true, |details| {
+                                                details.permalink.is_none()
+                                            }),
+                                        )
+                                        .when_some(
+                                            self.details
+                                                .as_ref()
+                                                .and_then(|details| details.permalink.clone()),
+                                            |this, url| {
+                                                this.on_click(move |_, cx| {
+                                                    cx.stop_propagation();
+                                                    cx.open_url(url.as_str())
+                                                })
+                                            },
+                                        ),
+                                ),
+                        ),
+                )
+        })
+    }
+}
+
 fn render_blame_entry(
     ix: usize,
     blame: &gpui::Model<GitBlame>,
     blame_entry: BlameEntry,
-    text_style: &TextStyle,
+    style: &EditorStyle,
     last_used_color: &mut Option<(PlayerColor, Oid)>,
     editor: View<Editor>,
     cx: &mut ElementContext<'_>,
@@ -2918,29 +3167,26 @@ fn render_blame_entry(
     };
     last_used_color.replace((sha_color, blame_entry.sha));
 
-    let relative_timestamp = match blame_entry.author_offset_date_time() {
-        Ok(timestamp) => time_format::format_localized_timestamp(
-            timestamp,
-            time::OffsetDateTime::now_utc(),
-            cx.local_timezone(),
-            time_format::TimestampFormat::Relative,
-        ),
-        Err(_) => "Error parsing date".to_string(),
-    };
+    let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
 
     let pretty_commit_id = format!("{}", blame_entry.sha);
-    let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::<String>();
+    let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
 
     let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
     let name = util::truncate_and_trailoff(author_name, 20);
 
-    let permalink = blame.read(cx).permalink_for_entry(&blame_entry);
-    let commit_message = blame.read(cx).message_for_entry(&blame_entry);
+    let details = blame.read(cx).details_for_entry(&blame_entry);
+
+    let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
+
+    let tooltip = cx.new_view(|_| {
+        BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)
+    });
 
     h_flex()
         .w_full()
-        .font(text_style.font().family)
-        .line_height(text_style.line_height)
+        .font(style.text.font().family)
+        .line_height(style.text.line_height)
         .id(("blame", ix))
         .children([
             div()
@@ -2962,21 +3208,17 @@ fn render_blame_entry(
             }
         })
         .hover(|style| style.bg(cx.theme().colors().element_hover))
-        .when_some(permalink, |this, url| {
-            let url = url.clone();
-            this.cursor_pointer().on_click(move |_, cx| {
-                cx.stop_propagation();
-                cx.open_url(url.as_str())
-            })
-        })
-        .hoverable_tooltip(move |cx| {
-            BlameEntryTooltip::new(
-                sha_color.cursor,
-                commit_message.clone(),
-                blame_entry.clone(),
-                cx,
-            )
-        })
+        .when_some(
+            details.and_then(|details| details.permalink),
+            |this, url| {
+                let url = url.clone();
+                this.cursor_pointer().on_click(move |_, cx| {
+                    cx.stop_propagation();
+                    cx.open_url(url.as_str())
+                })
+            },
+        )
+        .hoverable_tooltip(move |_| tooltip.clone().into())
         .into_any()
 }
 
@@ -2999,84 +3241,6 @@ fn deploy_blame_entry_context_menu(
     });
 }
 
-struct BlameEntryTooltip {
-    color: Hsla,
-    commit_message: Option<String>,
-    blame_entry: BlameEntry,
-}
-
-impl BlameEntryTooltip {
-    fn new(
-        color: Hsla,
-        commit_message: Option<String>,
-        blame_entry: BlameEntry,
-        cx: &mut WindowContext,
-    ) -> AnyView {
-        cx.new_view(|_cx| Self {
-            color,
-            commit_message,
-            blame_entry,
-        })
-        .into()
-    }
-}
-
-impl Render for BlameEntryTooltip {
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let author = self
-            .blame_entry
-            .author
-            .clone()
-            .unwrap_or("<no name>".to_string());
-        let author_email = self.blame_entry.author_mail.clone().unwrap_or_default();
-        let absolute_timestamp = match self.blame_entry.author_offset_date_time() {
-            Ok(timestamp) => time_format::format_localized_timestamp(
-                timestamp,
-                time::OffsetDateTime::now_utc(),
-                cx.local_timezone(),
-                time_format::TimestampFormat::Absolute,
-            ),
-            Err(_) => "Error parsing date".to_string(),
-        };
-
-        let message = match &self.commit_message {
-            Some(message) => util::truncate_lines_and_trailoff(message, 15),
-            None => self.blame_entry.summary.clone().unwrap_or_default(),
-        };
-
-        let pretty_commit_id = format!("{}", self.blame_entry.sha);
-
-        tooltip_container(cx, move |this, cx| {
-            this.occlude()
-                .on_mouse_move(|_, cx| cx.stop_propagation())
-                .child(
-                    v_flex()
-                        .child(
-                            h_flex()
-                                .child(
-                                    div()
-                                        .text_color(cx.theme().colors().text_muted)
-                                        .child("Commit")
-                                        .pr_2(),
-                                )
-                                .child(
-                                    div().text_color(self.color).child(pretty_commit_id.clone()),
-                                ),
-                        )
-                        .child(
-                            div()
-                                .child(format!(
-                                    "{} {} - {}",
-                                    author, author_email, absolute_timestamp
-                                ))
-                                .text_color(cx.theme().colors().text_muted),
-                        )
-                        .child(div().child(message)),
-                )
-        })
-    }
-}
-
 #[derive(Debug)]
 pub(crate) struct LineWithInvisibles {
     pub line: ShapedLine,
@@ -3205,13 +3369,9 @@ impl LineWithInvisibles {
         let line_y =
             line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);
 
-        self.line
-            .paint(
-                content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y),
-                line_height,
-                cx,
-            )
-            .log_err();
+        let line_origin =
+            content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y);
+        self.line.paint(line_origin, line_height, cx).log_err();
 
         self.draw_invisibles(
             &selection_ranges,
@@ -3490,16 +3650,6 @@ impl Element for EditorElement {
 
                 let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
 
-                let blamed_display_rows = self.layout_blame_entries(
-                    buffer_rows,
-                    em_width,
-                    scroll_position,
-                    line_height,
-                    &gutter_hitbox,
-                    gutter_dimensions.git_blame_entries_width,
-                    cx,
-                );
-
                 let mut max_visible_line_width = Pixels::ZERO;
                 let line_layouts =
                     self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
@@ -3528,6 +3678,37 @@ impl Element for EditorElement {
                     cx,
                 );
 
+                let scroll_pixel_position = point(
+                    scroll_position.x * em_width,
+                    scroll_position.y * line_height,
+                );
+
+                let mut inline_blame = None;
+                if let Some(newest_selection_head) = newest_selection_head {
+                    if (start_row..end_row).contains(&newest_selection_head.row()) {
+                        inline_blame = self.layout_inline_blame(
+                            start_row,
+                            newest_selection_head.row(),
+                            &line_layouts,
+                            em_width,
+                            content_origin,
+                            scroll_pixel_position,
+                            line_height,
+                            cx,
+                        );
+                    }
+                }
+
+                let blamed_display_rows = self.layout_blame_entries(
+                    buffer_rows,
+                    em_width,
+                    scroll_position,
+                    line_height,
+                    &gutter_hitbox,
+                    gutter_dimensions.git_blame_entries_width,
+                    cx,
+                );
+
                 let scroll_max = point(
                     ((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
                     max_row as f32,
@@ -3555,11 +3736,6 @@ impl Element for EditorElement {
                     }
                 });
 
-                let scroll_pixel_position = point(
-                    scroll_position.x * em_width,
-                    scroll_position.y * line_height,
-                );
-
                 cx.with_element_id(Some("blocks"), |cx| {
                     self.layout_blocks(
                         &mut blocks,
@@ -3728,6 +3904,7 @@ impl Element for EditorElement {
                     line_numbers,
                     display_hunks,
                     blamed_display_rows,
+                    inline_blame,
                     folds,
                     blocks,
                     cursors,
@@ -3815,6 +3992,7 @@ pub struct EditorLayout {
     line_numbers: Vec<Option<ShapedLine>>,
     display_hunks: Vec<DisplayDiffHunk>,
     blamed_display_rows: Option<Vec<AnyElement>>,
+    inline_blame: Option<AnyElement>,
     folds: Vec<FoldLayout>,
     blocks: Vec<BlockLayout>,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,

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

@@ -1,3 +1,5 @@
+use std::sync::Arc;
+
 use anyhow::Result;
 use collections::HashMap;
 use git::{
@@ -5,7 +7,7 @@ use git::{
     Oid,
 };
 use gpui::{Model, ModelContext, Subscription, Task};
-use language::{Bias, Buffer, BufferSnapshot, Edit};
+use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
 use project::{Item, Project};
 use smallvec::SmallVec;
 use sum_tree::SumTree;
@@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
     }
 }
 
+#[derive(Clone, Debug)]
+pub struct CommitDetails {
+    pub message: String,
+    pub parsed_message: ParsedMarkdown,
+    pub permalink: Option<Url>,
+}
+
 pub struct GitBlame {
     project: Model<Project>,
     buffer: Model<Buffer>,
     entries: SumTree<GitBlameEntry>,
-    permalinks: HashMap<Oid, Url>,
-    messages: HashMap<Oid, String>,
+    commit_details: HashMap<Oid, CommitDetails>,
     buffer_snapshot: BufferSnapshot,
     buffer_edits: text::Subscription,
     task: Task<Result<()>>,
     generated: bool,
+    user_triggered: bool,
     _refresh_subscription: Subscription,
 }
 
@@ -61,6 +70,7 @@ impl GitBlame {
     pub fn new(
         buffer: Model<Buffer>,
         project: Model<Project>,
+        user_triggered: bool,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let entries = SumTree::from_item(
@@ -102,8 +112,8 @@ impl GitBlame {
             buffer_snapshot,
             entries,
             buffer_edits,
-            permalinks: HashMap::default(),
-            messages: HashMap::default(),
+            user_triggered,
+            commit_details: HashMap::default(),
             task: Task::ready(Ok(())),
             generated: false,
             _refresh_subscription: refresh_subscription,
@@ -116,12 +126,8 @@ impl GitBlame {
         self.generated
     }
 
-    pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {
-        self.permalinks.get(&entry.sha).cloned()
-    }
-
-    pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
-        self.messages.get(&entry.sha).cloned()
+    pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
+        self.commit_details.get(&entry.sha).cloned()
     }
 
     pub fn blame_for_rows<'a>(
@@ -254,6 +260,7 @@ impl GitBlame {
         let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
         let snapshot = self.buffer.read(cx).snapshot();
         let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
+        let languages = self.project.read(cx).languages().clone();
 
         self.task = cx.spawn(|this, mut cx| async move {
             let result = cx
@@ -267,65 +274,121 @@ impl GitBlame {
                             messages,
                         } = blame.await?;
 
-                        let mut current_row = 0;
-                        let mut entries = SumTree::from_iter(
-                            entries.into_iter().flat_map(|entry| {
-                                let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
-
-                                if entry.range.start > current_row {
-                                    let skipped_rows = entry.range.start - current_row;
-                                    entries.push(GitBlameEntry {
-                                        rows: skipped_rows,
-                                        blame: None,
-                                    });
-                                }
-                                entries.push(GitBlameEntry {
-                                    rows: entry.range.len() as u32,
-                                    blame: Some(entry.clone()),
-                                });
-
-                                current_row = entry.range.end;
-                                entries
-                            }),
-                            &(),
-                        );
+                        let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
+                        let commit_details =
+                            parse_commit_messages(messages, &permalinks, &languages).await;
 
-                        let max_row = snapshot.max_point().row;
-                        if max_row >= current_row {
-                            entries.push(
-                                GitBlameEntry {
-                                    rows: (max_row + 1) - current_row,
-                                    blame: None,
-                                },
-                                &(),
-                            );
-                        }
-
-                        anyhow::Ok((entries, permalinks, messages))
+                        anyhow::Ok((entries, commit_details))
                     }
                 })
                 .await;
 
             this.update(&mut cx, |this, cx| match result {
-                Ok((entries, permalinks, messages)) => {
+                Ok((entries, commit_details)) => {
                     this.buffer_edits = buffer_edits;
                     this.buffer_snapshot = snapshot;
                     this.entries = entries;
-                    this.permalinks = permalinks;
-                    this.messages = messages;
+                    this.commit_details = commit_details;
                     this.generated = true;
                     cx.notify();
                 }
                 Err(error) => this.project.update(cx, |_, cx| {
-                    log::error!("failed to get git blame data: {error:?}");
-                    let notification = format!("{:#}", error).trim().to_string();
-                    cx.emit(project::Event::Notification(notification));
+                    if this.user_triggered {
+                        log::error!("failed to get git blame data: {error:?}");
+                        let notification = format!("{:#}", error).trim().to_string();
+                        cx.emit(project::Event::Notification(notification));
+                    } else {
+                        // If we weren't triggered by a user, we just log errors in the background, instead of sending
+                        // notifications.
+                        // Except for `NoRepositoryError`, which can  happen often if a user has inline-blame turned on
+                        // and opens a non-git file.
+                        if error.downcast_ref::<project::NoRepositoryError>().is_none() {
+                            log::error!("failed to get git blame data: {error:?}");
+                        }
+                    }
                 }),
             })
         });
     }
 }
 
+fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
+    let mut current_row = 0;
+    let mut entries = SumTree::from_iter(
+        entries.into_iter().flat_map(|entry| {
+            let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
+
+            if entry.range.start > current_row {
+                let skipped_rows = entry.range.start - current_row;
+                entries.push(GitBlameEntry {
+                    rows: skipped_rows,
+                    blame: None,
+                });
+            }
+            entries.push(GitBlameEntry {
+                rows: entry.range.len() as u32,
+                blame: Some(entry.clone()),
+            });
+
+            current_row = entry.range.end;
+            entries
+        }),
+        &(),
+    );
+
+    if max_row >= current_row {
+        entries.push(
+            GitBlameEntry {
+                rows: (max_row + 1) - current_row,
+                blame: None,
+            },
+            &(),
+        );
+    }
+
+    entries
+}
+
+async fn parse_commit_messages(
+    messages: impl IntoIterator<Item = (Oid, String)>,
+    permalinks: &HashMap<Oid, Url>,
+    languages: &Arc<LanguageRegistry>,
+) -> HashMap<Oid, CommitDetails> {
+    let mut commit_details = HashMap::default();
+    for (oid, message) in messages {
+        let parsed_message = parse_markdown(&message, &languages).await;
+        let permalink = permalinks.get(&oid).cloned();
+
+        commit_details.insert(
+            oid,
+            CommitDetails {
+                message,
+                parsed_message,
+                permalink,
+            },
+        );
+    }
+
+    commit_details
+}
+
+async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
+    let mut parsed_message = ParsedMarkdown::default();
+
+    markdown::parse_markdown_block(
+        text,
+        language_registry,
+        None,
+        &mut parsed_message.text,
+        &mut parsed_message.highlights,
+        &mut parsed_message.region_ranges,
+        &mut parsed_message.regions,
+    )
+    .await;
+
+    parsed_message
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -394,7 +457,7 @@ mod tests {
             .await
             .unwrap();
 
-        let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), cx));
+        let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
 
         let event = project.next_event(cx).await;
         assert_eq!(
@@ -463,7 +526,7 @@ mod tests {
             .await
             .unwrap();
 
-        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
+        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
 
         cx.executor().run_until_parked();
 
@@ -543,7 +606,7 @@ mod tests {
             .await
             .unwrap();
 
-        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
+        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
 
         cx.executor().run_until_parked();
 
@@ -692,7 +755,7 @@ mod tests {
             .await
             .unwrap();
 
-        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
+        let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
         cx.executor().run_until_parked();
         git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
 

crates/language/src/markdown.rs 🔗

@@ -8,7 +8,7 @@ use gpui::{px, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Underl
 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
 
 /// Parsed Markdown content.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 pub struct ParsedMarkdown {
     /// The Markdown text.
     pub text: String,

crates/project/src/project.rs 🔗

@@ -7756,13 +7756,20 @@ impl Project {
                     .as_local()
                     .context("worktree was not local")?
                     .snapshot();
-                let (work_directory, repo) = worktree
+
+                let (work_directory, repo) = match worktree
                     .repository_and_work_directory_for_path(&buffer_project_path.path)
-                    .context("failed to get repo for blamed buffer")?;
+                {
+                    Some(work_dir_repo) => work_dir_repo,
+                    None => anyhow::bail!(NoRepositoryError {}),
+                };
 
-                let repo_entry = worktree
-                    .get_local_repo(&repo)
-                    .context("failed to get repo for blamed buffer")?;
+                let repo_entry = match worktree.get_local_repo(&repo) {
+                    Some(repo_entry) => repo_entry,
+                    None => anyhow::bail!(NoRepositoryError {}),
+                };
+
+                let repo = repo_entry.repo().clone();
 
                 let relative_path = buffer_project_path
                     .path
@@ -7773,7 +7780,6 @@ impl Project {
                     Some(version) => buffer.rope_for_version(&version).clone(),
                     None => buffer.as_rope().clone(),
                 };
-                let repo = repo_entry.repo().clone();
 
                 anyhow::Ok((repo, relative_path, content))
             });
@@ -10782,3 +10788,14 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> {
         Some(hover)
     }
 }
+
+#[derive(Debug)]
+pub struct NoRepositoryError {}
+
+impl std::fmt::Display for NoRepositoryError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "no git repository for worktree found")
+    }
+}
+
+impl std::error::Error for NoRepositoryError {}

crates/project/src/project_settings.rs 🔗

@@ -3,7 +3,7 @@ use gpui::AppContext;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
-use std::sync::Arc;
+use std::{sync::Arc, time::Duration};
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct ProjectSettings {
@@ -29,6 +29,30 @@ pub struct GitSettings {
     /// Default: tracked_files
     pub git_gutter: Option<GitGutterSetting>,
     pub gutter_debounce: Option<u64>,
+    /// Whether or not to show git blame data inline in
+    /// the currently focused line.
+    ///
+    /// Default: off
+    pub inline_blame: Option<InlineBlameSettings>,
+}
+
+impl GitSettings {
+    pub fn inline_blame_enabled(&self) -> bool {
+        match self.inline_blame {
+            Some(InlineBlameSettings { enabled, .. }) => enabled,
+            _ => false,
+        }
+    }
+
+    pub fn inline_blame_delay(&self) -> Option<Duration> {
+        match self.inline_blame {
+            Some(InlineBlameSettings {
+                delay_ms: Some(delay_ms),
+                ..
+            }) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
+            _ => None,
+        }
+    }
 }
 
 #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -41,6 +65,21 @@ pub enum GitGutterSetting {
     Hide,
 }
 
+#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct InlineBlameSettings {
+    /// Whether or not to show git blame data inline in
+    /// the currently focused line.
+    ///
+    /// Default: false
+    pub enabled: bool,
+    /// Whether to only show the inline blame information
+    /// after a delay once the cursor stops moving.
+    ///
+    /// Default: 0
+    pub delay_ms: Option<u64>,
+}
+
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 pub struct BinarySettings {
     pub path: Option<String>,

crates/search/src/buffer_search.rs 🔗

@@ -1096,6 +1096,7 @@ mod tests {
     use editor::{DisplayPoint, Editor};
     use gpui::{Context, Hsla, TestAppContext, VisualTestContext};
     use language::Buffer;
+    use project::Project;
     use smol::stream::StreamExt as _;
     use unindent::Unindent as _;
 
@@ -1106,6 +1107,7 @@ mod tests {
             editor::init(cx);
 
             language::init(cx);
+            Project::init_settings(cx);
             theme::init(theme::LoadThemes::JustBase, cx);
         });
     }

crates/time_format/src/time_format.rs 🔗

@@ -9,6 +9,8 @@ pub enum TimestampFormat {
     /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
     /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
     EnhancedAbsolute,
+    /// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
+    MediumAbsolute,
     /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
     Relative,
 }
@@ -30,6 +32,9 @@ pub fn format_localized_timestamp(
         TimestampFormat::EnhancedAbsolute => {
             format_absolute_timestamp(timestamp_local, reference_local, true)
         }
+        TimestampFormat::MediumAbsolute => {
+            format_absolute_timestamp_medium(timestamp_local, reference_local)
+        }
         TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
             .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
     }
@@ -72,6 +77,22 @@ fn format_absolute_timestamp(
     }
 }
 
+fn format_absolute_timestamp_medium(
+    timestamp: OffsetDateTime,
+    #[allow(unused_variables)] reference: OffsetDateTime,
+) -> String {
+    #[cfg(target_os = "macos")]
+    {
+        macos::format_date_medium(&timestamp)
+    }
+    #[cfg(not(target_os = "macos"))]
+    {
+        // todo(linux) respect user's date/time preferences
+        // todo(windows) respect user's date/time preferences
+        format_timestamp_fallback(timestamp, reference)
+    }
+}
+
 fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
     let difference = reference - timestamp;
     let minutes = difference.whole_minutes();
@@ -253,7 +274,8 @@ mod macos {
     use core_foundation_sys::{
         base::kCFAllocatorDefault,
         date_formatter::{
-            kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
+            kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle,
+            CFDateFormatterCreate,
         },
         locale::CFLocaleCopyCurrent,
     };
@@ -266,6 +288,10 @@ mod macos {
         format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
     }
 
+    pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
+        format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
+    }
+
     fn format_with_date_formatter(
         timestamp: &time::OffsetDateTime,
         fmt: CFDateFormatterRef,
@@ -302,6 +328,15 @@ mod macos {
                 kCFDateFormatterNoStyle,
             )
         };
+
+        static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
+            CFDateFormatterCreate(
+                kCFAllocatorDefault,
+                CURRENT_LOCALE.with(|locale| *locale),
+                kCFDateFormatterMediumStyle,
+                kCFDateFormatterNoStyle,
+            )
+        };
     }
 }
 

crates/ui/src/styles/color.rs 🔗

@@ -12,6 +12,7 @@ pub enum Color {
     Disabled,
     Error,
     Hidden,
+    Hint,
     Info,
     Modified,
     Conflict,
@@ -36,6 +37,7 @@ impl Color {
             Color::Deleted => cx.theme().status().deleted,
             Color::Disabled => cx.theme().colors().text_disabled,
             Color::Hidden => cx.theme().status().hidden,
+            Color::Hint => cx.theme().status().hint,
             Color::Info => cx.theme().status().info,
             Color::Placeholder => cx.theme().colors().text_placeholder,
             Color::Accent => cx.theme().colors().text_accent,