git: Add git stash picker (#35927)

Alvaro Parker created

Closes #ISSUE

This PR continues work from #32821 by adding a stash entry picker for
pop/drop operations. Additionally, the stash pop action in the git panel
is now disabled when no stash entries exist, preventing error logs from
attempted pops on empty stashes.

Preview:

<img width="1920" height="1256" alt="Screenshot From 2025-09-11
14-08-31"
src="https://github.com/user-attachments/assets/b2f32974-8c69-4e50-8951-24ab2cf93c12"
/>

<img width="1920" height="1256" alt="Screenshot From 2025-09-11
14-08-12"
src="https://github.com/user-attachments/assets/992ce237-43c9-456e-979c-c2e2149d633e"
/>



Release Notes:

- Added a stash picker to pop and drop a specific stash entry
- Disabled the stash pop action on the git panel when no stash entries
exist
- Added git stash apply command
- Added git stash drop command

Change summary

assets/keymaps/default-linux.json        |   6 
assets/keymaps/default-macos.json        |   7 
assets/keymaps/default-windows.json      |   7 
crates/collab/src/db/queries/projects.rs |   1 
crates/collab/src/db/queries/rooms.rs    |   1 
crates/collab/src/rpc.rs                 |   1 
crates/fs/src/fake_git_repo.rs           |  26 +
crates/git/src/git.rs                    |   3 
crates/git/src/repository.rs             | 111 +++++
crates/git/src/stash.rs                  | 223 +++++++++++
crates/git_ui/src/git_panel.rs           |  41 +
crates/git_ui/src/git_ui.rs              |  10 
crates/git_ui/src/stash_picker.rs        | 513 ++++++++++++++++++++++++++
crates/project/src/git_store.rs          | 212 ++++++++++
crates/proto/proto/git.proto             |  22 +
crates/proto/proto/zed.proto             |   5 
crates/proto/src/proto.rs                |   6 
crates/zed/src/zed.rs                    |   1 
crates/zed_actions/src/lib.rs            |   4 
19 files changed, 1,179 insertions(+), 21 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -1075,6 +1075,12 @@
       "ctrl-backspace": "tab_switcher::CloseSelectedItem"
     }
   },
+  {
+    "context": "StashList || (StashList > Picker > Editor)",
+    "bindings": {
+      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+    }
+  },
   {
     "context": "Terminal",
     "bindings": {

assets/keymaps/default-macos.json πŸ”—

@@ -1146,6 +1146,13 @@
       "ctrl-backspace": "tab_switcher::CloseSelectedItem"
     }
   },
+  {
+    "context": "StashList || (StashList > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+    }
+  },
   {
     "context": "Terminal",
     "use_key_equivalents": true,

assets/keymaps/default-windows.json πŸ”—

@@ -1092,6 +1092,13 @@
       "ctrl-backspace": "tab_switcher::CloseSelectedItem"
     }
   },
+  {
+    "context": "StashList || (StashList > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-backspace": "stash_picker::DropStashItem"
+    }
+  },
   {
     "context": "Terminal",
     "use_key_equivalents": true,

crates/collab/src/db/queries/projects.rs πŸ”—

@@ -995,6 +995,7 @@ impl Database {
                         scan_id: db_repository_entry.scan_id as u64,
                         is_last_update: true,
                         merge_message: db_repository_entry.merge_message,
+                        stash_entries: Vec::new(),
                     });
                 }
             }

crates/collab/src/db/queries/rooms.rs πŸ”—

@@ -794,6 +794,7 @@ impl Database {
                             scan_id: db_repository.scan_id as u64,
                             is_last_update: true,
                             merge_message: db_repository.merge_message,
+                            stash_entries: Vec::new(),
                         });
                     }
                 }

crates/collab/src/rpc.rs πŸ”—

@@ -448,6 +448,7 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
             .add_request_handler(forward_mutating_project_request::<proto::Stash>)
             .add_request_handler(forward_mutating_project_request::<proto::StashPop>)
+            .add_request_handler(forward_mutating_project_request::<proto::StashDrop>)
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)
             .add_request_handler(forward_mutating_project_request::<proto::GitInit>)
             .add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)

crates/fs/src/fake_git_repo.rs πŸ”—

