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 | 390 | // "git_gutter": "tracked_files" |
| 391 | 391 | // 2. Hide the gutter |
| 392 | 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 | 403 | "copilot": { |
| 396 | 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 | 2101 | blame.update(cx, |blame, _| { |
| 2102 | 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 | 2105 | assert_eq!( |
| 2104 | − blame.permalink_for_entry(entry).unwrap().to_string(), | |
| 2106 | + details.permalink.unwrap().to_string(), | |
| 2105 | 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 | 557 | use clock::FakeSystemClock; |
| 558 | 558 | use gpui::TestAppContext; |
| 559 | 559 | use language::{Language, LanguageConfig}; |
| 560 | + use project::Project; | |
| 560 | 561 | use rpc::proto; |
| 561 | 562 | use settings::SettingsStore; |
| 562 | 563 | use util::{http::FakeHttpClient, test::marked_text_ranges}; |
| @@ -630,6 +631,7 @@ mod tests { · 67 unmodified lines | ||
| 630 | 631 | let client = Client::new(clock, http.clone(), cx); |
| 631 | 632 | let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); |
| 632 | 633 | theme::init(theme::LoadThemes::JustBase, cx); |
| 634 | + Project::init_settings(cx); | |
| 633 | 635 | language::init(cx); |
| 634 | 636 | editor::init(cx); |
| 635 | 637 | client::init(&client, cx); |
| @@ -245,6 +245,7 @@ gpui::actions!( · 244 unmodified lines | ||
| 245 | 245 | Tab, |
| 246 | 246 | TabPrev, |
| 247 | 247 | ToggleGitBlame, |
| 248 | + ToggleGitBlameInline, | |
| 248 | 249 | ToggleInlayHints, |
| 249 | 250 | ToggleLineNumbers, |
| 250 | 251 | ToggleSoftWrap, |
| @@ -155,7 +155,7 @@ pub fn render_parsed_markdown( · 154 unmodified lines | ||
| 155 | 155 | parsed: &language::ParsedMarkdown, |
| 156 | 156 | editor_style: &EditorStyle, |
| 157 | 157 | workspace: Option<WeakView<Workspace>>, |
| 158 | − cx: &mut ViewContext<Editor>, | |
| 158 | + cx: &mut WindowContext, | |
| 159 | 159 | ) -> InteractiveText { |
| 160 | 160 | let code_span_background_color = cx |
| 161 | 161 | .theme() |
| @@ -463,7 +463,9 @@ pub struct Editor { · 301 unmodified lines | ||
| 463 | 463 | editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>, |
| 464 | 464 | use_autoclose: bool, |
| 465 | 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 | 469 | blame: Option<Model<GitBlame>>, |
| 468 | 470 | blame_subscription: Option<Subscription>, |
| 469 | 471 | custom_context_menu: Option< |
| @@ -480,7 +482,7 @@ pub struct Editor { · 10 unmodified lines | ||
| 480 | 482 | pub struct EditorSnapshot { |
| 481 | 483 | pub mode: EditorMode, |
| 482 | 484 | show_gutter: bool, |
| 483 | − show_git_blame: bool, | |
| 485 | + render_git_blame_gutter: bool, | |
| 484 | 486 | pub display_snapshot: DisplaySnapshot, |
| 485 | 487 | pub placeholder_text: Option<Arc<str>>, |
| 486 | 488 | is_focused: bool, |
| @@ -1498,7 +1500,9 @@ impl Editor { · 1011 unmodified lines | ||
| 1498 | 1500 | vim_replace_map: Default::default(), |
| 1499 | 1501 | show_inline_completions: mode == EditorMode::Full, |
| 1500 | 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 | 1506 | blame: None, |
| 1503 | 1507 | blame_subscription: None, |
| 1504 | 1508 | _subscriptions: vec![ |
| @@ -1530,6 +1534,10 @@ impl Editor { · 25 unmodified lines | ||
| 1530 | 1534 | if mode == EditorMode::Full { |
| 1531 | 1535 | let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); |
| 1532 | 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 | 1543 | this.report_editor_event("open", None, cx); |
| @@ -1646,10 +1654,7 @@ impl Editor { · 110 unmodified lines | ||
| 1646 | 1654 | EditorSnapshot { |
| 1647 | 1655 | mode: self.mode, |
| 1648 | 1656 | show_gutter: self.show_gutter, |
| 1649 | − show_git_blame: self | |
| 1650 | − .blame | |
| 1651 | − .as_ref() | |
| 1652 | − .map_or(false, |blame| blame.read(cx).has_generated_entries()), | |
| 1657 | + render_git_blame_gutter: self.render_git_blame_gutter(cx), | |
| 1653 | 1658 | display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), |
| 1654 | 1659 | scroll_anchor: self.scroll_manager.anchor(), |
| 1655 | 1660 | ongoing_scroll: self.scroll_manager.ongoing_scroll(), |
| @@ -1915,6 +1920,7 @@ impl Editor { · 259 unmodified lines | ||
| 1915 | 1920 | self.refresh_document_highlights(cx); |
| 1916 | 1921 | refresh_matching_bracket_highlights(self, cx); |
| 1917 | 1922 | self.discard_inline_completion(cx); |
| 1923 | + self.start_inline_blame_timer(cx); | |
| 1918 | 1924 | } |
| 1919 | 1925 | |
| 1920 | 1926 | self.blink_manager.update(cx, BlinkManager::pause_blinking); |
| @@ -3794,6 +3800,22 @@ impl Editor { · 1873 unmodified lines | ||
| 3794 | 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 | 3819 | fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> { |
| 3798 | 3820 | if self.pending_rename.is_some() { |
| 3799 | 3821 | return None; |
| @@ -8843,40 +8865,83 @@ impl Editor { · 5043 unmodified lines | ||
| 8843 | 8865 | } |
| 8844 | 8866 | |
| 8845 | 8867 | pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) { |
| 8846 | − if self.show_git_blame { | |
| 8847 | − self.blame_subscription.take(); | |
| 8848 | − self.blame.take(); | |
| 8849 | − self.show_git_blame = false | |
| 8850 | − } else { | |
| 8851 | − if let Err(error) = self.show_git_blame_internal(cx) { | |
| 8852 | − log::error!("failed to toggle on 'git blame': {}", error); | |
| 8853 | − return; | |
| 8854 | − } | |
| 8855 | − self.show_git_blame = true | |
| 8868 | + self.show_git_blame_gutter = !self.show_git_blame_gutter; | |
| 8869 | + | |
| 8870 | + if self.show_git_blame_gutter && !self.has_blame_entries(cx) { | |
| 8871 | + self.start_git_blame(true, cx); | |
| 8856 | 8872 | } |
| 8857 | 8873 | |
| 8858 | 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 | 8887 | if let Some(project) = self.project.as_ref() { |
| 8863 | 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 | 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 | 8894 | self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify())); |
| 8870 | 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 | 8928 | pub fn blame(&self) -> Option<&Model<GitBlame>> { |
| 8877 | 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 | 8945 | fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> { |
| 8881 | 8946 | let (path, repo) = maybe!({ |
| 8882 | 8947 | let project_handle = self.project.as_ref()?.clone(); |
| @@ -9446,6 +9511,14 @@ impl Editor { · 563 unmodified lines | ||
| 9446 | 9511 | let editor_settings = EditorSettings::get_global(cx); |
| 9447 | 9512 | self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; |
| 9448 | 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 | 9522 | cx.notify(); |
| 9450 | 9523 | } |
| 9451 | 9524 | |
| @@ -10058,7 +10131,7 @@ impl EditorSnapshot { · 606 unmodified lines | ||
| 10058 | 10131 | }; |
| 10059 | 10132 | |
| 10060 | 10133 | let git_blame_entries_width = self |
| 10061 | − .show_git_blame | |
| 10134 | + .render_git_blame_gutter | |
| 10062 | 10135 | .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS); |
| 10063 | 10136 | |
| 10064 | 10137 | let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); |
| @@ -4,7 +4,10 @@ use crate::{ · 3 unmodified lines | ||
| 4 | 4 | TransformBlock, |
| 5 | 5 | }, |
| 6 | 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 | 11 | hover_popover::{ |
| 9 | 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 | 24 | use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid}; |
| 22 | 25 | use gpui::{ |
| 23 | 26 | anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg, |
| 24 | − transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds, | |
| 25 | − ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, | |
| 26 | − ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement, | |
| 27 | + transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem, | |
| 28 | + ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext, | |
| 29 | + ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement, | |
| 27 | 30 | ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, |
| 28 | − ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful, | |
| 29 | − StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, | |
| 30 | − ViewContext, WindowContext, | |
| 31 | + ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, | |
| 32 | + Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, | |
| 33 | + TextStyleRefinement, View, ViewContext, WeakView, WindowContext, | |
| 31 | 34 | }; |
| 32 | 35 | use itertools::Itertools; |
| 33 | 36 | use language::language_settings::ShowWhitespaceSetting; |
| @@ -49,11 +52,11 @@ use std::{ · 15 unmodified lines | ||
| 49 | 52 | sync::Arc, |
| 50 | 53 | }; |
| 51 | 54 | use sum_tree::Bias; |
| 52 | −use theme::{ActiveTheme, PlayerColor}; | |
| 55 | +use theme::{ActiveTheme, PlayerColor, ThemeSettings}; | |
| 53 | 56 | use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; |
| 54 | 57 | use ui::{prelude::*, tooltip_container}; |
| 55 | 58 | use util::ResultExt; |
| 56 | −use workspace::item::Item; | |
| 59 | +use workspace::{item::Item, Workspace}; | |
| 57 | 60 | |
| 58 | 61 | struct SelectionLayout { |
| 59 | 62 | head: DisplayPoint, |
| @@ -303,6 +306,7 @@ impl EditorElement { · 243 unmodified lines | ||
| 303 | 306 | register_action(view, cx, Editor::copy_permalink_to_line); |
| 304 | 307 | register_action(view, cx, Editor::open_permalink_to_line); |
| 305 | 308 | register_action(view, cx, Editor::toggle_git_blame); |
| 309 | + register_action(view, cx, Editor::toggle_git_blame_inline); | |
| 306 | 310 | register_action(view, cx, |editor, action, cx| { |
| 307 | 311 | if let Some(task) = editor.format(action, cx) { |
| 308 | 312 | task.detach_and_log_err(cx); |
| @@ -1092,6 +1096,58 @@ impl EditorElement { · 783 unmodified lines | ||
| 1092 | 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 | 1151 | #[allow(clippy::too_many_arguments)] |
| 1096 | 1152 | fn layout_blame_entries( |
| 1097 | 1153 | &self, |
| @@ -1103,10 +1159,14 @@ impl EditorElement { · 5 unmodified lines | ||
| 1103 | 1159 | max_width: Option<Pixels>, |
| 1104 | 1160 | cx: &mut ElementContext, |
| 1105 | 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 | 1166 | return None; |
| 1108 | − }; | |
| 1167 | + } | |
| 1109 | 1168 | |
| 1169 | + let blame = self.editor.read(cx).blame.clone()?; | |
| 1110 | 1170 | let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| { |
| 1111 | 1171 | blame.blame_for_rows(buffer_rows, cx).collect() |
| 1112 | 1172 | }); |
| @@ -1120,7 +1180,6 @@ impl EditorElement { · 7 unmodified lines | ||
| 1120 | 1180 | let start_x = em_width * 1; |
| 1121 | 1181 | |
| 1122 | 1182 | let mut last_used_color: Option<(PlayerColor, Oid)> = None; |
| 1123 | − let text_style = &self.style.text; | |
| 1124 | 1183 | |
| 1125 | 1184 | let shaped_lines = blamed_rows |
| 1126 | 1185 | .into_iter() |
| @@ -1131,7 +1190,7 @@ impl EditorElement { · 4 unmodified lines | ||
| 1131 | 1190 | ix, |
| 1132 | 1191 | &blame, |
| 1133 | 1192 | blame_entry, |
| 1134 | − text_style, | |
| 1193 | + &self.style, | |
| 1135 | 1194 | &mut last_used_color, |
| 1136 | 1195 | self.editor.clone(), |
| 1137 | 1196 | cx, |
| @@ -2256,6 +2315,7 @@ impl EditorElement { · 1118 unmodified lines | ||
| 2256 | 2315 | self.paint_lines(&invisible_display_ranges, layout, cx); |
| 2257 | 2316 | self.paint_redactions(layout, cx); |
| 2258 | 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 | 2801 | fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { |
| 2734 | 2802 | for mut block in layout.blocks.drain(..) { |
| 2735 | 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 | 3146 | fn render_blame_entry( |
| 2898 | 3147 | ix: usize, |
| 2899 | 3148 | blame: &gpui::Model<GitBlame>, |
| 2900 | 3149 | blame_entry: BlameEntry, |
| 2901 | − text_style: &TextStyle, | |
| 3150 | + style: &EditorStyle, | |
| 2902 | 3151 | last_used_color: &mut Option<(PlayerColor, Oid)>, |
| 2903 | 3152 | editor: View<Editor>, |
| 2904 | 3153 | cx: &mut ElementContext<'_>, |
| @@ -2918,29 +3167,26 @@ fn render_blame_entry( · 13 unmodified lines | ||
| 2918 | 3167 | }; |
| 2919 | 3168 | last_used_color.replace((sha_color, blame_entry.sha)); |
| 2920 | 3169 | |
| 2921 | − let relative_timestamp = match blame_entry.author_offset_date_time() { | |
| 2922 | − Ok(timestamp) => time_format::format_localized_timestamp( | |
| 2923 | − timestamp, | |
| 2924 | − time::OffsetDateTime::now_utc(), | |
| 2925 | − cx.local_timezone(), | |
| 2926 | − time_format::TimestampFormat::Relative, | |
| 2927 | − ), | |
| 2928 | − Err(_) => "Error parsing date".to_string(), | |
| 2929 | − }; | |
| 3170 | + let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx); | |
| 2930 | 3171 | |
| 2931 | 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 | 3175 | let author_name = blame_entry.author.as_deref().unwrap_or("<no name>"); |
| 2935 | 3176 | let name = util::truncate_and_trailoff(author_name, 20); |
| 2936 | 3177 | |
| 2937 | − let permalink = blame.read(cx).permalink_for_entry(&blame_entry); | |
| 2938 | − let commit_message = blame.read(cx).message_for_entry(&blame_entry); | |
| 3178 | + let details = blame.read(cx).details_for_entry(&blame_entry); | |
| 3179 | + | |
| 3180 | + let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone()); | |
| 3181 | + | |
| 3182 | + let tooltip = cx.new_view(|_| { | |
| 3183 | + BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace) | |
| 3184 | + }); | |
| 2939 | 3185 | |
| 2940 | 3186 | h_flex() |
| 2941 | 3187 | .w_full() |
| 2942 | − .font(text_style.font().family) | |
| 2943 | − .line_height(text_style.line_height) | |
| 3188 | + .font(style.text.font().family) | |
| 3189 | + .line_height(style.text.line_height) | |
| 2944 | 3190 | .id(("blame", ix)) |
| 2945 | 3191 | .children([ |
| 2946 | 3192 | div() |
| @@ -2962,21 +3208,17 @@ fn render_blame_entry( · 15 unmodified lines | ||
| 2962 | 3208 | } |
| 2963 | 3209 | }) |
| 2964 | 3210 | .hover(|style| style.bg(cx.theme().colors().element_hover)) |
| 2965 | − .when_some(permalink, |this, url| { | |
| 2966 | − let url = url.clone(); | |
| 2967 | − this.cursor_pointer().on_click(move |_, cx| { | |
| 2968 | − cx.stop_propagation(); | |
| 2969 | − cx.open_url(url.as_str()) | |
| 2970 | − }) | |
| 2971 | − }) | |
| 2972 | − .hoverable_tooltip(move |cx| { | |
| 2973 | − BlameEntryTooltip::new( | |
| 2974 | − sha_color.cursor, | |
| 2975 | − commit_message.clone(), | |
| 2976 | − blame_entry.clone(), | |
| 2977 | − cx, | |
| 2978 | − ) | |
| 2979 | − }) | |
| 3211 | + .when_some( | |
| 3212 | + details.and_then(|details| details.permalink), | |
| 3213 | + |this, url| { | |
| 3214 | + let url = url.clone(); | |
| 3215 | + this.cursor_pointer().on_click(move |_, cx| { | |
| 3216 | + cx.stop_propagation(); | |
| 3217 | + cx.open_url(url.as_str()) | |
| 3218 | + }) | |
| 3219 | + }, | |
| 3220 | + ) | |
| 3221 | + .hoverable_tooltip(move |_| tooltip.clone().into()) | |
| 2980 | 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 | 3244 | #[derive(Debug)] |
| 3081 | 3245 | pub(crate) struct LineWithInvisibles { |
| 3082 | 3246 | pub line: ShapedLine, |
| @@ -3205,13 +3369,9 @@ impl LineWithInvisibles { · 122 unmodified lines | ||
| 3205 | 3369 | let line_y = |
| 3206 | 3370 | line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height); |
| 3207 | 3371 | |
| 3208 | − self.line | |
| 3209 | − .paint( | |
| 3210 | − content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y), | |
| 3211 | − line_height, | |
| 3212 | − cx, | |
| 3213 | − ) | |
| 3214 | − .log_err(); | |
| 3372 | + let line_origin = | |
| 3373 | + content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y); | |
| 3374 | + self.line.paint(line_origin, line_height, cx).log_err(); | |
| 3215 | 3375 | |
| 3216 | 3376 | self.draw_invisibles( |
| 3217 | 3377 | &selection_ranges, |
| @@ -3490,16 +3650,6 @@ impl Element for EditorElement { · 272 unmodified lines | ||
| 3490 | 3650 | |
| 3491 | 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 | 3653 | let mut max_visible_line_width = Pixels::ZERO; |
| 3504 | 3654 | let line_layouts = |
| 3505 | 3655 | self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx); |
| @@ -3528,6 +3678,37 @@ impl Element for EditorElement { · 22 unmodified lines | ||
| 3528 | 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 | 3712 | let scroll_max = point( |
| 3532 | 3713 | ((scroll_width - text_hitbox.size.width) / em_width).max(0.0), |
| 3533 | 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 | 3739 | cx.with_element_id(Some("blocks"), |cx| { |
| 3564 | 3740 | self.layout_blocks( |
| 3565 | 3741 | &mut blocks, |
| @@ -3728,6 +3904,7 @@ impl Element for EditorElement { · 162 unmodified lines | ||
| 3728 | 3904 | line_numbers, |
| 3729 | 3905 | display_hunks, |
| 3730 | 3906 | blamed_display_rows, |
| 3907 | + inline_blame, | |
| 3731 | 3908 | folds, |
| 3732 | 3909 | blocks, |
| 3733 | 3910 | cursors, |
| @@ -3815,6 +3992,7 @@ pub struct EditorLayout { · 81 unmodified lines | ||
| 3815 | 3992 | line_numbers: Vec<Option<ShapedLine>>, |
| 3816 | 3993 | display_hunks: Vec<DisplayDiffHunk>, |
| 3817 | 3994 | blamed_display_rows: Option<Vec<AnyElement>>, |
| 3995 | + inline_blame: Option<AnyElement>, | |
| 3818 | 3996 | folds: Vec<FoldLayout>, |
| 3819 | 3997 | blocks: Vec<BlockLayout>, |
| 3820 | 3998 | highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>, |
| @@ -1,3 +1,5 @@ | ||
| 1 | +use std::sync::Arc; | |
| 2 | + | |
| 1 | 3 | use anyhow::Result; |
| 2 | 4 | use collections::HashMap; |
| 3 | 5 | use git::{ |
| @@ -5,7 +7,7 @@ use git::{ · 1 unmodified line | ||
| 5 | 7 | Oid, |
| 6 | 8 | }; |
| 7 | 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 | 11 | use project::{Item, Project}; |
| 10 | 12 | use smallvec::SmallVec; |
| 11 | 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 | 56 | pub struct GitBlame { |
| 48 | 57 | project: Model<Project>, |
| 49 | 58 | buffer: Model<Buffer>, |
| 50 | 59 | entries: SumTree<GitBlameEntry>, |
| 51 | − permalinks: HashMap<Oid, Url>, | |
| 52 | − messages: HashMap<Oid, String>, | |
| 60 | + commit_details: HashMap<Oid, CommitDetails>, | |
| 53 | 61 | buffer_snapshot: BufferSnapshot, |
| 54 | 62 | buffer_edits: text::Subscription, |
| 55 | 63 | task: Task<Result<()>>, |
| 56 | 64 | generated: bool, |
| 65 | + user_triggered: bool, | |
| 57 | 66 | _refresh_subscription: Subscription, |
| 58 | 67 | } |
| 59 | 68 | |
| @@ -61,6 +70,7 @@ impl GitBlame { · 1 unmodified line | ||
| 61 | 70 | pub fn new( |
| 62 | 71 | buffer: Model<Buffer>, |
| 63 | 72 | project: Model<Project>, |
| 73 | + user_triggered: bool, | |
| 64 | 74 | cx: &mut ModelContext<Self>, |
| 65 | 75 | ) -> Self { |
| 66 | 76 | let entries = SumTree::from_item( |
| @@ -102,8 +112,8 @@ impl GitBlame { · 35 unmodified lines | ||
| 102 | 112 | buffer_snapshot, |
| 103 | 113 | entries, |
| 104 | 114 | buffer_edits, |
| 105 | − permalinks: HashMap::default(), | |
| 106 | − messages: HashMap::default(), | |
| 115 | + user_triggered, | |
| 116 | + commit_details: HashMap::default(), | |
| 107 | 117 | task: Task::ready(Ok(())), |
| 108 | 118 | generated: false, |
| 109 | 119 | _refresh_subscription: refresh_subscription, |
| @@ -116,12 +126,8 @@ impl GitBlame { · 6 unmodified lines | ||
| 116 | 126 | self.generated |
| 117 | 127 | } |
| 118 | 128 | |
| 119 | − pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> { | |
| 120 | − self.permalinks.get(&entry.sha).cloned() | |
| 121 | − } | |
| 122 | − | |
| 123 | − pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> { | |
| 124 | − self.messages.get(&entry.sha).cloned() | |
| 129 | + pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> { | |
| 130 | + self.commit_details.get(&entry.sha).cloned() | |
| 125 | 131 | } |
| 126 | 132 | |
| 127 | 133 | pub fn blame_for_rows<'a>( |
| @@ -254,6 +260,7 @@ impl GitBlame { · 126 unmodified lines | ||
| 254 | 260 | let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe()); |
| 255 | 261 | let snapshot = self.buffer.read(cx).snapshot(); |
| 256 | 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 | 265 | self.task = cx.spawn(|this, mut cx| async move { |
| 259 | 266 | let result = cx |
| @@ -267,65 +274,121 @@ impl GitBlame { · 7 unmodified lines | ||
| 267 | 274 | messages, |
| 268 | 275 | } = blame.await?; |
| 269 | 276 | |
| 270 | − let mut current_row = 0; | |
| 271 | − let mut entries = SumTree::from_iter( | |
| 272 | − entries.into_iter().flat_map(|entry| { | |
| 273 | − let mut entries = SmallVec::<[GitBlameEntry; 2]>::new(); | |
| 274 | − | |
| 275 | − if entry.range.start > current_row { | |
| 276 | − let skipped_rows = entry.range.start - current_row; | |
| 277 | − entries.push(GitBlameEntry { | |
| 278 | − rows: skipped_rows, | |
| 279 | − blame: None, | |
| 280 | − }); | |
| 281 | − } | |
| 282 | − entries.push(GitBlameEntry { | |
| 283 | − rows: entry.range.len() as u32, | |
| 284 | − blame: Some(entry.clone()), | |
| 285 | − }); | |
| 286 | − | |
| 287 | − current_row = entry.range.end; | |
| 288 | − entries | |
| 289 | − }), | |
| 290 | − &(), | |
| 291 | − ); | |
| 277 | + let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row); | |
| 278 | + let commit_details = | |
| 279 | + parse_commit_messages(messages, &permalinks, &languages).await; | |
| 292 | 280 | |
| 293 | − let max_row = snapshot.max_point().row; | |
| 294 | − if max_row >= current_row { | |
| 295 | − entries.push( | |
| 296 | − GitBlameEntry { | |
| 297 | − rows: (max_row + 1) - current_row, | |
| 298 | − blame: None, | |
| 299 | − }, | |
| 300 | − &(), | |
| 301 | − ); | |
| 302 | − } | |
| 303 | − | |
| 304 | − anyhow::Ok((entries, permalinks, messages)) | |
| 281 | + anyhow::Ok((entries, commit_details)) | |
| 305 | 282 | } |
| 306 | 283 | }) |
| 307 | 284 | .await; |
| 308 | 285 | |
| 309 | 286 | this.update(&mut cx, |this, cx| match result { |
| 310 | − Ok((entries, permalinks, messages)) => { | |
| 287 | + Ok((entries, commit_details)) => { | |
| 311 | 288 | this.buffer_edits = buffer_edits; |
| 312 | 289 | this.buffer_snapshot = snapshot; |
| 313 | 290 | this.entries = entries; |
| 314 | − this.permalinks = permalinks; | |
| 315 | − this.messages = messages; | |
| 291 | + this.commit_details = commit_details; | |
| 316 | 292 | this.generated = true; |
| 317 | 293 | cx.notify(); |
| 318 | 294 | } |
| 319 | 295 | Err(error) => this.project.update(cx, |_, cx| { |
| 320 | − log::error!("failed to get git blame data: {error:?}"); | |
| 321 | − let notification = format!("{:#}", error).trim().to_string(); | |
| 322 | − cx.emit(project::Event::Notification(notification)); | |
| 296 | + if this.user_triggered { | |
| 297 | + log::error!("failed to get git blame data: {error:?}"); | |
| 298 | + let notification = format!("{:#}", error).trim().to_string(); | |
| 299 | + cx.emit(project::Event::Notification(notification)); | |
| 300 | + } else { | |
| 301 | + // If we weren't triggered by a user, we just log errors in the background, instead of sending | |
| 302 | + // notifications. | |
| 303 | + // Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on | |
| 304 | + // and opens a non-git file. | |
| 305 | + if error.downcast_ref::<project::NoRepositoryError>().is_none() { | |
| 306 | + log::error!("failed to get git blame data: {error:?}"); | |
| 307 | + } | |
| 308 | + } | |
| 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 | 392 | #[cfg(test)] |
| 330 | 393 | mod tests { |
| 331 | 394 | use super::*; |
| @@ -394,7 +457,7 @@ mod tests { · 62 unmodified lines | ||
| 394 | 457 | .await |
| 395 | 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 | 462 | let event = project.next_event(cx).await; |
| 400 | 463 | assert_eq!( |
| @@ -463,7 +526,7 @@ mod tests { · 62 unmodified lines | ||
| 463 | 526 | .await |
| 464 | 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 | 531 | cx.executor().run_until_parked(); |
| 469 | 532 | |
| @@ -543,7 +606,7 @@ mod tests { · 73 unmodified lines | ||
| 543 | 606 | .await |
| 544 | 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 | 611 | cx.executor().run_until_parked(); |
| 549 | 612 | |
| @@ -692,7 +755,7 @@ mod tests { · 142 unmodified lines | ||
| 692 | 755 | .await |
| 693 | 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 | 759 | cx.executor().run_until_parked(); |
| 697 | 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 | 8 | use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; |
| 9 | 9 | |
| 10 | 10 | /// Parsed Markdown content. |
| 11 | −#[derive(Debug, Clone)] | |
| 11 | +#[derive(Debug, Clone, Default)] | |
| 12 | 12 | pub struct ParsedMarkdown { |
| 13 | 13 | /// The Markdown text. |
| 14 | 14 | pub text: String, |
| @@ -7756,13 +7756,20 @@ impl Project { · 7755 unmodified lines | ||
| 7756 | 7756 | .as_local() |
| 7757 | 7757 | .context("worktree was not local")? |
| 7758 | 7758 | .snapshot(); |
| 7759 | − let (work_directory, repo) = worktree | |
| 7759 | + | |
| 7760 | + let (work_directory, repo) = match worktree | |
| 7760 | 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 | |
| 7764 | − .get_local_repo(&repo) | |
| 7765 | − .context("failed to get repo for blamed buffer")?; | |
| 7767 | + let repo_entry = match worktree.get_local_repo(&repo) { | |
| 7768 | + Some(repo_entry) => repo_entry, | |
| 7769 | + None => anyhow::bail!(NoRepositoryError {}), | |
| 7770 | + }; | |
| 7771 | + | |
| 7772 | + let repo = repo_entry.repo().clone(); | |
| 7766 | 7773 | |
| 7767 | 7774 | let relative_path = buffer_project_path |
| 7768 | 7775 | .path |
| @@ -7773,7 +7780,6 @@ impl Project { · 4 unmodified lines | ||
| 7773 | 7780 | Some(version) => buffer.rope_for_version(&version).clone(), |
| 7774 | 7781 | None => buffer.as_rope().clone(), |
| 7775 | 7782 | }; |
| 7776 | − let repo = repo_entry.repo().clone(); | |
| 7777 | 7783 | |
| 7778 | 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 | 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 | 3 | use schemars::JsonSchema; |
| 4 | 4 | use serde::{Deserialize, Serialize}; |
| 5 | 5 | use settings::{Settings, SettingsSources}; |
| 6 | −use std::sync::Arc; | |
| 6 | +use std::{sync::Arc, time::Duration}; | |
| 7 | 7 | |
| 8 | 8 | #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] |
| 9 | 9 | pub struct ProjectSettings { |
| @@ -29,6 +29,30 @@ pub struct GitSettings { · 19 unmodified lines | ||
| 29 | 29 | /// Default: tracked_files |
| 30 | 30 | pub git_gutter: Option<GitGutterSetting>, |
| 31 | 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 | 58 | #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)] |
| @@ -41,6 +65,21 @@ pub enum GitGutterSetting { · 6 unmodified lines | ||
| 41 | 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 | 83 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] |
| 45 | 84 | pub struct BinarySettings { |
| 46 | 85 | pub path: Option<String>, |
| @@ -1096,6 +1096,7 @@ mod tests { · 1095 unmodified lines | ||
| 1096 | 1096 | use editor::{DisplayPoint, Editor}; |
| 1097 | 1097 | use gpui::{Context, Hsla, TestAppContext, VisualTestContext}; |
| 1098 | 1098 | use language::Buffer; |
| 1099 | + use project::Project; | |
| 1099 | 1100 | use smol::stream::StreamExt as _; |
| 1100 | 1101 | use unindent::Unindent as _; |
| 1101 | 1102 | |
| @@ -1106,6 +1107,7 @@ mod tests { · 4 unmodified lines | ||
| 1106 | 1107 | editor::init(cx); |
| 1107 | 1108 | |
| 1108 | 1109 | language::init(cx); |
| 1110 | + Project::init_settings(cx); | |
| 1109 | 1111 | theme::init(theme::LoadThemes::JustBase, cx); |
| 1110 | 1112 | }); |
| 1111 | 1113 | } |
| @@ -9,6 +9,8 @@ pub enum TimestampFormat { · 8 unmodified lines | ||
| 9 | 9 | /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively. |
| 10 | 10 | /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM". |
| 11 | 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 | 14 | /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago". |
| 13 | 15 | Relative, |
| 14 | 16 | } |
| @@ -30,6 +32,9 @@ pub fn format_localized_timestamp( · 15 unmodified lines | ||
| 30 | 32 | TimestampFormat::EnhancedAbsolute => { |
| 31 | 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 | 38 | TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local) |
| 34 | 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 | 96 | fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> { |
| 76 | 97 | let difference = reference - timestamp; |
| 77 | 98 | let minutes = difference.whole_minutes(); |
| @@ -253,7 +274,8 @@ mod macos { · 175 unmodified lines | ||
| 253 | 274 | use core_foundation_sys::{ |
| 254 | 275 | base::kCFAllocatorDefault, |
| 255 | 276 | date_formatter::{ |
| 256 | − kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate, | |
| 277 | + kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, | |
| 278 | + CFDateFormatterCreate, | |
| 257 | 279 | }, |
| 258 | 280 | locale::CFLocaleCopyCurrent, |
| 259 | 281 | }; |
| @@ -266,6 +288,10 @@ mod macos { · 6 unmodified lines | ||
| 266 | 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 | 295 | fn format_with_date_formatter( |
| 270 | 296 | timestamp: &time::OffsetDateTime, |
| 271 | 297 | fmt: CFDateFormatterRef, |
| @@ -302,6 +328,15 @@ mod macos { · 30 unmodified lines | ||
| 302 | 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 | 12 | Disabled, |
| 13 | 13 | Error, |
| 14 | 14 | Hidden, |
| 15 | + Hint, | |
| 15 | 16 | Info, |
| 16 | 17 | Modified, |
| 17 | 18 | Conflict, |
| @@ -36,6 +37,7 @@ impl Color { · 18 unmodified lines | ||
| 36 | 37 | Color::Deleted => cx.theme().status().deleted, |
| 37 | 38 | Color::Disabled => cx.theme().colors().text_disabled, |
| 38 | 39 | Color::Hidden => cx.theme().status().hidden, |
| 40 | + Color::Hint => cx.theme().status().hint, | |
| 39 | 41 | Color::Info => cx.theme().status().info, |
| 40 | 42 | Color::Placeholder => cx.theme().colors().text_placeholder, |
| 41 | 43 | Color::Accent => cx.theme().colors().text_accent, |