git_graph: Add some design adjustments (#49899)

Danilo Leal and Anthony Eid created

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

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

--- 

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

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

Cargo.lock                                |   1 
crates/git_graph/Cargo.toml               |   1 
crates/git_graph/src/git_graph.rs         | 747 +++++++++++++++---------
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, 543 insertions(+), 404 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -7156,6 +7156,7 @@ dependencies = [
  "git",
  "git_ui",
  "gpui",
+ "language",
  "menu",
  "project",
  "rand 0.9.2",

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

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<Instant>,
+}
+
+impl CopiedState {
+    fn new(_window: &mut Window, _cx: &mut Context<Self>) -> 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<Repository>,
         workspace: WeakEntity<Workspace>,
-        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<usize>,
+    hovered_entry_idx: Option<usize>,
+    graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     log_source: LogSource,
     log_order: LogOrder,
     selected_commit_diff: Option<CommitDiff>,
+    selected_commit_diff_stats: Option<(usize, usize)>,
     _commit_diff_task: Option<Task<()>>,
     commit_details_split_state: Entity<SplitState>,
     selected_repo_id: Option<RepositoryId>,
+    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>) {
         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<Vec<ChangedFileEntry>> = 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<CopiedState> = 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<CopiedState> =
+                                    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<GitGraph>) -> impl IntoElement {
+    pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> 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<usize, Vec<_>> = 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<Pixels>, _: (), 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<Self>) -> Option<usize> {
+        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<Self>,
+    ) {
+        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<Self>,
+    ) {
+        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,

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<SharedString>,
     remote: Option<&'a GitRemote>,
-    size: Option<IconSize>,
+    size: Option<AbsoluteLength>,
 }
 
 impl<'a> CommitAvatar<'a> {
@@ -59,21 +59,38 @@ impl<'a> CommitAvatar<'a> {
         }
     }
 
-    pub fn size(mut self, size: IconSize) -> Self {
-        self.size = Some(size);
+    pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
+        self.size = Some(size.into());
         self
     }
 
     pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement {
+        let border_color = cx.theme().colors().border_variant;
+        let border_width = px(1.);
+
         match self.avatar(window, cx) {
-            // Loading or no avatar found
-            None => Icon::new(IconName::Person)
-                .color(Color::Muted)
-                .when_some(self.size, |this, size| this.size(size))
-                .into_any_element(),
-            // Found
+            None => {
+                let container_size = self
+                    .size
+                    .map(|s| s.to_pixels(window.rem_size()) + border_width * 2.);
+
+                h_flex()
+                    .when_some(container_size, |this, size| this.size(size))
+                    .justify_center()
+                    .rounded_full()
+                    .border(border_width)
+                    .border_color(border_color)
+                    .bg(cx.theme().colors().element_disabled)
+                    .child(
+                        Icon::new(IconName::Person)
+                            .color(Color::Muted)
+                            .size(IconSize::Small),
+                    )
+                    .into_any_element()
+            }
             Some(avatar) => avatar
-                .when_some(self.size, |this, size| this.size(size.rems()))
+                .when_some(self.size, |this, size| this.size(size))
+                .border_color(border_color)
                 .into_any_element(),
         }
     }

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) {

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<SharedString>,
         window: &mut Window,
         cx: &mut App,
-    ) -> impl IntoElement {
-        let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
-        let size = rems_from_px(20.);
-
-        if let Some(remote) = remote {
-            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone(), author_email);
-            if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
-                Avatar::new(url.to_string()).size(size)
-            } else {
-                Avatar::new("").size(size)
-            }
-        } else {
-            Avatar::new("").size(size)
-        }
+    ) -> AnyElement {
+        CommitAvatar::new(sha, author_email, self.remote.as_ref())
+            .size(rems_from_px(20.))
+            .render(window, cx)
     }
 
     fn render_commit_entry(
@@ -388,60 +378,6 @@ impl FileHistoryView {
     }
 }
 
