git: Add diff view for stash entries (#38280)

Alvaro Parker created

Continues the work from #35927 to add a git diff view for stash entries.

[Screencast From 2025-09-17
19-46-01.webm](https://github.com/user-attachments/assets/ded33782-adef-4696-8e34-3665911c09c7)

Stash entries are [represented as
commits](https://git-scm.com/docs/git-stash#_discussion) except they
have up to 3 parents:

```
       .----W (this is the stash entry)
      /    /|
-----H----I |
           \|
            U
```

Where `H` is the `HEAD` commit, `I` is a commit that records the state
of the index, and `U` is another commit that records untracked files
(when using `git stash -u`).

Given this, I modified the existing commit view struct to allow loading
stash and commits entries with git sha identifier so that we can get a
similar git diff view for both of them.

The stash diff is generated by comparing the stash commit with its
parent (`<commit>^` or `H` in the diagram) which generates the same diff
as doing `git stash show -p <stash entry>`. This *can* be
counter-intuitive since a user may expect the comparison to be made
between the stash commit and the current commit (`HEAD`), but given that
the default behavior in git cli is to compare with the stash parent, I
went for that approach.

Hoping to get some feedback from a Zed team member to see if they agree
with this approach.

Release Notes:

- Add git diff view for stash entries
- Add toolbar on git diff view for stash entries
- Prompt before executing a destructive stash action on diff view
- Fix commit view for merge commits  (see #38289)

Change summary

assets/keymaps/default-linux.json   |  11 
assets/keymaps/default-macos.json   |  12 
assets/keymaps/default-windows.json |  12 
crates/git/src/repository.rs        |   8 
crates/git_ui/src/blame_ui.rs       |  31 --
crates/git_ui/src/commit_tooltip.rs |   3 
crates/git_ui/src/commit_view.rs    | 354 +++++++++++++++++++++++++++++-
crates/git_ui/src/git_panel.rs      |   3 
crates/git_ui/src/git_ui.rs         |   3 
crates/git_ui/src/stash_picker.rs   |  78 ++++++
crates/zed/src/zed.rs               |   3 
11 files changed, 461 insertions(+), 57 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1080,7 +1080,8 @@
   {
     "context": "StashList || (StashList > Picker > Editor)",
     "bindings": {
-      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+      "ctrl-shift-backspace": "stash_picker::DropStashItem",
+      "ctrl-shift-v": "stash_picker::ShowStashItem"
     }
   },
   {
@@ -1266,6 +1267,14 @@
       "ctrl-pagedown": "settings_editor::FocusNextFile"
     }
   },
+  {
+    "context": "StashDiff > Editor",
+    "bindings": {
+      "ctrl-space": "git::ApplyCurrentStash",
+      "ctrl-shift-space": "git::PopCurrentStash",
+      "ctrl-shift-backspace": "git::DropCurrentStash"
+    }
+  },
   {
     "context": "SettingsWindow > NavigationMenu",
     "use_key_equivalents": true,

assets/keymaps/default-macos.json 🔗

@@ -1153,7 +1153,8 @@
     "context": "StashList || (StashList > Picker > Editor)",
     "use_key_equivalents": true,
     "bindings": {
-      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+      "ctrl-shift-backspace": "stash_picker::DropStashItem",
+      "ctrl-shift-v": "stash_picker::ShowStashItem"
     }
   },
   {
@@ -1371,6 +1372,15 @@
       "cmd-}": "settings_editor::FocusNextFile"
     }
   },
+  {
+    "context": "StashDiff > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-space": "git::ApplyCurrentStash",
+      "ctrl-shift-space": "git::PopCurrentStash",
+      "ctrl-shift-backspace": "git::DropCurrentStash"
+    }
+  },
   {
     "context": "SettingsWindow > NavigationMenu",
     "use_key_equivalents": true,

assets/keymaps/default-windows.json 🔗

@@ -1106,7 +1106,8 @@
     "context": "StashList || (StashList > Picker > Editor)",
     "use_key_equivalents": true,
     "bindings": {
-      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+      "ctrl-shift-backspace": "stash_picker::DropStashItem",
+      "ctrl-shift-v": "stash_picker::ShowStashItem"
     }
   },
   {
@@ -1294,6 +1295,15 @@
       "ctrl-pagedown": "settings_editor::FocusNextFile"
     }
   },
