From faebce8cd0314dbcc2279da5d6fa74c8785fa8ab Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Mon, 15 Apr 2024 14:21:52 +0200 Subject: [PATCH] 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`. --- assets/settings/default.json | 10 +- crates/collab/src/tests/editor_tests.rs | 8 +- .../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 | 31 +- 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, 655 insertions(+), 237 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 4657057d06..e81060a707 100644 --- a/assets/settings/default.json +++ b/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 diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 20db56bbe8..7ec5c84042 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/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) - ); } }); }); diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index ac598a8c50..c1c21d6ab7 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/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); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 6520fd2af8..a5a3e4fee1 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -245,6 +245,7 @@ gpui::actions!( Tab, TabPrev, ToggleGitBlame, + ToggleGitBlameInline, ToggleInlayHints, ToggleLineNumbers, ToggleSoftWrap, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a1e4d0db05..3df1fa6732 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -155,7 +155,7 @@ pub fn render_parsed_markdown( parsed: &language::ParsedMarkdown, editor_style: &EditorStyle, workspace: Option>, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> InteractiveText { let code_span_background_color = cx .theme() @@ -463,7 +463,9 @@ pub struct Editor { editor_actions: Vec)>>, 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>, blame: Option>, blame_subscription: Option, 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>, 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) { + 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) -> 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) { - 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) -> Result<()> { + pub fn toggle_git_blame_inline( + &mut self, + _: &ToggleGitBlameInline, + cx: &mut ViewContext, + ) { + self.toggle_git_blame_inline_internal(true, cx); + cx.notify(); + } + + fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext) { 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, + ) { + 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) { + 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> { 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) -> Result { 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); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 444efa4cf6..a3e8680056 100644 --- a/crates/editor/src/element.rs +++ b/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, + scroll_pixel_position: gpui::Point, + line_height: Pixels, + cx: &mut ElementContext, + ) -> Option { + 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, cx: &mut ElementContext, ) -> Option> { - 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, + blame_entry: BlameEntry, + style: &EditorStyle, + workspace: Option>, + 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, + style: EditorStyle, + workspace: Option>, + scroll_handle: ScrollHandle, +} + +impl BlameEntryTooltip { + fn new( + blame_entry: BlameEntry, + details: Option, + style: &EditorStyle, + workspace: Option>, + ) -> Self { + Self { + style: style.clone(), + blame_entry, + details, + workspace, + scroll_handle: ScrollHandle::new(), + } + } +} + +impl Render for BlameEntryTooltip { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let author = self + .blame_entry + .author + .clone() + .unwrap_or("".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::(); + 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("".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, blame_entry: BlameEntry, - text_style: &TextStyle, + style: &EditorStyle, last_used_color: &mut Option<(PlayerColor, Oid)>, editor: View, 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::(); + let short_commit_id = pretty_commit_id.chars().take(6).collect::(); let author_name = blame_entry.author.as_deref().unwrap_or(""); 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, - blame_entry: BlameEntry, -} - -impl BlameEntryTooltip { - fn new( - color: Hsla, - commit_message: Option, - 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) -> impl IntoElement { - let author = self - .blame_entry - .author - .clone() - .unwrap_or("".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>, display_hunks: Vec, blamed_display_rows: Option>, + inline_blame: Option, folds: Vec, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 6b5420f15c..ddfa14a7c4 100644 --- a/crates/editor/src/git/blame.rs +++ b/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, +} + pub struct GitBlame { project: Model, buffer: Model, entries: SumTree, - permalinks: HashMap, - messages: HashMap, + commit_details: HashMap, buffer_snapshot: BufferSnapshot, buffer_edits: text::Subscription, task: Task>, generated: bool, + user_triggered: bool, _refresh_subscription: Subscription, } @@ -61,6 +70,7 @@ impl GitBlame { pub fn new( buffer: Model, project: Model, + user_triggered: bool, cx: &mut ModelContext, ) -> 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 { - self.permalinks.get(&entry.sha).cloned() - } - - pub fn message_for_entry(&self, entry: &BlameEntry) -> Option { - self.messages.get(&entry.sha).cloned() + pub fn details_for_entry(&self, entry: &BlameEntry) -> Option { + 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(); + let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row); + let commit_details = + parse_commit_messages(messages, &permalinks, &languages).await; - 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 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::().is_none() { + log::error!("failed to get git blame data: {error:?}"); + } + } }), }) }); } } +fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree { + 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, + permalinks: &HashMap, + languages: &Arc, +) -> HashMap { + 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) -> 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)); diff --git a/crates/language/src/markdown.rs b/crates/language/src/markdown.rs index cc0c0becce..8f027c76e3 100644 --- a/crates/language/src/markdown.rs +++ b/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, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 92b522b3e9..f0f400198a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -7756,13 +7756,20 @@ impl Project { .as_local() .context("worktree was not local")? .snapshot(); - let (work_directory, repo) = worktree - .repository_and_work_directory_for_path(&buffer_project_path.path) - .context("failed to get repo for blamed buffer")?; - let repo_entry = worktree - .get_local_repo(&repo) - .context("failed to get repo for blamed buffer")?; + let (work_directory, repo) = match worktree + .repository_and_work_directory_for_path(&buffer_project_path.path) + { + Some(work_dir_repo) => work_dir_repo, + None => anyhow::bail!(NoRepositoryError {}), + }; + + 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 { 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 {} diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 79642646c4..863ea7fdbb 100644 --- a/crates/project/src/project_settings.rs +++ b/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, pub gutter_debounce: Option, + /// Whether or not to show git blame data inline in + /// the currently focused line. + /// + /// Default: off + pub inline_blame: Option, +} + +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 { + 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, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct BinarySettings { pub path: Option, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d467140dda..633b588f11 100644 --- a/crates/search/src/buffer_search.rs +++ b/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); }); } diff --git a/crates/time_format/src/time_format.rs b/crates/time_format/src/time_format.rs index 81b031c199..583caf7884 100644 --- a/crates/time_format/src/time_format.rs +++ b/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(×tamp) + } + #[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 { 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, + ) + }; } } diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index 51bba25acd..0ccafdc1c6 100644 --- a/crates/ui/src/styles/color.rs +++ b/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,