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 Screenshot 2026-02-23 at 11  31@2x --- 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 + 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 for FileHistoryView {} impl Focusable for FileHistoryView { diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 7b2ba8ce5cbfee2589695c1a9d0dcd61a266b093..6ae969246f899e307d72ad8cd4b88599b1a6a905 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -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.) }; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 83b50b6341edceb881876af0058a54b14c98c11a..2ac3b9ca13123a0d9330d71e8b73d034d65faf89 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -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(), diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index f9e52c877c442a39d068b2ce8fcc4c8dcb63c3dc..ce709fe3962f742f5208808315f3bdac09c1f513 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -16,6 +16,8 @@ pub struct Chip { label_color: Color, label_size: LabelSize, bg_color: Option, + border_color: Option, + height: Option, tooltip: Option 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)) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index e09e3184079d0f1f37c520b830faab08bb27bd1d..8a40c246ca44ea9dbb25e61bb611882343ba7f94 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -724,6 +724,7 @@ impl TableWidths { pub struct Table { striped: bool, show_row_borders: bool, + show_row_hover: bool, width: Option, headers: Option>, 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>, pub map_row: Option), &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(),