+  {
+    "context": "StashDiff > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-space": "git::ApplyCurrentStash",
+      "ctrl-shift-space": "git::PopCurrentStash",
+      "ctrl-shift-backspace": "git::DropCurrentStash"
+    }
+  },
   {
     "context": "SettingsWindow > NavigationMenu",
     "use_key_equivalents": true,

crates/git/src/repository.rs 🔗

@@ -693,10 +693,11 @@ impl GitRepository for RealGitRepository {
                 .args([
                     "--no-optional-locks",
                     "show",
-                    "--format=%P",
+                    "--format=",
                     "-z",
                     "--no-renames",
                     "--name-status",
+                    "--first-parent",
                 ])
                 .arg(&commit)
                 .stdin(Stdio::null())
@@ -707,9 +708,8 @@ impl GitRepository for RealGitRepository {
                 .context("starting git show process")?;
 
             let show_stdout = String::from_utf8_lossy(&show_output.stdout);
-            let mut lines = show_stdout.split('\n');
-            let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
-            let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
+            let changes = parse_git_diff_name_status(&show_stdout);
+            let parent_sha = format!("{}^", commit);
 
             let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
                 .current_dir(&working_directory)

crates/git_ui/src/blame_ui.rs 🔗

@@ -98,25 +98,10 @@ impl BlameRenderer for GitBlameRenderer {
                             let workspace = workspace.clone();
                             move |_, window, cx| {
                                 CommitView::open(
-                                    CommitSummary {
-                                        sha: blame_entry.sha.to_string().into(),
-                                        subject: blame_entry
-                                            .summary
-                                            .clone()
-                                            .unwrap_or_default()
-                                            .into(),
-                                        commit_timestamp: blame_entry
-                                            .committer_time
-                                            .unwrap_or_default(),
-                                        author_name: blame_entry
-                                            .committer_name
-                                            .clone()
-                                            .unwrap_or_default()
-                                            .into(),
-                                        has_parent: true,
-                                    },
+                                    blame_entry.sha.to_string(),
                                     repository.downgrade(),
                                     workspace.clone(),
+                                    None,
                                     window,
                                     cx,
                                 )
@@ -335,9 +320,10 @@ impl BlameRenderer for GitBlameRenderer {
                                                 .icon_size(IconSize::Small)
                                                 .on_click(move |_, window, cx| {
                                                     CommitView::open(
-                                                        commit_summary.clone(),
+                                                        commit_summary.sha.clone().into(),
                                                         repository.downgrade(),
                                                         workspace.clone(),
+                                                        None,
                                                         window,
                                                         cx,
                                                     );
@@ -374,15 +360,10 @@ impl BlameRenderer for GitBlameRenderer {
         cx: &mut App,
     ) {
         CommitView::open(
-            CommitSummary {
-                sha: blame_entry.sha.to_string().into(),
-                subject: blame_entry.summary.clone().unwrap_or_default().into(),
-                commit_timestamp: blame_entry.committer_time.unwrap_or_default(),
-                author_name: blame_entry.committer_name.unwrap_or_default().into(),
-                has_parent: true,
-            },
+            blame_entry.sha.to_string(),
             repository.downgrade(),
             workspace,
+            None,
             window,
             cx,
         )

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -318,9 +318,10 @@ impl Render for CommitTooltip {
                                             .on_click(
                                                 move |_, window, cx| {
                                                     CommitView::open(
-                                                        commit_summary.clone(),
+                                                        commit_summary.sha.to_string(),
                                                         repo.downgrade(),
                                                         workspace.clone(),
+                                                        None,
                                                         window,
                                                         cx,
                                                     );

crates/git_ui/src/commit_view.rs 🔗

@@ -1,10 +1,11 @@
 use anyhow::{Context as _, Result};
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines};
-use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath};
+use git::repository::{CommitDetails, CommitDiff, RepoPath};
 use gpui::{
-    AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
-    FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window,
+    Action, AnyElement, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, Context,
+    Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, WeakEntity,
+    Window, actions,
 };
 use language::{
     Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
@@ -18,17 +19,42 @@ use std::{
     path::PathBuf,
     sync::Arc,
 };
-use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
+use ui::{
+    Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*,
+};
 use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
 use workspace::{
-    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
+    Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    Workspace,
     item::{BreadcrumbText, ItemEvent, TabContentParams},
+    notifications::NotifyTaskExt,
+    pane::SaveIntent,
     searchable::SearchableItemHandle,
 };
 
+use crate::git_panel::GitPanel;
+
+actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| {
+            toolbar.apply_stash(window, cx);
+        });
+        register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| {
+            toolbar.remove_stash(window, cx);
+        });
+        register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| {
+            toolbar.pop_stash(window, cx);
+        });
+    })
+    .detach();
+}
+
 pub struct CommitView {
     commit: CommitDetails,
     editor: Entity<Editor>,
+    stash: Option<usize>,
     multibuffer: Entity<MultiBuffer>,
 }
 
@@ -48,17 +74,18 @@ const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
 
 impl CommitView {
     pub fn open(
-        commit: CommitSummary,
+        commit_sha: String,
         repo: WeakEntity<Repository>,
         workspace: WeakEntity<Workspace>,
+        stash: Option<usize>,
         window: &mut Window,
         cx: &mut App,
     ) {
         let commit_diff = repo
-            .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string()))
+            .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
             .ok();
         let commit_details = repo
-            .update(cx, |repo, _| repo.show(commit.sha.to_string()))
+            .update(cx, |repo, _| repo.show(commit_sha.clone()))
             .ok();
 
         window
@@ -77,6 +104,7 @@ impl CommitView {
                                 commit_diff,
                                 repo,
                                 project.clone(),
+                                stash,
                                 window,
                                 cx,
                             )
@@ -87,7 +115,7 @@ impl CommitView {
                             let ix = pane.items().position(|item| {
                                 let commit_view = item.downcast::<CommitView>();
                                 commit_view
-                                    .is_some_and(|view| view.read(cx).commit.sha == commit.sha)
+                                    .is_some_and(|view| view.read(cx).commit.sha == commit_sha)
                             });
                             if let Some(ix) = ix {
                                 pane.activate_item(ix, true, true, window, cx);
@@ -106,6 +134,7 @@ impl CommitView {
         commit_diff: CommitDiff,
         repository: Entity<Repository>,
         project: Entity<Project>,
+        stash: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -127,10 +156,13 @@ impl CommitView {
 
         let mut metadata_buffer_id = None;
         if let Some(worktree_id) = first_worktree_id {
+            let title = if let Some(stash) = stash {
+                format!("stash@{{{}}}", stash)
+            } else {
+                format!("commit {}", commit.sha)
+            };
             let file = Arc::new(CommitMetadataFile {
-                title: RelPath::unix(&format!("commit {}", commit.sha))
-                    .unwrap()
-                    .into(),
+                title: RelPath::unix(&title).unwrap().into(),
                 worktree_id,
             });
             let buffer = cx.new(|cx| {
@@ -138,7 +170,7 @@ impl CommitView {
                     ReplicaId::LOCAL,
                     cx.entity_id().as_non_zero_u64().into(),
                     LineEnding::default(),
-                    format_commit(&commit).into(),
+                    format_commit(&commit, stash.is_some()).into(),
                 );
                 metadata_buffer_id = Some(buffer.remote_id());
                 Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
@@ -211,6 +243,7 @@ impl CommitView {
             commit,
             editor,
             multibuffer,
+            stash,
         }
     }
 }
@@ -369,9 +402,13 @@ async fn build_buffer_diff(
     })
 }
 
-fn format_commit(commit: &CommitDetails) -> String {
+fn format_commit(commit: &CommitDetails, is_stash: bool) -> String {
     let mut result = String::new();
-    writeln!(&mut result, "commit {}", commit.sha).unwrap();
+    if is_stash {
+        writeln!(&mut result, "stash commit {}", commit.sha).unwrap();
+    } else {
+        writeln!(&mut result, "commit {}", commit.sha).unwrap();
+    }
     writeln!(
         &mut result,
         "Author: {} <{}>",
@@ -538,13 +575,296 @@ impl Item for CommitView {
                 editor,
                 multibuffer,
                 commit: self.commit.clone(),
+                stash: self.stash,
             }
         }))
     }
 }
 
 impl Render for CommitView {
-    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
-        self.editor.clone()
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_stash = self.stash.is_some();
+        div()
+            .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
+            .bg(cx.theme().colors().editor_background)
+            .flex()
+            .items_center()
+            .justify_center()
+            .size_full()
+            .child(self.editor.clone())
+    }
+}
+
+pub struct CommitViewToolbar {
+    commit_view: Option<WeakEntity<CommitView>>,
+    workspace: WeakEntity<Workspace>,
+}
+
+impl CommitViewToolbar {
+    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
+        Self {
+            commit_view: None,
+            workspace: workspace.weak_handle(),
+        }
+    }
+
+    fn commit_view(&self, _: &App) -> Option<Entity<CommitView>> {
+        self.commit_view.as_ref()?.upgrade()
+    }
+
+    async fn close_commit_view(
+        commit_view: Entity<CommitView>,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut AsyncWindowContext,
+    ) -> anyhow::Result<()> {
+        workspace
+            .update_in(cx, |workspace, window, cx| {
+                let active_pane = workspace.active_pane();
+                let commit_view_id = commit_view.entity_id();
+                active_pane.update(cx, |pane, cx| {
+                    pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
+                })
+            })?
+            .await?;
+        anyhow::Ok(())
+    }
+
+    fn apply_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.stash_action(
+            "Apply",
+            window,
+            cx,
+            async move |repository, sha, stash, commit_view, workspace, cx| {
+                let result = repository.update(cx, |repo, cx| {
+                    if !stash_matches_index(&sha, stash, repo) {
+                        return Err(anyhow::anyhow!("Stash has changed, not applying"));
+                    }
+                    Ok(repo.stash_apply(Some(stash), cx))
+                })?;
+
+                match result {
+                    Ok(task) => task.await?,
+                    Err(err) => {
+                        Self::close_commit_view(commit_view, workspace, cx).await?;
+                        return Err(err);
+                    }
+                };
+                Self::close_commit_view(commit_view, workspace, cx).await?;
+                anyhow::Ok(())
+            },
+        );
+    }
+
+    fn pop_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.stash_action(
+            "Pop",
+            window,
+            cx,
+            async move |repository, sha, stash, commit_view, workspace, cx| {
+                let result = repository.update(cx, |repo, cx| {
+                    if !stash_matches_index(&sha, stash, repo) {
+                        return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
+                    }
+                    Ok(repo.stash_pop(Some(stash), cx))
+                })?;
+
+                match result {
+                    Ok(task) => task.await?,
+                    Err(err) => {
+                        Self::close_commit_view(commit_view, workspace, cx).await?;
+                        return Err(err);
+                    }
+                };
+                Self::close_commit_view(commit_view, workspace, cx).await?;
+                anyhow::Ok(())
+            },
+        );
+    }
+
+    fn remove_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.stash_action(
+            "Drop",
+            window,
+            cx,
+            async move |repository, sha, stash, commit_view, workspace, cx| {
+                let result = repository.update(cx, |repo, cx| {
+                    if !stash_matches_index(&sha, stash, repo) {
+                        return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
+                    }
+                    Ok(repo.stash_drop(Some(stash), cx))
+                })?;
+
+                match result {
+                    Ok(task) => task.await??,
+                    Err(err) => {
+                        Self::close_commit_view(commit_view, workspace, cx).await?;
+                        return Err(err);
+                    }
+                };
+                Self::close_commit_view(commit_view, workspace, cx).await?;
+                anyhow::Ok(())
+            },
+        );
+    }
+
+    fn stash_action<AsyncFn>(
+        &mut self,
+        str_action: &str,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        callback: AsyncFn,
+    ) where
+        AsyncFn: AsyncFnOnce(
+                Entity<Repository>,
+                &SharedString,
+                usize,
+                Entity<CommitView>,
+                WeakEntity<Workspace>,
+                &mut AsyncWindowContext,
+            ) -> anyhow::Result<()>
+            + 'static,
+    {
+        let Some(commit_view) = self.commit_view(cx) else {
+            return;
+        };
+        let Some(stash) = commit_view.read(cx).stash else {
+            return;
+        };
+        let sha = commit_view.read(cx).commit.sha.clone();
+        let answer = window.prompt(
+            PromptLevel::Info,
+            &format!("{} stash@{{{}}}?", str_action, stash),
+            None,
+            &[str_action, "Cancel"],
+            cx,
+        );
+
+        let workspace = self.workspace.clone();
+        cx.spawn_in(window, async move |_, cx| {
+            if answer.await != Ok(0) {
+                return anyhow::Ok(());
+            }
+            let repo = workspace.update(cx, |workspace, cx| {
+                workspace
+                    .panel::<GitPanel>(cx)
+                    .and_then(|p| p.read(cx).active_repository.clone())
+            })?;
+
+            let Some(repo) = repo else {
+                return Ok(());
+            };
+            callback(repo, &sha, stash, commit_view, workspace, cx).await?;
+            anyhow::Ok(())
+        })
+        .detach_and_notify_err(window, cx);
+    }
+}
+
+impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
+
+impl ToolbarItemView for CommitViewToolbar {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _: &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()
+        {
+            self.commit_view = Some(entity.downgrade());
+            return ToolbarItemLocation::PrimaryRight;
+        }
+        ToolbarItemLocation::Hidden
+    }
+
+    fn pane_focus_update(
+        &mut self,
+        _pane_focused: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+    }
+}
+
+impl Render for CommitViewToolbar {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let Some(commit_view) = self.commit_view(cx) else {
+            return div();
+        };
+
+        let is_stash = commit_view.read(cx).stash.is_some();
+        if !is_stash {
+            return div();
+        }
+
+        let focus_handle = commit_view.focus_handle(cx);
+
+        h_group_xl().my_neg_1().py_1().items_center().child(
+            h_group_sm()
+                .child(
+                    Button::new("apply-stash", "Apply")
+                        .tooltip(Tooltip::for_action_title_in(
+                            "Apply current stash",
+                            &ApplyCurrentStash,
+                            &focus_handle,
+                        ))
+                        .on_click(cx.listener(|this, _, window, cx| this.apply_stash(window, cx))),
+                )
+                .child(
+                    Button::new("pop-stash", "Pop")
+                        .tooltip(Tooltip::for_action_title_in(
+                            "Pop current stash",
+                            &PopCurrentStash,
+                            &focus_handle,
+                        ))
+                        .on_click(cx.listener(|this, _, window, cx| this.pop_stash(window, cx))),
+                )
+                .child(
+                    Button::new("remove-stash", "Remove")
+                        .icon(IconName::Trash)
+                        .tooltip(Tooltip::for_action_title_in(
+                            "Remove current stash",
+                            &DropCurrentStash,
+                            &focus_handle,
+                        ))
+                        .on_click(cx.listener(|this, _, window, cx| this.remove_stash(window, cx))),
+                ),
+        )
+    }
+}
+
+fn register_workspace_action<A: Action>(
+    workspace: &mut Workspace,
+    callback: fn(&mut CommitViewToolbar, &A, &mut Window, &mut Context<CommitViewToolbar>),
+) {
+    workspace.register_action(move |workspace, action: &A, window, cx| {
+        if workspace.has_active_modal(window, cx) {
+            cx.propagate();
+            return;
+        }
+
+        workspace.active_pane().update(cx, |pane, cx| {
+            pane.toolbar().update(cx, move |workspace, cx| {
+                if let Some(toolbar) = workspace.item_of_type::<CommitViewToolbar>() {
+                    toolbar.update(cx, move |toolbar, cx| {
+                        callback(toolbar, action, window, cx);
+                        cx.notify();
+                    });
+                }
+            });
+        })
+    });
+}
+
+fn stash_matches_index(sha: &str, index: usize, repo: &mut Repository) -> bool {
+    match repo
+        .cached_stash()
+        .entries
+        .iter()
+        .find(|entry| entry.index == index)
+    {
+        Some(entry) => entry.oid.to_string() == sha,
+        None => false,
     }
 }

