History

Inline git blame (#10398)

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

Thorsten Ball committed 2 years ago

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

Unified · Split

13 files
  1. assets/settings/default.json +9 −1
  2. crates/collab/src/tests/editor_tests.rs +3 −5
  3. crates/collab_ui/src/chat_panel/message_editor.rs +2 −0
  4. crates/editor/src/actions.rs +1 −0
  5. crates/editor/src/editor.rs +96 −23
  6. crates/editor/src/element.rs +321 −143
  7. crates/editor/src/git/blame.rs +118 −55
  8. crates/language/src/markdown.rs +1 −1
  9. crates/project/src/project.rs +23 −6
  10. crates/project/src/project_settings.rs +40 −1
  11. crates/search/src/buffer_search.rs +2 −0
  12. crates/time_format/src/time_format.rs +36 −1
  13. crates/ui/src/styles/color.rs +2 −0
assets/settings/default.json+9−1
@@ -390,7 +390,15 @@ · 389 unmodified lines
390 // "git_gutter": "tracked_files"390 // "git_gutter": "tracked_files"
391 // 2. Hide the gutter391 // 2. Hide the gutter
392 // "git_gutter": "hide"392 // "git_gutter": "hide"
393 "git_gutter": "tracked_files"393 "git_gutter": "tracked_files",
394 // Control whether the git blame information is shown inline,
395 // in the currently focused line.
396 "inline_blame": {
397 "enabled": false
398 // Sets a delay after which the inline blame information is shown.
399 // Delay is restarted with every cursor movement.
400 // "delay_ms": 600
401 }
394 },402 },
395 "copilot": {403 "copilot": {
396 // The set of glob patterns for which copilot should be disabled404 // The set of glob patterns for which copilot should be disabled
crates/collab/src/tests/editor_tests.rs+3−5
@@ -2100,14 +2100,12 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA · 2099 unmodified lines
2100 2100
2101 blame.update(cx, |blame, _| {2101 blame.update(cx, |blame, _| {
2102 for (idx, entry) in entries.iter().flatten().enumerate() {2102 for (idx, entry) in entries.iter().flatten().enumerate() {
2103 let details = blame.details_for_entry(entry).unwrap();
2104 assert_eq!(details.message, format!("message for idx-{}", idx));
2103 assert_eq!(2105 assert_eq!(
2104 blame.permalink_for_entry(entry).unwrap().to_string(),2106 details.permalink.unwrap().to_string(),
2105 format!("http://example.com/codehost/idx-{}", idx)2107 format!("http://example.com/codehost/idx-{}", idx)
2106 );2108 );
2107 assert_eq!(
2108 blame.message_for_entry(entry).unwrap(),
2109 format!("message for idx-{}", idx)
2110 );
2111 }2109 }
2112 });2110 });
2113 });2111 });
crates/collab_ui/src/chat_panel/message_editor.rs+2−0
@@ -557,6 +557,7 @@ mod tests { · 556 unmodified lines
557 use clock::FakeSystemClock;557 use clock::FakeSystemClock;
558 use gpui::TestAppContext;558 use gpui::TestAppContext;
559 use language::{Language, LanguageConfig};559 use language::{Language, LanguageConfig};
560 use project::Project;
560 use rpc::proto;561 use rpc::proto;
561 use settings::SettingsStore;562 use settings::SettingsStore;
562 use util::{http::FakeHttpClient, test::marked_text_ranges};563 use util::{http::FakeHttpClient, test::marked_text_ranges};
@@ -630,6 +631,7 @@ mod tests { · 67 unmodified lines
630 let client = Client::new(clock, http.clone(), cx);631 let client = Client::new(clock, http.clone(), cx);
631 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));632 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
632 theme::init(theme::LoadThemes::JustBase, cx);633 theme::init(theme::LoadThemes::JustBase, cx);
634 Project::init_settings(cx);
633 language::init(cx);635 language::init(cx);
634 editor::init(cx);636 editor::init(cx);
635 client::init(&client, cx);637 client::init(&client, cx);
crates/editor/src/actions.rs+1−0
@@ -245,6 +245,7 @@ gpui::actions!( · 244 unmodified lines
245 Tab,245 Tab,
246 TabPrev,246 TabPrev,
247 ToggleGitBlame,247 ToggleGitBlame,
248 ToggleGitBlameInline,
248 ToggleInlayHints,249 ToggleInlayHints,
249 ToggleLineNumbers,250 ToggleLineNumbers,
250 ToggleSoftWrap,251 ToggleSoftWrap,
crates/editor/src/editor.rs+96−23
@@ -155,7 +155,7 @@ pub fn render_parsed_markdown( · 154 unmodified lines
155 parsed: &language::ParsedMarkdown,155 parsed: &language::ParsedMarkdown,
156 editor_style: &EditorStyle,156 editor_style: &EditorStyle,
157 workspace: Option<WeakView<Workspace>>,157 workspace: Option<WeakView<Workspace>>,
158 cx: &mut ViewContext<Editor>,158 cx: &mut WindowContext,
159) -> InteractiveText {159) -> InteractiveText {
160 let code_span_background_color = cx160 let code_span_background_color = cx
161 .theme()161 .theme()
@@ -463,7 +463,9 @@ pub struct Editor { · 301 unmodified lines
463 editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,463 editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
464 use_autoclose: bool,464 use_autoclose: bool,
465 auto_replace_emoji_shortcode: bool,465 auto_replace_emoji_shortcode: bool,
466 show_git_blame: bool,466 show_git_blame_gutter: bool,
467 show_git_blame_inline: bool,
468 show_git_blame_inline_delay_task: Option<Task<()>>,
467 blame: Option<Model<GitBlame>>,469 blame: Option<Model<GitBlame>>,
468 blame_subscription: Option<Subscription>,470 blame_subscription: Option<Subscription>,
469 custom_context_menu: Option<471 custom_context_menu: Option<
@@ -480,7 +482,7 @@ pub struct Editor { · 10 unmodified lines
480pub struct EditorSnapshot {482pub struct EditorSnapshot {
481 pub mode: EditorMode,483 pub mode: EditorMode,
482 show_gutter: bool,484 show_gutter: bool,
483 show_git_blame: bool,485 render_git_blame_gutter: bool,
484 pub display_snapshot: DisplaySnapshot,486 pub display_snapshot: DisplaySnapshot,
485 pub placeholder_text: Option<Arc<str>>,487 pub placeholder_text: Option<Arc<str>>,
486 is_focused: bool,488 is_focused: bool,
@@ -1498,7 +1500,9 @@ impl Editor { · 1011 unmodified lines
1498 vim_replace_map: Default::default(),1500 vim_replace_map: Default::default(),
1499 show_inline_completions: mode == EditorMode::Full,1501 show_inline_completions: mode == EditorMode::Full,
1500 custom_context_menu: None,1502 custom_context_menu: None,
1501 show_git_blame: false,1503 show_git_blame_gutter: false,
1504 show_git_blame_inline: false,
1505 show_git_blame_inline_delay_task: None,
1502 blame: None,1506 blame: None,
1503 blame_subscription: None,1507 blame_subscription: None,
1504 _subscriptions: vec![1508 _subscriptions: vec![
@@ -1530,6 +1534,10 @@ impl Editor { · 25 unmodified lines
1530 if mode == EditorMode::Full {1534 if mode == EditorMode::Full {
1531 let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();1535 let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
1532 cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));1536 cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
1537
1538 if ProjectSettings::get_global(cx).git.inline_blame_enabled() {
1539 this.start_git_blame_inline(false, cx);
1540 }
1533 }1541 }
1534 1542
1535 this.report_editor_event("open", None, cx);1543 this.report_editor_event("open", None, cx);
@@ -1646,10 +1654,7 @@ impl Editor { · 110 unmodified lines
1646 EditorSnapshot {1654 EditorSnapshot {
1647 mode: self.mode,1655 mode: self.mode,
1648 show_gutter: self.show_gutter,1656 show_gutter: self.show_gutter,
1649 show_git_blame: self1657 render_git_blame_gutter: self.render_git_blame_gutter(cx),
1650 .blame
1651 .as_ref()
1652 .map_or(false, |blame| blame.read(cx).has_generated_entries()),
1653 display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),1658 display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
1654 scroll_anchor: self.scroll_manager.anchor(),1659 scroll_anchor: self.scroll_manager.anchor(),
1655 ongoing_scroll: self.scroll_manager.ongoing_scroll(),1660 ongoing_scroll: self.scroll_manager.ongoing_scroll(),
@@ -1915,6 +1920,7 @@ impl Editor { · 259 unmodified lines
1915 self.refresh_document_highlights(cx);1920 self.refresh_document_highlights(cx);
1916 refresh_matching_bracket_highlights(self, cx);1921 refresh_matching_bracket_highlights(self, cx);
1917 self.discard_inline_completion(cx);1922 self.discard_inline_completion(cx);
1923 self.start_inline_blame_timer(cx);
1918 }1924 }
1919 1925
1920 self.blink_manager.update(cx, BlinkManager::pause_blinking);1926 self.blink_manager.update(cx, BlinkManager::pause_blinking);
@@ -3794,6 +3800,22 @@ impl Editor { · 1873 unmodified lines
3794 None3800 None
3795 }3801 }
3796 3802
3803 fn start_inline_blame_timer(&mut self, cx: &mut ViewContext<Self>) {
3804 if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() {
3805 self.show_git_blame_inline = false;
3806
3807 self.show_git_blame_inline_delay_task = Some(cx.spawn(|this, mut cx| async move {
3808 cx.background_executor().timer(delay).await;
3809
3810 this.update(&mut cx, |this, cx| {
3811 this.show_git_blame_inline = true;
3812 cx.notify();
3813 })
3814 .log_err();
3815 }));
3816 }
3817 }
3818
3797 fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {3819 fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
3798 if self.pending_rename.is_some() {3820 if self.pending_rename.is_some() {
3799 return None;3821 return None;
@@ -8843,40 +8865,83 @@ impl Editor { · 5043 unmodified lines
8843 }8865 }
8844 8866
8845 pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {8867 pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {
8846 if self.show_git_blame {8868 self.show_git_blame_gutter = !self.show_git_blame_gutter;
8847 self.blame_subscription.take();8869
8848 self.blame.take();8870 if self.show_git_blame_gutter && !self.has_blame_entries(cx) {
8849 self.show_git_blame = false8871 self.start_git_blame(true, cx);
8850 } else {
8851 if let Err(error) = self.show_git_blame_internal(cx) {
8852 log::error!("failed to toggle on 'git blame': {}", error);
8853 return;
8854 }
8855 self.show_git_blame = true
8856 }8872 }
8857 8873
8858 cx.notify();8874 cx.notify();
8859 }8875 }
8860 8876
8861 fn show_git_blame_internal(&mut self, cx: &mut ViewContext<Self>) -> Result<()> {8877 pub fn toggle_git_blame_inline(
8878 &mut self,
8879 _: &ToggleGitBlameInline,
8880 cx: &mut ViewContext<Self>,
8881 ) {
8882 self.toggle_git_blame_inline_internal(true, cx);
8883 cx.notify();
8884 }
8885
8886 fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
8862 if let Some(project) = self.project.as_ref() {8887 if let Some(project) = self.project.as_ref() {
8863 let Some(buffer) = self.buffer().read(cx).as_singleton() else {8888 let Some(buffer) = self.buffer().read(cx).as_singleton() else {
8864 anyhow::bail!("git blame not available in multi buffers")8889 return;
8865 };8890 };
8866 8891
8867 let project = project.clone();8892 let project = project.clone();
8868 let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx));8893 let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx));
8869 self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));8894 self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));
8870 self.blame = Some(blame);8895 self.blame = Some(blame);
8871 }8896 }
8897 }
8872 8898
8873 Ok(())8899 fn toggle_git_blame_inline_internal(
8900 &mut self,
8901 user_triggered: bool,
8902 cx: &mut ViewContext<Self>,
8903 ) {
8904 if self.show_git_blame_inline || self.show_git_blame_inline_delay_task.is_some() {
8905 self.show_git_blame_inline = false;
8906 self.show_git_blame_inline_delay_task.take();
8907 } else {
8908 self.start_git_blame_inline(user_triggered, cx);
8909 }
8910
8911 cx.notify();
8912 }
8913
8914 fn start_git_blame_inline(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
8915 if let Some(inline_blame_settings) = ProjectSettings::get_global(cx).git.inline_blame {
8916 if inline_blame_settings.enabled {
8917 self.start_git_blame(user_triggered, cx);
8918
8919 if inline_blame_settings.delay_ms.is_some() {
8920 self.start_inline_blame_timer(cx);
8921 } else {
8922 self.show_git_blame_inline = true
8923 }
8924 }
8925 }
8874 }8926 }
8875 8927
8876 pub fn blame(&self) -> Option<&Model<GitBlame>> {8928 pub fn blame(&self) -> Option<&Model<GitBlame>> {
8877 self.blame.as_ref()8929 self.blame.as_ref()
8878 }8930 }
8879 8931
8932 pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool {
8933 self.show_git_blame_gutter && self.has_blame_entries(cx)
8934 }
8935
8936 pub fn render_git_blame_inline(&mut self, cx: &mut WindowContext) -> bool {
8937 self.show_git_blame_inline && self.has_blame_entries(cx)
8938 }
8939
8940 fn has_blame_entries(&self, cx: &mut WindowContext) -> bool {
8941 self.blame()
8942 .map_or(false, |blame| blame.read(cx).has_generated_entries())
8943 }
8944
8880 fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {8945 fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
8881 let (path, repo) = maybe!({8946 let (path, repo) = maybe!({
8882 let project_handle = self.project.as_ref()?.clone();8947 let project_handle = self.project.as_ref()?.clone();
@@ -9446,6 +9511,14 @@ impl Editor { · 563 unmodified lines
9446 let editor_settings = EditorSettings::get_global(cx);9511 let editor_settings = EditorSettings::get_global(cx);
9447 self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;9512 self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
9448 self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;9513 self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
9514
9515 if self.mode == EditorMode::Full {
9516 let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
9517 if self.show_git_blame_inline != inline_blame_enabled {
9518 self.toggle_git_blame_inline_internal(false, cx);
9519 }
9520 }
9521
9449 cx.notify();9522 cx.notify();
9450 }9523 }
9451 9524
@@ -10058,7 +10131,7 @@ impl EditorSnapshot { · 606 unmodified lines
10058 };10131 };
10059 10132
10060 let git_blame_entries_width = self10133 let git_blame_entries_width = self
10061 .show_git_blame10134 .render_git_blame_gutter
10062 .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);10135 .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
10063 10136
10064 let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);10137 let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
crates/editor/src/element.rs+321−143
@@ -4,7 +4,10 @@ use crate::{ · 3 unmodified lines
4 TransformBlock,4 TransformBlock,
5 },5 },
6 editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},6 editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},
7 git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk},7 git::{
8 blame::{CommitDetails, GitBlame},
9 diff_hunk_to_display, DisplayDiffHunk,
10 },
8 hover_popover::{11 hover_popover::{
9 self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,12 self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
10 },13 },
@@ -21,13 +24,13 @@ use collections::{BTreeMap, HashMap}; · 10 unmodified lines
21use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};24use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
22use gpui::{25use gpui::{
23 anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,26 anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
24 transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds,27 transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
25 ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,28 ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext,
26 ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,29 ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
27 ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,30 ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
28 ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful,31 ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
29 StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,32 Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle,
30 ViewContext, WindowContext,33 TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
31};34};
32use itertools::Itertools;35use itertools::Itertools;
33use language::language_settings::ShowWhitespaceSetting;36use language::language_settings::ShowWhitespaceSetting;
@@ -49,11 +52,11 @@ use std::{ · 15 unmodified lines
49 sync::Arc,52 sync::Arc,
50};53};
51use sum_tree::Bias;54use sum_tree::Bias;
52use theme::{ActiveTheme, PlayerColor};55use theme::{ActiveTheme, PlayerColor, ThemeSettings};
53use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};56use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
54use ui::{prelude::*, tooltip_container};57use ui::{prelude::*, tooltip_container};
55use util::ResultExt;58use util::ResultExt;
56use workspace::item::Item;59use workspace::{item::Item, Workspace};
57 60
58struct SelectionLayout {61struct SelectionLayout {
59 head: DisplayPoint,62 head: DisplayPoint,
@@ -303,6 +306,7 @@ impl EditorElement { · 243 unmodified lines
303 register_action(view, cx, Editor::copy_permalink_to_line);306 register_action(view, cx, Editor::copy_permalink_to_line);
304 register_action(view, cx, Editor::open_permalink_to_line);307 register_action(view, cx, Editor::open_permalink_to_line);
305 register_action(view, cx, Editor::toggle_git_blame);308 register_action(view, cx, Editor::toggle_git_blame);
309 register_action(view, cx, Editor::toggle_git_blame_inline);
306 register_action(view, cx, |editor, action, cx| {310 register_action(view, cx, |editor, action, cx| {
307 if let Some(task) = editor.format(action, cx) {311 if let Some(task) = editor.format(action, cx) {
308 task.detach_and_log_err(cx);312 task.detach_and_log_err(cx);
@@ -1092,6 +1096,58 @@ impl EditorElement { · 783 unmodified lines
1092 .collect()1096 .collect()
1093 }1097 }
1094 1098
1099 #[allow(clippy::too_many_arguments)]
1100 fn layout_inline_blame(
1101 &self,
1102 start_row: u32,
1103 row: u32,
1104 line_layouts: &[LineWithInvisibles],
1105 em_width: Pixels,
1106 content_origin: gpui::Point<Pixels>,
1107 scroll_pixel_position: gpui::Point<Pixels>,
1108 line_height: Pixels,
1109 cx: &mut ElementContext,
1110 ) -> Option<AnyElement> {
1111 if !self
1112 .editor
1113 .update(cx, |editor, cx| editor.render_git_blame_inline(cx))
1114 {
1115 return None;
1116 }
1117
1118 let blame = self.editor.read(cx).blame.clone()?;
1119 let workspace = self
1120 .editor
1121 .read(cx)
1122 .workspace
1123 .as_ref()
1124 .map(|(w, _)| w.clone());
1125 let blame_entry = blame
1126 .update(cx, |blame, cx| blame.blame_for_rows([Some(row)], cx).next())
1127 .flatten()?;
1128
1129 let mut element =
1130 render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx);
1131
1132 let start_y =
1133 content_origin.y + line_height * (row as f32 - scroll_pixel_position.y / line_height);
1134
1135 let start_x = {
1136 let line_layout = &line_layouts[(row - start_row) as usize];
1137 let line_width = line_layout.line.width;
1138
1139 // TODO: define the padding as a constant
1140 content_origin.x + line_width + (em_width * 6.)
1141 };
1142
1143 let absolute_offset = point(start_x, start_y);
1144 let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
1145
1146 element.layout(absolute_offset, available_space, cx);
1147
1148 Some(element)
1149 }
1150
1095 #[allow(clippy::too_many_arguments)]1151 #[allow(clippy::too_many_arguments)]
1096 fn layout_blame_entries(1152 fn layout_blame_entries(
1097 &self,1153 &self,
@@ -1103,10 +1159,14 @@ impl EditorElement { · 5 unmodified lines
1103 max_width: Option<Pixels>,1159 max_width: Option<Pixels>,
1104 cx: &mut ElementContext,1160 cx: &mut ElementContext,
1105 ) -> Option<Vec<AnyElement>> {1161 ) -> Option<Vec<AnyElement>> {
1106 let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else {1162 if !self
1163 .editor
1164 .update(cx, |editor, cx| editor.render_git_blame_gutter(cx))
1165 {
1107 return None;1166 return None;
1108 };1167 }
1109 1168
1169 let blame = self.editor.read(cx).blame.clone()?;
1110 let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {1170 let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
1111 blame.blame_for_rows(buffer_rows, cx).collect()1171 blame.blame_for_rows(buffer_rows, cx).collect()
1112 });1172 });
@@ -1120,7 +1180,6 @@ impl EditorElement { · 7 unmodified lines
1120 let start_x = em_width * 1;1180 let start_x = em_width * 1;
1121 1181
1122 let mut last_used_color: Option<(PlayerColor, Oid)> = None;1182 let mut last_used_color: Option<(PlayerColor, Oid)> = None;
1123 let text_style = &self.style.text;
1124 1183
1125 let shaped_lines = blamed_rows1184 let shaped_lines = blamed_rows
1126 .into_iter()1185 .into_iter()
@@ -1131,7 +1190,7 @@ impl EditorElement { · 4 unmodified lines
1131 ix,1190 ix,
1132 &blame,1191 &blame,
1133 blame_entry,1192 blame_entry,
1134 text_style,1193 &self.style,
1135 &mut last_used_color,1194 &mut last_used_color,
1136 self.editor.clone(),1195 self.editor.clone(),
1137 cx,1196 cx,
@@ -2256,6 +2315,7 @@ impl EditorElement { · 1118 unmodified lines
2256 self.paint_lines(&invisible_display_ranges, layout, cx);2315 self.paint_lines(&invisible_display_ranges, layout, cx);
2257 self.paint_redactions(layout, cx);2316 self.paint_redactions(layout, cx);
2258 self.paint_cursors(layout, cx);2317 self.paint_cursors(layout, cx);
2318 self.paint_inline_blame(layout, cx);
2259 },2319 },
2260 )2320 )
2261 }2321 }
@@ -2730,6 +2790,14 @@ impl EditorElement { · 468 unmodified lines
2730 })2790 })
2731 }2791 }
2732 2792
2793 fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
2794 if let Some(mut inline_blame) = layout.inline_blame.take() {
2795 cx.paint_layer(layout.text_hitbox.bounds, |cx| {
2796 inline_blame.paint(cx);
2797 })
2798 }
2799 }
2800
2733 fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {2801 fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
2734 for mut block in layout.blocks.drain(..) {2802 for mut block in layout.blocks.drain(..) {
2735 block.element.paint(cx);2803 block.element.paint(cx);
@@ -2894,11 +2962,192 @@ impl EditorElement { · 158 unmodified lines
2894 }2962 }
2895}2963}
2896 2964
2965fn render_inline_blame_entry(
2966 blame: &gpui::Model<GitBlame>,
2967 blame_entry: BlameEntry,
2968 style: &EditorStyle,
2969 workspace: Option<WeakView<Workspace>>,
2970 cx: &mut ElementContext<'_>,
2971) -> AnyElement {
2972 let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
2973
2974 let author = blame_entry.author.as_deref().unwrap_or_default();
2975 let text = format!("{}, {}", author, relative_timestamp);
2976
2977 let details = blame.read(cx).details_for_entry(&blame_entry);
2978
2979 let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
2980
2981 h_flex()
2982 .id("inline-blame")
2983 .w_full()
2984 .font(style.text.font().family)
2985 .text_color(cx.theme().status().hint)
2986 .line_height(style.text.line_height)
2987 .child(Icon::new(IconName::FileGit).color(Color::Hint))
2988 .child(text)
2989 .gap_2()
2990 .hoverable_tooltip(move |_| tooltip.clone().into())
2991 .into_any()
2992}
2993
2994fn blame_entry_timestamp(
2995 blame_entry: &BlameEntry,
2996 format: time_format::TimestampFormat,
2997 cx: &WindowContext,
2998) -> String {
2999 match blame_entry.author_offset_date_time() {
3000 Ok(timestamp) => time_format::format_localized_timestamp(
3001 timestamp,
3002 time::OffsetDateTime::now_utc(),
3003 cx.local_timezone(),
3004 format,
3005 ),
3006 Err(_) => "Error parsing date".to_string(),
3007 }
3008}
3009
3010fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
3011 blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx)
3012}
3013
3014fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
3015 blame_entry_timestamp(
3016 blame_entry,
3017 time_format::TimestampFormat::MediumAbsolute,
3018 cx,
3019 )
3020}
3021
3022struct BlameEntryTooltip {
3023 blame_entry: BlameEntry,
3024 details: Option<CommitDetails>,
3025 style: EditorStyle,
3026 workspace: Option<WeakView<Workspace>>,
3027 scroll_handle: ScrollHandle,
3028}
3029
3030impl BlameEntryTooltip {
3031 fn new(
3032 blame_entry: BlameEntry,
3033 details: Option<CommitDetails>,
3034 style: &EditorStyle,
3035 workspace: Option<WeakView<Workspace>>,
3036 ) -> Self {
3037 Self {
3038 style: style.clone(),
3039 blame_entry,
3040 details,
3041 workspace,
3042 scroll_handle: ScrollHandle::new(),
3043 }
3044 }
3045}
3046
3047impl Render for BlameEntryTooltip {
3048 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3049 let author = self
3050 .blame_entry
3051 .author
3052 .clone()
3053 .unwrap_or("<no name>".to_string());
3054
3055 let author_email = self.blame_entry.author_mail.clone();
3056
3057 let pretty_commit_id = format!("{}", self.blame_entry.sha);
3058 let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
3059 let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx);
3060
3061 let message = self
3062 .details
3063 .as_ref()
3064 .map(|details| {
3065 crate::render_parsed_markdown(
3066 "blame-message",
3067 &details.parsed_message,
3068 &self.style,
3069 self.workspace.clone(),
3070 cx,
3071 )
3072 .into_any()
3073 })
3074 .unwrap_or("<no commit message>".into_any());
3075
3076 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
3077 let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
3078
3079 tooltip_container(cx, move |this, cx| {
3080 this.occlude()
3081 .on_mouse_move(|_, cx| cx.stop_propagation())
3082 .child(
3083 v_flex()
3084 .w(gpui::rems(30.))
3085 .gap_4()
3086 .child(
3087 h_flex()
3088 .gap_2()
3089 .child(author)
3090 .when_some(author_email, |this, author_email| {
3091 this.child(
3092 div()
3093 .text_color(cx.theme().colors().text_muted)
3094 .child(author_email),
3095 )
3096 })
3097 .pb_1()
3098 .border_b_1()
3099 .border_color(cx.theme().colors().border),
3100 )
3101 .child(
3102 div()
3103 .id("inline-blame-commit-message")
3104 .occlude()
3105 .child(message)
3106 .max_h(message_max_height)
3107 .overflow_y_scroll()
3108 .track_scroll(&self.scroll_handle),
3109 )
3110 .child(
3111 h_flex()
3112 .text_color(cx.theme().colors().text_muted)
3113 .w_full()
3114 .justify_between()
3115 .child(absolute_timestamp)
3116 .child(
3117 Button::new("commit-sha-button", short_commit_id.clone())
3118 .style(ButtonStyle::Transparent)
3119 .color(Color::Muted)
3120 .icon(IconName::FileGit)
3121 .icon_color(Color::Muted)
3122 .icon_position(IconPosition::Start)
3123 .disabled(
3124 self.details.as_ref().map_or(true, |details| {
3125 details.permalink.is_none()
3126 }),
3127 )
3128 .when_some(
3129 self.details
3130 .as_ref()
3131 .and_then(|details| details.permalink.clone()),
3132 |this, url| {
3133 this.on_click(move |_, cx| {
3134 cx.stop_propagation();
3135 cx.open_url(url.as_str())
3136 })
3137 },
3138 ),
3139 ),
3140 ),
3141 )
3142 })
3143 }
3144}
3145
2897fn render_blame_entry(3146fn render_blame_entry(
2898 ix: usize,3147 ix: usize,
2899 blame: &gpui::Model<GitBlame>,3148 blame: &gpui::Model<GitBlame>,
2900 blame_entry: BlameEntry,3149 blame_entry: BlameEntry,
2901 text_style: &TextStyle,3150 style: &EditorStyle,
2902 last_used_color: &mut Option<(PlayerColor, Oid)>,3151 last_used_color: &mut Option<(PlayerColor, Oid)>,
2903 editor: View<Editor>,3152 editor: View<Editor>,
2904 cx: &mut ElementContext<'_>,3153 cx: &mut ElementContext<'_>,
@@ -2918,29 +3167,26 @@ fn render_blame_entry( · 13 unmodified lines
2918 };3167 };
2919 last_used_color.replace((sha_color, blame_entry.sha));3168 last_used_color.replace((sha_color, blame_entry.sha));
2920 3169
2921 let relative_timestamp = match blame_entry.author_offset_date_time() {3170 let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
2922 Ok(timestamp) => time_format::format_localized_timestamp(
2923 timestamp,
2924 time::OffsetDateTime::now_utc(),
2925 cx.local_timezone(),
2926 time_format::TimestampFormat::Relative,
2927 ),
2928 Err(_) => "Error parsing date".to_string(),
2929 };
2930 3171
2931 let pretty_commit_id = format!("{}", blame_entry.sha);3172 let pretty_commit_id = format!("{}", blame_entry.sha);
2932 let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::<String>();3173 let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
2933 3174
2934 let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");3175 let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
2935 let name = util::truncate_and_trailoff(author_name, 20);3176 let name = util::truncate_and_trailoff(author_name, 20);
2936 3177
2937 let permalink = blame.read(cx).permalink_for_entry(&blame_entry);3178 let details = blame.read(cx).details_for_entry(&blame_entry);
2938 let commit_message = blame.read(cx).message_for_entry(&blame_entry);3179
3180 let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
3181
3182 let tooltip = cx.new_view(|_| {
3183 BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)
3184 });
2939 3185
2940 h_flex()3186 h_flex()
2941 .w_full()3187 .w_full()
2942 .font(text_style.font().family)3188 .font(style.text.font().family)
2943 .line_height(text_style.line_height)3189 .line_height(style.text.line_height)
2944 .id(("blame", ix))3190 .id(("blame", ix))
2945 .children([3191 .children([
2946 div()3192 div()
@@ -2962,21 +3208,17 @@ fn render_blame_entry( · 15 unmodified lines
2962 }3208 }
2963 })3209 })
2964 .hover(|style| style.bg(cx.theme().colors().element_hover))3210 .hover(|style| style.bg(cx.theme().colors().element_hover))
2965 .when_some(permalink, |this, url| {3211 .when_some(
2966 let url = url.clone();3212 details.and_then(|details| details.permalink),
2967 this.cursor_pointer().on_click(move |_, cx| {3213 |this, url| {
2968 cx.stop_propagation();3214 let url = url.clone();
2969 cx.open_url(url.as_str())3215 this.cursor_pointer().on_click(move |_, cx| {
2970 })3216 cx.stop_propagation();
2971 })3217 cx.open_url(url.as_str())
2972 .hoverable_tooltip(move |cx| {3218 })
2973 BlameEntryTooltip::new(3219 },
2974 sha_color.cursor,3220 )
2975 commit_message.clone(),3221 .hoverable_tooltip(move |_| tooltip.clone().into())
2976 blame_entry.clone(),
2977 cx,
2978 )
2979 })
2980 .into_any()3222 .into_any()
2981}3223}
2982 3224
@@ -2999,84 +3241,6 @@ fn deploy_blame_entry_context_menu( · 16 unmodified lines
2999 });3241 });
3000}3242}
3001 3243
3002struct BlameEntryTooltip {
3003 color: Hsla,
3004 commit_message: Option<String>,
3005 blame_entry: BlameEntry,
3006}
3007
3008impl BlameEntryTooltip {
3009 fn new(
3010 color: Hsla,
3011 commit_message: Option<String>,
3012 blame_entry: BlameEntry,
3013 cx: &mut WindowContext,
3014 ) -> AnyView {
3015 cx.new_view(|_cx| Self {
3016 color,
3017 commit_message,
3018 blame_entry,
3019 })
3020 .into()
3021 }
3022}
3023
3024impl Render for BlameEntryTooltip {
3025 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3026 let author = self
3027 .blame_entry
3028 .author
3029 .clone()
3030 .unwrap_or("<no name>".to_string());
3031 let author_email = self.blame_entry.author_mail.clone().unwrap_or_default();
3032 let absolute_timestamp = match self.blame_entry.author_offset_date_time() {
3033 Ok(timestamp) => time_format::format_localized_timestamp(
3034 timestamp,
3035 time::OffsetDateTime::now_utc(),
3036 cx.local_timezone(),
3037 time_format::TimestampFormat::Absolute,
3038 ),
3039 Err(_) => "Error parsing date".to_string(),
3040 };
3041
3042 let message = match &self.commit_message {
3043 Some(message) => util::truncate_lines_and_trailoff(message, 15),
3044 None => self.blame_entry.summary.clone().unwrap_or_default(),
3045 };
3046
3047 let pretty_commit_id = format!("{}", self.blame_entry.sha);
3048
3049 tooltip_container(cx, move |this, cx| {
3050 this.occlude()
3051 .on_mouse_move(|_, cx| cx.stop_propagation())
3052 .child(
3053 v_flex()
3054 .child(
3055 h_flex()
3056 .child(
3057 div()
3058 .text_color(cx.theme().colors().text_muted)
3059 .child("Commit")
3060 .pr_2(),
3061 )
3062 .child(
3063 div().text_color(self.color).child(pretty_commit_id.clone()),
3064 ),
3065 )
3066 .child(
3067 div()
3068 .child(format!(
3069 "{} {} - {}",
3070 author, author_email, absolute_timestamp
3071 ))
3072 .text_color(cx.theme().colors().text_muted),
3073 )
3074 .child(div().child(message)),
3075 )
3076 })
3077 }
3078}
3079
3080#[derive(Debug)]3244#[derive(Debug)]
3081pub(crate) struct LineWithInvisibles {3245pub(crate) struct LineWithInvisibles {
3082 pub line: ShapedLine,3246 pub line: ShapedLine,
@@ -3205,13 +3369,9 @@ impl LineWithInvisibles { · 122 unmodified lines
3205 let line_y =3369 let line_y =
3206 line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);3370 line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);
3207 3371
3208 self.line3372 let line_origin =
3209 .paint(3373 content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y);
3210 content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y),3374 self.line.paint(line_origin, line_height, cx).log_err();
3211 line_height,
3212 cx,
3213 )
3214 .log_err();
3215 3375
3216 self.draw_invisibles(3376 self.draw_invisibles(
3217 &selection_ranges,3377 &selection_ranges,
@@ -3490,16 +3650,6 @@ impl Element for EditorElement { · 272 unmodified lines
3490 3650
3491 let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);3651 let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
3492 3652
3493 let blamed_display_rows = self.layout_blame_entries(
3494 buffer_rows,
3495 em_width,
3496 scroll_position,
3497 line_height,
3498 &gutter_hitbox,
3499 gutter_dimensions.git_blame_entries_width,
3500 cx,
3501 );
3502
3503 let mut max_visible_line_width = Pixels::ZERO;3653 let mut max_visible_line_width = Pixels::ZERO;
3504 let line_layouts =3654 let line_layouts =
3505 self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);3655 self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
@@ -3528,6 +3678,37 @@ impl Element for EditorElement { · 22 unmodified lines
3528 cx,3678 cx,
3529 );3679 );
3530 3680
3681 let scroll_pixel_position = point(
3682 scroll_position.x * em_width,
3683 scroll_position.y * line_height,
3684 );
3685
3686 let mut inline_blame = None;
3687 if let Some(newest_selection_head) = newest_selection_head {
3688 if (start_row..end_row).contains(&newest_selection_head.row()) {
3689 inline_blame = self.layout_inline_blame(
3690 start_row,
3691 newest_selection_head.row(),
3692 &line_layouts,
3693 em_width,
3694 content_origin,
3695 scroll_pixel_position,
3696 line_height,
3697 cx,
3698 );
3699 }
3700 }
3701
3702 let blamed_display_rows = self.layout_blame_entries(
3703 buffer_rows,
3704 em_width,
3705 scroll_position,
3706 line_height,
3707 &gutter_hitbox,
3708 gutter_dimensions.git_blame_entries_width,
3709 cx,
3710 );
3711
3531 let scroll_max = point(3712 let scroll_max = point(
3532 ((scroll_width - text_hitbox.size.width) / em_width).max(0.0),3713 ((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
3533 max_row as f32,3714 max_row as f32,
@@ -3555,11 +3736,6 @@ impl Element for EditorElement { · 21 unmodified lines
3555 }3736 }
3556 });3737 });
3557 3738
3558 let scroll_pixel_position = point(
3559 scroll_position.x * em_width,
3560 scroll_position.y * line_height,
3561 );
3562
3563 cx.with_element_id(Some("blocks"), |cx| {3739 cx.with_element_id(Some("blocks"), |cx| {
3564 self.layout_blocks(3740 self.layout_blocks(
3565 &mut blocks,3741 &mut blocks,
@@ -3728,6 +3904,7 @@ impl Element for EditorElement { · 162 unmodified lines
3728 line_numbers,3904 line_numbers,
3729 display_hunks,3905 display_hunks,
3730 blamed_display_rows,3906 blamed_display_rows,
3907 inline_blame,
3731 folds,3908 folds,
3732 blocks,3909 blocks,
3733 cursors,3910 cursors,
@@ -3815,6 +3992,7 @@ pub struct EditorLayout { · 81 unmodified lines
3815 line_numbers: Vec<Option<ShapedLine>>,3992 line_numbers: Vec<Option<ShapedLine>>,
3816 display_hunks: Vec<DisplayDiffHunk>,3993 display_hunks: Vec<DisplayDiffHunk>,
3817 blamed_display_rows: Option<Vec<AnyElement>>,3994 blamed_display_rows: Option<Vec<AnyElement>>,
3995 inline_blame: Option<AnyElement>,
3818 folds: Vec<FoldLayout>,3996 folds: Vec<FoldLayout>,
3819 blocks: Vec<BlockLayout>,3997 blocks: Vec<BlockLayout>,
3820 highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,3998 highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,
crates/editor/src/git/blame.rs+118−55
@@ -1,3 +1,5 @@
1use std::sync::Arc;
2
1use anyhow::Result;3use anyhow::Result;
2use collections::HashMap;4use collections::HashMap;
3use git::{5use git::{
@@ -5,7 +7,7 @@ use git::{ · 1 unmodified line
5 Oid,7 Oid,
6};8};
7use gpui::{Model, ModelContext, Subscription, Task};9use gpui::{Model, ModelContext, Subscription, Task};
8use language::{Bias, Buffer, BufferSnapshot, Edit};10use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
9use project::{Item, Project};11use project::{Item, Project};
10use smallvec::SmallVec;12use smallvec::SmallVec;
11use sum_tree::SumTree;13use sum_tree::SumTree;
@@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { · 32 unmodified lines
44 }46 }
45}47}
46 48
49#[derive(Clone, Debug)]
50pub struct CommitDetails {
51 pub message: String,
52 pub parsed_message: ParsedMarkdown,
53 pub permalink: Option<Url>,
54}
55
47pub struct GitBlame {56pub struct GitBlame {
48 project: Model<Project>,57 project: Model<Project>,
49 buffer: Model<Buffer>,58 buffer: Model<Buffer>,
50 entries: SumTree<GitBlameEntry>,59 entries: SumTree<GitBlameEntry>,
51 permalinks: HashMap<Oid, Url>,60 commit_details: HashMap<Oid, CommitDetails>,
52 messages: HashMap<Oid, String>,
53 buffer_snapshot: BufferSnapshot,61 buffer_snapshot: BufferSnapshot,
54 buffer_edits: text::Subscription,62 buffer_edits: text::Subscription,
55 task: Task<Result<()>>,63 task: Task<Result<()>>,
56 generated: bool,64 generated: bool,
65 user_triggered: bool,
57 _refresh_subscription: Subscription,66 _refresh_subscription: Subscription,
58}67}
59 68
@@ -61,6 +70,7 @@ impl GitBlame { · 1 unmodified line
61 pub fn new(70 pub fn new(
62 buffer: Model<Buffer>,71 buffer: Model<Buffer>,
63 project: Model<Project>,72 project: Model<Project>,
73 user_triggered: bool,
64 cx: &mut ModelContext<Self>,74 cx: &mut ModelContext<Self>,
65 ) -> Self {75 ) -> Self {
66 let entries = SumTree::from_item(76 let entries = SumTree::from_item(
@@ -102,8 +112,8 @@ impl GitBlame { · 35 unmodified lines
102 buffer_snapshot,112 buffer_snapshot,
103 entries,113 entries,
104 buffer_edits,114 buffer_edits,
105 permalinks: HashMap::default(),115 user_triggered,
106 messages: HashMap::default(),116 commit_details: HashMap::default(),
107 task: Task::ready(Ok(())),117 task: Task::ready(Ok(())),
108 generated: false,118 generated: false,
109 _refresh_subscription: refresh_subscription,119 _refresh_subscription: refresh_subscription,
@@ -116,12 +126,8 @@ impl GitBlame { · 6 unmodified lines
116 self.generated126 self.generated
117 }127 }
118 128
119 pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {129 pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
120 self.permalinks.get(&entry.sha).cloned()130 self.commit_details.get(&entry.sha).cloned()
121 }
122
123 pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
124 self.messages.get(&entry.sha).cloned()
125 }131 }
126 132
127 pub fn blame_for_rows<'a>(133 pub fn blame_for_rows<'a>(
@@ -254,6 +260,7 @@ impl GitBlame { · 126 unmodified lines
254 let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());260 let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
255 let snapshot = self.buffer.read(cx).snapshot();261 let snapshot = self.buffer.read(cx).snapshot();
256 let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);262 let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
263 let languages = self.project.read(cx).languages().clone();
257 264
258 self.task = cx.spawn(|this, mut cx| async move {265 self.task = cx.spawn(|this, mut cx| async move {
259 let result = cx266 let result = cx
@@ -267,65 +274,121 @@ impl GitBlame { · 7 unmodified lines
267 messages,274 messages,
268 } = blame.await?;275 } = blame.await?;
269 276
270 let mut current_row = 0;277 let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
271 let mut entries = SumTree::from_iter(278 let commit_details =
272 entries.into_iter().flat_map(|entry| {279 parse_commit_messages(messages, &permalinks, &languages).await;
273 let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
274
275 if entry.range.start > current_row {
276 let skipped_rows = entry.range.start - current_row;
277 entries.push(GitBlameEntry {
278 rows: skipped_rows,
279 blame: None,
280 });
281 }
282 entries.push(GitBlameEntry {
283 rows: entry.range.len() as u32,
284 blame: Some(entry.clone()),
285 });
286
287 current_row = entry.range.end;
288 entries
289 }),
290 &(),
291 );
292 280
293 let max_row = snapshot.max_point().row;281 anyhow::Ok((entries, commit_details))
294 if max_row >= current_row {
295 entries.push(
296 GitBlameEntry {
297 rows: (max_row + 1) - current_row,
298 blame: None,
299 },
300 &(),
301 );
302 }
303
304 anyhow::Ok((entries, permalinks, messages))
305 }282 }
306 })283 })
307 .await;284 .await;
308 285
309 this.update(&mut cx, |this, cx| match result {286 this.update(&mut cx, |this, cx| match result {
310 Ok((entries, permalinks, messages)) => {287 Ok((entries, commit_details)) => {
311 this.buffer_edits = buffer_edits;288 this.buffer_edits = buffer_edits;
312 this.buffer_snapshot = snapshot;289 this.buffer_snapshot = snapshot;
313 this.entries = entries;290 this.entries = entries;
314 this.permalinks = permalinks;291 this.commit_details = commit_details;
315 this.messages = messages;
316 this.generated = true;292 this.generated = true;
317 cx.notify();293 cx.notify();
318 }294 }
319 Err(error) => this.project.update(cx, |_, cx| {295 Err(error) => this.project.update(cx, |_, cx| {
320 log::error!("failed to get git blame data: {error:?}");296 if this.user_triggered {
321 let notification = format!("{:#}", error).trim().to_string();297 log::error!("failed to get git blame data: {error:?}");
322 cx.emit(project::Event::Notification(notification));298 let notification = format!("{:#}", error).trim().to_string();
299 cx.emit(project::Event::Notification(notification));
300 } else {
301 // If we weren't triggered by a user, we just log errors in the background, instead of sending
302 // notifications.
303 // Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
304 // and opens a non-git file.
305 if error.downcast_ref::<project::NoRepositoryError>().is_none() {
306 log::error!("failed to get git blame data: {error:?}");
307 }
308 }
323 }),309 }),
324 })310 })
325 });311 });
326 }312 }
327}313}
328 314
315fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
316 let mut current_row = 0;
317 let mut entries = SumTree::from_iter(
318 entries.into_iter().flat_map(|entry| {
319 let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
320
321 if entry.range.start > current_row {
322 let skipped_rows = entry.range.start - current_row;
323 entries.push(GitBlameEntry {
324 rows: skipped_rows,
325 blame: None,
326 });
327 }
328 entries.push(GitBlameEntry {
329 rows: entry.range.len() as u32,
330 blame: Some(entry.clone()),
331 });
332
333 current_row = entry.range.end;
334 entries
335 }),
336 &(),
337 );
338
339 if max_row >= current_row {
340 entries.push(
341 GitBlameEntry {
342 rows: (max_row + 1) - current_row,
343 blame: None,
344 },
345 &(),
346 );
347 }
348
349 entries
350}
351
352async fn parse_commit_messages(
353 messages: impl IntoIterator<Item = (Oid, String)>,
354 permalinks: &HashMap<Oid, Url>,
355 languages: &Arc<LanguageRegistry>,
356) -> HashMap<Oid, CommitDetails> {
357 let mut commit_details = HashMap::default();
358 for (oid, message) in messages {
359 let parsed_message = parse_markdown(&message, &languages).await;
360 let permalink = permalinks.get(&oid).cloned();
361
362 commit_details.insert(
363 oid,
364 CommitDetails {
365 message,
366 parsed_message,
367 permalink,
368 },
369 );
370 }
371
372 commit_details
373}
374
375async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
376 let mut parsed_message = ParsedMarkdown::default();
377
378 markdown::parse_markdown_block(
379 text,
380 language_registry,
381 None,
382 &mut parsed_message.text,
383 &mut parsed_message.highlights,
384 &mut parsed_message.region_ranges,
385 &mut parsed_message.regions,
386 )
387 .await;
388
389 parsed_message
390}
391
329#[cfg(test)]392#[cfg(test)]
330mod tests {393mod tests {
331 use super::*;394 use super::*;
@@ -394,7 +457,7 @@ mod tests { · 62 unmodified lines
394 .await457 .await
395 .unwrap();458 .unwrap();
396 459
397 let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), cx));460 let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
398 461
399 let event = project.next_event(cx).await;462 let event = project.next_event(cx).await;
400 assert_eq!(463 assert_eq!(
@@ -463,7 +526,7 @@ mod tests { · 62 unmodified lines
463 .await526 .await
464 .unwrap();527 .unwrap();
465 528
466 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));529 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
467 530
468 cx.executor().run_until_parked();531 cx.executor().run_until_parked();
469 532
@@ -543,7 +606,7 @@ mod tests { · 73 unmodified lines
543 .await606 .await
544 .unwrap();607 .unwrap();
545 608
546 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));609 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
547 610
548 cx.executor().run_until_parked();611 cx.executor().run_until_parked();
549 612
@@ -692,7 +755,7 @@ mod tests { · 142 unmodified lines
692 .await755 .await
693 .unwrap();756 .unwrap();
694 757
695 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));758 let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
696 cx.executor().run_until_parked();759 cx.executor().run_until_parked();
697 git_blame.update(cx, |blame, cx| blame.check_invariants(cx));760 git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
698 761
crates/language/src/markdown.rs+1−1
@@ -8,7 +8,7 @@ use gpui::{px, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Underl · 7 unmodified lines
8use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};8use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
9 9
10/// Parsed Markdown content.10/// Parsed Markdown content.
11#[derive(Debug, Clone)]11#[derive(Debug, Clone, Default)]
12pub struct ParsedMarkdown {12pub struct ParsedMarkdown {
13 /// The Markdown text.13 /// The Markdown text.
14 pub text: String,14 pub text: String,
crates/project/src/project.rs+23−6
@@ -7756,13 +7756,20 @@ impl Project { · 7755 unmodified lines
7756 .as_local()7756 .as_local()
7757 .context("worktree was not local")?7757 .context("worktree was not local")?
7758 .snapshot();7758 .snapshot();
7759 let (work_directory, repo) = worktree7759
7760 let (work_directory, repo) = match worktree
7760 .repository_and_work_directory_for_path(&buffer_project_path.path)7761 .repository_and_work_directory_for_path(&buffer_project_path.path)
7761 .context("failed to get repo for blamed buffer")?;7762 {
7763 Some(work_dir_repo) => work_dir_repo,
7764 None => anyhow::bail!(NoRepositoryError {}),
7765 };
7762 7766
7763 let repo_entry = worktree7767 let repo_entry = match worktree.get_local_repo(&repo) {
7764 .get_local_repo(&repo)7768 Some(repo_entry) => repo_entry,
7765 .context("failed to get repo for blamed buffer")?;7769 None => anyhow::bail!(NoRepositoryError {}),
7770 };
7771
7772 let repo = repo_entry.repo().clone();
7766 7773
7767 let relative_path = buffer_project_path7774 let relative_path = buffer_project_path
7768 .path7775 .path
@@ -7773,7 +7780,6 @@ impl Project { · 4 unmodified lines
7773 Some(version) => buffer.rope_for_version(&version).clone(),7780 Some(version) => buffer.rope_for_version(&version).clone(),
7774 None => buffer.as_rope().clone(),7781 None => buffer.as_rope().clone(),
7775 };7782 };
7776 let repo = repo_entry.repo().clone();
7777 7783
7778 anyhow::Ok((repo, relative_path, content))7784 anyhow::Ok((repo, relative_path, content))
7779 });7785 });
@@ -10782,3 +10788,14 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> { · 3002 unmodified lines
10782 Some(hover)10788 Some(hover)
10783 }10789 }
10784}10790}
10791
10792#[derive(Debug)]
10793pub struct NoRepositoryError {}
10794
10795impl std::fmt::Display for NoRepositoryError {
10796 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10797 write!(f, "no git repository for worktree found")
10798 }
10799}
10800
10801impl std::error::Error for NoRepositoryError {}
crates/project/src/project_settings.rs+40−1
@@ -3,7 +3,7 @@ use gpui::AppContext; · 2 unmodified lines
3use schemars::JsonSchema;3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};4use serde::{Deserialize, Serialize};
5use settings::{Settings, SettingsSources};5use settings::{Settings, SettingsSources};
6use std::sync::Arc;6use std::{sync::Arc, time::Duration};
7 7
8#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]8#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
9pub struct ProjectSettings {9pub struct ProjectSettings {
@@ -29,6 +29,30 @@ pub struct GitSettings { · 19 unmodified lines
29 /// Default: tracked_files29 /// Default: tracked_files
30 pub git_gutter: Option<GitGutterSetting>,30 pub git_gutter: Option<GitGutterSetting>,
31 pub gutter_debounce: Option<u64>,31 pub gutter_debounce: Option<u64>,
32 /// Whether or not to show git blame data inline in
33 /// the currently focused line.
34 ///
35 /// Default: off
36 pub inline_blame: Option<InlineBlameSettings>,
37}
38
39impl GitSettings {
40 pub fn inline_blame_enabled(&self) -> bool {
41 match self.inline_blame {
42 Some(InlineBlameSettings { enabled, .. }) => enabled,
43 _ => false,
44 }
45 }
46
47 pub fn inline_blame_delay(&self) -> Option<Duration> {
48 match self.inline_blame {
49 Some(InlineBlameSettings {
50 delay_ms: Some(delay_ms),
51 ..
52 }) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
53 _ => None,
54 }
55 }
32}56}
33 57
34#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]58#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -41,6 +65,21 @@ pub enum GitGutterSetting { · 6 unmodified lines
41 Hide,65 Hide,
42}66}
43 67
68#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "snake_case")]
70pub struct InlineBlameSettings {
71 /// Whether or not to show git blame data inline in
72 /// the currently focused line.
73 ///
74 /// Default: false
75 pub enabled: bool,
76 /// Whether to only show the inline blame information
77 /// after a delay once the cursor stops moving.
78 ///
79 /// Default: 0
80 pub delay_ms: Option<u64>,
81}
82
44#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]83#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
45pub struct BinarySettings {84pub struct BinarySettings {
46 pub path: Option<String>,85 pub path: Option<String>,
crates/search/src/buffer_search.rs+2−0
@@ -1096,6 +1096,7 @@ mod tests { · 1095 unmodified lines
1096 use editor::{DisplayPoint, Editor};1096 use editor::{DisplayPoint, Editor};
1097 use gpui::{Context, Hsla, TestAppContext, VisualTestContext};1097 use gpui::{Context, Hsla, TestAppContext, VisualTestContext};
1098 use language::Buffer;1098 use language::Buffer;
1099 use project::Project;
1099 use smol::stream::StreamExt as _;1100 use smol::stream::StreamExt as _;
1100 use unindent::Unindent as _;1101 use unindent::Unindent as _;
1101 1102
@@ -1106,6 +1107,7 @@ mod tests { · 4 unmodified lines
1106 editor::init(cx);1107 editor::init(cx);
1107 1108
1108 language::init(cx);1109 language::init(cx);
1110 Project::init_settings(cx);
1109 theme::init(theme::LoadThemes::JustBase, cx);1111 theme::init(theme::LoadThemes::JustBase, cx);
1110 });1112 });
1111 }1113 }
crates/time_format/src/time_format.rs+36−1
@@ -9,6 +9,8 @@ pub enum TimestampFormat { · 8 unmodified lines
9 /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.9 /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
10 /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".10 /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
11 EnhancedAbsolute,11 EnhancedAbsolute,
12 /// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
13 MediumAbsolute,
12 /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".14 /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
13 Relative,15 Relative,
14}16}
@@ -30,6 +32,9 @@ pub fn format_localized_timestamp( · 15 unmodified lines
30 TimestampFormat::EnhancedAbsolute => {32 TimestampFormat::EnhancedAbsolute => {
31 format_absolute_timestamp(timestamp_local, reference_local, true)33 format_absolute_timestamp(timestamp_local, reference_local, true)
32 }34 }
35 TimestampFormat::MediumAbsolute => {
36 format_absolute_timestamp_medium(timestamp_local, reference_local)
37 }
33 TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)38 TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
34 .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),39 .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
35 }40 }
@@ -72,6 +77,22 @@ fn format_absolute_timestamp( · 36 unmodified lines
72 }77 }
73}78}
74 79
80fn format_absolute_timestamp_medium(
81 timestamp: OffsetDateTime,
82 #[allow(unused_variables)] reference: OffsetDateTime,
83) -> String {
84 #[cfg(target_os = "macos")]
85 {
86 macos::format_date_medium(&timestamp)
87 }
88 #[cfg(not(target_os = "macos"))]
89 {
90 // todo(linux) respect user's date/time preferences
91 // todo(windows) respect user's date/time preferences
92 format_timestamp_fallback(timestamp, reference)
93 }
94}
95
75fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {96fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
76 let difference = reference - timestamp;97 let difference = reference - timestamp;
77 let minutes = difference.whole_minutes();98 let minutes = difference.whole_minutes();
@@ -253,7 +274,8 @@ mod macos { · 175 unmodified lines
253 use core_foundation_sys::{274 use core_foundation_sys::{
254 base::kCFAllocatorDefault,275 base::kCFAllocatorDefault,
255 date_formatter::{276 date_formatter::{
256 kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,277 kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle,
278 CFDateFormatterCreate,
257 },279 },
258 locale::CFLocaleCopyCurrent,280 locale::CFLocaleCopyCurrent,
259 };281 };
@@ -266,6 +288,10 @@ mod macos { · 6 unmodified lines
266 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))288 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
267 }289 }
268 290
291 pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
292 format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
293 }
294
269 fn format_with_date_formatter(295 fn format_with_date_formatter(
270 timestamp: &time::OffsetDateTime,296 timestamp: &time::OffsetDateTime,
271 fmt: CFDateFormatterRef,297 fmt: CFDateFormatterRef,
@@ -302,6 +328,15 @@ mod macos { · 30 unmodified lines
302 kCFDateFormatterNoStyle,328 kCFDateFormatterNoStyle,
303 )329 )
304 };330 };
331
332 static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
333 CFDateFormatterCreate(
334 kCFAllocatorDefault,
335 CURRENT_LOCALE.with(|locale| *locale),
336 kCFDateFormatterMediumStyle,
337 kCFDateFormatterNoStyle,
338 )
339 };
305 }340 }
306}341}
307 342
crates/ui/src/styles/color.rs+2−0
@@ -12,6 +12,7 @@ pub enum Color { · 11 unmodified lines
12 Disabled,12 Disabled,
13 Error,13 Error,
14 Hidden,14 Hidden,
15 Hint,
15 Info,16 Info,
16 Modified,17 Modified,
17 Conflict,18 Conflict,
@@ -36,6 +37,7 @@ impl Color { · 18 unmodified lines
36 Color::Deleted => cx.theme().status().deleted,37 Color::Deleted => cx.theme().status().deleted,
37 Color::Disabled => cx.theme().colors().text_disabled,38 Color::Disabled => cx.theme().colors().text_disabled,
38 Color::Hidden => cx.theme().status().hidden,39 Color::Hidden => cx.theme().status().hidden,
40 Color::Hint => cx.theme().status().hint,
39 Color::Info => cx.theme().status().info,41 Color::Info => cx.theme().status().info,
40 Color::Placeholder => cx.theme().colors().text_placeholder,42 Color::Placeholder => cx.theme().colors().text_placeholder,
41 Color::Accent => cx.theme().colors().text_accent,43 Color::Accent => cx.theme().colors().text_accent,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t