@@ -320,6 +320,10 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
+        async { Ok(git::stash::GitStash::default()) }.boxed()
+    }
+
     fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
         self.with_state_async(false, move |state| {
             let current_branch = &state.current_branch_name;
@@ -412,7 +416,27 @@ impl GitRepository for FakeGitRepository {
         unimplemented!()
     }
 
-    fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
+    fn stash_pop(
+        &self,
+        _index: Option<usize>,
+        _env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        unimplemented!()
+    }
+
+    fn stash_apply(
+        &self,
+        _index: Option<usize>,
+        _env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        unimplemented!()
+    }
+
+    fn stash_drop(
+        &self,
+        _index: Option<usize>,
+        _env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 

crates/git/src/git.rs πŸ”—

@@ -3,6 +3,7 @@ pub mod commit;
 mod hosting_provider;
 mod remote;
 pub mod repository;
+pub mod stash;
 pub mod status;
 
 pub use crate::hosting_provider::*;
@@ -59,6 +60,8 @@ actions!(
         StashAll,
         /// Pops the most recent stash.
         StashPop,
+        /// Apply the most recent stash.
+        StashApply,
         /// Restores all tracked files to their last committed state.
         RestoreTrackedFiles,
         /// Moves all untracked files to trash.

crates/git/src/repository.rs πŸ”—

@@ -1,4 +1,5 @@
 use crate::commit::parse_git_diff_name_status;
+use crate::stash::GitStash;
 use crate::status::{GitStatus, StatusCode};
 use crate::{Oid, SHORT_SHA_LENGTH};
 use anyhow::{Context as _, Result, anyhow, bail};
@@ -339,6 +340,8 @@ pub trait GitRepository: Send + Sync {
 
     fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 
+    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
+
     fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
@@ -400,7 +403,23 @@ pub trait GitRepository: Send + Sync {
         env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>>;
 
-    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
+    fn stash_pop(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>>;
+
+    fn stash_apply(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>>;
+
+    fn stash_drop(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>>;
 
     fn push(
         &self,
@@ -975,6 +994,26 @@ impl GitRepository for RealGitRepository {
         })
     }
 
+    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
+        let git_binary_path = self.git_binary_path.clone();
+        let working_directory = self.working_directory();
+        self.executor
+            .spawn(async move {
+                let output = new_std_command(&git_binary_path)
+                    .current_dir(working_directory?)
+                    .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
+                    .output()?;
+                if output.status.success() {
+                    let stdout = String::from_utf8_lossy(&output.stdout);
+                    stdout.parse()
+                } else {
+                    let stderr = String::from_utf8_lossy(&output.stderr);
+                    anyhow::bail!("git status failed: {stderr}");
+                }
+            })
+            .boxed()
+    }
+
     fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
@@ -1229,14 +1268,22 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
+    fn stash_pop(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         self.executor
             .spawn(async move {
                 let mut cmd = new_smol_command("git");
+                let mut args = vec!["stash".to_string(), "pop".to_string()];
+                if let Some(index) = index {
+                    args.push(format!("stash@{{{}}}", index));
+                }
                 cmd.current_dir(&working_directory?)
                     .envs(env.iter())
-                    .args(["stash", "pop"]);
+                    .args(args);
 
                 let output = cmd.output().await?;
 
@@ -1250,6 +1297,64 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn stash_apply(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        let working_directory = self.working_directory();
+        self.executor
+            .spawn(async move {
+                let mut cmd = new_smol_command("git");
+                let mut args = vec!["stash".to_string(), "apply".to_string()];
+                if let Some(index) = index {
+                    args.push(format!("stash@{{{}}}", index));
+                }
+                cmd.current_dir(&working_directory?)
+                    .envs(env.iter())
+                    .args(args);
+
+                let output = cmd.output().await?;
+
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to apply stash:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                Ok(())
+            })
+            .boxed()
+    }
+
+    fn stash_drop(
+        &self,
+        index: Option<usize>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        let working_directory = self.working_directory();
+        self.executor
+            .spawn(async move {
+                let mut cmd = new_smol_command("git");
+                let mut args = vec!["stash".to_string(), "drop".to_string()];
+                if let Some(index) = index {
+                    args.push(format!("stash@{{{}}}", index));
+                }
+                cmd.current_dir(&working_directory?)
+                    .envs(env.iter())
+                    .args(args);
+
+                let output = cmd.output().await?;
+
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to stash drop:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                Ok(())
+            })
+            .boxed()
+    }
+
     fn commit(
         &self,
         message: SharedString,

crates/git/src/stash.rs πŸ”—

@@ -0,0 +1,223 @@
+use crate::Oid;
+use anyhow::{Context, Result, anyhow};
+use std::{str::FromStr, sync::Arc};
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct StashEntry {
+    pub index: usize,
+    pub oid: Oid,
+    pub message: String,
+    pub branch: Option<String>,
+    pub timestamp: i64,
+}
+
+#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
+pub struct GitStash {
+    pub entries: Arc<[StashEntry]>,
+}
+
+impl GitStash {
+    pub fn apply(&mut self, other: GitStash) {
+        self.entries = other.entries;
+    }
+}
+
+impl FromStr for GitStash {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self> {
+        if s.trim().is_empty() {
+            return Ok(Self::default());
+        }
+
+        let mut entries = Vec::new();
+        let mut errors = Vec::new();
+
+        for (line_num, line) in s.lines().enumerate() {
+            if line.trim().is_empty() {
+                continue;
+            }
+
+            match parse_stash_line(line) {
+                Ok(entry) => entries.push(entry),
+                Err(e) => {
+                    errors.push(format!("Line {}: {}", line_num + 1, e));
+                }
+            }
+        }
+
+        // If we have some valid entries but also some errors, log the errors but continue
+        if !errors.is_empty() && !entries.is_empty() {
+            log::warn!("Failed to parse some stash entries: {}", errors.join(", "));
+        } else if !errors.is_empty() {
+            return Err(anyhow!(
+                "Failed to parse stash entries: {}",
+                errors.join(", ")
+            ));
+        }
+
+        Ok(Self {
+            entries: entries.into(),
+        })
+    }
+}
+
+/// Parse a single stash line in the format: "stash@{N}\0<oid>\0<timestamp>\0<message>"
+fn parse_stash_line(line: &str) -> Result<StashEntry> {
+    let parts: Vec<&str> = line.splitn(4, '\0').collect();
+
+    if parts.len() != 4 {
+        return Err(anyhow!(
+            "Expected 4 null-separated parts, got {}",
+            parts.len()
+        ));
+    }
+
+    let index = parse_stash_index(parts[0])
+        .with_context(|| format!("Failed to parse stash index from '{}'", parts[0]))?;
+
+    let oid = Oid::from_str(parts[1])
+        .with_context(|| format!("Failed to parse OID from '{}'", parts[1]))?;
+
+    let timestamp = parts[2]
+        .parse::<i64>()
+        .with_context(|| format!("Failed to parse timestamp from '{}'", parts[2]))?;
+
+    let (branch, message) = parse_stash_message(parts[3]);
+
+    Ok(StashEntry {
+        index,
+        oid,
+        message: message.to_string(),
+        branch: branch.map(Into::into),
+        timestamp,
+    })
+}
+
+/// Parse stash index from format "stash@{N}" where N is the index
+fn parse_stash_index(input: &str) -> Result<usize> {
+    let trimmed = input.trim();
+
+    if !trimmed.starts_with("stash@{") || !trimmed.ends_with('}') {
+        return Err(anyhow!(
+            "Invalid stash index format: expected 'stash@{{N}}'"
+        ));
+    }
+
+    let index_str = trimmed
+        .strip_prefix("stash@{")
+        .and_then(|s| s.strip_suffix('}'))
+        .ok_or_else(|| anyhow!("Failed to extract index from stash reference"))?;
+
+    index_str
+        .parse::<usize>()
+        .with_context(|| format!("Invalid stash index number: '{}'", index_str))
+}
+
+/// Parse stash message and extract branch information if present
+///
+/// Handles the following formats:
+/// - "WIP on <branch>: <message>" -> (Some(branch), message)
+/// - "On <branch>: <message>" -> (Some(branch), message)
+/// - "<message>" -> (None, message)
+fn parse_stash_message(input: &str) -> (Option<&str>, &str) {
+    // Handle "WIP on <branch>: <message>" pattern
+    if let Some(stripped) = input.strip_prefix("WIP on ")
+        && let Some(colon_pos) = stripped.find(": ")
+    {
+        let branch = &stripped[..colon_pos];
+        let message = &stripped[colon_pos + 2..];
+        if !branch.is_empty() && !message.is_empty() {
+            return (Some(branch), message);
+        }
+    }
+
+    // Handle "On <branch>: <message>" pattern
+    if let Some(stripped) = input.strip_prefix("On ")
+        && let Some(colon_pos) = stripped.find(": ")
+    {
+        let branch = &stripped[..colon_pos];
+        let message = &stripped[colon_pos + 2..];
+        if !branch.is_empty() && !message.is_empty() {
+            return (Some(branch), message);
+        }
+    }
+
+    // Fallback: treat entire input as message with no branch
+    (None, input)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_stash_index() {
+        assert_eq!(parse_stash_index("stash@{0}").unwrap(), 0);
+        assert_eq!(parse_stash_index("stash@{42}").unwrap(), 42);
+        assert_eq!(parse_stash_index("  stash@{5}  ").unwrap(), 5);
+
+        assert!(parse_stash_index("invalid").is_err());
+        assert!(parse_stash_index("stash@{not_a_number}").is_err());
+        assert!(parse_stash_index("stash@{0").is_err());
+    }
+
+    #[test]
+    fn test_parse_stash_message() {
+        // WIP format
+        let (branch, message) = parse_stash_message("WIP on main: working on feature");
+        assert_eq!(branch, Some("main"));
+        assert_eq!(message, "working on feature");
+
+        // On format
+        let (branch, message) = parse_stash_message("On feature-branch: some changes");
+        assert_eq!(branch, Some("feature-branch"));
+        assert_eq!(message, "some changes");
+
+        // No branch format
+        let (branch, message) = parse_stash_message("just a regular message");
+        assert_eq!(branch, None);
+        assert_eq!(message, "just a regular message");
+
+        // Edge cases
+        let (branch, message) = parse_stash_message("WIP on : empty message");
+        assert_eq!(branch, None);
+        assert_eq!(message, "WIP on : empty message");
+
+        let (branch, message) = parse_stash_message("On branch-name:");
+        assert_eq!(branch, None);
+        assert_eq!(message, "On branch-name:");
+    }
+
+    #[test]
+    fn test_parse_stash_line() {
+        let line = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: test commit";
+        let entry = parse_stash_line(line).unwrap();
+
+        assert_eq!(entry.index, 0);
+        assert_eq!(entry.message, "test commit");
+        assert_eq!(entry.branch, Some("main".to_string()));
+        assert_eq!(entry.timestamp, 1234567890);
+    }
+
+    #[test]
+    fn test_git_stash_from_str() {
+        let input = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: first stash\nstash@{1}\u{0000}def456\u{0000}1234567891\u{0000}On feature: second stash";
+        let stash = GitStash::from_str(input).unwrap();
+
+        assert_eq!(stash.entries.len(), 2);
+        assert_eq!(stash.entries[0].index, 0);
+        assert_eq!(stash.entries[0].branch, Some("main".to_string()));
+        assert_eq!(stash.entries[1].index, 1);
+        assert_eq!(stash.entries[1].branch, Some("feature".to_string()));
+    }
+
+    #[test]
+    fn test_git_stash_empty_input() {
+        let stash = GitStash::from_str("").unwrap();
+        assert_eq!(stash.entries.len(), 0);
+
+        let stash = GitStash::from_str("   \n  \n  ").unwrap();
+        assert_eq!(stash.entries.len(), 0);
+    }
+}

crates/git_ui/src/git_panel.rs πŸ”—

@@ -24,11 +24,12 @@ use git::repository::{
     PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
     UpstreamTrackingStatus, get_git_committer,
 };
+use git::stash::GitStash;
 use git::status::StageStatus;
 use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
 use git::{
-    ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashPop, TrashUntrackedFiles,
-    UnstageAll,
+    ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
+    TrashUntrackedFiles, UnstageAll,
 };
 use gpui::{
     Action, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity,
@@ -121,6 +122,7 @@ struct GitMenuState {
     has_unstaged_changes: bool,
     has_new_changes: bool,
     sort_by_path: bool,
+    has_stash_items: bool,
 }
 
 fn git_panel_context_menu(
@@ -148,7 +150,8 @@ fn git_panel_context_menu(
                 "Stash All",
                 StashAll.boxed_clone(),
             )
-            .action("Stash Pop", StashPop.boxed_clone())
+            .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone())
+            .action("View Stash", zed_actions::git::ViewStash.boxed_clone())
             .separator()
             .action("Open Diff", project_diff::Diff.boxed_clone())
             .separator()
@@ -382,6 +385,7 @@ pub struct GitPanel {
     local_committer: Option<GitCommitter>,
     local_committer_task: Option<Task<()>>,
     bulk_staging: Option<BulkStaging>,
+    stash_entries: GitStash,
     _settings_subscription: Subscription,
 }
 
@@ -569,6 +573,7 @@ impl GitPanel {
                 horizontal_scrollbar,
                 vertical_scrollbar,
                 bulk_staging: None,
+                stash_entries: Default::default(),
                 _settings_subscription,
             };
 
@@ -1438,7 +1443,7 @@ impl GitPanel {
         cx.spawn({
             async move |this, cx| {
                 let stash_task = active_repository
-                    .update(cx, |repo, cx| repo.stash_pop(cx))?
+                    .update(cx, |repo, cx| repo.stash_pop(None, cx))?
                     .await;
                 this.update(cx, |this, cx| {
                     stash_task
@@ -1453,6 +1458,29 @@ impl GitPanel {
         .detach();
     }
 
+    pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context<Self>) {
+        let Some(active_repository) = self.active_repository.clone() else {
+            return;
+        };
+
+        cx.spawn({
+            async move |this, cx| {
+                let stash_task = active_repository
+                    .update(cx, |repo, cx| repo.stash_apply(None, cx))?
+                    .await;
+                this.update(cx, |this, cx| {
+                    stash_task
+                        .map_err(|e| {
+                            this.show_error_toast("stash apply", e, cx);
+                        })
+                        .ok();
+                    cx.notify();
+                })
+            }
+        })
+        .detach();
+    }
+
     pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
@@ -2734,6 +2762,8 @@ impl GitPanel {
 
         let repo = repo.read(cx);
 
+        self.stash_entries = repo.cached_stash();
+
         for entry in repo.cached_status() {
             let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
             let is_new = entry.status.is_created();
@@ -3102,6 +3132,7 @@ impl GitPanel {
         let has_staged_changes = self.has_staged_changes();
         let has_unstaged_changes = self.has_unstaged_changes();
         let has_new_changes = self.new_count > 0;
+        let has_stash_items = self.stash_entries.entries.len() > 0;
 
         PopoverMenu::new(id.into())
             .trigger(
@@ -3118,6 +3149,7 @@ impl GitPanel {
                         has_unstaged_changes,
                         has_new_changes,
                         sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
+                        has_stash_items,
                     },
                     window,
                     cx,
@@ -4173,6 +4205,7 @@ impl GitPanel {
                 has_unstaged_changes: self.has_unstaged_changes(),
                 has_new_changes: self.new_count > 0,
                 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
+                has_stash_items: self.stash_entries.entries.len() > 0,
             },
             window,
             cx,

crates/git_ui/src/git_ui.rs πŸ”—

@@ -36,6 +36,7 @@ pub mod picker_prompt;
 pub mod project_diff;
 pub(crate) mod remote_output;
 pub mod repository_selector;
+pub mod stash_picker;
 pub mod text_diff_view;
 
 actions!(
@@ -62,6 +63,7 @@ pub fn init(cx: &mut App) {
         git_panel::register(workspace);
         repository_selector::register(workspace);
         branch_picker::register(workspace);
+        stash_picker::register(workspace);
 
         let project = workspace.project().read(cx);
         if project.is_read_only(cx) {
@@ -133,6 +135,14 @@ pub fn init(cx: &mut App) {
                 panel.stash_pop(action, window, cx);
             });
         });
+        workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
+            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                return;
+            };
+            panel.update(cx, |panel, cx| {
+                panel.stash_apply(action, window, cx);
+            });
+        });
         workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
             let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
                 return;

crates/git_ui/src/stash_picker.rs πŸ”—

@@ -0,0 +1,513 @@
+use fuzzy::StringMatchCandidate;
+
+use chrono;
+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,
+};
+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 util::ResultExt;
+use workspace::notifications::DetachAndPromptErr;
+use workspace::{ModalView, Workspace};
+
+use crate::stash_picker;
+
+actions!(
+    stash_picker,
+    [
+        /// Drop the selected stash entry.
+        DropStashItem,
+    ]
+);
+
+pub fn register(workspace: &mut Workspace) {
+    workspace.register_action(open);
+}
+
+pub fn open(
+    workspace: &mut Workspace,
+    _: &zed_actions::git::ViewStash,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let repository = workspace.project().read(cx).active_repository(cx);
+    workspace.toggle_modal(window, cx, |window, cx| {
+        StashList::new(repository, rems(34.), window, cx)
+    })
+}
+
+pub struct StashList {
+    width: Rems,
+    pub picker: Entity<Picker<StashListDelegate>>,
+    picker_focus_handle: FocusHandle,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl StashList {
+    fn new(
+        repository: Option<Entity<Repository>>,
+        width: Rems,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let mut _subscriptions = Vec::new();
+        let stash_request = repository
+            .clone()
+            .map(|repository| repository.read_with(cx, |repo, _| repo.cached_stash()));
+
+        if let Some(repo) = repository.clone() {
+            _subscriptions.push(
+                cx.subscribe_in(&repo, window, |this, _, event, window, cx| {
+                    if matches!(event, RepositoryEvent::Updated { .. }) {
+                        let stash_entries = this.picker.read_with(cx, |picker, cx| {
+                            picker
+                                .delegate
+                                .repo
+                                .clone()
+                                .map(|repo| repo.read(cx).cached_stash().entries.to_vec())
+                        });
+                        this.picker.update(cx, |this, cx| {
+                            this.delegate.all_stash_entries = stash_entries;
+                            this.refresh(window, cx);
+                        });
+                    }
+                }),
+            )
+        }
+
+        cx.spawn_in(window, async move |this, cx| {
+            let stash_entries = stash_request
+                .map(|git_stash| git_stash.entries.to_vec())
+                .unwrap_or_default();
+
+            this.update_in(cx, |this, window, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    picker.delegate.all_stash_entries = Some(stash_entries);
+                    picker.refresh(window, cx);
+                })
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+
+        let delegate = StashListDelegate::new(repository, 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, _| {
+            picker.delegate.focus_handle = picker_focus_handle.clone();
+        });
+
+        _subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
+            cx.emit(DismissEvent);
+        }));
+
+        Self {
+            picker,
+            picker_focus_handle,
+            width,
+            _subscriptions,
+        }
+    }
+
+    fn handle_drop_stash(
+        &mut self,
+        _: &DropStashItem,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .drop_stash_at(picker.delegate.selected_index(), window, cx);
+        });
+        cx.notify();
+    }
+
+    fn handle_modifiers_changed(
+        &mut self,
+        ev: &ModifiersChangedEvent,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker
+            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
+    }
+}
+
+impl ModalView for StashList {}
+impl EventEmitter<DismissEvent> for StashList {}
+impl Focusable for StashList {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.picker_focus_handle.clone()
+    }
+}
+
+impl Render for StashList {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("StashList")
+            .w(self.width)
+            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+            .on_action(cx.listener(Self::handle_drop_stash))
+            .child(self.picker.clone())
+    }
+}
+
+#[derive(Debug, Clone)]
+struct StashEntryMatch {
+    entry: StashEntry,
+    positions: Vec<usize>,
+    formatted_timestamp: String,
+}
+
+pub struct StashListDelegate {
+    matches: Vec<StashEntryMatch>,
+    all_stash_entries: Option<Vec<StashEntry>>,
+    repo: Option<Entity<Repository>>,
+    selected_index: usize,
+    last_query: String,
+    modifiers: Modifiers,
+    focus_handle: FocusHandle,
+    timezone: UtcOffset,
+}
+
+impl StashListDelegate {
+    fn new(
+        repo: Option<Entity<Repository>>,
+        _window: &mut Window,
+        cx: &mut Context<StashList>,
+    ) -> Self {
+        let timezone =
+            UtcOffset::from_whole_seconds(chrono::Local::now().offset().local_minus_utc())
+                .unwrap_or(UtcOffset::UTC);
+
+        Self {
+            matches: vec![],
+            repo,
+            all_stash_entries: None,
+            selected_index: 0,
+            last_query: Default::default(),
+            modifiers: Default::default(),
+            focus_handle: cx.focus_handle(),
+            timezone,
+        }
+    }
+
+    fn format_message(ix: usize, message: &String) -> String {
+        format!("#{}: {}", ix, message)
+    }
+
+    fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
+        let timestamp =
+            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
+        time_format::format_localized_timestamp(
+            timestamp,
+            OffsetDateTime::now_utc(),
+            timezone,
+            time_format::TimestampFormat::EnhancedAbsolute,
+        )
+    }
+
+    fn drop_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_index = entry_match.entry.index;
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+
+        cx.spawn(async move |_, cx| {
+            repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))?
+                .await??;
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
+            Some(e.to_string())
+        });
+    }
+
+    fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+
+        cx.spawn(async move |_, cx| {
+            repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))?
+                .await?;
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
+            Some(e.to_string())
+        });
+        cx.emit(DismissEvent);
+    }
+
+    fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+
+        cx.spawn(async move |_, cx| {
+            repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))?
+                .await?;
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
+            Some(e.to_string())
+        });
+        cx.emit(DismissEvent);
+    }
+}
+
+impl PickerDelegate for StashListDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Select a stash…".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let Some(all_stash_entries) = self.all_stash_entries.clone() else {
+            return Task::ready(());
+        };
+
+        let timezone = self.timezone;
+
+        cx.spawn_in(window, async move |picker, cx| {
+            let matches: Vec<StashEntryMatch> = if query.is_empty() {
+                all_stash_entries
+                    .into_iter()
+                    .map(|entry| {
+                        let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
+
+                        StashEntryMatch {
+                            entry,
+                            positions: Vec::new(),
+                            formatted_timestamp,
+                        }
+                    })
+                    .collect()
+            } else {
+                let candidates = all_stash_entries
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, entry)| {
+                        StringMatchCandidate::new(
+                            ix,
+                            &Self::format_message(entry.index, &entry.message),
+                        )
+                    })
+                    .collect::<Vec<StringMatchCandidate>>();
+                fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    true,
+                    10000,
+                    &Default::default(),
+                    cx.background_executor().clone(),
+                )
+                .await
+                .into_iter()
+                .map(|candidate| {
+                    let entry = all_stash_entries[candidate.candidate_id].clone();
+                    let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
+
+                    StashEntryMatch {
+                        entry,
+                        positions: candidate.positions,
+                        formatted_timestamp,
+                    }
+                })
+                .collect()
+            };
+
+            picker
+                .update(cx, |picker, _| {
+                    let delegate = &mut picker.delegate;
+                    delegate.matches = matches;
+                    if delegate.matches.is_empty() {
+                        delegate.selected_index = 0;
+                    } else {
+                        delegate.selected_index =
+                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
+                    }
+                    delegate.last_query = query;
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(entry_match) = self.matches.get(self.selected_index()) else {
+            return;
+        };
+        let stash_index = entry_match.entry.index;
+        if secondary {
+            self.pop_stash(stash_index, window, cx);
+        } else {
+            self.apply_stash(stash_index, window, cx);
+        }
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let entry_match = &self.matches[ix];
+
+        let stash_message =
+            Self::format_message(entry_match.entry.index, &entry_match.entry.message);
+        let positions = entry_match.positions.clone();
+        let stash_label = HighlightedLabel::new(stash_message, positions)
+            .truncate()
+            .into_any_element();
+        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
+        let branch_label = h_flex()
+            .gap_1()
+            .w_full()
+            .child(
+                Icon::new(IconName::GitBranch)
+                    .color(Color::Muted)
+                    .size(IconSize::Small),
+            )
+            .child(
+                Label::new(branch_name)
+                    .truncate()
+                    .color(Color::Muted)
+                    .size(LabelSize::Small),
+            );
+
+        let tooltip_text = format!(
+            "stash@{{{}}} created {}",
+            entry_match.entry.index, entry_match.formatted_timestamp
+        );
+
+        Some(
+            ListItem::new(SharedString::from(format!("stash-{ix}")))
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(
+                    v_flex()
+                        .w_full()
+                        .overflow_hidden()
+                        .child(stash_label)
+                        .child(branch_label.into_element()),
+                )
+                .tooltip(Tooltip::text(tooltip_text)),
+        )
+    }
+
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        Some("No stashes found".into())
+    }
+
+    fn render_footer(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            h_flex()
+                .w_full()
+                .p_1p5()
+                .justify_between()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    h_flex()
+                        .gap_0p5()
+                        .child(
+                            Button::new("apply-stash", "Apply")
+                                .key_binding(
+                                    KeyBinding::for_action_in(
+                                        &menu::Confirm,
+                                        &focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                                )
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                                }),
+                        )
+                        .child(
+                            Button::new("pop-stash", "Pop")
+                                .key_binding(
+                                    KeyBinding::for_action_in(
+                                        &menu::SecondaryConfirm,
+                                        &focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                                )
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
+                                }),
+                        )
+                        .child(
+                            Button::new("drop-stash", "Drop")
+                                .key_binding(
+                                    KeyBinding::for_action_in(
+                                        &stash_picker::DropStashItem,
+                                        &focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                                )
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(
+                                        stash_picker::DropStashItem.boxed_clone(),
+                                        cx,
+                                    )
+                                }),
+                        ),
+                )
+                .into_any(),
+        )
+    }
+}