crates/git_ui/src/git_panel.rs 🔗

@@ -3611,9 +3611,10 @@ impl GitPanel {
                             let repo = active_repository.downgrade();
                             move |_, window, cx| {
                                 CommitView::open(
-                                    commit.clone(),
+                                    commit.sha.to_string(),
                                     repo.clone(),
                                     workspace.clone(),
+                                    None,
                                     window,
                                     cx,
                                 );

crates/git_ui/src/git_ui.rs 🔗

@@ -34,7 +34,7 @@ mod askpass_modal;
 pub mod branch_picker;
 mod commit_modal;
 pub mod commit_tooltip;
-mod commit_view;
+pub mod commit_view;
 mod conflict_view;
 pub mod file_diff_view;
 pub mod git_panel;
@@ -59,6 +59,7 @@ pub fn init(cx: &mut App) {
     GitPanelSettings::register(cx);
 
     editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
+    commit_view::init(cx);
 
     cx.observe_new(|editor: &mut Editor, _, cx| {
         conflict_view::register_editor(editor, editor.buffer().clone(), cx);

crates/git_ui/src/stash_picker.rs 🔗

@@ -5,18 +5,21 @@ use git::stash::StashEntry;
 use gpui::{
     Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
-    SharedString, Styled, Subscription, Task, Window, actions, rems,
+    SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, svg,
 };
 use picker::{Picker, PickerDelegate};
 use project::git_store::{Repository, RepositoryEvent};
 use std::sync::Arc;
 use time::{OffsetDateTime, UtcOffset};
 use time_format;
-use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use ui::{
+    ButtonLike, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*,
+};
 use util::ResultExt;
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{ModalView, Workspace};
 
+use crate::commit_view::CommitView;
 use crate::stash_picker;
 
 actions!(
@@ -24,6 +27,8 @@ actions!(
     [
         /// Drop the selected stash entry.
         DropStashItem,
+        /// Show the diff view of the selected stash entry.
+        ShowStashItem,
     ]
 );
 
@@ -38,8 +43,9 @@ pub fn open(
     cx: &mut Context<Workspace>,
 ) {
     let repository = workspace.project().read(cx).active_repository(cx);
+    let weak_workspace = workspace.weak_handle();
     workspace.toggle_modal(window, cx, |window, cx| {
-        StashList::new(repository, rems(34.), window, cx)
+        StashList::new(repository, weak_workspace, rems(34.), window, cx)
     })
 }
 
@@ -53,6 +59,7 @@ pub struct StashList {
 impl StashList {
     fn new(
         repository: Option<Entity<Repository>>,
+        workspace: WeakEntity<Workspace>,
         width: Rems,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -98,7 +105,7 @@ impl StashList {
         })
         .detach_and_log_err(cx);
 
-        let delegate = StashListDelegate::new(repository, window, cx);
+        let delegate = StashListDelegate::new(repository, workspace, window, cx);
         let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
         let picker_focus_handle = picker.focus_handle(cx);
         picker.update(cx, |picker, _| {
@@ -131,6 +138,20 @@ impl StashList {
         cx.notify();
     }
 
+    fn handle_show_stash(
+        &mut self,
+        _: &ShowStashItem,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .show_stash_at(picker.delegate.selected_index(), window, cx);
+        });
+        cx.notify();
+    }
+
     fn handle_modifiers_changed(
         &mut self,
         ev: &ModifiersChangedEvent,
@@ -157,6 +178,7 @@ impl Render for StashList {
             .w(self.width)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::handle_drop_stash))
+            .on_action(cx.listener(Self::handle_show_stash))
             .child(self.picker.clone())
     }
 }
@@ -172,6 +194,7 @@ pub struct StashListDelegate {
     matches: Vec<StashEntryMatch>,
     all_stash_entries: Option<Vec<StashEntry>>,
     repo: Option<Entity<Repository>>,
+    workspace: WeakEntity<Workspace>,
     selected_index: usize,
     last_query: String,
     modifiers: Modifiers,
@@ -182,6 +205,7 @@ pub struct StashListDelegate {
 impl StashListDelegate {
     fn new(
         repo: Option<Entity<Repository>>,
+        workspace: WeakEntity<Workspace>,
         _window: &mut Window,
         cx: &mut Context<StashList>,
     ) -> Self {
@@ -192,6 +216,7 @@ impl StashListDelegate {
         Self {
             matches: vec![],
             repo,
+            workspace,
             all_stash_entries: None,
             selected_index: 0,
             last_query: Default::default(),
@@ -235,6 +260,25 @@ impl StashListDelegate {
         });
     }
 
+    fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(entry_match) = self.matches.get(ix) else {
+            return;
+        };
+        let stash_sha = entry_match.entry.oid.to_string();
+        let stash_index = entry_match.entry.index;
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+        CommitView::open(
+            stash_sha,
+            repo.downgrade(),
+            self.workspace.clone(),
+            Some(stash_index),
+            window,
+            cx,
+        );
+    }
+
     fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         let Some(repo) = self.repo.clone() else {
             return;
@@ -390,7 +434,7 @@ impl PickerDelegate for StashListDelegate {
         ix: usize,
         selected: bool,
         _window: &mut Window,
-        _cx: &mut Context<Picker<Self>>,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let entry_match = &self.matches[ix];
 
@@ -432,11 +476,35 @@ impl PickerDelegate for StashListDelegate {
                     .size(LabelSize::Small),
             );
 
+        let show_button = div()
+            .group("show-button-hover")
+            .child(
+                ButtonLike::new("show-button")
+                    .child(
+                        svg()
+                            .size(IconSize::Medium.rems())
+                            .flex_none()
+                            .path(IconName::Eye.path())
+                            .text_color(Color::Default.color(cx))
+                            .group_hover("show-button-hover", |this| {
+                                this.text_color(Color::Accent.color(cx))
+                            })
+                            .hover(|this| this.text_color(Color::Accent.color(cx))),
+                    )
+                    .tooltip(Tooltip::for_action_title("Show Stash", &ShowStashItem))
+                    .on_click(cx.listener(move |picker, _, window, cx| {
+                        cx.stop_propagation();
+                        picker.delegate.show_stash_at(ix, window, cx);
+                    })),
+            )
+            .into_any_element();
+
         Some(
             ListItem::new(SharedString::from(format!("stash-{ix}")))
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
+                .end_slot(show_button)
                 .child(
                     v_flex()
                         .w_full()

crates/zed/src/zed.rs 🔗

@@ -25,6 +25,7 @@ use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
 use fs::Fs;
 use futures::future::Either;
 use futures::{StreamExt, channel::mpsc, select_biased};
+use git_ui::commit_view::CommitViewToolbar;
 use git_ui::git_panel::GitPanel;
 use git_ui::project_diff::ProjectDiffToolbar;
 use gpui::{
@@ -1049,6 +1050,8 @@ fn initialize_pane(
             toolbar.add_item(migration_banner, window, cx);
             let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
             toolbar.add_item(project_diff_toolbar, window, cx);
+            let commit_view_toolbar = cx.new(|cx| CommitViewToolbar::new(workspace, cx));
+            toolbar.add_item(commit_view_toolbar, window, cx);
             let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
             toolbar.add_item(agent_diff_toolbar, window, cx);
             let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));