git_graph: Improve commit detail panel UI (#49876)

- 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
<img width="602" height="1704" alt="image"
src="https://github.com/user-attachments/assets/75a12fff-8a6a-4d0f-90dd-544adb0c2814"
/>

## After
<img width="227" height="856" alt="Screenshot 2026-02-23 at 1 23 45 PM"
src="https://github.com/user-attachments/assets/244cc9f3-e94d-4cc6-ac46-80fe70a619ff"
/>


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
This commit is contained in:
Anthony Eid 2026-02-23 13:44:08 +01:00 committed by GitHub
parent 1240fd1f36
commit 15885647e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 219 additions and 77 deletions

View file

@ -451,6 +451,13 @@ pub struct CommitDiff {
pub files: Vec<CommitFile>,
}
#[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()

View file

@ -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<Repository>,
workspace: &WeakEntity<Workspace>,
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<Repository>,
workspace: WeakEntity<Workspace>,
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<Vec<ChangedFileEntry>> = 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),
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,
)
.child(
Label::new(file_name)
.size(LabelSize::Small)
.single_line(),
)
.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()
}