From 15885647e14786e6317840689aa27b8d14f3c761 Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Mon, 23 Feb 2026 13:44:08 +0100
Subject: [PATCH] git_graph: Improve commit detail panel UI (#49876)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Use git panel icons to show a changed file's state
- Centered avatar at top and move close button to top right
- Made changed file list scrollable
- clicking on a file open's it's historic commit view
- Note: The commit view doesn't fully populate the multibuffer, will fix
this in a different PR because it involves updating the commit view
interface to add more functionality
## Before
## After
Before you mark this PR as ready for review, make sure that you have:
- [ ] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
Release Notes:
- N/A
---
crates/git/src/repository.rs | 17 ++
crates/git_graph/src/git_graph.rs | 279 +++++++++++++++++++++---------
2 files changed, 219 insertions(+), 77 deletions(-)
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index baa0ed09239fcd62b7e58a49397c33a0eb3813b6..592c04427dc860b77d8ba7a2a677c47ea648b47e 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -451,6 +451,13 @@ pub struct CommitDiff {
pub files: Vec,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum CommitFileStatus {
+ Added,
+ Modified,
+ Deleted,
+}
+
#[derive(Debug)]
pub struct CommitFile {
pub path: RepoPath,
@@ -459,6 +466,16 @@ pub struct CommitFile {
pub is_binary: bool,
}
+impl CommitFile {
+ pub fn status(&self) -> CommitFileStatus {
+ match (&self.old_text, &self.new_text) {
+ (None, Some(_)) => CommitFileStatus::Added,
+ (Some(_), None) => CommitFileStatus::Deleted,
+ _ => CommitFileStatus::Modified,
+ }
+ }
+}
+
impl CommitDetails {
pub fn short_sha(&self) -> SharedString {
self.sha[..SHORT_SHA_LENGTH].to_string().into()
diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs
index a8318e5122f7f37b00ee9a2d966b387282c8073d..dead8c377b4320cef912e09691eee4ee447c2aa1 100644
--- a/crates/git_graph/src/git_graph.rs
+++ b/crates/git_graph/src/git_graph.rs
@@ -3,7 +3,10 @@ use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
use git::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
parse_git_remote_url,
- repository::{CommitDiff, InitialGraphCommitData, LogOrder, LogSource},
+ repository::{
+ CommitDiff, CommitFile, CommitFileStatus, InitialGraphCommitData, LogOrder, LogSource,
+ RepoPath,
+ },
};
use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView};
use gpui::{
@@ -11,7 +14,7 @@ use gpui::{
DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight,
Hsla, InteractiveElement, ParentElement, PathBuilder, Pixels, Point, Render, ScrollStrategy,
ScrollWheelEvent, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions,
- anchored, deferred, point, px,
+ anchored, deferred, point, px, uniform_list,
};
use menu::{Cancel, SelectNext, SelectPrevious};
use project::{
@@ -41,6 +44,117 @@ const RESIZE_HANDLE_WIDTH: f32 = 8.0;
struct DraggedSplitHandle;
+#[derive(Clone)]
+struct ChangedFileEntry {
+ icon_name: IconName,
+ icon_color: Hsla,
+ file_name: SharedString,
+ dir_path: SharedString,
+ repo_path: RepoPath,
+}
+
+impl ChangedFileEntry {
+ fn from_commit_file(file: &CommitFile, cx: &App) -> Self {
+ let file_name: SharedString = file
+ .path
+ .file_name()
+ .map(|n| n.to_string())
+ .unwrap_or_default()
+ .into();
+ let dir_path: SharedString = file
+ .path
+ .parent()
+ .map(|p| p.as_unix_str().to_string())
+ .unwrap_or_default()
+ .into();
+ let colors = cx.theme().colors();
+ let (icon_name, icon_color) = match file.status() {
+ CommitFileStatus::Added => (IconName::SquarePlus, colors.version_control_added),
+ CommitFileStatus::Modified => (IconName::SquareDot, colors.version_control_modified),
+ CommitFileStatus::Deleted => (IconName::SquareMinus, colors.version_control_deleted),
+ };
+ Self {
+ icon_name,
+ icon_color,
+ file_name,
+ dir_path,
+ repo_path: file.path.clone(),
+ }
+ }
+
+ fn open_in_commit_view(
+ &self,
+ commit_sha: &SharedString,
+ repository: &WeakEntity,
+ workspace: &WeakEntity,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ CommitView::open(
+ commit_sha.to_string(),
+ repository.clone(),
+ workspace.clone(),
+ None,
+ Some(self.repo_path.clone()),
+ window,
+ cx,
+ );
+ }
+
+ fn render(
+ &self,
+ ix: usize,
+ commit_sha: SharedString,
+ repository: WeakEntity,
+ workspace: WeakEntity,
+ cx: &App,
+ ) -> AnyElement {
+ h_flex()
+ .id(("changed-file", ix))
+ .px_3()
+ .py_px()
+ .gap_1()
+ .min_w_0()
+ .overflow_hidden()
+ .cursor_pointer()
+ .rounded_md()
+ .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+ .active(|style| style.bg(cx.theme().colors().ghost_element_active))
+ .child(
+ div().flex_none().child(
+ Icon::new(self.icon_name)
+ .size(IconSize::Small)
+ .color(Color::Custom(self.icon_color)),
+ ),
+ )
+ .child(
+ div().flex_none().child(
+ Label::new(self.file_name.clone())
+ .size(LabelSize::Small)
+ .single_line(),
+ ),
+ )
+ .when(!self.dir_path.is_empty(), |this| {
+ this.child(
+ div().min_w_0().overflow_hidden().child(
+ Label::new(self.dir_path.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate()
+ .single_line(),
+ ),
+ )
+ })
+ .on_click({
+ let entry = self.clone();
+ move |_, window, cx| {
+ entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx);
+ }
+ })
+ .into_any_element()
+ }
+}
+
pub struct SplitState {
left_ratio: f32,
visible_left_ratio: f32,
@@ -1154,7 +1268,22 @@ impl GitGraph {
.map(|diff| diff.files.len())
.unwrap_or(0);
+ let sorted_file_entries: Rc> = Rc::new(
+ self.selected_commit_diff
+ .as_ref()
+ .map(|diff| {
+ let mut files: Vec<_> = diff.files.iter().collect();
+ files.sort_by_key(|file| file.status());
+ files
+ .into_iter()
+ .map(|file| ChangedFileEntry::from_commit_file(file, cx))
+ .collect()
+ })
+ .unwrap_or_default(),
+ );
+
v_flex()
+ .relative()
.w(px(300.))
.h_full()
.border_l_1()
@@ -1163,25 +1292,32 @@ impl GitGraph {
.flex_basis(DefiniteLength::Fraction(
self.commit_details_split_state.read(cx).right_ratio(),
))
+ .child(
+ div().absolute().top_2().right_2().child(
+ IconButton::new("close-detail", IconName::Close)
+ .icon_size(IconSize::Small)
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.selected_entry_idx = None;
+ this.selected_commit_diff = None;
+ this._commit_diff_task = None;
+ cx.notify();
+ })),
+ ),
+ )
.child(
v_flex()
- .p_3()
+ .w_full()
+ .min_w_0()
+ .flex_none()
+ .pt_3()
.gap_3()
- .child(
- h_flex().justify_between().child(avatar).child(
- IconButton::new("close-detail", IconName::Close)
- .icon_size(IconSize::Small)
- .on_click(cx.listener(move |this, _, _, cx| {
- this.selected_entry_idx = None;
- this.selected_commit_diff = None;
- this._commit_diff_task = None;
- cx.notify();
- })),
- ),
- )
.child(
v_flex()
+ .w_full()
+ .px_3()
+ .items_center()
.gap_0p5()
+ .child(avatar)
.child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
.child(
Label::new(date_string)
@@ -1190,14 +1326,20 @@ impl GitGraph {
),
)
.children((!ref_names.is_empty()).then(|| {
- h_flex().gap_1().flex_wrap().children(
- ref_names
- .iter()
- .map(|name| self.render_badge(name, accent_color)),
- )
+ h_flex()
+ .px_3()
+ .justify_center()
+ .gap_1()
+ .flex_wrap()
+ .children(
+ ref_names
+ .iter()
+ .map(|name| self.render_badge(name, accent_color)),
+ )
}))
.child(
v_flex()
+ .px_3()
.gap_1p5()
.child(
h_flex()
@@ -1224,7 +1366,7 @@ impl GitGraph {
h_flex()
.gap_1()
.child(
- Icon::new(IconName::Hash)
+ Icon::new(IconName::FileGit)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -1286,72 +1428,55 @@ impl GitGraph {
),
)
}),
- ),
- )
- .child(
- div()
- .border_t_1()
- .border_color(cx.theme().colors().border)
- .p_3()
- .min_w_0()
+ )
.child(
- v_flex()
- .gap_2()
+ div()
+ .w_full()
+ .min_w_0()
+ .border_t_1()
+ .border_color(cx.theme().colors().border)
+ .p_3()
.child(Label::new(subject).weight(FontWeight::MEDIUM)),
),
)
.child(
- div()
+ v_flex()
.flex_1()
- .overflow_hidden()
+ .min_h_0()
.border_t_1()
.border_color(cx.theme().colors().border)
- .p_3()
.child(
- v_flex()
- .gap_2()
- .child(
- Label::new(format!("{} Changed Files", changed_files_count))
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- .children(self.selected_commit_diff.as_ref().map(|diff| {
- v_flex().gap_1().children(diff.files.iter().map(|file| {
- let file_name: String = file
- .path
- .file_name()
- .map(|n| n.to_string())
- .unwrap_or_default();
- let dir_path: String = file
- .path
- .parent()
- .map(|p| p.as_unix_str().to_string())
- .unwrap_or_default();
-
- h_flex()
- .gap_1()
- .overflow_hidden()
- .child(
- Icon::new(IconName::File)
- .size(IconSize::Small)
- .color(Color::Accent),
- )
- .child(
- Label::new(file_name)
- .size(LabelSize::Small)
- .single_line(),
+ div().px_3().pt_3().pb_1().child(
+ Label::new(format!("{} Changed Files", changed_files_count))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child({
+ let entries = sorted_file_entries;
+ let entry_count = entries.len();
+ let commit_sha = full_sha.clone();
+ let repository = repository.downgrade();
+ let workspace = self.workspace.clone();
+ uniform_list(
+ "changed-files-list",
+ entry_count,
+ move |range, _window, cx| {
+ range
+ .map(|ix| {
+ entries[ix].render(
+ ix,
+ commit_sha.clone(),
+ repository.clone(),
+ workspace.clone(),
+ cx,
)
- .when(!dir_path.is_empty(), |this| {
- this.child(
- Label::new(dir_path)
- .size(LabelSize::Small)
- .color(Color::Muted)
- .single_line(),
- )
- })
- }))
- })),
- ),
+ })
+ .collect()
+ },
+ )
+ .flex_1()
+ }),
)
.into_any_element()
}