From 13eb0f68327177a859131e375cfadd056321dc69 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:45:14 -0300 Subject: [PATCH] git_ui: Improve connection between the graph and commit views (#50027) - Enabled opening the Git Graph, with the corresponding commit detail drawer open, from the commit view - Redesigned the commit view's header and toolbar to allow addition of the Git Graph icon button - Redesigned icons for the Git Graph and commit view https://github.com/user-attachments/assets/8efef60a-0893-4752-9b40-838da21ceb54 --- 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 (_Git Graph is still feature flagged, so no release notes for now_) --- Cargo.lock | 1 + assets/icons/git_commit.svg | 5 + assets/icons/git_graph.svg | 7 +- crates/feature_flags/src/flags.rs | 6 + crates/git_graph/src/git_graph.rs | 149 ++++++++++++---- crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/commit_view.rs | 273 ++++++++++++++++-------------- crates/git_ui/src/git_panel.rs | 7 + crates/icons/src/icons.rs | 1 + 9 files changed, 290 insertions(+), 160 deletions(-) create mode 100644 assets/icons/git_commit.svg diff --git a/Cargo.lock b/Cargo.lock index ef6fd4e2c22cf53a5aa145600435983beae86437..dae0fef9c224c0dda72996dc2c58dc75768569fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7217,6 +7217,7 @@ dependencies = [ "ctor", "db", "editor", + "feature_flags", "futures 0.3.31", "fuzzy", "git", diff --git a/assets/icons/git_commit.svg b/assets/icons/git_commit.svg new file mode 100644 index 0000000000000000000000000000000000000000..38b36ec7efb72275e5e6efbbe761deb54050cfe7 --- /dev/null +++ b/assets/icons/git_commit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/git_graph.svg b/assets/icons/git_graph.svg index 8f372a305d3fddf2901756108c83d09b31fb657e..7ae33e365d40bfccd9c48e4f7e94b10d3687f8dc 100644 --- a/assets/icons/git_graph.svg +++ b/assets/icons/git_graph.svg @@ -1,4 +1,7 @@ - - + + + + + diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 8f96de0e7b6d9b385fcda533a31ecc34b5afdbcc..087e76c4129254d3b6f488259bc8fa19aa91370d 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -57,6 +57,12 @@ impl FeatureFlag for DiffReviewFeatureFlag { } } +pub struct GitGraphFeatureFlag; + +impl FeatureFlag for GitGraphFeatureFlag { + const NAME: &'static str = "git-graph"; +} + pub struct StreamingEditFileToolFeatureFlag; impl FeatureFlag for StreamingEditFileToolFeatureFlag { diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 37f170ada5ecd23daf5ee58ee1011af95bfc6b8d..3bdb2b0d717ca4cae181fee9dd690755e29075d0 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,5 +1,5 @@ use collections::{BTreeMap, HashMap}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, @@ -39,7 +39,7 @@ use ui::{ }; use workspace::{ Workspace, - item::{Item, ItemEvent, SerializableItem}, + item::{Item, ItemEvent, SerializableItem, TabTooltipContent}, }; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); @@ -48,6 +48,7 @@ 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 PENDING_SELECT_MAX_RETRIES: usize = 5; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); struct CopiedState { @@ -246,12 +247,6 @@ actions!( ] ); -pub struct GitGraphFeatureFlag; - -impl FeatureFlag for GitGraphFeatureFlag { - const NAME: &'static str = "git-graph"; -} - fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { static FORMAT: OnceLock>> = OnceLock::new(); FORMAT.get_or_init(|| { @@ -710,29 +705,66 @@ pub fn init(cx: &mut App) { |div| { let workspace = workspace.weak_handle(); - div.on_action(move |_: &git_ui::git_panel::Open, window, cx| { - workspace - .update(cx, |workspace, cx| { - let existing = workspace.items_of_type::(cx).next(); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - return; - } + div.on_action({ + let workspace = workspace.clone(); + move |_: &git_ui::git_panel::Open, window, cx| { + workspace + .update(cx, |workspace, cx| { + let existing = workspace.items_of_type::(cx).next(); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } - let project = workspace.project().clone(); - let workspace_handle = workspace.weak_handle(); - let git_graph = cx - .new(|cx| GitGraph::new(project, workspace_handle, window, cx)); - workspace.add_item_to_active_pane( - Box::new(git_graph), - None, - true, - window, - cx, - ); - }) - .ok(); + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + let git_graph = cx.new(|cx| { + GitGraph::new(project, workspace_handle, window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(git_graph), + None, + true, + window, + cx, + ); + }) + .ok(); + } }) + .on_action( + move |action: &git_ui::git_panel::OpenAtCommit, window, cx| { + let sha = action.sha.clone(); + workspace + .update(cx, |workspace, cx| { + let existing = workspace.items_of_type::(cx).next(); + if let Some(existing) = existing { + existing.update(cx, |graph, cx| { + graph.select_commit_by_sha(&sha, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); + return; + } + + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + let git_graph = cx.new(|cx| { + let mut graph = + GitGraph::new(project, workspace_handle, window, cx); + graph.select_commit_by_sha(&sha, cx); + graph + }); + workspace.add_item_to_active_pane( + Box::new(git_graph), + None, + true, + window, + cx, + ); + }) + .ok(); + }, + ) }, ) }); @@ -821,6 +853,7 @@ pub struct GitGraph { commit_details_split_state: Entity, selected_repo_id: Option, changed_files_scroll_handle: UniformListScrollHandle, + pending_select_sha: Option<(String, usize)>, } impl GitGraph { @@ -918,6 +951,7 @@ impl GitGraph { commit_details_split_state: cx.new(|_cx| SplitState::new()), selected_repo_id: active_repository, changed_files_scroll_handle: UniformListScrollHandle::new(), + pending_select_sha: None, }; this.fetch_initial_graph_data(cx); @@ -944,8 +978,10 @@ impl GitGraph { self.graph_data.add_commits(commits); }); cx.notify(); + self.retry_pending_select(cx); } RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => { + self.pending_select_sha = None; // Only invalidate if we scanned atleast once, // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression @@ -1153,6 +1189,37 @@ impl GitGraph { cx.notify(); } + pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { + let Ok(oid) = sha.parse::() else { + return; + }; + for (idx, commit) in self.graph_data.commits.iter().enumerate() { + if commit.data.sha == oid { + self.pending_select_sha = None; + self.select_entry(idx, cx); + return; + } + } + self.pending_select_sha = Some((sha.to_string(), PENDING_SELECT_MAX_RETRIES)); + } + + fn retry_pending_select(&mut self, cx: &mut Context) { + let Some((sha, retries_remaining)) = self.pending_select_sha.take() else { + return; + }; + if let Ok(oid) = sha.parse::() { + for (idx, commit) in self.graph_data.commits.iter().enumerate() { + if commit.data.sha == oid { + self.select_entry(idx, cx); + return; + } + } + } + if retries_remaining > 0 { + self.pending_select_sha = Some((sha, retries_remaining - 1)); + } + } + fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { let Some(selected_entry_index) = self.selected_entry_idx else { return; @@ -2179,6 +2246,30 @@ impl Focusable for GitGraph { impl Item for GitGraph { type Event = ItemEvent; + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitGraph)) + } + + fn tab_tooltip_content(&self, cx: &App) -> Option { + let repo_name = self.get_selected_repository(cx).and_then(|repo| { + repo.read(cx) + .work_directory_abs_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }); + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + move |_, _| { + v_flex() + .child(Label::new("Git Graph")) + .when_some(repo_name.clone(), |this, name| { + this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small)) + }) + .into_any_element() + } + })))) + } + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { "Git Graph".into() } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index f779570be471fd1a097e350d59ef2fb1d4003d2b..28fac0f849a487c6654e2ac5976191cd3e1a733f 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,6 +27,7 @@ component.workspace = true db.workspace = true editor.workspace = true futures.workspace = true +feature_flags.workspace = true fuzzy.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index f5ed23a6a84e7649ddf7f1e7b6b3651a323ee3c6..8f2a019fddf0513c100a53956c81012d11c2ca30 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -3,6 +3,7 @@ use buffer_diff::BufferDiff; use collections::HashMap; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; +use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git::{ @@ -27,7 +28,7 @@ use std::{ sync::Arc, }; use theme::ActiveTheme; -use ui::{ButtonLike, DiffStat, Tooltip, prelude::*}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::item::TabTooltipContent; use workspace::{ @@ -450,6 +451,7 @@ impl CommitView { fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let commit = &self.commit; let author_name = commit.author_name.clone(); + let author_email = commit.author_email.clone(); let commit_sha = commit.sha.clone(); let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); @@ -461,36 +463,6 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); - let remote_info = self - .remote - .as_ref() - .filter(|_| self.stash.is_none()) - .map(|remote| { - let provider = remote.host.name(); - let parsed_remote = ParsedGitRemote { - owner: remote.owner.as_ref().into(), - repo: remote.repo.as_ref().into(), - }; - let params = BuildCommitPermalinkParams { sha: &commit.sha }; - let url = remote - .host - .build_commit_permalink(&parsed_remote, params) - .to_string(); - (provider, url) - }); - - let (additions, deletions) = self.calculate_changed_lines(cx); - - let commit_diff_stat = if additions > 0 || deletions > 0 { - Some(DiffStat::new( - "commit-diff-stat", - additions as usize, - deletions as usize, - )) - } else { - None - }; - let gutter_width = self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let style = editor.style(cx); @@ -501,116 +473,75 @@ impl CommitView { .full_width() }); - let clipboard_has_link = cx + let clipboard_has_sha = cx .read_from_clipboard() .and_then(|entry| entry.text()) .map_or(false, |clipboard_text| { clipboard_text.trim() == commit_sha.as_ref() }); - let (copy_icon, copy_icon_color) = if clipboard_has_link { + let (copy_icon, copy_icon_color) = if clipboard_has_sha { (IconName::Check, Color::Success) } else { (IconName::Copy, Color::Muted) }; h_flex() + .py_2() + .pr_2p5() + .w_full() + .justify_between() .border_b_1() .border_color(cx.theme().colors().border_variant) - .w_full() - .child( - h_flex() - .w(gutter_width) - .justify_center() - .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), - ) .child( h_flex() - .py_4() - .pl_1() - .pr_4() - .w_full() - .items_start() - .justify_between() - .flex_wrap() + .child(h_flex().w(gutter_width).justify_center().child( + self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx), + )) .child( - v_flex() - .child( - h_flex() - .gap_1() - .child(Label::new(author_name).color(Color::Default)) - .child({ - ButtonLike::new("sha") - .child( - h_flex() - .group("sha_btn") - .size_full() - .max_w_32() - .gap_0p5() - .child( - Label::new(commit_sha.clone()) - .color(Color::Muted) - .size(LabelSize::Small) - .truncate() - .buffer_font(cx), - ) - .child( - div().visible_on_hover("sha_btn").child( - Icon::new(copy_icon) - .color(copy_icon_color) - .size(IconSize::Small), - ), - ), - ) - .tooltip({ - let commit_sha = commit_sha.clone(); - move |_, cx| { - Tooltip::with_meta( - "Copy Commit SHA", - None, - commit_sha.clone(), - cx, - ) - } - }) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string( - commit_sha.to_string(), - )); - }) - }), - ) - .child( - h_flex() - .gap_1p5() - .child( - Label::new(date_string) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - Label::new("•") - .color(Color::Ignored) - .size(LabelSize::Small), - ) - .children(commit_diff_stat), - ), - ) - .children(remote_info.map(|(provider_name, url)| { - let icon = match provider_name.as_str() { - "GitHub" => IconName::Github, - _ => IconName::Link, - }; - - Button::new("view_on_provider", format!("View on {}", provider_name)) - .icon(icon) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(move |_, _, cx| cx.open_url(&url)) - })), + v_flex().child(Label::new(author_name)).child( + h_flex() + .gap_1p5() + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + .child( + Label::new(author_email) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ), ) + .when(self.stash.is_none(), |this| { + this.child( + Button::new("sha", "Commit SHA") + .icon(copy_icon) + .icon_color(copy_icon_color) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .tooltip({ + let commit_sha = commit_sha.clone(); + move |_, cx| { + Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx) + } + }) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string( + commit_sha.to_string(), + )); + }), + ) + }) } fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { @@ -898,7 +829,7 @@ impl Item for CommitView { type Event = EditorEvent; fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + Some(Icon::new(IconName::GitCommit).color(Color::Muted)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1081,8 +1012,93 @@ impl CommitViewToolbar { impl EventEmitter for CommitViewToolbar {} impl Render for CommitViewToolbar { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().hidden() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else { + return div(); + }; + + let commit_view_ref = commit_view.read(cx); + let is_stash = commit_view_ref.stash.is_some(); + + let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx); + + let commit_sha = commit_view_ref.commit.sha.clone(); + + let remote_info = commit_view_ref.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), + repo: remote.repo.as_ref().into(), + }; + let params = BuildCommitPermalinkParams { sha: &commit_sha }; + let url = remote + .host + .build_commit_permalink(&parsed_remote, params) + .to_string(); + (provider, url) + }); + + let sha_for_graph = commit_sha.to_string(); + + h_flex() + .gap_1() + .when(additions > 0 || deletions > 0, |this| { + this.child( + h_flex() + .gap_2() + .child(DiffStat::new( + "toolbar-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical()), + ) + }) + .child( + IconButton::new("buffer-search", IconName::MagnifyingGlass) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action( + "Buffer Search", + &zed_actions::buffer_search::Deploy::find(), + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::buffer_search::Deploy::find()), + cx, + ); + }), + ) + .when(!is_stash, |this| { + this.when(cx.has_flag::(), |this| { + this.child( + IconButton::new("show-in-git-graph", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Show in Git Graph")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::git_panel::OpenAtCommit { + sha: sha_for_graph.clone(), + }), + cx, + ); + }), + ) + }) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + IconButton::new("view_on_provider", icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(format!("View on {}", provider_name))) + .on_click(move |_, _, cx| cx.open_url(&url)) + })) + }) } } @@ -1093,12 +1109,11 @@ impl ToolbarItemView for CommitViewToolbar { _: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { - if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) - && entity.read(cx).stash.is_some() - { + if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) { self.commit_view = Some(entity.downgrade()); return ToolbarItemLocation::PrimaryRight; } + self.commit_view = None; ToolbarItemLocation::Hidden } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index fe7d8975010ecf1055bb45e6986ecca363314e2e..b86fa0196ae786db7a981427628295c4f9d81061 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -123,6 +123,13 @@ actions!( ] ); +/// Opens the Git Graph Tab at a specific commit. +#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] +#[action(namespace = git_graph)] +pub struct OpenAtCommit { + pub sha: String, +} + fn prompt( msg: &str, detail: Option<&str>, diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 9ed9a8b658cc8bbf89c9d14d131fc8faefbc80ed..d6356f831ea9bbbaec5313da1a5b56f101471411 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -142,6 +142,7 @@ pub enum IconName { GitBranch, GitBranchAlt, GitBranchPlus, + GitCommit, GitGraph, Github, Hash,