git_graph: Add some design adjustments (#49899)

- Made hover/active styles, as well as clicks, work for the entire row,
capture the graph element, too (needed to make some manual hover and
other states management to pull that off)
- Used the existing `Chip` component for the branch chip instead of a
local recreation
- Adjusted spacing and sizing of commit detail panel, including button
labels truncation and tooltip content
- Added diff stat numbers for the changed files, to match the commit
view
- Standardized the commit avatar component across the git graph, the
commit view, and the file history view
- Added scrollbar to the changed files uniform list
- Removed author name display redundancy (kept only email)
- Made the commit detail UI have a min-width

<img width="750" height="1964" alt="Screenshot 2026-02-23 at 11  31@2x"
src="https://github.com/user-attachments/assets/d1433bd8-5edb-4829-882b-52b1bffbd6db"
/>

--- 

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
This commit is contained in:
Danilo LealandAnthony Eid authored and GitHub committed 2026-02-23 17:07:28 +00:00
1 parent b775e5d63f
commit a3c62de696
10 files changed
+588 -412

No files matched your search

Generated
+1
View File
@@ -7156,6 +7156,7 @@ dependencies = [
"git",
"git_ui",
"gpui",
"language",
"menu",
"project",
"rand 0.9.2",
+1
View File
@@ -26,6 +26,7 @@ feature_flags.workspace = true
git.workspace = true
git_ui.workspace = true
gpui.workspace = true
language.workspace = true
menu.workspace = true
project.workspace = true
settings.workspace = true
File diff suppressed because it is too large. Load diff
+28 -11
View File
@@ -5,7 +5,7 @@ use git::blame::BlameEntry;
use git::repository::CommitSummary;
use git::{GitRemote, commit::ParsedCommitMessage};
use gpui::{
App, Asset, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
AbsoluteLength, App, Asset, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, WeakEntity, prelude::*,
};
use markdown::{Markdown, MarkdownElement};
@@ -30,7 +30,7 @@ pub struct CommitAvatar<'a> {
sha: &'a SharedString,
author_email: Option<SharedString>,
remote: Option<&'a GitRemote>,
size: Option<IconSize>,
size: Option<AbsoluteLength>,
}
impl<'a> CommitAvatar<'a> {
@@ -59,21 +59,38 @@ impl<'a> CommitAvatar<'a> {
}
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = Some(size);
pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
self.size = Some(size.into());
self
}
pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement {
let border_color = cx.theme().colors().border_variant;
let border_width = px(1.);
match self.avatar(window, cx) {
// Loading or no avatar found
None => Icon::new(IconName::Person)
.color(Color::Muted)
.when_some(self.size, |this, size| this.size(size))
.into_any_element(),
// Found
None => {
let container_size = self
.size
.map(|s| s.to_pixels(window.rem_size()) + border_width * 2.);
h_flex()
.when_some(container_size, |this, size| this.size(size))
.justify_center()
.rounded_full()
.border(border_width)
.border_color(border_color)
.bg(cx.theme().colors().element_disabled)
.child(
Icon::new(IconName::Person)
.color(Color::Muted)
.size(IconSize::Small),
)
.into_any_element()
}
Some(avatar) => avatar
.when_some(self.size, |this, size| this.size(size.rems()))
.when_some(self.size, |this, size| this.size(size))
.border_color(border_color)
.into_any_element(),
}
}
+7 -27
View File
@@ -10,9 +10,9 @@ use git::{
parse_git_remote_url,
};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context,
Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
ParentElement, PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
};
use language::{
Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
@@ -403,33 +403,13 @@ impl CommitView {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let size = size.into();
let avatar = CommitAvatar::new(
CommitAvatar::new(
sha,
Some(self.commit.author_email.clone()),
self.remote.as_ref(),
);
v_flex()
.w(size)
.h(size)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_full()
.justify_center()
.items_center()
.child(
avatar
.avatar(window, cx)
.map(|a| a.size(size).into_any_element())
.unwrap_or_else(|| {
Icon::new(IconName::Person)
.color(Color::Muted)
.size(IconSize::Medium)
.into_any_element()
}),
)
.into_any()
)
.size(size)
.render(window, cx)
}
fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
+9 -73
View File
@@ -1,11 +1,10 @@
use anyhow::Result;
use futures::Future;
use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
use gpui::{
AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window,
uniform_list,
AnyElement, AnyEntity, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list,
};
use project::{
Project, ProjectPath,
@@ -15,13 +14,14 @@ use std::any::{Any, TypeId};
use std::sync::Arc;
use time::OffsetDateTime;
use ui::{Avatar, Chip, Divider, ListItem, WithScrollbar, prelude::*};
use ui::{Chip, Divider, ListItem, WithScrollbar, prelude::*};
use util::ResultExt;
use workspace::{
Item, Workspace,
item::{ItemEvent, SaveOptions},
};
use crate::commit_tooltip::CommitAvatar;
use crate::commit_view::CommitView;
const PAGE_SIZE: usize = 50;
@@ -276,20 +276,10 @@ impl FileHistoryView {
author_email: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
let size = rems_from_px(20.);
if let Some(remote) = remote {
let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone(), author_email);
if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
Avatar::new(url.to_string()).size(size)
} else {
Avatar::new("").size(size)
}
} else {
Avatar::new("").size(size)
}
) -> AnyElement {
CommitAvatar::new(sha, author_email, self.remote.as_ref())
.size(rems_from_px(20.))
.render(window, cx)
}
fn render_commit_entry(
@@ -388,60 +378,6 @@ impl FileHistoryView {
}
}
#[derive(Clone, Debug)]
struct CommitAvatarAsset {
sha: SharedString,
author_email: Option<SharedString>,
remote: GitRemote,
}
impl std::hash::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, author_email: Option<SharedString>) -> Self {
Self {
remote,
sha,
author_email,
}
}
}
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 {
match source
.remote
.host
.commit_author_avatar_url(
&source.remote.owner,
&source.remote.repo,
source.sha.clone(),
source.author_email.clone(),
client,
)
.await
{
Ok(Some(url)) => Some(SharedString::from(url.to_string())),
Ok(None) => None,
Err(_) => None,
}
}
}
}
impl EventEmitter<ItemEvent> for FileHistoryView {}
impl Focusable for FileHistoryView {
+1 -1
View File
@@ -73,7 +73,7 @@ impl Avatar {
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
px(2.)
px(1.)
} else {
px(0.)
};
@@ -434,6 +434,7 @@ impl RenderOnce for Button {
self.base.child(
h_flex()
.when(self.truncate, |this| this.min_w_0().overflow_hidden())
.gap(DynamicSpacing::Base04.rems(cx))
.when(self.icon_position == Some(IconPosition::Start), |this| {
this.children(self.icon.map(|icon| {
@@ -448,6 +449,7 @@ impl RenderOnce for Button {
})
.child(
h_flex()
.when(self.truncate, |this| this.min_w_0().overflow_hidden())
.when(
self.key_binding_position == KeybindingPosition::Start,
|this| this.flex_row_reverse(),
+22 -2
View File
@@ -16,6 +16,8 @@ pub struct Chip {
label_color: Color,
label_size: LabelSize,
bg_color: Option<Hsla>,
border_color: Option<Hsla>,
height: Option<Pixels>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
}
@@ -27,6 +29,8 @@ impl Chip {
label_color: Color::Default,
label_size: LabelSize::XSmall,
bg_color: None,
border_color: None,
height: None,
tooltip: None,
}
}
@@ -49,6 +53,18 @@ impl Chip {
self
}
/// Sets a custom border color for the chip.
pub fn border_color(mut self, color: Hsla) -> Self {
self.border_color = Some(color);
self
}
/// Sets a custom height for the chip.
pub fn height(mut self, height: Pixels) -> Self {
self.height = Some(height);
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
@@ -61,20 +77,24 @@ impl RenderOnce for Chip {
.bg_color
.unwrap_or(cx.theme().colors().element_background);
let border_color = self.border_color.unwrap_or(cx.theme().colors().border);
h_flex()
.when_some(self.height, |this, h| this.h(h))
.min_w_0()
.flex_initial()
.px_1()
.border_1()
.rounded_sm()
.border_color(cx.theme().colors().border)
.border_color(border_color)
.bg(bg_color)
.overflow_hidden()
.child(
Label::new(self.label.clone())
.size(self.label_size)
.color(self.label_color)
.buffer_font(cx),
.buffer_font(cx)
.truncate(),
)
.id(self.label.clone())
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
+14 -1
View File
@@ -724,6 +724,7 @@ impl TableWidths {
pub struct Table {
striped: bool,
show_row_borders: bool,
show_row_hover: bool,
width: Option<Length>,
headers: Option<TableRow<AnyElement>>,
rows: TableContents,
@@ -743,6 +744,7 @@ impl Table {
cols,
striped: false,
show_row_borders: true,
show_row_hover: true,
width: None,
headers: None,
rows: TableContents::Vec(Vec::new()),
@@ -895,6 +897,13 @@ impl Table {
self
}
/// Hides the default hover background on table rows.
/// Use this when you want to handle row hover styling manually via `map_row`.
pub fn hide_row_hover(mut self) -> Self {
self.show_row_hover = false;
self
}
/// Provide a callback that is invoked when the table is rendered without any rows
pub fn empty_table_callback(
mut self,
@@ -948,7 +957,9 @@ pub fn render_table_row(
.id(("table_row", row_index))
.size_full()
.when_some(bg, |row, bg| row.bg(bg))
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
.when(table_context.show_row_hover, |row| {
row.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
})
.when(!is_striped && table_context.show_row_borders, |row| {
row.border_b_1()
.border_color(transparent_black())
@@ -1055,6 +1066,7 @@ pub fn render_table_header(
pub struct TableRenderContext {
pub striped: bool,
pub show_row_borders: bool,
pub show_row_hover: bool,
pub total_row_count: usize,
pub column_widths: Option<TableRow<Length>>,
pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
@@ -1066,6 +1078,7 @@ impl TableRenderContext {
Self {
striped: table.striped,
show_row_borders: table.show_row_borders,
show_row_hover: table.show_row_hover,
total_row_count: table.rows.len(),
column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
map_row: table.map_row.clone(),