Allow viewing past commits in Zed (#27636)

This PR adds functionality for loading the diff for an arbitrary git
commit, and displaying it in a tab. To retrieve the diff for the commit,
I'm using a single `git cat-file --batch` invocation to efficiently load
both the old and new versions of each file that was changed in the
commit.

Todo

* Features
* [x] Open the commit view when clicking the most recent commit message
in the commit panel
  * [x] Open the commit view when clicking a SHA in a git blame column
  * [x] Open the commit view when clicking a SHA in a commit tooltip
  * [x] Make it work over RPC
  * [x] Allow buffer search in commit view
* [x] Command palette action to open the commit for the current blame
line
* Styling
* [x] Add a header that shows the author, timestamp, and the full commit
message
  * [x] Remove stage/unstage buttons in commit view
  * [x] Truncate the commit message in the tab
* Bugs
  * [x] Dedup commit tabs within a pane
  * [x] Add a tooltip to the tab

Release Notes:

- Added the ability to show past commits in Zed. You can view the most
recent commit by clicking its message in the commit panel. And when
viewing a git blame, you can show any commit by clicking its sha.
This commit is contained in:
Max Brunsfeld 2025-03-31 16:26:47 -07:00 committed by GitHub
parent 33912011b7
commit 8546dc101d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1742 additions and 603 deletions

View file

@ -419,6 +419,7 @@ actions!(
EditLogBreakpoint,
ToggleAutoSignatureHelp,
ToggleGitBlameInline,
OpenGitBlameCommit,
ToggleIndentGuides,
ToggleInlayHints,
ToggleInlineDiagnostics,

View file

@ -1,343 +0,0 @@
use futures::Future;
use git::PullRequest;
use git::blame::BlameEntry;
use gpui::{
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement,
};
use markdown::Markdown;
use settings::Settings;
use std::hash::Hash;
use theme::ThemeSettings;
use time::{OffsetDateTime, UtcOffset};
use time_format::format_local_timestamp;
use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container};
use url::Url;
use crate::git::blame::GitRemote;
use crate::hover_popover::hover_markdown_style;
#[derive(Clone, Debug)]
pub struct CommitDetails {
pub sha: SharedString,
pub author_name: SharedString,
pub author_email: SharedString,
pub commit_time: OffsetDateTime,
pub message: Option<ParsedCommitMessage>,
}
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<Url>,
pub pull_request: Option<PullRequest>,
pub remote: Option<GitRemote>,
}
struct CommitAvatar<'a> {
commit: &'a CommitDetails,
}
impl<'a> CommitAvatar<'a> {
fn new(details: &'a CommitDetails) -> Self {
Self { commit: details }
}
}
impl<'a> CommitAvatar<'a> {
fn render(
&'a self,
window: &mut Window,
cx: &mut Context<CommitTooltip>,
) -> Option<impl IntoElement + use<>> {
let remote = self
.commit
.message
.as_ref()
.and_then(|details| details.remote.as_ref())
.filter(|remote| remote.host_supports_avatars())?;
let avatar_url = CommitAvatarAsset::new(remote.clone(), self.commit.sha.clone());
let element = match window.use_asset::<CommitAvatarAsset>(&avatar_url, cx) {
// Loading or no avatar found
None | Some(None) => Icon::new(IconName::Person)
.color(Color::Muted)
.into_element()
.into_any(),
// Found
Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(),
};
Some(element)
}
}
#[derive(Clone, Debug)]
struct CommitAvatarAsset {
sha: SharedString,
remote: GitRemote,
}
impl Hash for CommitAvatarAsset {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.sha.hash(state);
self.remote.host.name().hash(state);
}
}
impl CommitAvatarAsset {
fn new(remote: GitRemote, sha: SharedString) -> Self {
Self { remote, sha }
}
}
impl Asset for CommitAvatarAsset {
type Source = Self;
type Output = Option<SharedString>;
fn load(
source: Self::Source,
cx: &mut App,
) -> impl Future<Output = Self::Output> + Send + 'static {
let client = cx.http_client();
async move {
source
.remote
.avatar_url(source.sha, client)
.await
.map(|url| SharedString::from(url.to_string()))
}
}
}
pub struct CommitTooltip {
commit: CommitDetails,
scroll_handle: ScrollHandle,
markdown: Entity<Markdown>,
}
impl CommitTooltip {
pub fn blame_entry(
blame: &BlameEntry,
details: Option<ParsedCommitMessage>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let commit_time = blame
.committer_time
.and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
.unwrap_or(OffsetDateTime::now_utc());
Self::new(
CommitDetails {
sha: blame.sha.to_string().into(),
commit_time,
author_name: blame
.author
.clone()
.unwrap_or("<no name>".to_string())
.into(),
author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
message: details,
},
window,
cx,
)
}
pub fn new(commit: CommitDetails, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut style = hover_markdown_style(window, cx);
if let Some(code_block) = &style.code_block.text {
style.base_text_style.refine(code_block);
}
let markdown = cx.new(|cx| {
Markdown::new(
commit
.message
.as_ref()
.map(|message| message.message.clone())
.unwrap_or_default(),
style,
None,
None,
cx,
)
});
Self {
commit,
scroll_handle: ScrollHandle::new(),
markdown,
}
}
}
impl Render for CommitTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = CommitAvatar::new(&self.commit).render(window, cx);
let author = self.commit.author_name.clone();
let author_email = self.commit.author_email.clone();
let short_commit_id = self
.commit
.sha
.get(0..8)
.map(|sha| sha.to_string().into())
.unwrap_or_else(|| self.commit.sha.clone());
let full_sha = self.commit.sha.to_string().clone();
let absolute_timestamp = format_local_timestamp(
self.commit.commit_time,
OffsetDateTime::now_utc(),
time_format::TimestampFormat::MediumAbsolute,
);
let message = self
.commit
.message
.as_ref()
.map(|_| self.markdown.clone().into_any_element())
.unwrap_or("<no commit message>".into_any());
let pull_request = self
.commit
.message
.as_ref()
.and_then(|details| details.pull_request.clone());
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
tooltip_container(window, cx, move |this, _, cx| {
this.occlude()
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
v_flex()
.w(gpui::rems(30.))
.gap_4()
.child(
h_flex()
.pb_1p5()
.gap_x_2()
.overflow_x_hidden()
.flex_wrap()
.children(avatar)
.child(author)
.when(!author_email.is_empty(), |this| {
this.child(
div()
.text_color(cx.theme().colors().text_muted)
.child(author_email),
)
})
.border_b_1()
.border_color(cx.theme().colors().border_variant),
)
.child(
div()
.id("inline-blame-commit-message")
.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()
.pt_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(absolute_timestamp)
.child(
h_flex()
.gap_1p5()
.when_some(pull_request, |this, pr| {
this.child(
Button::new(
"pull-request-button",
format!("#{}", pr.number),
)
.color(Color::Muted)
.icon(IconName::PullRequest)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.style(ButtonStyle::Subtle)
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(pr.url.as_str())
}),
)
})
.child(Divider::vertical())
.child(
Button::new(
"commit-sha-button",
short_commit_id.clone(),
)
.style(ButtonStyle::Subtle)
.color(Color::Muted)
.icon(IconName::FileGit)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.disabled(
self.commit
.message
.as_ref()
.map_or(true, |details| {
details.permalink.is_none()
}),
)
.when_some(
self.commit
.message
.as_ref()
.and_then(|details| details.permalink.clone()),
|this, url| {
this.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(url.as_str())
})
},
),
)
.child(
IconButton::new("copy-sha-button", IconName::Copy)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.write_to_clipboard(
ClipboardItem::new_string(full_sha.clone()),
)
}),
),
),
),
)
})
}
}
fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String {
match blame_entry.author_offset_date_time() {
Ok(timestamp) => {
let local = chrono::Local::now().offset().local_minus_utc();
time_format::format_localized_timestamp(
timestamp,
time::OffsetDateTime::now_utc(),
UtcOffset::from_whole_seconds(local).unwrap(),
format,
)
}
Err(_) => "Error parsing date".to_string(),
}
}
pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative)
}

