Inline git blame (#10398)
This adds so-called "inline git blame" to the editor that, when turned on, shows `git blame` information about the current line inline:  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`.
This commit is contained in:
parent
573ba83034
commit
faebce8cd0
13 changed files with 655 additions and 237 deletions
|
@ -390,7 +390,15 @@
|
|||
// "git_gutter": "tracked_files"
|
||||
// 2. Hide the gutter
|
||||
// "git_gutter": "hide"
|
||||
"git_gutter": "tracked_files"
|
||||
"git_gutter": "tracked_files",
|
||||
// Control whether the git blame information is shown inline,
|
||||
// in the currently focused line.
|
||||
"inline_blame": {
|
||||
"enabled": false
|
||||
// Sets a delay after which the inline blame information is shown.
|
||||
// Delay is restarted with every cursor movement.
|
||||
// "delay_ms": 600
|
||||
}
|
||||
},
|
||||
"copilot": {
|
||||
// The set of glob patterns for which copilot should be disabled
|
||||
|
|
|
@ -2100,14 +2100,12 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
|||
|
||||
blame.update(cx, |blame, _| {
|
||||
for (idx, entry) in entries.iter().flatten().enumerate() {
|
||||
let details = blame.details_for_entry(entry).unwrap();
|
||||
assert_eq!(details.message, format!("message for idx-{}", idx));
|
||||
assert_eq!(
|
||||
blame.permalink_for_entry(entry).unwrap().to_string(),
|
||||
details.permalink.unwrap().to_string(),
|
||||
format!("http://example.com/codehost/idx-{}", idx)
|
||||
);
|
||||
assert_eq!(
|
||||
blame.message_for_entry(entry).unwrap(),
|
||||
format!("message for idx-{}", idx)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -557,6 +557,7 @@ mod tests {
|
|||
use clock::FakeSystemClock;
|
||||
use gpui::TestAppContext;
|
||||
use language::{Language, LanguageConfig};
|
||||
use project::Project;
|
||||
use rpc::proto;
|
||||
use settings::SettingsStore;
|
||||
use util::{http::FakeHttpClient, test::marked_text_ranges};
|
||||
|
@ -630,6 +631,7 @@ mod tests {
|
|||
let client = Client::new(clock, http.clone(), cx);
|
||||
let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
Project::init_settings(cx);
|
||||
language::init(cx);
|
||||
editor::init(cx);
|
||||
client::init(&client, cx);
|
||||
|
|
|
@ -245,6 +245,7 @@ gpui::actions!(
|
|||
Tab,
|
||||
TabPrev,
|
||||
ToggleGitBlame,
|
||||
ToggleGitBlameInline,
|
||||
ToggleInlayHints,
|
||||
ToggleLineNumbers,
|
||||
ToggleSoftWrap,
|
||||
|
|
|
@ -155,7 +155,7 @@ pub fn render_parsed_markdown(
|
|||
parsed: &language::ParsedMarkdown,
|
||||
editor_style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
cx: &mut WindowContext,
|
||||
) -> InteractiveText {
|
||||
let code_span_background_color = cx
|
||||
.theme()
|
||||
|
@ -463,7 +463,9 @@ pub struct Editor {
|
|||
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
|
||||
use_autoclose: bool,
|
||||
auto_replace_emoji_shortcode: bool,
|
||||
show_git_blame: bool,
|
||||
show_git_blame_gutter: bool,
|
||||
show_git_blame_inline: bool,
|
||||
show_git_blame_inline_delay_task: Option<Task<()>>,
|
||||
blame: Option<Model<GitBlame>>,
|
||||
blame_subscription: Option<Subscription>,
|
||||
custom_context_menu: Option<
|
||||
|
@ -480,7 +482,7 @@ pub struct Editor {
|
|||
pub struct EditorSnapshot {
|
||||
pub mode: EditorMode,
|
||||
show_gutter: bool,
|
||||
show_git_blame: bool,
|
||||
render_git_blame_gutter: bool,
|
||||
pub display_snapshot: DisplaySnapshot,
|
||||
pub placeholder_text: Option<Arc<str>>,
|
||||
is_focused: bool,
|
||||
|
@ -1498,7 +1500,9 @@ impl Editor {
|
|||
vim_replace_map: Default::default(),
|
||||
show_inline_completions: mode == EditorMode::Full,
|
||||
custom_context_menu: None,
|
||||
show_git_blame: false,
|
||||
show_git_blame_gutter: false,
|
||||
show_git_blame_inline: false,
|
||||
show_git_blame_inline_delay_task: None,
|
||||
blame: None,
|
||||
blame_subscription: None,
|
||||
_subscriptions: vec![
|
||||
|
@ -1530,6 +1534,10 @@ impl Editor {
|
|||
if mode == EditorMode::Full {
|
||||
let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
|
||||
cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
|
||||
|
||||
if ProjectSettings::get_global(cx).git.inline_blame_enabled() {
|
||||
this.start_git_blame_inline(false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
this.report_editor_event("open", None, cx);
|
||||
|
@ -1646,10 +1654,7 @@ impl Editor {
|
|||
EditorSnapshot {
|
||||
mode: self.mode,
|
||||
show_gutter: self.show_gutter,
|
||||
show_git_blame: self
|
||||
.blame
|
||||
.as_ref()
|
||||
.map_or(false, |blame| blame.read(cx).has_generated_entries()),
|
||||
render_git_blame_gutter: self.render_git_blame_gutter(cx),
|
||||
display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
|
||||
scroll_anchor: self.scroll_manager.anchor(),
|
||||
ongoing_scroll: self.scroll_manager.ongoing_scroll(),
|
||||
|
@ -1915,6 +1920,7 @@ impl Editor {
|
|||
self.refresh_document_highlights(cx);
|
||||
refresh_matching_bracket_highlights(self, cx);
|
||||
self.discard_inline_completion(cx);
|
||||
self.start_inline_blame_timer(cx);
|
||||
}
|
||||
|
||||
self.blink_manager.update(cx, BlinkManager::pause_blinking);
|
||||
|
@ -3794,6 +3800,22 @@ impl Editor {
|
|||
None
|
||||
}
|
||||
|
||||
fn start_inline_blame_timer(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() {
|
||||
self.show_git_blame_inline = false;
|
||||
|
||||
self.show_git_blame_inline_delay_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
cx.background_executor().timer(delay).await;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.show_git_blame_inline = true;
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
|
||||
if self.pending_rename.is_some() {
|
||||
return None;
|
||||
|
@ -8843,40 +8865,83 @@ impl Editor {
|
|||
}
|
||||
|
||||
pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {
|
||||
if self.show_git_blame {
|
||||
self.blame_subscription.take();
|
||||
self.blame.take();
|
||||
self.show_git_blame = false
|
||||
} else {
|
||||
if let Err(error) = self.show_git_blame_internal(cx) {
|
||||
log::error!("failed to toggle on 'git blame': {}", error);
|
||||
return;
|
||||
}
|
||||
self.show_git_blame = true
|
||||
self.show_git_blame_gutter = !self.show_git_blame_gutter;
|
||||
|
||||
if self.show_git_blame_gutter && !self.has_blame_entries(cx) {
|
||||
self.start_git_blame(true, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn show_git_blame_internal(&mut self, cx: &mut ViewContext<Self>) -> Result<()> {
|
||||
pub fn toggle_git_blame_inline(
|
||||
&mut self,
|
||||
_: &ToggleGitBlameInline,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.toggle_git_blame_inline_internal(true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
|
||||
if let Some(project) = self.project.as_ref() {
|
||||
let Some(buffer) = self.buffer().read(cx).as_singleton() else {
|
||||
anyhow::bail!("git blame not available in multi buffers")
|
||||
return;
|
||||
};
|
||||
|
||||
let project = project.clone();
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx));
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx));
|
||||
self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));
|
||||
self.blame = Some(blame);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn toggle_git_blame_inline_internal(
|
||||
&mut self,
|
||||
user_triggered: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if self.show_git_blame_inline || self.show_git_blame_inline_delay_task.is_some() {
|
||||
self.show_git_blame_inline = false;
|
||||
self.show_git_blame_inline_delay_task.take();
|
||||
} else {
|
||||
self.start_git_blame_inline(user_triggered, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_git_blame_inline(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
|
||||
if let Some(inline_blame_settings) = ProjectSettings::get_global(cx).git.inline_blame {
|
||||
if inline_blame_settings.enabled {
|
||||
self.start_git_blame(user_triggered, cx);
|
||||
|
||||
if inline_blame_settings.delay_ms.is_some() {
|
||||
self.start_inline_blame_timer(cx);
|
||||
} else {
|
||||
self.show_git_blame_inline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blame(&self) -> Option<&Model<GitBlame>> {
|
||||
self.blame.as_ref()
|
||||
}
|
||||
|
||||
pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool {
|
||||
self.show_git_blame_gutter && self.has_blame_entries(cx)
|
||||
}
|
||||
|
||||
pub fn render_git_blame_inline(&mut self, cx: &mut WindowContext) -> bool {
|
||||
self.show_git_blame_inline && self.has_blame_entries(cx)
|
||||
}
|
||||
|
||||
fn has_blame_entries(&self, cx: &mut WindowContext) -> bool {
|
||||
self.blame()
|
||||
.map_or(false, |blame| blame.read(cx).has_generated_entries())
|
||||
}
|
||||
|
||||
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
|
||||
let (path, repo) = maybe!({
|
||||
let project_handle = self.project.as_ref()?.clone();
|
||||
|
@ -9446,6 +9511,14 @@ impl Editor {
|
|||
let editor_settings = EditorSettings::get_global(cx);
|
||||
self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
|
||||
self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
|
||||
|
||||
if self.mode == EditorMode::Full {
|
||||
let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
|
||||
if self.show_git_blame_inline != inline_blame_enabled {
|
||||
self.toggle_git_blame_inline_internal(false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
@ -10058,7 +10131,7 @@ impl EditorSnapshot {
|
|||
};
|
||||
|
||||
let git_blame_entries_width = self
|
||||
.show_git_blame
|
||||
.render_git_blame_gutter
|
||||
.then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
|
||||
|
||||
let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
|
||||
|
|
|
@ -4,7 +4,10 @@ use crate::{
|
|||
TransformBlock,
|
||||
},
|
||||
editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},
|
||||
git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk},
|
||||
git::{
|
||||
blame::{CommitDetails, GitBlame},
|
||||
diff_hunk_to_display, DisplayDiffHunk,
|
||||
},
|
||||
hover_popover::{
|
||||
self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
|
||||
},
|
||||
|
@ -21,13 +24,13 @@ use collections::{BTreeMap, HashMap};
|
|||
use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
|
||||
use gpui::{
|
||||
anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
|
||||
transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds,
|
||||
ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,
|
||||
ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
|
||||
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
|
||||
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext,
|
||||
ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
|
||||
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
|
||||
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful,
|
||||
StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
|
||||
ViewContext, WindowContext,
|
||||
ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
|
||||
Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle,
|
||||
TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::language_settings::ShowWhitespaceSetting;
|
||||
|
@ -49,11 +52,11 @@ use std::{
|
|||
sync::Arc,
|
||||
};
|
||||
use sum_tree::Bias;
|
||||
use theme::{ActiveTheme, PlayerColor};
|
||||
use theme::{ActiveTheme, PlayerColor, ThemeSettings};
|
||||
use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
|
||||
use ui::{prelude::*, tooltip_container};
|
||||
use util::ResultExt;
|
||||
use workspace::item::Item;
|
||||
use workspace::{item::Item, Workspace};
|
||||
|
||||
struct SelectionLayout {
|
||||
head: DisplayPoint,
|
||||
|
@ -303,6 +306,7 @@ impl EditorElement {
|
|||
register_action(view, cx, Editor::copy_permalink_to_line);
|
||||
register_action(view, cx, Editor::open_permalink_to_line);
|
||||
register_action(view, cx, Editor::toggle_git_blame);
|
||||
register_action(view, cx, Editor::toggle_git_blame_inline);
|
||||
register_action(view, cx, |editor, action, cx| {
|
||||
if let Some(task) = editor.format(action, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
|
@ -1092,6 +1096,58 @@ impl EditorElement {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_inline_blame(
|
||||
&self,
|
||||
start_row: u32,
|
||||
row: u32,
|
||||
line_layouts: &[LineWithInvisibles],
|
||||
em_width: Pixels,
|
||||
content_origin: gpui::Point<Pixels>,
|
||||
scroll_pixel_position: gpui::Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<AnyElement> {
|
||||
if !self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.render_git_blame_inline(cx))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let blame = self.editor.read(cx).blame.clone()?;
|
||||
let workspace = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.workspace
|
||||
.as_ref()
|
||||
.map(|(w, _)| w.clone());
|
||||
let blame_entry = blame
|
||||
.update(cx, |blame, cx| blame.blame_for_rows([Some(row)], cx).next())
|
||||
.flatten()?;
|
||||
|
||||
let mut element =
|
||||
render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx);
|
||||
|
||||
let start_y =
|
||||
content_origin.y + line_height * (row as f32 - scroll_pixel_position.y / line_height);
|
||||
|
||||
let start_x = {
|
||||
let line_layout = &line_layouts[(row - start_row) as usize];
|
||||
let line_width = line_layout.line.width;
|
||||
|
||||
// TODO: define the padding as a constant
|
||||
content_origin.x + line_width + (em_width * 6.)
|
||||
};
|
||||
|
||||
let absolute_offset = point(start_x, start_y);
|
||||
let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
|
||||
|
||||
element.layout(absolute_offset, available_space, cx);
|
||||
|
||||
Some(element)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_blame_entries(
|
||||
&self,
|
||||
|
@ -1103,10 +1159,14 @@ impl EditorElement {
|
|||
max_width: Option<Pixels>,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<Vec<AnyElement>> {
|
||||
let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else {
|
||||
if !self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.render_git_blame_gutter(cx))
|
||||
{
|
||||
return None;
|
||||
};
|
||||
}
|
||||
|
||||
let blame = self.editor.read(cx).blame.clone()?;
|
||||
let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
|
||||
blame.blame_for_rows(buffer_rows, cx).collect()
|
||||
});
|
||||
|
@ -1120,7 +1180,6 @@ impl EditorElement {
|
|||
let start_x = em_width * 1;
|
||||
|
||||
let mut last_used_color: Option<(PlayerColor, Oid)> = None;
|
||||
let text_style = &self.style.text;
|
||||
|
||||
let shaped_lines = blamed_rows
|
||||
.into_iter()
|
||||
|
@ -1131,7 +1190,7 @@ impl EditorElement {
|
|||
ix,
|
||||
&blame,
|
||||
blame_entry,
|
||||
text_style,
|
||||
&self.style,
|
||||
&mut last_used_color,
|
||||
self.editor.clone(),
|
||||
cx,
|
||||
|
@ -2256,6 +2315,7 @@ impl EditorElement {
|
|||
self.paint_lines(&invisible_display_ranges, layout, cx);
|
||||
self.paint_redactions(layout, cx);
|
||||
self.paint_cursors(layout, cx);
|
||||
self.paint_inline_blame(layout, cx);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -2730,6 +2790,14 @@ impl EditorElement {
|
|||
})
|
||||
}
|
||||
|
||||
fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
|
||||
if let Some(mut inline_blame) = layout.inline_blame.take() {
|
||||
cx.paint_layer(layout.text_hitbox.bounds, |cx| {
|
||||
inline_blame.paint(cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
|
||||
for mut block in layout.blocks.drain(..) {
|
||||
block.element.paint(cx);
|
||||
|
@ -2894,11 +2962,192 @@ impl EditorElement {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_inline_blame_entry(
|
||||
blame: &gpui::Model<GitBlame>,
|
||||
blame_entry: BlameEntry,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
cx: &mut ElementContext<'_>,
|
||||
) -> AnyElement {
|
||||
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
|
||||
|
||||
let author = blame_entry.author.as_deref().unwrap_or_default();
|
||||
let text = format!("{}, {}", author, relative_timestamp);
|
||||
|
||||
let details = blame.read(cx).details_for_entry(&blame_entry);
|
||||
|
||||
let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
|
||||
|
||||
h_flex()
|
||||
.id("inline-blame")
|
||||
.w_full()
|
||||
.font(style.text.font().family)
|
||||
.text_color(cx.theme().status().hint)
|
||||
.line_height(style.text.line_height)
|
||||
.child(Icon::new(IconName::FileGit).color(Color::Hint))
|
||||
.child(text)
|
||||
.gap_2()
|
||||
.hoverable_tooltip(move |_| tooltip.clone().into())
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn blame_entry_timestamp(
|
||||
blame_entry: &BlameEntry,
|
||||
format: time_format::TimestampFormat,
|
||||
cx: &WindowContext,
|
||||
) -> String {
|
||||
match blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
format,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
|
||||
blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx)
|
||||
}
|
||||
|
||||
fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
|
||||
blame_entry_timestamp(
|
||||
blame_entry,
|
||||
time_format::TimestampFormat::MediumAbsolute,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
struct BlameEntryTooltip {
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
fn new(
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
style: style.clone(),
|
||||
blame_entry,
|
||||
details,
|
||||
workspace,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlameEntryTooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let author = self
|
||||
.blame_entry
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or("<no name>".to_string());
|
||||
|
||||
let author_email = self.blame_entry.author_mail.clone();
|
||||
|
||||
let pretty_commit_id = format!("{}", self.blame_entry.sha);
|
||||
let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
|
||||
let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx);
|
||||
|
||||
let message = self
|
||||
.details
|
||||
.as_ref()
|
||||
.map(|details| {
|
||||
crate::render_parsed_markdown(
|
||||
"blame-message",
|
||||
&details.parsed_message,
|
||||
&self.style,
|
||||
self.workspace.clone(),
|
||||
cx,
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("<no commit message>".into_any());
|
||||
|
||||
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
|
||||
let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
|
||||
|
||||
tooltip_container(cx, move |this, cx| {
|
||||
this.occlude()
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
.child(
|
||||
v_flex()
|
||||
.w(gpui::rems(30.))
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(author)
|
||||
.when_some(author_email, |this, author_email| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(author_email),
|
||||
)
|
||||
})
|
||||
.pb_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("inline-blame-commit-message")
|
||||
.occlude()
|
||||
.child(message)
|
||||
.max_h(message_max_height)
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(absolute_timestamp)
|
||||
.child(
|
||||
Button::new("commit-sha-button", short_commit_id.clone())
|
||||
.style(ButtonStyle::Transparent)
|
||||
.color(Color::Muted)
|
||||
.icon(IconName::FileGit)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.disabled(
|
||||
self.details.as_ref().map_or(true, |details| {
|
||||
details.permalink.is_none()
|
||||
}),
|
||||
)
|
||||
.when_some(
|
||||
self.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.permalink.clone()),
|
||||
|this, url| {
|
||||
this.on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_blame_entry(
|
||||
ix: usize,
|
||||
blame: &gpui::Model<GitBlame>,
|
||||
blame_entry: BlameEntry,
|
||||
text_style: &TextStyle,
|
||||
style: &EditorStyle,
|
||||
last_used_color: &mut Option<(PlayerColor, Oid)>,
|
||||
editor: View<Editor>,
|
||||
cx: &mut ElementContext<'_>,
|
||||
|
@ -2918,29 +3167,26 @@ fn render_blame_entry(
|
|||
};
|
||||
last_used_color.replace((sha_color, blame_entry.sha));
|
||||
|
||||
let relative_timestamp = match blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
time_format::TimestampFormat::Relative,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
};
|
||||
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
|
||||
|
||||
let pretty_commit_id = format!("{}", blame_entry.sha);
|
||||
let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::<String>();
|
||||
let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
|
||||
|
||||
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
|
||||
let name = util::truncate_and_trailoff(author_name, 20);
|
||||
|
||||
let permalink = blame.read(cx).permalink_for_entry(&blame_entry);
|
||||
let commit_message = blame.read(cx).message_for_entry(&blame_entry);
|
||||
let details = blame.read(cx).details_for_entry(&blame_entry);
|
||||
|
||||
let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
|
||||
|
||||
let tooltip = cx.new_view(|_| {
|
||||
BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.font(text_style.font().family)
|
||||
.line_height(text_style.line_height)
|
||||
.font(style.text.font().family)
|
||||
.line_height(style.text.line_height)
|
||||
.id(("blame", ix))
|
||||
.children([
|
||||
div()
|
||||
|
@ -2962,21 +3208,17 @@ fn render_blame_entry(
|
|||
}
|
||||
})
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when_some(permalink, |this, url| {
|
||||
let url = url.clone();
|
||||
this.cursor_pointer().on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
})
|
||||
.hoverable_tooltip(move |cx| {
|
||||
BlameEntryTooltip::new(
|
||||
sha_color.cursor,
|
||||
commit_message.clone(),
|
||||
blame_entry.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
details.and_then(|details| details.permalink),
|
||||
|this, url| {
|
||||
let url = url.clone();
|
||||
this.cursor_pointer().on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
},
|
||||
)
|
||||
.hoverable_tooltip(move |_| tooltip.clone().into())
|
||||
.into_any()
|
||||
}
|
||||
|
||||
|
@ -2999,84 +3241,6 @@ fn deploy_blame_entry_context_menu(
|
|||
});
|
||||
}
|
||||
|
||||
struct BlameEntryTooltip {
|
||||
color: Hsla,
|
||||
commit_message: Option<String>,
|
||||
blame_entry: BlameEntry,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
fn new(
|
||||
color: Hsla,
|
||||
commit_message: Option<String>,
|
||||
blame_entry: BlameEntry,
|
||||
cx: &mut WindowContext,
|
||||
) -> AnyView {
|
||||
cx.new_view(|_cx| Self {
|
||||
color,
|
||||
commit_message,
|
||||
blame_entry,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlameEntryTooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let author = self
|
||||
.blame_entry
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or("<no name>".to_string());
|
||||
let author_email = self.blame_entry.author_mail.clone().unwrap_or_default();
|
||||
let absolute_timestamp = match self.blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
time_format::TimestampFormat::Absolute,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
};
|
||||
|
||||
let message = match &self.commit_message {
|
||||
Some(message) => util::truncate_lines_and_trailoff(message, 15),
|
||||
None => self.blame_entry.summary.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let pretty_commit_id = format!("{}", self.blame_entry.sha);
|
||||
|
||||
tooltip_container(cx, move |this, cx| {
|
||||
this.occlude()
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
h_flex()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("Commit")
|
||||
.pr_2(),
|
||||
)
|
||||
.child(
|
||||
div().text_color(self.color).child(pretty_commit_id.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.child(format!(
|
||||
"{} {} - {}",
|
||||
author, author_email, absolute_timestamp
|
||||
))
|
||||
.text_color(cx.theme().colors().text_muted),
|
||||
)
|
||||
.child(div().child(message)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LineWithInvisibles {
|
||||
pub line: ShapedLine,
|
||||
|
@ -3205,13 +3369,9 @@ impl LineWithInvisibles {
|
|||
let line_y =
|
||||
line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);
|
||||
|
||||
self.line
|
||||
.paint(
|
||||
content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y),
|
||||
line_height,
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
let line_origin =
|
||||
content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y);
|
||||
self.line.paint(line_origin, line_height, cx).log_err();
|
||||
|
||||
self.draw_invisibles(
|
||||
&selection_ranges,
|
||||
|
@ -3490,16 +3650,6 @@ impl Element for EditorElement {
|
|||
|
||||
let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
|
||||
|
||||
let blamed_display_rows = self.layout_blame_entries(
|
||||
buffer_rows,
|
||||
em_width,
|
||||
scroll_position,
|
||||
line_height,
|
||||
&gutter_hitbox,
|
||||
gutter_dimensions.git_blame_entries_width,
|
||||
cx,
|
||||
);
|
||||
|
||||
let mut max_visible_line_width = Pixels::ZERO;
|
||||
let line_layouts =
|
||||
self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
|
||||
|
@ -3528,6 +3678,37 @@ impl Element for EditorElement {
|
|||
cx,
|
||||
);
|
||||
|
||||
let scroll_pixel_position = point(
|
||||
scroll_position.x * em_width,
|
||||
scroll_position.y * line_height,
|
||||
);
|
||||
|
||||
let mut inline_blame = None;
|
||||
if let Some(newest_selection_head) = newest_selection_head {
|
||||
if (start_row..end_row).contains(&newest_selection_head.row()) {
|
||||
inline_blame = self.layout_inline_blame(
|
||||
start_row,
|
||||
newest_selection_head.row(),
|
||||
&line_layouts,
|
||||
em_width,
|
||||
content_origin,
|
||||
scroll_pixel_position,
|
||||
line_height,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let blamed_display_rows = self.layout_blame_entries(
|
||||
buffer_rows,
|
||||
em_width,
|
||||
scroll_position,
|
||||
line_height,
|
||||
&gutter_hitbox,
|
||||
gutter_dimensions.git_blame_entries_width,
|
||||
cx,
|
||||
);
|
||||
|
||||
let scroll_max = point(
|
||||
((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
|
||||
max_row as f32,
|
||||
|
@ -3555,11 +3736,6 @@ impl Element for EditorElement {
|
|||
}
|
||||
});
|
||||
|
||||
let scroll_pixel_position = point(
|
||||
scroll_position.x * em_width,
|
||||
scroll_position.y * line_height,
|
||||
);
|
||||
|
||||
cx.with_element_id(Some("blocks"), |cx| {
|
||||
self.layout_blocks(
|
||||
&mut blocks,
|
||||
|
@ -3728,6 +3904,7 @@ impl Element for EditorElement {
|
|||
line_numbers,
|
||||
display_hunks,
|
||||
blamed_display_rows,
|
||||
inline_blame,
|
||||
folds,
|
||||
blocks,
|
||||
cursors,
|
||||
|
@ -3815,6 +3992,7 @@ pub struct EditorLayout {
|
|||
line_numbers: Vec<Option<ShapedLine>>,
|
||||
display_hunks: Vec<DisplayDiffHunk>,
|
||||
blamed_display_rows: Option<Vec<AnyElement>>,
|
||||
inline_blame: Option<AnyElement>,
|
||||
folds: Vec<FoldLayout>,
|
||||
blocks: Vec<BlockLayout>,
|
||||
highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use git::{
|
||||
|
@ -5,7 +7,7 @@ use git::{
|
|||
Oid,
|
||||
};
|
||||
use gpui::{Model, ModelContext, Subscription, Task};
|
||||
use language::{Bias, Buffer, BufferSnapshot, Edit};
|
||||
use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
|
||||
use project::{Item, Project};
|
||||
use smallvec::SmallVec;
|
||||
use sum_tree::SumTree;
|
||||
|
@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommitDetails {
|
||||
pub message: String,
|
||||
pub parsed_message: ParsedMarkdown,
|
||||
pub permalink: Option<Url>,
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Model<Project>,
|
||||
buffer: Model<Buffer>,
|
||||
entries: SumTree<GitBlameEntry>,
|
||||
permalinks: HashMap<Oid, Url>,
|
||||
messages: HashMap<Oid, String>,
|
||||
commit_details: HashMap<Oid, CommitDetails>,
|
||||
buffer_snapshot: BufferSnapshot,
|
||||
buffer_edits: text::Subscription,
|
||||
task: Task<Result<()>>,
|
||||
generated: bool,
|
||||
user_triggered: bool,
|
||||
_refresh_subscription: Subscription,
|
||||
}
|
||||
|
||||
|
@ -61,6 +70,7 @@ impl GitBlame {
|
|||
pub fn new(
|
||||
buffer: Model<Buffer>,
|
||||
project: Model<Project>,
|
||||
user_triggered: bool,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let entries = SumTree::from_item(
|
||||
|
@ -102,8 +112,8 @@ impl GitBlame {
|
|||
buffer_snapshot,
|
||||
entries,
|
||||
buffer_edits,
|
||||
permalinks: HashMap::default(),
|
||||
messages: HashMap::default(),
|
||||
user_triggered,
|
||||
commit_details: HashMap::default(),
|
||||
task: Task::ready(Ok(())),
|
||||
generated: false,
|
||||
_refresh_subscription: refresh_subscription,
|
||||
|
@ -116,12 +126,8 @@ impl GitBlame {
|
|||
self.generated
|
||||
}
|
||||
|
||||
pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {
|
||||
self.permalinks.get(&entry.sha).cloned()
|
||||
}
|
||||
|
||||
pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
|
||||
self.messages.get(&entry.sha).cloned()
|
||||
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
|
||||
self.commit_details.get(&entry.sha).cloned()
|
||||
}
|
||||
|
||||
pub fn blame_for_rows<'a>(
|
||||
|
@ -254,6 +260,7 @@ impl GitBlame {
|
|||
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
let snapshot = self.buffer.read(cx).snapshot();
|
||||
let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
|
||||
let languages = self.project.read(cx).languages().clone();
|
||||
|
||||
self.task = cx.spawn(|this, mut cx| async move {
|
||||
let result = cx
|
||||
|
@ -267,65 +274,121 @@ impl GitBlame {
|
|||
messages,
|
||||
} = blame.await?;
|
||||
|
||||
let mut current_row = 0;
|
||||
let mut entries = SumTree::from_iter(
|
||||
entries.into_iter().flat_map(|entry| {
|
||||
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
|
||||
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
|
||||
let commit_details =
|
||||
parse_commit_messages(messages, &permalinks, &languages).await;
|
||||
|
||||
if entry.range.start > current_row {
|
||||
let skipped_rows = entry.range.start - current_row;
|
||||
entries.push(GitBlameEntry {
|
||||
rows: skipped_rows,
|
||||
blame: None,
|
||||
});
|
||||
}
|
||||
entries.push(GitBlameEntry {
|
||||
rows: entry.range.len() as u32,
|
||||
blame: Some(entry.clone()),
|
||||
});
|
||||
|
||||
current_row = entry.range.end;
|
||||
entries
|
||||
}),
|
||||
&(),
|
||||
);
|
||||
|
||||
let max_row = snapshot.max_point().row;
|
||||
if max_row >= current_row {
|
||||
entries.push(
|
||||
GitBlameEntry {
|
||||
rows: (max_row + 1) - current_row,
|
||||
blame: None,
|
||||
},
|
||||
&(),
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::Ok((entries, permalinks, messages))
|
||||
anyhow::Ok((entries, commit_details))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(&mut cx, |this, cx| match result {
|
||||
Ok((entries, permalinks, messages)) => {
|
||||
Ok((entries, commit_details)) => {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
this.permalinks = permalinks;
|
||||
this.messages = messages;
|
||||
this.commit_details = commit_details;
|
||||
this.generated = true;
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => this.project.update(cx, |_, cx| {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
let notification = format!("{:#}", error).trim().to_string();
|
||||
cx.emit(project::Event::Notification(notification));
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
let notification = format!("{:#}", error).trim().to_string();
|
||||
cx.emit(project::Event::Notification(notification));
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
// Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
|
||||
// and opens a non-git file.
|
||||
if error.downcast_ref::<project::NoRepositoryError>().is_none() {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
|
||||
let mut current_row = 0;
|
||||
let mut entries = SumTree::from_iter(
|
||||
entries.into_iter().flat_map(|entry| {
|
||||
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
|
||||
|
||||
if entry.range.start > current_row {
|
||||
let skipped_rows = entry.range.start - current_row;
|
||||
entries.push(GitBlameEntry {
|
||||
rows: skipped_rows,
|
||||
blame: None,
|
||||
});
|
||||
}
|
||||
entries.push(GitBlameEntry {
|
||||
rows: entry.range.len() as u32,
|
||||
blame: Some(entry.clone()),
|
||||
});
|
||||
|
||||
current_row = entry.range.end;
|
||||
entries
|
||||
}),
|
||||
&(),
|
||||
);
|
||||
|
||||
if max_row >= current_row {
|
||||
entries.push(
|
||||
GitBlameEntry {
|
||||
rows: (max_row + 1) - current_row,
|
||||
blame: None,
|
||||
},
|
||||
&(),
|
||||
);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn parse_commit_messages(
|
||||
messages: impl IntoIterator<Item = (Oid, String)>,
|
||||
permalinks: &HashMap<Oid, Url>,
|
||||
languages: &Arc<LanguageRegistry>,
|
||||
) -> HashMap<Oid, CommitDetails> {
|
||||
let mut commit_details = HashMap::default();
|
||||
for (oid, message) in messages {
|
||||
let parsed_message = parse_markdown(&message, &languages).await;
|
||||
let permalink = permalinks.get(&oid).cloned();
|
||||
|
||||
commit_details.insert(
|
||||
oid,
|
||||
CommitDetails {
|
||||
message,
|
||||
parsed_message,
|
||||
permalink,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
commit_details
|
||||
}
|
||||
|
||||
async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
|
||||
let mut parsed_message = ParsedMarkdown::default();
|
||||
|
||||
markdown::parse_markdown_block(
|
||||
text,
|
||||
language_registry,
|
||||
None,
|
||||
&mut parsed_message.text,
|
||||
&mut parsed_message.highlights,
|
||||
&mut parsed_message.region_ranges,
|
||||
&mut parsed_message.regions,
|
||||
)
|
||||
.await;
|
||||
|
||||
parsed_message
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -394,7 +457,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), cx));
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
|
||||
|
||||
let event = project.next_event(cx).await;
|
||||
assert_eq!(
|
||||
|
@ -463,7 +526,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
|
@ -543,7 +606,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
|
@ -692,7 +755,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
cx.executor().run_until_parked();
|
||||
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ use gpui::{px, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Underl
|
|||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
/// Parsed Markdown content.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ParsedMarkdown {
|
||||
/// The Markdown text.
|
||||
pub text: String,
|
||||
|
|
|
@ -7756,13 +7756,20 @@ impl Project {
|
|||
.as_local()
|
||||
.context("worktree was not local")?
|
||||
.snapshot();
|
||||
let (work_directory, repo) = worktree
|
||||
.repository_and_work_directory_for_path(&buffer_project_path.path)
|
||||
.context("failed to get repo for blamed buffer")?;
|
||||
|
||||
let repo_entry = worktree
|
||||
.get_local_repo(&repo)
|
||||
.context("failed to get repo for blamed buffer")?;
|
||||
let (work_directory, repo) = match worktree
|
||||
.repository_and_work_directory_for_path(&buffer_project_path.path)
|
||||
{
|
||||
Some(work_dir_repo) => work_dir_repo,
|
||||
None => anyhow::bail!(NoRepositoryError {}),
|
||||
};
|
||||
|
||||
let repo_entry = match worktree.get_local_repo(&repo) {
|
||||
Some(repo_entry) => repo_entry,
|
||||
None => anyhow::bail!(NoRepositoryError {}),
|
||||
};
|
||||
|
||||
let repo = repo_entry.repo().clone();
|
||||
|
||||
let relative_path = buffer_project_path
|
||||
.path
|
||||
|
@ -7773,7 +7780,6 @@ impl Project {
|
|||
Some(version) => buffer.rope_for_version(&version).clone(),
|
||||
None => buffer.as_rope().clone(),
|
||||
};
|
||||
let repo = repo_entry.repo().clone();
|
||||
|
||||
anyhow::Ok((repo, relative_path, content))
|
||||
});
|
||||
|
@ -10782,3 +10788,14 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> {
|
|||
Some(hover)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoRepositoryError {}
|
||||
|
||||
impl std::fmt::Display for NoRepositoryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "no git repository for worktree found")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NoRepositoryError {}
|
||||
|
|
|
@ -3,7 +3,7 @@ use gpui::AppContext;
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources};
|
||||
use std::sync::Arc;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ProjectSettings {
|
||||
|
@ -29,6 +29,30 @@ pub struct GitSettings {
|
|||
/// Default: tracked_files
|
||||
pub git_gutter: Option<GitGutterSetting>,
|
||||
pub gutter_debounce: Option<u64>,
|
||||
/// Whether or not to show git blame data inline in
|
||||
/// the currently focused line.
|
||||
///
|
||||
/// Default: off
|
||||
pub inline_blame: Option<InlineBlameSettings>,
|
||||
}
|
||||
|
||||
impl GitSettings {
|
||||
pub fn inline_blame_enabled(&self) -> bool {
|
||||
match self.inline_blame {
|
||||
Some(InlineBlameSettings { enabled, .. }) => enabled,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_blame_delay(&self) -> Option<Duration> {
|
||||
match self.inline_blame {
|
||||
Some(InlineBlameSettings {
|
||||
delay_ms: Some(delay_ms),
|
||||
..
|
||||
}) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
|
@ -41,6 +65,21 @@ pub enum GitGutterSetting {
|
|||
Hide,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct InlineBlameSettings {
|
||||
/// Whether or not to show git blame data inline in
|
||||
/// the currently focused line.
|
||||
///
|
||||
/// Default: false
|
||||
pub enabled: bool,
|
||||
/// Whether to only show the inline blame information
|
||||
/// after a delay once the cursor stops moving.
|
||||
///
|
||||
/// Default: 0
|
||||
pub delay_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct BinarySettings {
|
||||
pub path: Option<String>,
|
||||
|
|
|
@ -1096,6 +1096,7 @@ mod tests {
|
|||
use editor::{DisplayPoint, Editor};
|
||||
use gpui::{Context, Hsla, TestAppContext, VisualTestContext};
|
||||
use language::Buffer;
|
||||
use project::Project;
|
||||
use smol::stream::StreamExt as _;
|
||||
use unindent::Unindent as _;
|
||||
|
||||
|
@ -1106,6 +1107,7 @@ mod tests {
|
|||
editor::init(cx);
|
||||
|
||||
language::init(cx);
|
||||
Project::init_settings(cx);
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ pub enum TimestampFormat {
|
|||
/// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
|
||||
/// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
|
||||
EnhancedAbsolute,
|
||||
/// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
|
||||
MediumAbsolute,
|
||||
/// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
|
||||
Relative,
|
||||
}
|
||||
|
@ -30,6 +32,9 @@ pub fn format_localized_timestamp(
|
|||
TimestampFormat::EnhancedAbsolute => {
|
||||
format_absolute_timestamp(timestamp_local, reference_local, true)
|
||||
}
|
||||
TimestampFormat::MediumAbsolute => {
|
||||
format_absolute_timestamp_medium(timestamp_local, reference_local)
|
||||
}
|
||||
TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
|
||||
.unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
|
||||
}
|
||||
|
@ -72,6 +77,22 @@ fn format_absolute_timestamp(
|
|||
}
|
||||
}
|
||||
|
||||
fn format_absolute_timestamp_medium(
|
||||
timestamp: OffsetDateTime,
|
||||
#[allow(unused_variables)] reference: OffsetDateTime,
|
||||
) -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::format_date_medium(×tamp)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// todo(linux) respect user's date/time preferences
|
||||
// todo(windows) respect user's date/time preferences
|
||||
format_timestamp_fallback(timestamp, reference)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
|
||||
let difference = reference - timestamp;
|
||||
let minutes = difference.whole_minutes();
|
||||
|
@ -253,7 +274,8 @@ mod macos {
|
|||
use core_foundation_sys::{
|
||||
base::kCFAllocatorDefault,
|
||||
date_formatter::{
|
||||
kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
|
||||
kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle,
|
||||
CFDateFormatterCreate,
|
||||
},
|
||||
locale::CFLocaleCopyCurrent,
|
||||
};
|
||||
|
@ -266,6 +288,10 @@ mod macos {
|
|||
format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
|
||||
}
|
||||
|
||||
pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
|
||||
format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
|
||||
}
|
||||
|
||||
fn format_with_date_formatter(
|
||||
timestamp: &time::OffsetDateTime,
|
||||
fmt: CFDateFormatterRef,
|
||||
|
@ -302,6 +328,15 @@ mod macos {
|
|||
kCFDateFormatterNoStyle,
|
||||
)
|
||||
};
|
||||
|
||||
static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
|
||||
CFDateFormatterCreate(
|
||||
kCFAllocatorDefault,
|
||||
CURRENT_LOCALE.with(|locale| *locale),
|
||||
kCFDateFormatterMediumStyle,
|
||||
kCFDateFormatterNoStyle,
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ pub enum Color {
|
|||
Disabled,
|
||||
Error,
|
||||
Hidden,
|
||||
Hint,
|
||||
Info,
|
||||
Modified,
|
||||
Conflict,
|
||||
|
@ -36,6 +37,7 @@ impl Color {
|
|||
Color::Deleted => cx.theme().status().deleted,
|
||||
Color::Disabled => cx.theme().colors().text_disabled,
|
||||
Color::Hidden => cx.theme().status().hidden,
|
||||
Color::Hint => cx.theme().status().hint,
|
||||
Color::Info => cx.theme().status().info,
|
||||
Color::Placeholder => cx.theme().colors().text_placeholder,
|
||||
Color::Accent => cx.theme().colors().text_accent,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue