git_ui: Improve connection between the graph and commit views (#50027)

Danilo Leal created

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

Change summary

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

Detailed changes

Cargo.lock 🔗

@@ -7217,6 +7217,7 @@ dependencies = [
  "ctor",
  "db",
  "editor",
+ "feature_flags",
  "futures 0.3.31",
  "fuzzy",
  "git",

assets/icons/git_commit.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 2V6" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 10V14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/git_graph.svg 🔗

@@ -1,4 +1,7 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 3V11.8889C3 12.1836 3.11706 12.4662 3.32544 12.6746C3.53381 12.8829 3.81643 13 4.11111 13H13" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.8889 6.33333L9.11112 9.11111L6.8889 6.88888L5.22223 8.55555" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.19617 6.09808L4.19617 13.7058" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.8159 11.8038L11.8159 13.7058" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.816 11.8038C12.8664 11.8038 13.7179 10.9523 13.7179 9.90192C13.7179 8.85152 12.8664 8 11.816 8C10.7656 8 9.91403 8.85152 9.91403 9.90192C9.91403 10.9523 10.7656 11.8038 11.816 11.8038Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.19617 6.09808C5.24657 6.09808 6.09809 5.24656 6.09809 4.19616C6.09809 3.14575 5.24657 2.29424 4.19617 2.29424C3.14577 2.29424 2.29425 3.14575 2.29425 4.19616C2.29425 5.24656 3.14577 6.09808 4.19617 6.09808Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.0842 9.90192H7.30429C5.58772 9.90192 4.19617 8.51036 4.19617 6.79379V5.58465" stroke="#C6CAD0" stroke-width="1.2"/>
 </svg>

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 {

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<Vec<BorrowedFormatItem<'static>>> = 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::<GitGraph>(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::<GitGraph>(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::<GitGraph>(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<SplitState>,
     selected_repo_id: Option<RepositoryId>,
     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<Self>) {
+        let Ok(oid) = sha.parse::<Oid>() 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<Self>) {
+        let Some((sha, retries_remaining)) = self.pending_select_sha.take() else {
+            return;
+        };
+        if let Ok(oid) = sha.parse::<Oid>() {
+            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<Self>) {
         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<Icon> {
+        Some(Icon::new(IconName::GitGraph))
+    }
+
+    fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
+        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()
     }

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

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<Self>) -> 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<Icon> {
-        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<ToolbarItemEvent> for CommitViewToolbar {}
 
 impl Render for CommitViewToolbar {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        div().hidden()
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<GitGraphFeatureFlag>(), |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<Self>,
     ) -> ToolbarItemLocation {
-        if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx))
-            && entity.read(cx).stash.is_some()
-        {
+        if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx)) {
             self.commit_view = Some(entity.downgrade());
             return ToolbarItemLocation::PrimaryRight;
         }
+        self.commit_view = None;
         ToolbarItemLocation::Hidden
     }
 

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<T>(
     msg: &str,
     detail: Option<&str>,

crates/icons/src/icons.rs 🔗

@@ -142,6 +142,7 @@ pub enum IconName {
     GitBranch,
     GitBranchAlt,
     GitBranchPlus,
+    GitCommit,
     GitGraph,
     Github,
     Hash,