This adds so-called "inline git blame" to the editor that, when turned on, shows `git blame` information about the current line inline:  When the inline information is hovered, a new tooltip appears that contains more information on the current commit:  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:  ## 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`.
Unified · Split
| @@ -390,7 +390,15 @@ · 389 unmodified lines | |||
| 390 | // "git_gutter": "tracked_files" | 390 | // "git_gutter": "tracked_files" |
| 391 | // 2. Hide the gutter | 391 | // 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 disabled | 404 | // The set of glob patterns for which copilot should be disabled |
| @@ -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 | }); |
| @@ -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); |
| @@ -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, |
| @@ -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 = cx | 160 | 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 | |||
| 480 | pub struct EditorSnapshot { | 482 | pub 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: self | 1657 | 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 | None | 3800 | 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 = false | 8871 | 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 = self | 10133 | let git_blame_entries_width = self |
| 10061 | .show_git_blame | 10134 | .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); |
| @@ -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 | |||
| 21 | use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid}; | 24 | use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid}; |
| 22 | use gpui::{ | 25 | use 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 | }; |
| 32 | use itertools::Itertools; | 35 | use itertools::Itertools; |
| 33 | use language::language_settings::ShowWhitespaceSetting; | 36 | use language::language_settings::ShowWhitespaceSetting; |
| @@ -49,11 +52,11 @@ use std::{ · 15 unmodified lines | |||
| 49 | sync::Arc, | 52 | sync::Arc, |
| 50 | }; | 53 | }; |
| 51 | use sum_tree::Bias; | 54 | use sum_tree::Bias; |
| 52 | use theme::{ActiveTheme, PlayerColor}; | 55 | use theme::{ActiveTheme, PlayerColor, ThemeSettings}; |
| 53 | use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; | 56 | use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; |
| 54 | use ui::{prelude::*, tooltip_container}; | 57 | use ui::{prelude::*, tooltip_container}; |
| 55 | use util::ResultExt; | 58 | use util::ResultExt; |
| 56 | use workspace::item::Item; | 59 | use workspace::{item::Item, Workspace}; |
| 57 | 60 | ||
| 58 | struct SelectionLayout { | 61 | struct 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_rows | 1184 | 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 | ||
| 2965 | fn render_inline_blame_entry( | ||
| 2966 | blame: &gpui::Model<GitBlame>, | ||
| 2967 | blame_entry: BlameEntry, | ||
| 2968 | style: &EditorStyle, | ||
| 2969 | workspace: Option<WeakView<Workspace>>, | ||
| 2970 | cx: &mut ElementContext<'_>, | ||
| 2971 | ) -> AnyElement { | ||
| 2972 | let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx); | ||
| 2973 | |||
| 2974 | let author = blame_entry.author.as_deref().unwrap_or_default(); | ||
| 2975 | let text = format!("{}, {}", author, relative_timestamp); | ||
| 2976 | |||
| 2977 | let details = blame.read(cx).details_for_entry(&blame_entry); | ||
| 2978 | |||
| 2979 | let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace)); | ||
| 2980 | |||
| 2981 | h_flex() | ||
| 2982 | .id("inline-blame") | ||
| 2983 | .w_full() | ||
| 2984 | .font(style.text.font().family) | ||
| 2985 | .text_color(cx.theme().status().hint) | ||
| 2986 | .line_height(style.text.line_height) | ||
| 2987 | .child(Icon::new(IconName::FileGit).color(Color::Hint)) | ||
| 2988 | .child(text) | ||
| 2989 | .gap_2() | ||
| 2990 | .hoverable_tooltip(move |_| tooltip.clone().into()) | ||
| 2991 | .into_any() | ||
| 2992 | } | ||
| 2993 | |||
| 2994 | fn blame_entry_timestamp( | ||
| 2995 | blame_entry: &BlameEntry, | ||
| 2996 | format: time_format::TimestampFormat, | ||
| 2997 | cx: &WindowContext, | ||
| 2998 | ) -> String { | ||
| 2999 | match blame_entry.author_offset_date_time() { | ||
| 3000 | Ok(timestamp) => time_format::format_localized_timestamp( | ||
| 3001 | timestamp, | ||
| 3002 | time::OffsetDateTime::now_utc(), | ||
| 3003 | cx.local_timezone(), | ||
| 3004 | format, | ||
| 3005 | ), | ||
| 3006 | Err(_) => "Error parsing date".to_string(), | ||
| 3007 | } | ||
| 3008 | } | ||
| 3009 | |||
| 3010 | fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { | ||
| 3011 | blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx) | ||
| 3012 | } | ||
| 3013 | |||
| 3014 | fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { | ||
| 3015 | blame_entry_timestamp( | ||
| 3016 | blame_entry, | ||
| 3017 | time_format::TimestampFormat::MediumAbsolute, | ||
| 3018 | cx, | ||
| 3019 | ) | ||
| 3020 | } | ||
| 3021 | |||
| 3022 | struct BlameEntryTooltip { | ||
| 3023 | blame_entry: BlameEntry, | ||
| 3024 | details: Option<CommitDetails>, | ||
| 3025 | style: EditorStyle, | ||
| 3026 | workspace: Option<WeakView<Workspace>>, | ||
| 3027 | scroll_handle: ScrollHandle, | ||
| 3028 | } | ||
| 3029 | |||
| 3030 | impl BlameEntryTooltip { | ||
| 3031 | fn new( | ||
| 3032 | blame_entry: BlameEntry, | ||
| 3033 | details: Option<CommitDetails>, | ||
| 3034 | style: &EditorStyle, | ||
| 3035 | workspace: Option<WeakView<Workspace>>, | ||
| 3036 | ) -> Self { | ||
| 3037 | Self { | ||
| 3038 | style: style.clone(), | ||
| 3039 | blame_entry, | ||
| 3040 | details, | ||
| 3041 | workspace, | ||
| 3042 | scroll_handle: ScrollHandle::new(), | ||
| 3043 | } | ||
| 3044 | } | ||
| 3045 | } | ||
| 3046 | |||
| 3047 | impl Render for BlameEntryTooltip { | ||
| 3048 | fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { | ||
| 3049 | let author = self | ||
| 3050 | .blame_entry | ||
| 3051 | .author | ||
| 3052 | .clone() | ||
| 3053 | .unwrap_or("<no name>".to_string()); | ||
| 3054 | |||
| 3055 | let author_email = self.blame_entry.author_mail.clone(); | ||
| 3056 | |||
| 3057 | let pretty_commit_id = format!("{}", self.blame_entry.sha); | ||
| 3058 | let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>(); | ||
| 3059 | let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx); | ||
| 3060 | |||
| 3061 | let message = self | ||
| 3062 | .details | ||
| 3063 | .as_ref() | ||
| 3064 | .map(|details| { | ||
| 3065 | crate::render_parsed_markdown( | ||
| 3066 | "blame-message", | ||
| 3067 | &details.parsed_message, | ||
| 3068 | &self.style, | ||
| 3069 | self.workspace.clone(), | ||
| 3070 | cx, | ||
| 3071 | ) | ||
| 3072 | .into_any() | ||
| 3073 | }) | ||
| 3074 | .unwrap_or("<no commit message>".into_any()); | ||
| 3075 | |||
| 3076 | let ui_font_size = ThemeSettings::get_global(cx).ui_font_size; | ||
| 3077 | let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4); | ||
| 3078 | |||
| 3079 | tooltip_container(cx, move |this, cx| { | ||
| 3080 | this.occlude() | ||
| 3081 | .on_mouse_move(|_, cx| cx.stop_propagation()) | ||
| 3082 | .child( | ||
| 3083 | v_flex() | ||
| 3084 | .w(gpui::rems(30.)) | ||
| 3085 | .gap_4() | ||
| 3086 | .child( | ||
| 3087 | h_flex() | ||
| 3088 | .gap_2() | ||
| 3089 | .child(author) | ||
| 3090 | .when_some(author_email, |this, author_email| { | ||
| 3091 | this.child( | ||
| 3092 | div() | ||
| 3093 | .text_color(cx.theme().colors().text_muted) | ||
| 3094 | .child(author_email), | ||
| 3095 | ) | ||
| 3096 | }) | ||
| 3097 | .pb_1() | ||
| 3098 | .border_b_1() | ||
| 3099 | .border_color(cx.theme().colors().border), | ||
| 3100 | ) | ||
| 3101 | .child( | ||
| 3102 | div() | ||
| 3103 | .id("inline-blame-commit-message") | ||
| 3104 | .occlude() | ||
| 3105 | .child(message) | ||
| 3106 | .max_h(message_max_height) | ||
| 3107 | .overflow_y_scroll() | ||
| 3108 | .track_scroll(&self.scroll_handle), | ||
| 3109 | ) | ||
| 3110 | .child( | ||
| 3111 | h_flex() | ||
| 3112 | .text_color(cx.theme().colors().text_muted) | ||
| 3113 | .w_full() | ||
| 3114 | .justify_between() | ||
| 3115 | .child(absolute_timestamp) | ||
| 3116 | .child( | ||
| 3117 | Button::new("commit-sha-button", short_commit_id.clone()) | ||
| 3118 | .style(ButtonStyle::Transparent) | ||
| 3119 | .color(Color::Muted) | ||
| 3120 | .icon(IconName::FileGit) | ||
| 3121 | .icon_color(Color::Muted) | ||
| 3122 | .icon_position(IconPosition::Start) | ||
| 3123 | .disabled( | ||
| 3124 | self.details.as_ref().map_or(true, |details| { | ||
| 3125 | details.permalink.is_none() | ||
| 3126 | }), | ||
| 3127 | ) | ||
| 3128 | .when_some( | ||
| 3129 | self.details | ||
| 3130 | .as_ref() | ||
| 3131 | .and_then(|details| details.permalink.clone()), | ||
| 3132 | |this, url| { | ||
| 3133 | this.on_click(move |_, cx| { | ||
| 3134 | cx.stop_propagation(); | ||
| 3135 | cx.open_url(url.as_str()) | ||
| 3136 | }) | ||
| 3137 | }, | ||
| 3138 | ), | ||
| 3139 | ), | ||
| 3140 | ), | ||
| 3141 | ) | ||
| 3142 | }) | ||
| 3143 | } | ||
| 3144 | } | ||
| 3145 | |||
| 2897 | fn render_blame_entry( | 3146 | fn 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 | ||
| 3002 | struct BlameEntryTooltip { | ||
| 3003 | color: Hsla, | ||
| 3004 | commit_message: Option<String>, | ||
| 3005 | blame_entry: BlameEntry, | ||
| 3006 | } | ||
| 3007 | |||
| 3008 | impl BlameEntryTooltip { | ||
| 3009 | fn new( | ||
| 3010 | color: Hsla, | ||
| 3011 | commit_message: Option<String>, | ||
| 3012 | blame_entry: BlameEntry, | ||
| 3013 | cx: &mut WindowContext, | ||
| 3014 | ) -> AnyView { | ||
| 3015 | cx.new_view(|_cx| Self { | ||
| 3016 | color, | ||
| 3017 | commit_message, | ||
| 3018 | blame_entry, | ||
| 3019 | }) | ||
| 3020 | .into() | ||
| 3021 | } | ||
| 3022 | } | ||
| 3023 | |||
| 3024 | impl Render for BlameEntryTooltip { | ||
| 3025 | fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { | ||
| 3026 | let author = self | ||
| 3027 | .blame_entry | ||
| 3028 | .author | ||
| 3029 | .clone() | ||
| 3030 | .unwrap_or("<no name>".to_string()); | ||
| 3031 | let author_email = self.blame_entry.author_mail.clone().unwrap_or_default(); | ||
| 3032 | let absolute_timestamp = match self.blame_entry.author_offset_date_time() { | ||
| 3033 | Ok(timestamp) => time_format::format_localized_timestamp( | ||
| 3034 | timestamp, | ||
| 3035 | time::OffsetDateTime::now_utc(), | ||
| 3036 | cx.local_timezone(), | ||
| 3037 | time_format::TimestampFormat::Absolute, | ||
| 3038 | ), | ||
| 3039 | Err(_) => "Error parsing date".to_string(), | ||
| 3040 | }; | ||
| 3041 | |||
| 3042 | let message = match &self.commit_message { | ||
| 3043 | Some(message) => util::truncate_lines_and_trailoff(message, 15), | ||
| 3044 | None => self.blame_entry.summary.clone().unwrap_or_default(), | ||
| 3045 | }; | ||
| 3046 | |||
| 3047 | let pretty_commit_id = format!("{}", self.blame_entry.sha); | ||
| 3048 | |||
| 3049 | tooltip_container(cx, move |this, cx| { | ||
| 3050 | this.occlude() | ||
| 3051 | .on_mouse_move(|_, cx| cx.stop_propagation()) | ||
| 3052 | .child( | ||
| 3053 | v_flex() | ||
| 3054 | .child( | ||
| 3055 | h_flex() | ||
| 3056 | .child( | ||
| 3057 | div() | ||
| 3058 | .text_color(cx.theme().colors().text_muted) | ||
| 3059 | .child("Commit") | ||
| 3060 | .pr_2(), | ||
| 3061 | ) | ||
| 3062 | .child( | ||
| 3063 | div().text_color(self.color).child(pretty_commit_id.clone()), | ||
| 3064 | ), | ||
| 3065 | ) | ||
| 3066 | .child( | ||
| 3067 | div() | ||
| 3068 | .child(format!( | ||
| 3069 | "{} {} - {}", | ||
| 3070 | author, author_email, absolute_timestamp | ||
| 3071 | )) | ||
| 3072 | .text_color(cx.theme().colors().text_muted), | ||
| 3073 | ) | ||
| 3074 | .child(div().child(message)), | ||
| 3075 | ) | ||
| 3076 | }) | ||
| 3077 | } | ||
| 3078 | } | ||
| 3079 | |||
| 3080 | #[derive(Debug)] | 3244 | #[derive(Debug)] |
| 3081 | pub(crate) struct LineWithInvisibles { | 3245 | pub(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.line | 3372 | 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)>, |
| @@ -1,3 +1,5 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 1 | use anyhow::Result; | 3 | use anyhow::Result; |
| 2 | use collections::HashMap; | 4 | use collections::HashMap; |
| 3 | use git::{ | 5 | use git::{ |
| @@ -5,7 +7,7 @@ use git::{ · 1 unmodified line | |||
| 5 | Oid, | 7 | Oid, |
| 6 | }; | 8 | }; |
| 7 | use gpui::{Model, ModelContext, Subscription, Task}; | 9 | use gpui::{Model, ModelContext, Subscription, Task}; |
| 8 | use language::{Bias, Buffer, BufferSnapshot, Edit}; | 10 | use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown}; |
| 9 | use project::{Item, Project}; | 11 | use project::{Item, Project}; |
| 10 | use smallvec::SmallVec; | 12 | use smallvec::SmallVec; |
| 11 | use sum_tree::SumTree; | 13 | use 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)] | ||
| 50 | pub struct CommitDetails { | ||
| 51 | pub message: String, | ||
| 52 | pub parsed_message: ParsedMarkdown, | ||
| 53 | pub permalink: Option<Url>, | ||
| 54 | } | ||
| 55 | |||
| 47 | pub struct GitBlame { | 56 | pub 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.generated | 126 | 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 = cx | 266 | 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 | ||
| 315 | fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> { | ||
| 316 | let mut current_row = 0; | ||
| 317 | let mut entries = SumTree::from_iter( | ||
| 318 | entries.into_iter().flat_map(|entry| { | ||
| 319 | let mut entries = SmallVec::<[GitBlameEntry; 2]>::new(); | ||
| 320 | |||
| 321 | if entry.range.start > current_row { | ||
| 322 | let skipped_rows = entry.range.start - current_row; | ||
| 323 | entries.push(GitBlameEntry { | ||
| 324 | rows: skipped_rows, | ||
| 325 | blame: None, | ||
| 326 | }); | ||
| 327 | } | ||
| 328 | entries.push(GitBlameEntry { | ||
| 329 | rows: entry.range.len() as u32, | ||
| 330 | blame: Some(entry.clone()), | ||
| 331 | }); | ||
| 332 | |||
| 333 | current_row = entry.range.end; | ||
| 334 | entries | ||
| 335 | }), | ||
| 336 | &(), | ||
| 337 | ); | ||
| 338 | |||
| 339 | if max_row >= current_row { | ||
| 340 | entries.push( | ||
| 341 | GitBlameEntry { | ||
| 342 | rows: (max_row + 1) - current_row, | ||
| 343 | blame: None, | ||
| 344 | }, | ||
| 345 | &(), | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | entries | ||
| 350 | } | ||
| 351 | |||
| 352 | async fn parse_commit_messages( | ||
| 353 | messages: impl IntoIterator<Item = (Oid, String)>, | ||
| 354 | permalinks: &HashMap<Oid, Url>, | ||
| 355 | languages: &Arc<LanguageRegistry>, | ||
| 356 | ) -> HashMap<Oid, CommitDetails> { | ||
| 357 | let mut commit_details = HashMap::default(); | ||
| 358 | for (oid, message) in messages { | ||
| 359 | let parsed_message = parse_markdown(&message, &languages).await; | ||
| 360 | let permalink = permalinks.get(&oid).cloned(); | ||
| 361 | |||
| 362 | commit_details.insert( | ||
| 363 | oid, | ||
| 364 | CommitDetails { | ||
| 365 | message, | ||
| 366 | parsed_message, | ||
| 367 | permalink, | ||
| 368 | }, | ||
| 369 | ); | ||
| 370 | } | ||
| 371 | |||
| 372 | commit_details | ||
| 373 | } | ||
| 374 | |||
| 375 | async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown { | ||
| 376 | let mut parsed_message = ParsedMarkdown::default(); | ||
| 377 | |||
| 378 | markdown::parse_markdown_block( | ||
| 379 | text, | ||
| 380 | language_registry, | ||
| 381 | None, | ||
| 382 | &mut parsed_message.text, | ||
| 383 | &mut parsed_message.highlights, | ||
| 384 | &mut parsed_message.region_ranges, | ||
| 385 | &mut parsed_message.regions, | ||
| 386 | ) | ||
| 387 | .await; | ||
| 388 | |||
| 389 | parsed_message | ||
| 390 | } | ||
| 391 | |||
| 329 | #[cfg(test)] | 392 | #[cfg(test)] |
| 330 | mod tests { | 393 | mod tests { |
| 331 | use super::*; | 394 | use super::*; |
| @@ -394,7 +457,7 @@ mod tests { · 62 unmodified lines | |||
| 394 | .await | 457 | .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 | .await | 526 | .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 | .await | 606 | .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 | .await | 755 | .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 | ||
| @@ -8,7 +8,7 @@ use gpui::{px, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Underl · 7 unmodified lines | |||
| 8 | use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; | 8 | use 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)] |
| 12 | pub struct ParsedMarkdown { | 12 | pub struct ParsedMarkdown { |
| 13 | /// The Markdown text. | 13 | /// The Markdown text. |
| 14 | pub text: String, | 14 | pub text: String, |
| @@ -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) = worktree | 7759 | |
| 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 = worktree | 7767 | 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_path | 7774 | let relative_path = buffer_project_path |
| 7768 | .path | 7775 | .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)] | ||
| 10793 | pub struct NoRepositoryError {} | ||
| 10794 | |||
| 10795 | impl std::fmt::Display for NoRepositoryError { | ||
| 10796 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 10797 | write!(f, "no git repository for worktree found") | ||
| 10798 | } | ||
| 10799 | } | ||
| 10800 | |||
| 10801 | impl std::error::Error for NoRepositoryError {} | ||
| @@ -3,7 +3,7 @@ use gpui::AppContext; · 2 unmodified lines | |||
| 3 | use schemars::JsonSchema; | 3 | use schemars::JsonSchema; |
| 4 | use serde::{Deserialize, Serialize}; | 4 | use serde::{Deserialize, Serialize}; |
| 5 | use settings::{Settings, SettingsSources}; | 5 | use settings::{Settings, SettingsSources}; |
| 6 | use std::sync::Arc; | 6 | use std::{sync::Arc, time::Duration}; |
| 7 | 7 | ||
| 8 | #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] | 8 | #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] |
| 9 | pub struct ProjectSettings { | 9 | pub struct ProjectSettings { |
| @@ -29,6 +29,30 @@ pub struct GitSettings { · 19 unmodified lines | |||
| 29 | /// Default: tracked_files | 29 | /// 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 | |||
| 39 | impl GitSettings { | ||
| 40 | pub fn inline_blame_enabled(&self) -> bool { | ||
| 41 | match self.inline_blame { | ||
| 42 | Some(InlineBlameSettings { enabled, .. }) => enabled, | ||
| 43 | _ => false, | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | pub fn inline_blame_delay(&self) -> Option<Duration> { | ||
| 48 | match self.inline_blame { | ||
| 49 | Some(InlineBlameSettings { | ||
| 50 | delay_ms: Some(delay_ms), | ||
| 51 | .. | ||
| 52 | }) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)), | ||
| 53 | _ => None, | ||
| 54 | } | ||
| 55 | } | ||
| 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")] | ||
| 70 | pub struct InlineBlameSettings { | ||
| 71 | /// Whether or not to show git blame data inline in | ||
| 72 | /// the currently focused line. | ||
| 73 | /// | ||
| 74 | /// Default: false | ||
| 75 | pub enabled: bool, | ||
| 76 | /// Whether to only show the inline blame information | ||
| 77 | /// after a delay once the cursor stops moving. | ||
| 78 | /// | ||
| 79 | /// Default: 0 | ||
| 80 | pub delay_ms: Option<u64>, | ||
| 81 | } | ||
| 82 | |||
| 44 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] | 83 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] |
| 45 | pub struct BinarySettings { | 84 | pub struct BinarySettings { |
| 46 | pub path: Option<String>, | 85 | pub path: Option<String>, |
| @@ -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 | } |
| @@ -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 | ||
| 80 | fn format_absolute_timestamp_medium( | ||
| 81 | timestamp: OffsetDateTime, | ||
| 82 | #[allow(unused_variables)] reference: OffsetDateTime, | ||
| 83 | ) -> String { | ||
| 84 | #[cfg(target_os = "macos")] | ||
| 85 | { | ||
| 86 | macos::format_date_medium(×tamp) | ||
| 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 | |||
| 75 | fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> { | 96 | fn 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 | ||
| @@ -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, |