View file

@ -321,6 +321,20 @@ impl DisplayMap {
block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive);
}
pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (snapshot, edits) = self.inlay_map.sync(snapshot, edits);
let (snapshot, edits) = self.fold_map.read(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
.update(cx, |map, cx| map.sync(snapshot, edits, cx));
let mut block_map = self.block_map.write(snapshot, edits);
block_map.disable_header_for_buffer(buffer_id)
}
pub fn fold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = language::BufferId>,

View file

@ -40,6 +40,7 @@ pub struct BlockMap {
buffer_header_height: u32,
excerpt_header_height: u32,
pub(super) folded_buffers: HashSet<BufferId>,
buffers_with_disabled_headers: HashSet<BufferId>,
}
pub struct BlockMapReader<'a> {
@ -422,6 +423,7 @@ impl BlockMap {
custom_blocks: Vec::new(),
custom_blocks_by_id: TreeMap::default(),
folded_buffers: HashSet::default(),
buffers_with_disabled_headers: HashSet::default(),
transforms: RefCell::new(transforms),
wrap_snapshot: RefCell::new(wrap_snapshot.clone()),
buffer_header_height,
@ -642,11 +644,8 @@ impl BlockMap {
);
if buffer.show_headers() {
blocks_in_edit.extend(BlockMap::header_and_footer_blocks(
self.buffer_header_height,
self.excerpt_header_height,
blocks_in_edit.extend(self.header_and_footer_blocks(
buffer,
&self.folded_buffers,
(start_bound, end_bound),
wrap_snapshot,
));
@ -714,10 +713,8 @@ impl BlockMap {
}
fn header_and_footer_blocks<'a, R, T>(
buffer_header_height: u32,
excerpt_header_height: u32,
&'a self,
buffer: &'a multi_buffer::MultiBufferSnapshot,
folded_buffers: &'a HashSet<BufferId>,
range: R,
wrap_snapshot: &'a WrapSnapshot,
) -> impl Iterator<Item = (BlockPlacement<WrapRow>, Block)> + 'a
@ -728,73 +725,78 @@ impl BlockMap {
let mut boundaries = buffer.excerpt_boundaries_in_range(range).peekable();
std::iter::from_fn(move || {
let excerpt_boundary = boundaries.next()?;
let wrap_row = wrap_snapshot
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row();
loop {
let excerpt_boundary = boundaries.next()?;
let wrap_row = wrap_snapshot
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row();
let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
(None, next) => Some(next.buffer_id),
(Some(prev), next) => {
if prev.buffer_id != next.buffer_id {
Some(next.buffer_id)
} else {
None
}
}
};
let mut height = 0;
if let Some(new_buffer_id) = new_buffer_id {
let first_excerpt = excerpt_boundary.next.clone();
if folded_buffers.contains(&new_buffer_id) {
let mut last_excerpt_end_row = first_excerpt.end_row;
while let Some(next_boundary) = boundaries.peek() {
if next_boundary.next.buffer_id == new_buffer_id {
last_excerpt_end_row = next_boundary.next.end_row;
let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
(None, next) => Some(next.buffer_id),
(Some(prev), next) => {
if prev.buffer_id != next.buffer_id {
Some(next.buffer_id)
} else {
break;
None
}
}
};
let mut height = 0;
if let Some(new_buffer_id) = new_buffer_id {
let first_excerpt = excerpt_boundary.next.clone();
if self.buffers_with_disabled_headers.contains(&new_buffer_id) {
continue;
}
if self.folded_buffers.contains(&new_buffer_id) {
let mut last_excerpt_end_row = first_excerpt.end_row;
while let Some(next_boundary) = boundaries.peek() {
if next_boundary.next.buffer_id == new_buffer_id {
last_excerpt_end_row = next_boundary.next.end_row;
} else {
break;
}
boundaries.next();
}
boundaries.next();
let wrap_end_row = wrap_snapshot
.make_wrap_point(
Point::new(
last_excerpt_end_row.0,
buffer.line_len(last_excerpt_end_row),
),
Bias::Right,
)
.row();
return Some((
BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)),
Block::FoldedBuffer {
height: height + self.buffer_header_height,
first_excerpt,
},
));
}
let wrap_end_row = wrap_snapshot
.make_wrap_point(
Point::new(
last_excerpt_end_row.0,
buffer.line_len(last_excerpt_end_row),
),
Bias::Right,
)
.row();
return Some((
BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)),
Block::FoldedBuffer {
height: height + buffer_header_height,
first_excerpt,
},
));
}
}
if new_buffer_id.is_some() {
height += buffer_header_height;
} else {
height += excerpt_header_height;
}
if new_buffer_id.is_some() {
height += self.buffer_header_height;
} else {
height += self.excerpt_header_height;
}
Some((
BlockPlacement::Above(WrapRow(wrap_row)),
Block::ExcerptBoundary {
excerpt: excerpt_boundary.next,
height,
starts_new_buffer: new_buffer_id.is_some(),
},
))
return Some((
BlockPlacement::Above(WrapRow(wrap_row)),
Block::ExcerptBoundary {
excerpt: excerpt_boundary.next,
height,
starts_new_buffer: new_buffer_id.is_some(),
},
));
}
})
}
@ -1168,6 +1170,10 @@ impl BlockMapWriter<'_> {
self.remove(blocks_to_remove);
}
pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId) {
self.0.buffers_with_disabled_headers.insert(buffer_id);
}
pub fn fold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = BufferId>,
@ -3159,11 +3165,8 @@ mod tests {
}));
// Note that this needs to be synced with the related section in BlockMap::sync
expected_blocks.extend(BlockMap::header_and_footer_blocks(
buffer_start_header_height,
excerpt_header_height,
expected_blocks.extend(block_map.header_and_footer_blocks(
&buffer_snapshot,
&block_map.folded_buffers,
0..,
&wraps_snapshot,
));

View file

@ -16,7 +16,6 @@ pub mod actions;
mod blink_manager;
mod clangd_ext;
mod code_context_menus;
pub mod commit_tooltip;
pub mod display_map;
mod editor_settings;
mod editor_settings_controls;
@ -82,19 +81,21 @@ use code_context_menus::{
AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
CompletionsMenu, ContextMenuOrigin,
};
use git::blame::GitBlame;
use git::blame::{GitBlame, GlobalBlameRenderer};
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext,
AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context,
DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers,
MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size,
Stateful, Styled, StyledText, Subscription, Task, TextStyle, TextStyleRefinement,
UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
div, impl_actions, point, prelude::*, pulsating_between, px, relative, size,
Action, Animation, AnimationExt, AnyElement, AnyWeakEntity, App, AppContext,
AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry,
ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter,
FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla,
KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render,
SharedString, Size, Stateful, Styled, StyledText, Subscription, Task, TextStyle,
TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity,
WeakFocusHandle, Window, div, impl_actions, point, prelude::*, pulsating_between, px, relative,
size,
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file};
pub use hover_popover::hover_markdown_style;
use hover_popover::{HoverState, hide_hover};
use indent_guides::ActiveIndentGuidesState;
use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
@ -124,6 +125,7 @@ use project::{
},
};
pub use git::blame::BlameRenderer;
pub use proposed_changes_editor::{
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
};
@ -187,8 +189,8 @@ use theme::{
observe_buffer_font_size_adjustment,
};
use ui::{
ButtonSize, ButtonStyle, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Key,
Tooltip, h_flex, prelude::*,
ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName,
IconSize, Key, Tooltip, h_flex, prelude::*,
};
use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
use workspace::{
@ -302,6 +304,8 @@ pub fn init_settings(cx: &mut App) {
pub fn init(cx: &mut App) {
init_settings(cx);
cx.set_global(GlobalBlameRenderer(Arc::new(())));
workspace::register_project_item::<Editor>(cx);
workspace::FollowableViewRegistry::register::<Editor>(cx);
workspace::register_serializable_item::<Editor>(cx);
@ -347,6 +351,10 @@ pub fn init(cx: &mut App) {
});
}
pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) {
cx.set_global(GlobalBlameRenderer(Arc::new(renderer)));
}
pub struct SearchWithinRange;
trait InvalidationRegion {
@ -766,7 +774,7 @@ pub struct Editor {
show_git_blame_gutter: bool,
show_git_blame_inline: bool,
show_git_blame_inline_delay_task: Option<Task<()>>,
git_blame_inline_tooltip: Option<WeakEntity<crate::commit_tooltip::CommitTooltip>>,
pub git_blame_inline_tooltip: Option<AnyWeakEntity>,
git_blame_inline_enabled: bool,
render_diff_hunk_controls: RenderDiffHunkControlsFn,
serialize_dirty_buffers: bool,
@ -848,8 +856,6 @@ pub struct EditorSnapshot {
gutter_hovered: bool,
}
const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
#[derive(Default, Debug, Clone, Copy)]
pub struct GutterDimensions {
pub left_padding: Pixels,
@ -1643,6 +1649,21 @@ impl Editor {
this
}
pub fn deploy_mouse_context_menu(
&mut self,
position: gpui::Point<Pixels>,
context_menu: Entity<ContextMenu>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mouse_context_menu = Some(MouseContextMenu::new(
crate::mouse_context_menu::MenuPosition::PinnedToScreen(position),
context_menu,
window,
cx,
));
}
pub fn mouse_menu_is_focused(&self, window: &Window, cx: &App) -> bool {
self.mouse_context_menu
.as_ref()
@ -14922,6 +14943,13 @@ impl Editor {
self.display_map.read(cx).folded_buffers()
}
pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
self.display_map.update(cx, |display_map, cx| {
display_map.disable_header_for_buffer(buffer_id, cx);
});
cx.notify();
}
/// Removes any folds with the given ranges.
pub fn remove_folds_with_type<T: ToOffset + Clone>(
&mut self,
@ -15861,6 +15889,45 @@ impl Editor {
cx.notify();
}
pub fn open_git_blame_commit(
&mut self,
_: &OpenGitBlameCommit,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_git_blame_commit_internal(window, cx);
}
fn open_git_blame_commit_internal(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let blame = self.blame.as_ref()?;
let snapshot = self.snapshot(window, cx);
let cursor = self.selections.newest::<Point>(cx).head();
let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?;
let blame_entry = blame
.update(cx, |blame, cx| {
blame
.blame_for_rows(
&[RowInfo {
buffer_id: Some(buffer.remote_id()),
buffer_row: Some(point.row),
..Default::default()
}],
cx,
)
.next()
})
.flatten()?;
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let repo = blame.read(cx).repository(cx)?;
let workspace = self.workspace()?.downgrade();
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
None
}
pub fn git_blame_inline_enabled(&self) -> bool {
self.git_blame_inline_enabled
}
@ -17794,7 +17861,9 @@ fn get_uncommitted_diff_for_buffer(
let mut tasks = Vec::new();
project.update(cx, |project, cx| {
for buffer in buffers {
tasks.push(project.open_uncommitted_diff(buffer.clone(), cx))
if project::File::from_dyn(buffer.read(cx).file()).is_some() {
tasks.push(project.open_uncommitted_diff(buffer.clone(), cx))
}
}
});
cx.spawn(async move |cx| {
@ -18911,13 +18980,13 @@ impl EditorSnapshot {
let git_blame_entries_width =
self.git_blame_gutter_max_author_length
.map(|max_author_length| {
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago";
/// The number of characters to dedicate to gaps and margins.
const SPACING_WIDTH: usize = 4;
let max_char_count = max_author_length
.min(GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED)
let max_char_count = max_author_length.min(renderer.max_author_length())
+ ::git::SHORT_SHA_LENGTH
+ MAX_RELATIVE_TIMESTAMP.len()
+ SPACING_WIDTH;

View file

@ -3,13 +3,12 @@ use crate::{
ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow,
DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode,
EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock,
GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput,
HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight,
LineUp, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection,
SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold,
GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason,
InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN,
MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp,
Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
StickyHeaderExcerpt, ToPoint, ToggleFold,
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
commit_tooltip::{CommitTooltip, ParsedCommitMessage, blame_entry_relative_timestamp},
display_map::{
Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint,
},
@ -17,13 +16,13 @@ use crate::{
CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine,
ScrollbarAxes, ScrollbarDiagnostics, ShowScrollbar,
},
git::blame::GitBlame,
git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
hover_popover::{
self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, hover_at,
},
inlay_hint_settings,
items::BufferSearchHighlights,
mouse_context_menu::{self, MenuPosition, MouseContextMenu},
mouse_context_menu::{self, MenuPosition},
scroll::scroll_amount::ScrollAmount,
};
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
@ -34,12 +33,12 @@ use file_icons::FileIcons;
use git::{Oid, blame::BlameEntry, status::FileStatus};
use gpui::{
Action, Along, AnyElement, App, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds,
ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase,
Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
Hsla, InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton,
ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element,
ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
Subscription, TextRun, TextStyleRefinement, Window, anchored, deferred, div, fill,
TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
transparent_black,
};
@ -76,10 +75,10 @@ use std::{
use sum_tree::Bias;
use text::BufferId;
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
use ui::{ButtonLike, ContextMenu, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
use unicode_segmentation::UnicodeSegmentation;
use util::{RangeExt, ResultExt, debug_panic};
use workspace::{item::Item, notifications::NotifyTaskExt};
use workspace::{Workspace, item::Item, notifications::NotifyTaskExt};
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
@ -426,6 +425,7 @@ impl EditorElement {
register_action(editor, window, Editor::copy_file_location);
register_action(editor, window, Editor::toggle_git_blame);
register_action(editor, window, Editor::toggle_git_blame_inline);
register_action(editor, window, Editor::open_git_blame_commit);
register_action(editor, window, Editor::toggle_selected_diff_hunks);
register_action(editor, window, Editor::toggle_staged_selected_diff_hunks);
register_action(editor, window, Editor::stage_and_next);
@ -1759,14 +1759,21 @@ impl EditorElement {
padding * em_width
};
let workspace = editor.workspace()?.downgrade();
let blame_entry = blame
.update(cx, |blame, cx| {
blame.blame_for_rows(&[*row_info], cx).next()
})
.flatten()?;
let mut element =
render_inline_blame_entry(self.editor.clone(), &blame, blame_entry, &self.style, cx);
let mut element = render_inline_blame_entry(
self.editor.clone(),
workspace,
&blame,
blame_entry,
&self.style,
cx,
)?;
let start_y = content_origin.y
+ line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height);
@ -1816,6 +1823,7 @@ impl EditorElement {
}
let blame = self.editor.read(cx).blame.clone()?;
let workspace = self.editor.read(cx).workspace()?;
let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
blame.blame_for_rows(buffer_rows, cx).collect()
});
@ -1829,36 +1837,35 @@ impl EditorElement {
let start_x = em_width;
let mut last_used_color: Option<(PlayerColor, Oid)> = None;
let blame_renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let shaped_lines = blamed_rows
.into_iter()
.enumerate()
.flat_map(|(ix, blame_entry)| {
if let Some(blame_entry) = blame_entry {
let mut element = render_blame_entry(
ix,
&blame,
blame_entry,
&self.style,
&mut last_used_color,
self.editor.clone(),
cx,
);
let mut element = render_blame_entry(
ix,
&blame,
blame_entry?,
&self.style,
&mut last_used_color,
self.editor.clone(),
workspace.clone(),
blame_renderer.clone(),
cx,
)?;
let start_y = ix as f32 * line_height - (scroll_top % line_height);
let absolute_offset = gutter_hitbox.origin + point(start_x, start_y);
let start_y = ix as f32 * line_height - (scroll_top % line_height);
let absolute_offset = gutter_hitbox.origin + point(start_x, start_y);
element.prepaint_as_root(
absolute_offset,
size(width, AvailableSpace::MinContent),
window,
cx,
);
element.prepaint_as_root(
absolute_offset,
size(width, AvailableSpace::MinContent),
window,
cx,
);
Some(element)
} else {
None
}
Some(element)
})
.collect();
@ -5725,61 +5732,43 @@ fn prepaint_gutter_button(
fn render_inline_blame_entry(
editor: Entity<Editor>,
blame: &gpui::Entity<GitBlame>,
workspace: WeakEntity<Workspace>,
blame: &Entity<GitBlame>,
blame_entry: BlameEntry,
style: &EditorStyle,
cx: &mut App,
) -> AnyElement {
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
let author = blame_entry.author.as_deref().unwrap_or_default();
let summary_enabled = ProjectSettings::get_global(cx)
.git
.show_inline_commit_summary();
let text = match blame_entry.summary.as_ref() {
Some(summary) if summary_enabled => {
format!("{}, {} - {}", author, relative_timestamp, summary)
}
_ => format!("{}, {}", author, relative_timestamp),
};
let blame = blame.clone();
let blame_entry = blame_entry.clone();
h_flex()
.id("inline-blame")
.w_full()
.font_family(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 |window, cx| {
let details = blame.read(cx).details_for_entry(&blame_entry);
let tooltip =
cx.new(|cx| CommitTooltip::blame_entry(&blame_entry, details, window, cx));
editor.update(cx, |editor, _| {
editor.git_blame_inline_tooltip = Some(tooltip.downgrade())
});
tooltip.into()
})
.into_any()
) -> Option<AnyElement> {
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let blame = blame.read(cx);
let details = blame.details_for_entry(&blame_entry);
let repository = blame.repository(cx)?.clone();
renderer.render_inline_blame_entry(
&style.text,
blame_entry,
details,
repository,
workspace,
editor,
cx,
)
}
fn render_blame_entry(
ix: usize,
blame: &gpui::Entity<GitBlame>,
blame: &Entity<GitBlame>,
blame_entry: BlameEntry,
style: &EditorStyle,
last_used_color: &mut Option<(PlayerColor, Oid)>,
editor: Entity<Editor>,
workspace: Entity<Workspace>,
renderer: Arc<dyn BlameRenderer>,
cx: &mut App,
) -> AnyElement {
) -> Option<AnyElement> {
let mut sha_color = cx
.theme()
.players()
.color_for_participant(blame_entry.sha.into());
// If the last color we used is the same as the one we get for this line, but
// the commit SHAs are different, then we try again to get a different color.
match *last_used_color {
@ -5791,97 +5780,20 @@ fn render_blame_entry(
};
last_used_color.replace((sha_color, blame_entry.sha));
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
let short_commit_id = blame_entry.sha.display_short();
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
let details = blame.read(cx).details_for_entry(&blame_entry);
h_flex()
.w_full()
.justify_between()
.font_family(style.text.font().family)
.line_height(style.text.line_height)
.id(("blame", ix))
.text_color(cx.theme().status().hint)
.pr_2()
.gap_2()
.child(
h_flex()
.items_center()
.gap_2()
.child(div().text_color(sha_color.cursor).child(short_commit_id))
.child(name),
)
.child(relative_timestamp)
.on_mouse_down(MouseButton::Right, {
let blame_entry = blame_entry.clone();
let details = details.clone();
move |event, window, cx| {
deploy_blame_entry_context_menu(
&blame_entry,
details.as_ref(),
editor.clone(),
event.position,
window,
cx,
);
}
})
.hover(|style| style.bg(cx.theme().colors().element_hover))
.when_some(
details
.as_ref()
.and_then(|details| details.permalink.clone()),
|this, url| {
this.cursor_pointer().on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(url.as_str())
})
},
)
.hoverable_tooltip(move |window, cx| {
cx.new(|cx| CommitTooltip::blame_entry(&blame_entry, details.clone(), window, cx))
.into()
})
.into_any()
}
fn deploy_blame_entry_context_menu(
blame_entry: &BlameEntry,
details: Option<&ParsedCommitMessage>,
editor: Entity<Editor>,
position: gpui::Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let context_menu = ContextMenu::build(window, cx, move |menu, _, _| {
let sha = format!("{}", blame_entry.sha);
menu.on_blur_subscription(Subscription::new(|| {}))
.entry("Copy commit SHA", None, move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(sha.clone()));
})
.when_some(
details.and_then(|details| details.permalink.clone()),
|this, url| {
this.entry("Open permalink", None, move |_, cx| {
cx.open_url(url.as_str())
})
},
)
});
editor.update(cx, move |editor, cx| {
editor.mouse_context_menu = Some(MouseContextMenu::new(
MenuPosition::PinnedToScreen(position),
context_menu,
window,
cx,
));
cx.notify();
});
let blame = blame.read(cx);
let details = blame.details_for_entry(&blame_entry);
let repository = blame.repository(cx)?;
renderer.render_blame_entry(
&style.text,
blame_entry,
details,
repository,
workspace.downgrade(),
editor,
ix,
sha_color.cursor,
cx,
)
}
#[derive(Debug)]
@ -6588,9 +6500,9 @@ impl Element for EditorElement {
window.with_rem_size(rem_size, |window| {
window.with_text_style(Some(text_style), |window| {
window.with_content_mask(Some(ContentMask { bounds }), |window| {
let mut snapshot = self
.editor
.update(cx, |editor, cx| editor.snapshot(window, cx));
let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| {
(editor.snapshot(window, cx), editor.read_only(cx))
});
let style = self.style.clone();
let font_id = window.text_system().resolve_font(&style.text.font());
@ -6970,11 +6882,12 @@ impl Element for EditorElement {
.flatten()?;
let mut element = render_inline_blame_entry(
self.editor.clone(),
editor.workspace()?.downgrade(),
blame,
blame_entry,
&style,
cx,
);
)?;
let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance;
Some(
element
@ -7507,19 +7420,23 @@ impl Element for EditorElement {
editor.last_position_map = Some(position_map.clone())
});
let diff_hunk_controls = self.layout_diff_hunk_controls(
start_row..end_row,
&row_infos,
&text_hitbox,
&position_map,
newest_selection_head,
line_height,
scroll_pixel_position,
&display_hunks,
self.editor.clone(),
window,
cx,
);
let diff_hunk_controls = if is_read_only {
vec![]
} else {
self.layout_diff_hunk_controls(
start_row..end_row,
&row_infos,
&text_hitbox,
&position_map,
newest_selection_head,
line_height,
scroll_pixel_position,
&display_hunks,
self.editor.clone(),
window,
cx,
)
};
EditorLayout {
mode,

View file

@ -1,22 +1,22 @@
use crate::Editor;
use anyhow::Result;
use collections::HashMap;
use git::{
GitHostingProvider, GitHostingProviderRegistry, Oid,
blame::{Blame, BlameEntry},
GitHostingProviderRegistry, GitRemote, Oid,
blame::{Blame, BlameEntry, ParsedCommitMessage},
parse_git_remote_url,
};
use gpui::{App, AppContext as _, Context, Entity, Subscription, Task};
use http_client::HttpClient;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, Subscription, Task, TextStyle,
WeakEntity, Window,
};
use language::{Bias, Buffer, BufferSnapshot, Edit};
use multi_buffer::RowInfo;
use project::{Project, ProjectItem};
use project::{Project, ProjectItem, git_store::Repository};
use smallvec::SmallVec;
use std::{sync::Arc, time::Duration};
use sum_tree::SumTree;
use ui::SharedString;
use url::Url;
use crate::commit_tooltip::ParsedCommitMessage;
use workspace::Workspace;
#[derive(Clone, Debug, Default)]
pub struct GitBlameEntry {
@ -59,45 +59,11 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
}
}
#[derive(Clone)]
pub struct GitRemote {
pub host: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
pub owner: String,
pub repo: String,
}
impl std::fmt::Debug for GitRemote {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitRemote")
.field("host", &self.host.name())
.field("owner", &self.owner)
.field("repo", &self.repo)
.finish()
}
}
impl GitRemote {
pub fn host_supports_avatars(&self) -> bool {
self.host.supports_avatars()
}
pub async fn avatar_url(
&self,
commit: SharedString,
client: Arc<dyn HttpClient>,
) -> Option<Url> {
self.host
.commit_author_avatar_url(&self.owner, &self.repo, commit, client)
.await
.ok()
.flatten()
}
}
pub struct GitBlame {
project: Entity<Project>,
buffer: Entity<Buffer>,
entries: SumTree<GitBlameEntry>,
commit_details: HashMap<Oid, crate::commit_tooltip::ParsedCommitMessage>,
commit_details: HashMap<Oid, ParsedCommitMessage>,
buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription,
task: Task<Result<()>>,
@ -109,6 +75,91 @@ pub struct GitBlame {
_regenerate_subscriptions: Vec<Subscription>,
}
pub trait BlameRenderer {
fn max_author_length(&self) -> usize;
fn render_blame_entry(
&self,
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
_: usize,
_: Hsla,
_: &mut App,
) -> Option<AnyElement>;
fn render_inline_blame_entry(
&self,
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
_: &mut App,
) -> Option<AnyElement>;
fn open_blame_commit(
&self,
_: BlameEntry,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: &mut Window,
_: &mut App,
);
}
impl BlameRenderer for () {
fn max_author_length(&self) -> usize {
0
}
fn render_blame_entry(
&self,
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
_: usize,
_: Hsla,
_: &mut App,
) -> Option<AnyElement> {
None
}
fn render_inline_blame_entry(
&self,
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
_: &mut App,
) -> Option<AnyElement> {
None
}
fn open_blame_commit(
&self,
_: BlameEntry,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: &mut Window,
_: &mut App,
) {
}
}
pub(crate) struct GlobalBlameRenderer(pub Arc<dyn BlameRenderer>);
impl gpui::Global for GlobalBlameRenderer {}
impl GitBlame {
pub fn new(
buffer: Entity<Buffer>,
@ -181,6 +232,15 @@ impl GitBlame {
this
}
pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
self.project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
.map(|(repo, _)| repo)
}
pub fn has_generated_entries(&self) -> bool {
self.generated
}

View file

@ -109,7 +109,7 @@ impl ProposedChangesEditor {
let diff =
this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
Some(diff.update(cx, |diff, cx| {
diff.set_base_text(base_buffer.clone(), buffer, cx)
diff.set_base_text_buffer(base_buffer.clone(), buffer, cx)
}))
})
.collect::<Vec<_>>()
@ -185,7 +185,7 @@ impl ProposedChangesEditor {
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
new_diffs.push(cx.new(|cx| {
let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx);
let _ = diff.set_base_text(
let _ = diff.set_base_text_buffer(
location.buffer.clone(),
branch_buffer.read(cx).text_snapshot(),
cx,