From a3c62de696aa1cf13a2d46cfa89e91aa50aeef91 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Mon, 23 Feb 2026 14:07:28 -0300
Subject: [PATCH] git_graph: Add some design adjustments (#49899)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
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
---
Cargo.lock | 1 +
crates/git_graph/Cargo.toml | 1 +
crates/git_graph/src/git_graph.rs | 794 ++++++++++++++--------
crates/git_ui/src/commit_tooltip.rs | 39 +-
crates/git_ui/src/commit_view.rs | 34 +-
crates/git_ui/src/file_history_view.rs | 82 +--
crates/ui/src/components/avatar.rs | 2 +-
crates/ui/src/components/button/button.rs | 2 +
crates/ui/src/components/chip.rs | 24 +-
crates/ui/src/components/data_table.rs | 15 +-
10 files changed, 585 insertions(+), 409 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 5c5328080cd94f78957a6be4930076202c24d51d..aa52141ae7529bab47e8aeab6959525227cb04c5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7156,6 +7156,7 @@ dependencies = [
"git",
"git_ui",
"gpui",
+ "language",
"menu",
"project",
"rand 0.9.2",
diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml
index 518798279ddbd21cd95a044387204d6d64104dba..386d82389ca3370f071f8733b039f91fc3f21feb 100644
--- a/crates/git_graph/Cargo.toml
+++ b/crates/git_graph/Cargo.toml
@@ -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
diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs
index dead8c377b4320cef912e09691eee4ee447c2aa1..472fbaa6483fa110364d2c6e4affccc3e3772991 100644
--- a/crates/git_graph/src/git_graph.rs
+++ b/crates/git_graph/src/git_graph.rs
@@ -3,19 +3,18 @@ use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
use git::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
parse_git_remote_url,
- repository::{
- CommitDiff, CommitFile, CommitFileStatus, InitialGraphCommitData, LogOrder, LogSource,
- RepoPath,
- },
+ repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath},
+ status::{FileStatus, StatusCode, TrackedStatus},
};
-use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView};
+use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
use gpui::{
- AnyElement, App, Bounds, ClickEvent, ClipboardItem, Context, Corner, DefiniteLength,
- 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, uniform_list,
+ AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent,
+ ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
+ Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task,
+ UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
+ px, uniform_list,
};
+use language::line_diff;
use menu::{Cancel, SelectNext, SelectPrevious};
use project::{
Project,
@@ -23,38 +22,66 @@ use project::{
};
use settings::Settings;
use smallvec::{SmallVec, smallvec};
-use std::{ops::Range, rc::Rc, sync::Arc, sync::OnceLock};
+use std::{
+ cell::Cell,
+ ops::Range,
+ rc::Rc,
+ sync::Arc,
+ sync::OnceLock,
+ time::{Duration, Instant},
+};
use theme::{AccentColors, ThemeSettings};
use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
use ui::{
- CommonAnimationExt as _, ContextMenu, ScrollableHandle, Table, TableColumnWidths,
- TableInteractionState, TableResizeBehavior, Tooltip, prelude::*,
+ ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle,
+ Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar,
+ prelude::*,
};
use workspace::{
Workspace,
item::{Item, ItemEvent, SerializableItem},
};
-const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5);
+const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
const LANE_WIDTH: Pixels = px(16.0);
const LEFT_PADDING: Pixels = px(12.0);
const LINE_WIDTH: Pixels = px(1.5);
const RESIZE_HANDLE_WIDTH: f32 = 8.0;
+const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
+
+struct CopiedState {
+ copied_at: Option,
+}
+
+impl CopiedState {
+ fn new(_window: &mut Window, _cx: &mut Context) -> Self {
+ Self { copied_at: None }
+ }
+
+ fn is_copied(&self) -> bool {
+ self.copied_at
+ .map(|t| t.elapsed() < COPIED_STATE_DURATION)
+ .unwrap_or(false)
+ }
+
+ fn mark_copied(&mut self) {
+ self.copied_at = Some(Instant::now());
+ }
+}
struct DraggedSplitHandle;
#[derive(Clone)]
struct ChangedFileEntry {
- icon_name: IconName,
- icon_color: Hsla,
+ status: FileStatus,
file_name: SharedString,
dir_path: SharedString,
repo_path: RepoPath,
}
impl ChangedFileEntry {
- fn from_commit_file(file: &CommitFile, cx: &App) -> Self {
+ fn from_commit_file(file: &CommitFile, _cx: &App) -> Self {
let file_name: SharedString = file
.path
.file_name()
@@ -67,15 +94,20 @@ impl ChangedFileEntry {
.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),
+
+ let status_code = match (&file.old_text, &file.new_text) {
+ (None, Some(_)) => StatusCode::Added,
+ (Some(_), None) => StatusCode::Deleted,
+ _ => StatusCode::Modified,
};
+
+ let status = FileStatus::Tracked(TrackedStatus {
+ index_status: status_code,
+ worktree_status: StatusCode::Unmodified,
+ });
+
Self {
- icon_name,
- icon_color,
+ status,
file_name,
dir_path,
repo_path: file.path.clone(),
@@ -107,50 +139,57 @@ impl ChangedFileEntry {
commit_sha: SharedString,
repository: WeakEntity,
workspace: WeakEntity,
- cx: &App,
+ _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)),
- ),
- )
+ let file_name = self.file_name.clone();
+ let dir_path = self.dir_path.clone();
+
+ div()
+ .w_full()
.child(
- div().flex_none().child(
- Label::new(self.file_name.clone())
- .size(LabelSize::Small)
- .single_line(),
- ),
+ ButtonLike::new(("changed-file", ix))
+ .child(
+ h_flex()
+ .min_w_0()
+ .w_full()
+ .gap_1()
+ .overflow_hidden()
+ .child(git_status_icon(self.status))
+ .child(
+ Label::new(file_name.clone())
+ .size(LabelSize::Small)
+ .truncate(),
+ )
+ .when(!dir_path.is_empty(), |this| {
+ this.child(
+ Label::new(dir_path.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate_start(),
+ )
+ }),
+ )
+ .tooltip({
+ let meta = if dir_path.is_empty() {
+ file_name
+ } else {
+ format!("{}/{}", dir_path, file_name).into()
+ };
+ move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx)
+ })
+ .on_click({
+ let entry = self.clone();
+ move |_, window, cx| {
+ entry.open_in_commit_view(
+ &commit_sha,
+ &repository,
+ &workspace,
+ window,
+ cx,
+ );
+ }
+ }),
)
- .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()
}
}
@@ -716,9 +755,8 @@ fn to_row_center(
fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
let radius = COMMIT_CIRCLE_RADIUS;
- let stroke_width = COMMIT_CIRCLE_STROKE_WIDTH;
- let mut builder = PathBuilder::stroke(stroke_width);
+ let mut builder = PathBuilder::fill();
// Start at the rightmost point of the circle
builder.move_to(point(center_x + radius, center_y));
@@ -745,6 +783,22 @@ fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &
}
}
+fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
+ diff.files.iter().fold((0, 0), |(added, removed), file| {
+ let old_text = file.old_text.as_deref().unwrap_or("");
+ let new_text = file.new_text.as_deref().unwrap_or("");
+ let hunks = line_diff(old_text, new_text);
+ hunks
+ .iter()
+ .fold((added, removed), |(a, r), (old_range, new_range)| {
+ (
+ a + (new_range.end - new_range.start) as usize,
+ r + (old_range.end - old_range.start) as usize,
+ )
+ })
+ })
+}
+
pub struct GitGraph {
focus_handle: FocusHandle,
graph_data: GraphData,
@@ -757,12 +811,16 @@ pub struct GitGraph {
horizontal_scroll_offset: Pixels,
graph_viewport_width: Pixels,
selected_entry_idx: Option,
+ hovered_entry_idx: Option,
+ graph_canvas_bounds: Rc>>>,
log_source: LogSource,
log_order: LogOrder,
selected_commit_diff: Option,
+ selected_commit_diff_stats: Option<(usize, usize)>,
_commit_diff_task: Option>,
commit_details_split_state: Entity,
selected_repo_id: Option,
+ changed_files_scroll_handle: UniformListScrollHandle,
}
impl GitGraph {
@@ -851,11 +909,15 @@ impl GitGraph {
horizontal_scroll_offset: px(0.),
graph_viewport_width: px(88.),
selected_entry_idx: None,
+ hovered_entry_idx: None,
+ graph_canvas_bounds: Rc::new(Cell::new(None)),
selected_commit_diff: None,
+ selected_commit_diff_stats: None,
log_source,
log_order,
commit_details_split_state: cx.new(|_cx| SplitState::new()),
selected_repo_id: active_repository,
+ changed_files_scroll_handle: UniformListScrollHandle::new(),
};
this.fetch_initial_graph_data(cx);
@@ -917,24 +979,11 @@ impl GitGraph {
.and_then(|repo_id| project.repositories(cx).get(&repo_id).cloned())
}
- fn render_badge(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
- div()
- .px_1p5()
- .py_0p5()
- .h(self.row_height - px(4.0))
- .flex()
- .items_center()
- .justify_center()
- .rounded_md()
- .bg(accent_color.opacity(0.18))
- .border_1()
- .border_color(accent_color.opacity(0.55))
- .child(
- Label::new(name.clone())
- .size(LabelSize::Small)
- .color(Color::Default)
- .single_line(),
- )
+ fn render_chip(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
+ Chip::new(name.clone())
+ .label_size(LabelSize::Small)
+ .bg_color(accent_color.opacity(0.1))
+ .border_color(accent_color.opacity(0.5))
}
fn render_table_rows(
@@ -981,15 +1030,15 @@ impl GitGraph {
let short_sha = commit.data.sha.display_short();
let mut formatted_time = String::new();
- let subject;
- let author_name;
+ let subject: SharedString;
+ let author_name: SharedString;
if let CommitDataState::Loaded(data) = data {
subject = data.subject.clone();
author_name = data.author_name.clone();
formatted_time = format_timestamp(data.commit_timestamp);
} else {
- subject = "Loading...".into();
+ subject = "Loading…".into();
author_name = "".into();
}
@@ -999,11 +1048,13 @@ impl GitGraph {
.get(commit.color_idx)
.copied()
.unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
+
let is_selected = self.selected_entry_idx == Some(idx);
- let text_color = if is_selected {
- Color::Default
- } else {
- Color::Muted
+ let column_label = |label: SharedString| {
+ Label::new(label)
+ .when(!is_selected, |c| c.color(Color::Muted))
+ .truncate()
+ .into_any_element()
};
vec![
@@ -1013,38 +1064,23 @@ impl GitGraph {
.tooltip(Tooltip::text(subject.clone()))
.child(
h_flex()
- .gap_1()
- .items_center()
+ .gap_2()
.overflow_hidden()
.children((!commit.data.ref_names.is_empty()).then(|| {
- h_flex().flex_shrink().gap_2().items_center().children(
+ h_flex().gap_1().children(
commit
.data
.ref_names
.iter()
- .map(|name| self.render_badge(name, accent_color)),
+ .map(|name| self.render_chip(name, accent_color)),
)
}))
- .child(
- Label::new(subject)
- .color(text_color)
- .truncate()
- .single_line(),
- ),
+ .child(column_label(subject)),
)
.into_any_element(),
- Label::new(formatted_time)
- .color(text_color)
- .single_line()
- .into_any_element(),
- Label::new(author_name)
- .color(text_color)
- .single_line()
- .into_any_element(),
- Label::new(short_sha)
- .color(text_color)
- .single_line()
- .into_any_element(),
+ column_label(formatted_time.into()),
+ column_label(author_name),
+ column_label(short_sha.into()),
]
})
.collect()
@@ -1053,6 +1089,7 @@ impl GitGraph {
fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) {
self.selected_entry_idx = None;
self.selected_commit_diff = None;
+ self.selected_commit_diff_stats = None;
cx.notify();
}
@@ -1079,6 +1116,9 @@ impl GitGraph {
self.selected_entry_idx = Some(idx);
self.selected_commit_diff = None;
+ self.selected_commit_diff_stats = None;
+ self.changed_files_scroll_handle
+ .scroll_to_item(0, ScrollStrategy::Top);
self.table_interaction_state.update(cx, |state, cx| {
state
.scroll_handle
@@ -1101,7 +1141,9 @@ impl GitGraph {
self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
if let Ok(Ok(diff)) = diff_receiver.await {
this.update(cx, |this, cx| {
+ let stats = compute_diff_stats(&diff);
this.selected_commit_diff = Some(diff);
+ this.selected_commit_diff_stats = Some(stats);
cx.notify();
})
.ok();
@@ -1193,15 +1235,8 @@ impl GitGraph {
});
let full_sha: SharedString = commit_entry.data.sha.to_string().into();
- let truncated_sha: SharedString = {
- let sha_str = full_sha.as_ref();
- if sha_str.len() > 24 {
- format!("{}...", &sha_str[..24]).into()
- } else {
- full_sha.clone()
- }
- };
let ref_names = commit_entry.data.ref_names.clone();
+
let accent_colors = cx.theme().accents();
let accent_color = accent_colors
.0
@@ -1216,7 +1251,7 @@ impl GitGraph {
Some(data.commit_timestamp),
data.subject.clone(),
),
- CommitDataState::Loading => ("Loading...".into(), "".into(), None, "Loading...".into()),
+ CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
};
let date_string = commit_timestamp
@@ -1240,26 +1275,10 @@ impl GitGraph {
} else {
Some(author_email.clone())
};
- let avatar = CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref());
- v_flex()
- .w(px(64.))
- .h(px(64.))
- .border_1()
- .border_color(cx.theme().colors().border)
- .rounded_full()
- .justify_center()
- .items_center()
- .child(
- avatar
- .avatar(window, cx)
- .map(|a| a.size(px(64.)).into_any_element())
- .unwrap_or_else(|| {
- Icon::new(IconName::Person)
- .color(Color::Muted)
- .size(IconSize::XLarge)
- .into_any_element()
- }),
- )
+
+ CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
+ .size(px(40.))
+ .render(window, cx)
};
let changed_files_count = self
@@ -1268,6 +1287,9 @@ impl GitGraph {
.map(|diff| diff.files.len())
.unwrap_or(0);
+ let (total_lines_added, total_lines_removed) =
+ self.selected_commit_diff_stats.unwrap_or((0, 0));
+
let sorted_file_entries: Rc> = Rc::new(
self.selected_commit_diff
.as_ref()
@@ -1283,110 +1305,164 @@ impl GitGraph {
);
v_flex()
- .relative()
- .w(px(300.))
+ .min_w(px(300.))
.h_full()
- .border_l_1()
- .border_color(cx.theme().colors().border)
.bg(cx.theme().colors().surface_background)
.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()
+ .relative()
.w_full()
- .min_w_0()
- .flex_none()
- .pt_3()
- .gap_3()
+ .p_2()
+ .gap_2()
+ .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.selected_commit_diff_stats = None;
+ this._commit_diff_task = None;
+ cx.notify();
+ })),
+ ),
+ )
.child(
v_flex()
+ .py_1()
.w_full()
- .px_3()
.items_center()
- .gap_0p5()
+ .gap_1()
.child(avatar)
- .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
.child(
- Label::new(date_string)
- .color(Color::Muted)
- .size(LabelSize::Small),
+ v_flex()
+ .items_center()
+ .child(Label::new(author_name))
+ .child(
+ Label::new(date_string)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ ),
),
)
.children((!ref_names.is_empty()).then(|| {
- h_flex()
- .px_3()
- .justify_center()
- .gap_1()
- .flex_wrap()
- .children(
- ref_names
- .iter()
- .map(|name| self.render_badge(name, accent_color)),
- )
+ h_flex().gap_1().flex_wrap().justify_center().children(
+ ref_names
+ .iter()
+ .map(|name| self.render_chip(name, accent_color)),
+ )
}))
.child(
v_flex()
- .px_3()
+ .ml_neg_1()
.gap_1p5()
- .child(
- h_flex()
- .gap_1()
- .child(
- Icon::new(IconName::Person)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .child(
- Label::new(author_name)
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- .when(!author_email.is_empty(), |this| {
- this.child(
- Label::new(format!("<{}>", author_email))
- .size(LabelSize::Small)
- .color(Color::Ignored),
- )
- }),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Icon::new(IconName::FileGit)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .child({
- let copy_sha = full_sha.clone();
- Button::new("sha-button", truncated_sha)
- .style(ButtonStyle::Transparent)
- .label_size(LabelSize::Small)
- .color(Color::Muted)
- .tooltip(Tooltip::text(format!(
- "Copy SHA: {}",
- copy_sha
- )))
- .on_click(move |_, _, cx| {
- cx.write_to_clipboard(ClipboardItem::new_string(
- copy_sha.to_string(),
- ));
+ .when(!author_email.is_empty(), |this| {
+ let copied_state: Entity = window.use_keyed_state(
+ "author-email-copy",
+ cx,
+ CopiedState::new,
+ );
+ let is_copied = copied_state.read(cx).is_copied();
+
+ let (icon, icon_color, tooltip_label) = if is_copied {
+ (IconName::Check, Color::Success, "Email Copied!")
+ } else {
+ (IconName::Envelope, Color::Muted, "Copy Email")
+ };
+
+ let copy_email = author_email.clone();
+ let author_email_for_tooltip = author_email.clone();
+
+ this.child(
+ Button::new("author-email-copy", author_email.clone())
+ .icon(icon)
+ .icon_size(IconSize::Small)
+ .icon_color(icon_color)
+ .icon_position(IconPosition::Start)
+ .label_size(LabelSize::Small)
+ .truncate(true)
+ .color(Color::Muted)
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ tooltip_label,
+ None,
+ author_email_for_tooltip.clone(),
+ cx,
+ )
+ })
+ .on_click(move |_, _, cx| {
+ copied_state.update(cx, |state, _cx| {
+ state.mark_copied();
+ });
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ copy_email.to_string(),
+ ));
+ let state_id = copied_state.entity_id();
+ cx.spawn(async move |cx| {
+ cx.background_executor()
+ .timer(COPIED_STATE_DURATION)
+ .await;
+ cx.update(|cx| {
+ cx.notify(state_id);
+ })
})
- }),
- )
+ .detach();
+ }),
+ )
+ })
+ .child({
+ let copy_sha = full_sha.clone();
+ let copied_state: Entity =
+ window.use_keyed_state("sha-copy", cx, CopiedState::new);
+ let is_copied = copied_state.read(cx).is_copied();
+
+ let (icon, icon_color, tooltip_label) = if is_copied {
+ (IconName::Check, Color::Success, "Commit SHA Copied!")
+ } else {
+ (IconName::Hash, Color::Muted, "Copy Commit SHA")
+ };
+
+ Button::new("sha-button", &full_sha)
+ .icon(icon)
+ .icon_size(IconSize::Small)
+ .icon_color(icon_color)
+ .icon_position(IconPosition::Start)
+ .label_size(LabelSize::Small)
+ .truncate(true)
+ .color(Color::Muted)
+ .tooltip({
+ let full_sha = full_sha.clone();
+ move |_, cx| {
+ Tooltip::with_meta(
+ tooltip_label,
+ None,
+ full_sha.clone(),
+ cx,
+ )
+ }
+ })
+ .on_click(move |_, _, cx| {
+ copied_state.update(cx, |state, _cx| {
+ state.mark_copied();
+ });
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ copy_sha.to_string(),
+ ));
+ let state_id = copied_state.entity_id();
+ cx.spawn(async move |cx| {
+ cx.background_executor()
+ .timer(COPIED_STATE_DURATION)
+ .await;
+ cx.update(|cx| {
+ cx.notify(state_id);
+ })
+ })
+ .detach();
+ })
+ })
.when_some(remote.clone(), |this, remote| {
let provider_name = remote.host.name();
let icon = match provider_name.as_str() {
@@ -1404,84 +1480,90 @@ impl GitGraph {
.host
.build_commit_permalink(&parsed_remote, params)
.to_string();
+
this.child(
- h_flex()
- .gap_1()
- .child(
- Icon::new(icon)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .child(
- Button::new(
- "view-on-provider",
- format!("View on {}", provider_name),
- )
- .style(ButtonStyle::Transparent)
- .label_size(LabelSize::Small)
- .color(Color::Muted)
- .on_click(
- move |_, _, cx| {
- cx.open_url(&url);
- },
- ),
- ),
+ Button::new(
+ "view-on-provider",
+ format!("View on {}", provider_name),
+ )
+ .icon(icon)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .icon_position(IconPosition::Start)
+ .label_size(LabelSize::Small)
+ .truncate(true)
+ .color(Color::Muted)
+ .on_click(
+ move |_, _, cx| {
+ cx.open_url(&url);
+ },
+ ),
)
}),
- )
- .child(
- 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(Divider::horizontal())
+ .child(div().min_w_0().p_2().child(Label::new(subject)))
+ .child(Divider::horizontal())
.child(
v_flex()
+ .min_w_0()
+ .p_2()
.flex_1()
- .min_h_0()
- .border_t_1()
- .border_color(cx.theme().colors().border)
+ .gap_1()
.child(
- div().px_3().pt_3().pb_1().child(
- Label::new(format!("{} Changed Files", changed_files_count))
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
+ h_flex()
+ .gap_1()
+ .child(
+ Label::new(format!("{} Changed Files", changed_files_count))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(DiffStat::new(
+ "commit-diff-stat",
+ total_lines_added,
+ total_lines_removed,
+ )),
)
- .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,
- )
- })
- .collect()
- },
- )
- .flex_1()
- }),
+ .child(
+ div()
+ .id("changed-files-container")
+ .flex_1()
+ .min_h_0()
+ .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,
+ )
+ })
+ .collect()
+ },
+ )
+ .size_full()
+ .ml_neg_1()
+ .track_scroll(&self.changed_files_scroll_handle)
+ })
+ .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
+ ),
)
.into_any_element()
}
- pub fn render_graph(&self, cx: &mut Context) -> impl IntoElement {
+ pub fn render_graph(&self, window: &Window, cx: &mut Context) -> impl IntoElement {
let row_height = self.row_height;
let table_state = self.table_interaction_state.read(cx);
let viewport_height = table_state
@@ -1522,12 +1604,48 @@ impl GitGraph {
let mut lines: BTreeMap> = BTreeMap::new();
+ let hovered_entry_idx = self.hovered_entry_idx;
+ let selected_entry_idx = self.selected_entry_idx;
+ let is_focused = self.focus_handle.is_focused(window);
+ let graph_canvas_bounds = self.graph_canvas_bounds.clone();
+
gpui::canvas(
move |_bounds, _window, _cx| {},
move |bounds: Bounds, _: (), window: &mut Window, cx: &mut App| {
+ graph_canvas_bounds.set(Some(bounds));
+
window.paint_layer(bounds, |window| {
let accent_colors = cx.theme().accents();
+ let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
+ let selected_bg = if is_focused {
+ cx.theme().colors().element_selected
+ } else {
+ cx.theme().colors().element_hover
+ };
+
+ for visible_row_idx in 0..rows.len() {
+ let absolute_row_idx = first_visible_row + visible_row_idx;
+ let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
+ let is_selected = selected_entry_idx == Some(absolute_row_idx);
+
+ if is_hovered || is_selected {
+ let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
+ - vertical_scroll_offset;
+
+ let row_bounds = Bounds::new(
+ point(bounds.origin.x, row_y),
+ gpui::Size {
+ width: bounds.size.width,
+ height: row_height,
+ },
+ );
+
+ let bg_color = if is_selected { selected_bg } else { hover_bg };
+ window.paint_quad(gpui::fill(row_bounds, bg_color));
+ }
+ }
+
for (row_idx, row) in rows.into_iter().enumerate() {
let row_color = accent_colors.color_for_index(row.color_idx as u32);
let row_y_center =
@@ -1690,6 +1808,57 @@ impl GitGraph {
.h_full()
}
+ fn row_at_position(&self, position_y: Pixels, cx: &Context) -> Option {
+ let canvas_bounds = self.graph_canvas_bounds.get()?;
+ let table_state = self.table_interaction_state.read(cx);
+ let scroll_offset_y = -table_state.scroll_offset().y;
+
+ let local_y = position_y - canvas_bounds.origin.y;
+
+ if local_y >= px(0.) && local_y < canvas_bounds.size.height {
+ let row_in_viewport = (local_y / self.row_height).floor() as usize;
+ let scroll_rows = (scroll_offset_y / self.row_height).floor() as usize;
+ let absolute_row = scroll_rows + row_in_viewport;
+
+ if absolute_row < self.graph_data.commits.len() {
+ return Some(absolute_row);
+ }
+ }
+
+ None
+ }
+
+ fn handle_graph_mouse_move(
+ &mut self,
+ event: &gpui::MouseMoveEvent,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if let Some(row) = self.row_at_position(event.position.y, cx) {
+ if self.hovered_entry_idx != Some(row) {
+ self.hovered_entry_idx = Some(row);
+ cx.notify();
+ }
+ } else if self.hovered_entry_idx.is_some() {
+ self.hovered_entry_idx = None;
+ cx.notify();
+ }
+ }
+
+ fn handle_graph_click(
+ &mut self,
+ event: &ClickEvent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if let Some(row) = self.row_at_position(event.position().y, cx) {
+ self.select_entry(row, cx);
+ if event.click_count() >= 2 {
+ self.open_commit_view(row, window, cx);
+ }
+ }
+ }
+
fn handle_graph_scroll(
&mut self,
event: &ScrollWheelEvent,
@@ -1846,18 +2015,29 @@ impl Render for GitGraph {
.id("graph-canvas")
.flex_1()
.overflow_hidden()
- .child(self.render_graph(cx))
- .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)),
+ .child(self.render_graph(window, cx))
+ .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
+ .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
+ .on_click(cx.listener(Self::handle_graph_click))
+ .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
+ if !is_hovered && this.hovered_entry_idx.is_some() {
+ this.hovered_entry_idx = None;
+ cx.notify();
+ }
+ })),
),
)
.child({
let row_height = self.row_height;
let selected_entry_idx = self.selected_entry_idx;
+ let hovered_entry_idx = self.hovered_entry_idx;
let weak_self = cx.weak_entity();
+ let focus_handle = self.focus_handle.clone();
div().flex_1().size_full().child(
Table::new(4)
.interactable(&self.table_interaction_state)
.hide_row_borders()
+ .hide_row_hover()
.header(vec![
Label::new("Description")
.color(Color::Muted)
@@ -1885,12 +2065,38 @@ impl Render for GitGraph {
&self.table_column_widths,
cx,
)
- .map_row(move |(index, row), _window, cx| {
+ .map_row(move |(index, row), window, cx| {
let is_selected = selected_entry_idx == Some(index);
+ let is_hovered = hovered_entry_idx == Some(index);
+ let is_focused = focus_handle.is_focused(window);
let weak = weak_self.clone();
+ let weak_for_hover = weak.clone();
+
+ let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
+ let selected_bg = if is_focused {
+ cx.theme().colors().element_selected
+ } else {
+ cx.theme().colors().element_hover
+ };
+
row.h(row_height)
- .when(is_selected, |row| {
- row.bg(cx.theme().colors().element_selected)
+ .when(is_selected, |row| row.bg(selected_bg))
+ .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
+ .on_hover(move |&is_hovered, _, cx| {
+ weak_for_hover
+ .update(cx, |this, cx| {
+ if is_hovered {
+ if this.hovered_entry_idx != Some(index) {
+ this.hovered_entry_idx = Some(index);
+ cx.notify();
+ }
+ } else if this.hovered_entry_idx == Some(index) {
+ // Only clear if this row was the hovered one
+ this.hovered_entry_idx = None;
+ cx.notify();
+ }
+ })
+ .ok();
})
.on_click(move |event, window, cx| {
let click_count = event.click_count();
diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs
index 114a185542f719b4779a7dccded34819b82701ef..21e7d8a5d1f8e3f5c5b124fe8b276028df91b752 100644
--- a/crates/git_ui/src/commit_tooltip.rs
+++ b/crates/git_ui/src/commit_tooltip.rs
@@ -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,
remote: Option<&'a GitRemote>,
- size: Option,
+ size: Option,
}
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) -> 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(),
}
}
diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs
index 0c3f2bc175989d32b83fc292b815b57af960d4da..f5ed23a6a84e7649ddf7f1e7b6b3651a323ee3c6 100644
--- a/crates/git_ui/src/commit_view.rs
+++ b/crates/git_ui/src/commit_view.rs
@@ -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) {
diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs
index 0925828447b5d1baeb4cee60b7b14e4b62dcd56b..ffd600c32af5be8fe9f390b93b6f96911bfecb07 100644
--- a/crates/git_ui/src/file_history_view.rs
+++ b/crates/git_ui/src/file_history_view.rs
@@ -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,
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::(&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,
- remote: GitRemote,
-}
-
-impl std::hash::Hash for CommitAvatarAsset {
- fn hash(&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) -> Self {
- Self {
- remote,
- sha,
- author_email,
- }
- }
-}
-
-impl Asset for CommitAvatarAsset {
- type Source = Self;
- type Output = Option;
-
- fn load(
- source: Self::Source,
- cx: &mut App,
- ) -> impl Future |