crates/project/src/git_store.rs πŸ”—

@@ -20,7 +20,7 @@ use futures::{
     stream::FuturesOrdered,
 };
 use git::{
-    BuildPermalinkParams, GitHostingProviderRegistry, WORK_DIRECTORY_REPO_PATH,
+    BuildPermalinkParams, GitHostingProviderRegistry, Oid, WORK_DIRECTORY_REPO_PATH,
     blame::Blame,
     parse_git_remote_url,
     repository::{
@@ -28,6 +28,7 @@ use git::{
         GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
         ResetMode, UpstreamTrackingStatus,
     },
+    stash::{GitStash, StashEntry},
     status::{
         FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
     },
@@ -244,6 +245,7 @@ pub struct RepositorySnapshot {
     pub merge: MergeDetails,
     pub remote_origin_url: Option<String>,
     pub remote_upstream_url: Option<String>,
+    pub stash_entries: GitStash,
 }
 
 type JobId = u64;
@@ -404,6 +406,8 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_unstage);
         client.add_entity_request_handler(Self::handle_stash);
         client.add_entity_request_handler(Self::handle_stash_pop);
+        client.add_entity_request_handler(Self::handle_stash_apply);
+        client.add_entity_request_handler(Self::handle_stash_drop);
         client.add_entity_request_handler(Self::handle_commit);
         client.add_entity_request_handler(Self::handle_reset);
         client.add_entity_request_handler(Self::handle_show);
@@ -1744,16 +1748,53 @@ impl GitStore {
     ) -> Result<proto::Ack> {
         let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
         let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let stash_index = envelope.payload.stash_index.map(|i| i as usize);
 
         repository_handle
             .update(&mut cx, |repository_handle, cx| {
-                repository_handle.stash_pop(cx)
+                repository_handle.stash_pop(stash_index, cx)
             })?
             .await?;
 
         Ok(proto::Ack {})
     }
 
+    async fn handle_stash_apply(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::StashApply>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let stash_index = envelope.payload.stash_index.map(|i| i as usize);
+
+        repository_handle
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.stash_apply(stash_index, cx)
+            })?
+            .await?;
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_stash_drop(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::StashDrop>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let stash_index = envelope.payload.stash_index.map(|i| i as usize);
+
+        repository_handle
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.stash_drop(stash_index, cx)
+            })?
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
     async fn handle_set_index_text(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::SetIndexText>,
@@ -2710,6 +2751,7 @@ impl RepositorySnapshot {
             merge: Default::default(),
             remote_origin_url: None,
             remote_upstream_url: None,
+            stash_entries: Default::default(),
         }
     }
 