-#[derive(Clone, Debug)]
-struct CommitAvatarAsset {
-    sha: SharedString,
-    author_email: Option<SharedString>,
-    remote: GitRemote,
-}
-
-impl std::hash::Hash for CommitAvatarAsset {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.sha.hash(state);
-        self.remote.host.name().hash(state);
-    }
-}
-
-impl CommitAvatarAsset {
-    fn new(remote: GitRemote, sha: SharedString, author_email: Option<SharedString>) -> Self {
-        Self {
-            remote,
-            sha,
-            author_email,
-        }
-    }
-}
-
-impl Asset for CommitAvatarAsset {
-    type Source = Self;
-    type Output = Option<SharedString>;
-
-    fn load(
-        source: Self::Source,
-        cx: &mut App,
-    ) -> impl Future<Output = Self::Output> + Send + 'static {
-        let client = cx.http_client();
-        async move {
-            match source
-                .remote
-                .host
-                .commit_author_avatar_url(
-                    &source.remote.owner,
-                    &source.remote.repo,
-                    source.sha.clone(),
-                    source.author_email.clone(),
-                    client,
-                )
-                .await
-            {
-                Ok(Some(url)) => Some(SharedString::from(url.to_string())),
-                Ok(None) => None,
-                Err(_) => None,
-            }
-        }
-    }
-}
-
 impl EventEmitter<ItemEvent> for FileHistoryView {}
 
 impl Focusable for FileHistoryView {

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.)
         };

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(),

crates/ui/src/components/chip.rs πŸ”—

@@ -16,6 +16,8 @@ pub struct Chip {
     label_color: Color,
     label_size: LabelSize,
     bg_color: Option<Hsla>,
+    border_color: Option<Hsla>,
+    height: Option<Pixels>,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 }
 
@@ -27,6 +29,8 @@ impl Chip {
             label_color: Color::Default,
             label_size: LabelSize::XSmall,
             bg_color: None,
+            border_color: None,
+            height: None,
             tooltip: None,
         }
     }
@@ -49,6 +53,18 @@ impl Chip {
         self
     }
 
+    /// Sets a custom border color for the chip.
+    pub fn border_color(mut self, color: Hsla) -> Self {
+        self.border_color = Some(color);
+        self
+    }
+
+    /// Sets a custom height for the chip.
+    pub fn height(mut self, height: Pixels) -> Self {
+        self.height = Some(height);
+        self
+    }
+
     pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
         self.tooltip = Some(Box::new(tooltip));
         self
@@ -61,20 +77,24 @@ impl RenderOnce for Chip {
             .bg_color
             .unwrap_or(cx.theme().colors().element_background);
 
+        let border_color = self.border_color.unwrap_or(cx.theme().colors().border);
+
         h_flex()
+            .when_some(self.height, |this, h| this.h(h))
             .min_w_0()
             .flex_initial()
             .px_1()
             .border_1()
             .rounded_sm()
-            .border_color(cx.theme().colors().border)
+            .border_color(border_color)
             .bg(bg_color)
             .overflow_hidden()
             .child(
                 Label::new(self.label.clone())
                     .size(self.label_size)
                     .color(self.label_color)
-                    .buffer_font(cx),
+                    .buffer_font(cx)
+                    .truncate(),
             )
             .id(self.label.clone())
             .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))

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<Length>,
     headers: Option<TableRow<AnyElement>>,
     rows: TableContents,
@@ -743,6 +744,7 @@ impl Table {
             cols,
             striped: false,
             show_row_borders: true,
+            show_row_hover: true,
             width: None,
             headers: None,
             rows: TableContents::Vec(Vec::new()),
@@ -895,6 +897,13 @@ impl Table {
         self
     }
 
+    /// Hides the default hover background on table rows.
+    /// Use this when you want to handle row hover styling manually via `map_row`.
+    pub fn hide_row_hover(mut self) -> Self {
+        self.show_row_hover = false;
+        self
+    }
+
     /// Provide a callback that is invoked when the table is rendered without any rows
     pub fn empty_table_callback(
         mut self,
@@ -948,7 +957,9 @@ pub fn render_table_row(
         .id(("table_row", row_index))
         .size_full()
         .when_some(bg, |row, bg| row.bg(bg))
-        .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
+        .when(table_context.show_row_hover, |row| {
+            row.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
+        })
         .when(!is_striped && table_context.show_row_borders, |row| {
             row.border_b_1()
                 .border_color(transparent_black())
@@ -1055,6 +1066,7 @@ pub fn render_table_header(
 pub struct TableRenderContext {
     pub striped: bool,
     pub show_row_borders: bool,
+    pub show_row_hover: bool,
     pub total_row_count: usize,
     pub column_widths: Option<TableRow<Length>>,
     pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
@@ -1066,6 +1078,7 @@ impl TableRenderContext {
         Self {
             striped: table.striped,
             show_row_borders: table.show_row_borders,
+            show_row_hover: table.show_row_hover,
             total_row_count: table.rows.len(),
             column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
             map_row: table.map_row.clone(),