@@ -2736,6 +2778,12 @@ impl RepositorySnapshot {
             entry_ids: vec![self.id.to_proto()],
             scan_id: self.scan_id,
             is_last_update: true,
+            stash_entries: self
+                .stash_entries
+                .entries
+                .iter()
+                .map(stash_to_proto)
+                .collect(),
         }
     }
 
@@ -2799,6 +2847,12 @@ impl RepositorySnapshot {
             entry_ids: vec![],
             scan_id: self.scan_id,
             is_last_update: true,
+            stash_entries: self
+                .stash_entries
+                .entries
+                .iter()
+                .map(stash_to_proto)
+                .collect(),
         }
     }
 
@@ -2855,6 +2909,26 @@ impl RepositorySnapshot {
     }
 }
 
+pub fn stash_to_proto(entry: &StashEntry) -> proto::StashEntry {
+    proto::StashEntry {
+        oid: entry.oid.as_bytes().to_vec(),
+        message: entry.message.clone(),
+        branch: entry.branch.clone(),
+        index: entry.index as u64,
+        timestamp: entry.timestamp,
+    }
+}
+
+pub fn proto_to_stash(entry: &proto::StashEntry) -> Result<StashEntry> {
+    Ok(StashEntry {
+        oid: Oid::from_bytes(&entry.oid)?,
+        message: entry.message.clone(),
+        index: entry.index as usize,
+        branch: entry.branch.clone(),
+        timestamp: entry.timestamp,
+    })
+}
+
 impl MergeDetails {
     async fn load(
         backend: &Arc<dyn GitRepository>,
@@ -3230,6 +3304,10 @@ impl Repository {
         self.snapshot.status()
     }
 
+    pub fn cached_stash(&self) -> GitStash {
+        self.snapshot.stash_entries.clone()
+    }
+
     pub fn repo_path_to_project_path(&self, path: &RepoPath, cx: &App) -> Option<ProjectPath> {
         let git_store = self.git_store.upgrade()?;
         let worktree_store = git_store.read(cx).worktree_store.read(cx);
@@ -3665,7 +3743,11 @@ impl Repository {
         })
     }
 
-    pub fn stash_pop(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
+    pub fn stash_pop(
+        &mut self,
+        index: Option<usize>,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<()>> {
         let id = self.id;
         cx.spawn(async move |this, cx| {
             this.update(cx, |this, _| {
@@ -3675,12 +3757,13 @@ impl Repository {
                             backend,
                             environment,
                             ..
-                        } => backend.stash_pop(environment).await,
+                        } => backend.stash_pop(index, environment).await,
                         RepositoryState::Remote { project_id, client } => {
                             client
                                 .request(proto::StashPop {
                                     project_id: project_id.0,
                                     repository_id: id.to_proto(),
+                                    stash_index: index.map(|i| i as u64),
                                 })
                                 .await
                                 .context("sending stash pop request")?;
@@ -3694,6 +3777,99 @@ impl Repository {
         })
     }
 
+    pub fn stash_apply(
+        &mut self,
+        index: Option<usize>,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<()>> {
+        let id = self.id;
+        cx.spawn(async move |this, cx| {
+            this.update(cx, |this, _| {
+                this.send_job(None, move |git_repo, _cx| async move {
+                    match git_repo {
+                        RepositoryState::Local {
+                            backend,
+                            environment,
+                            ..
+                        } => backend.stash_apply(index, environment).await,
+                        RepositoryState::Remote { project_id, client } => {
+                            client
+                                .request(proto::StashApply {
+                                    project_id: project_id.0,
+                                    repository_id: id.to_proto(),
+                                    stash_index: index.map(|i| i as u64),
+                                })
+                                .await
+                                .context("sending stash apply request")?;
+                            Ok(())
+                        }
+                    }
+                })
+            })?
+            .await??;
+            Ok(())
+        })
+    }
+
+    pub fn stash_drop(
+        &mut self,
+        index: Option<usize>,
+        cx: &mut Context<Self>,
+    ) -> oneshot::Receiver<anyhow::Result<()>> {
+        let id = self.id;
+        let updates_tx = self
+            .git_store()
+            .and_then(|git_store| match &git_store.read(cx).state {
+                GitStoreState::Local { downstream, .. } => downstream
+                    .as_ref()
+                    .map(|downstream| downstream.updates_tx.clone()),
+                _ => None,
+            });
+        let this = cx.weak_entity();
+        self.send_job(None, move |git_repo, mut cx| async move {
+            match git_repo {
+                RepositoryState::Local {
+                    backend,
+                    environment,
+                    ..
+                } => {
+                    let result = backend.stash_drop(index, environment).await;
+                    if result.is_ok()
+                        && let Ok(stash_entries) = backend.stash_entries().await
+                    {
+                        let snapshot = this.update(&mut cx, |this, cx| {
+                            this.snapshot.stash_entries = stash_entries;
+                            let snapshot = this.snapshot.clone();
+                            cx.emit(RepositoryEvent::Updated {
+                                full_scan: false,
+                                new_instance: false,
+                            });
+                            snapshot
+                        })?;
+                        if let Some(updates_tx) = updates_tx {
+                            updates_tx
+                                .unbounded_send(DownstreamUpdate::UpdateRepository(snapshot))
+                                .ok();
+                        }
+                    }
+
+                    result
+                }
+                RepositoryState::Remote { project_id, client } => {
+                    client
+                        .request(proto::StashDrop {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            stash_index: index.map(|i| i as u64),
+                        })
+                        .await
+                        .context("sending stash pop request")?;
+                    Ok(())
+                }
+            }
+        })
+    }
+
     pub fn commit(
         &mut self,
         message: SharedString,
@@ -4219,6 +4395,13 @@ impl Repository {
 
         self.snapshot.merge.conflicted_paths = conflicted_paths;
         self.snapshot.merge.message = update.merge_message.map(SharedString::from);
+        self.snapshot.stash_entries = GitStash {
+            entries: update
+                .stash_entries
+                .iter()
+                .filter_map(|entry| proto_to_stash(entry).ok())
+                .collect(),
+        };
 
         let edits = update
             .removed_statuses
@@ -4528,6 +4711,7 @@ impl Repository {
                     return Ok(());
                 }
                 let statuses = backend.status(&paths).await?;
+                let stash_entries = backend.stash_entries().await?;
 
                 let changed_path_statuses = cx
                     .background_spawn(async move {
@@ -4559,18 +4743,22 @@ impl Repository {
                     .await;
 
                 this.update(&mut cx, |this, cx| {
+                    let needs_update = !changed_path_statuses.is_empty()
+                        || this.snapshot.stash_entries != stash_entries;
+                    this.snapshot.stash_entries = stash_entries;
                     if !changed_path_statuses.is_empty() {
                         this.snapshot
                             .statuses_by_path
                             .edit(changed_path_statuses, &());
                         this.snapshot.scan_id += 1;
-                        if let Some(updates_tx) = updates_tx {
-                            updates_tx
-                                .unbounded_send(DownstreamUpdate::UpdateRepository(
-                                    this.snapshot.clone(),
-                                ))
-                                .ok();
-                        }
+                    }
+
+                    if needs_update && let Some(updates_tx) = updates_tx {
+                        updates_tx
+                            .unbounded_send(DownstreamUpdate::UpdateRepository(
+                                this.snapshot.clone(),
+                            ))
+                            .ok();
                     }
                     cx.emit(RepositoryEvent::Updated {
                         full_scan: false,
@@ -4821,6 +5009,7 @@ async fn compute_snapshot(
     let statuses = backend
         .status(std::slice::from_ref(&WORK_DIRECTORY_REPO_PATH))
         .await?;
+    let stash_entries = backend.stash_entries().await?;
     let statuses_by_path = SumTree::from_iter(
         statuses
             .entries
@@ -4871,6 +5060,7 @@ async fn compute_snapshot(
         merge: merge_details,
         remote_origin_url,
         remote_upstream_url,
+        stash_entries,
     };
 
     Ok((snapshot, events))

crates/proto/proto/git.proto πŸ”—

@@ -123,6 +123,7 @@ message UpdateRepository {
     bool is_last_update = 10;
     optional GitCommitDetails head_commit_details = 11;
     optional string merge_message = 12;
+    repeated StashEntry stash_entries = 13;
 }
 
 message RemoveRepository {
@@ -284,6 +285,14 @@ message StatusEntry {
     GitFileStatus status = 3;
 }
 
+message StashEntry {
+    bytes oid = 1;
+    string message = 2;
+    optional string branch = 3;
+    uint64 index = 4;
+    int64 timestamp = 5;
+}
+
 message Stage {
     uint64 project_id = 1;
     reserved 2;
@@ -307,6 +316,19 @@ message Stash {
 message StashPop {
     uint64 project_id = 1;
     uint64 repository_id = 2;
+    optional uint64 stash_index = 3;
+}
+
+message StashApply {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    optional uint64 stash_index = 3;
+}
+
+message StashDrop {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    optional uint64 stash_index = 3;
 }
 
 message Commit {

crates/proto/proto/zed.proto πŸ”—

@@ -411,7 +411,10 @@ message Envelope {
         ExternalAgentsUpdated external_agents_updated = 375;
 
         ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376;
-        NewExternalAgentVersionAvailable new_external_agent_version_available = 377; // current max
+        NewExternalAgentVersionAvailable new_external_agent_version_available = 377;
+
+        StashDrop stash_drop = 378;
+        StashApply stash_apply = 379; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs πŸ”—

@@ -258,6 +258,8 @@ messages!(
     (Unstage, Background),
     (Stash, Background),
     (StashPop, Background),
+    (StashApply, Background),
+    (StashDrop, Background),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateChannelBuffer, Foreground),
@@ -425,6 +427,8 @@ request_messages!(
     (Unstage, Ack),
     (Stash, Ack),
     (StashPop, Ack),
+    (StashApply, Ack),
+    (StashDrop, Ack),
     (UpdateBuffer, Ack),
     (UpdateParticipantLocation, Ack),
     (UpdateProject, Ack),
@@ -578,6 +582,8 @@ entity_messages!(
     Unstage,
     Stash,
     StashPop,
+    StashApply,
+    StashDrop,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,

crates/zed/src/zed.rs πŸ”—

@@ -4492,6 +4492,7 @@ mod tests {
                 "search",
                 "settings_profile_selector",
                 "snippets",
+                "stash_picker",
                 "supermaven",
                 "svg",
                 "syntax_tree_view",

crates/zed_actions/src/lib.rs πŸ”—

@@ -180,7 +180,9 @@ pub mod git {
             SelectRepo,
             /// Opens the git branch selector.
             #[action(deprecated_aliases = ["branches::OpenRecent"])]
-            Branch
+            Branch,
+            /// Opens the git stash selector.
+            ViewStash
         ]
     );
 }