From 4b7595c94ca64cac6e1ba033c86d7e3faf3ae5d2 Mon Sep 17 00:00:00 2001
From: Alvaro Parker <64918109+AlvaroParker@users.noreply.github.com>
Date: Fri, 12 Sep 2025 15:45:38 -0300
Subject: [PATCH] git: Add git stash picker (#35927)
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:
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
---
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, 1179 insertions(+), 21 deletions(-)
create mode 100644 crates/git/src/stash.rs
create mode 100644 crates/git_ui/src/stash_picker.rs
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index ea5f64c822f52c0b985616d37a4b51dcaac8ee29..6b4c4e0fac95cf751c21cfaa0770d1279a35adcc 100644
--- a/assets/keymaps/default-linux.json
+++ b/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": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index bc870400e9eff1b27b012d3a80c1e58dc1726492..0ef4757fc523c9ae145175da07a52ced322efa0c 100644
--- a/assets/keymaps/default-macos.json
+++ b/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,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 3f465d883dc098a5f36950ca369fdc775031b5f2..e5839964ad545f3994d675da817a5f4571b88db4 100644
--- a/assets/keymaps/default-windows.json
+++ b/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,
diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs
index a3f0ea6cbc6e762e365f82e74b886234e62da109..d83f6de206b414f00ea8f176672aeb41641f289a 100644
--- a/crates/collab/src/db/queries/projects.rs
+++ b/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(),
});
}
}
diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs
index b4cca2a2b15de0c10a641e847c32d2dfe300deb2..175361af351b1529d04f6a5d30b512bbcf7d7568 100644
--- a/crates/collab/src/db/queries/rooms.rs
+++ b/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(),
});
}
}
diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs
index 873aee54737966cb4761ca58d9b7a47b12177b50..49f97eb11d4b4dd44591ca500668828e30013c03 100644
--- a/crates/collab/src/rpc.rs
+++ b/crates/collab/src/rpc.rs
@@ -448,6 +448,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(forward_mutating_project_request::)
+ .add_request_handler(forward_mutating_project_request::)
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(forward_read_only_project_request::)
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 8a67eddcd775746f5dbfc55c3a9a21a5d1f7d8e3..549c788dfac6acbb69fec8c715fb2a31b3674040 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -320,6 +320,10 @@ impl GitRepository for FakeGitRepository {
})
}
+ fn stash_entries(&self) -> BoxFuture<'_, Result> {
+ async { Ok(git::stash::GitStash::default()) }.boxed()
+ }
+
fn branches(&self) -> BoxFuture<'_, Result>> {
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>) -> BoxFuture<'_, Result<()>> {
+ fn stash_pop(
+ &self,
+ _index: Option,
+ _env: Arc>,
+ ) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
+ fn stash_apply(
+ &self,
+ _index: Option,
+ _env: Arc>,
+ ) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
+ fn stash_drop(
+ &self,
+ _index: Option,
+ _env: Arc>,
+ ) -> BoxFuture<'_, Result<()>> {
unimplemented!()
}
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index e84014129cf5a423279b84bed897a4fac2528e02..73d32ac9e468b57e13fc9bf714bc96d55549167c 100644
--- a/crates/git/src/git.rs
+++ b/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.
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 1beaadbefd1bb4b9df5093428844b326a093b338..10aaca38bbb3f7326e9bae27d4e6b1e9c20bb59a 100644
--- a/crates/git/src/repository.rs
+++ b/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>;
+ fn stash_entries(&self) -> BoxFuture<'_, Result>;
+
fn branches(&self) -> BoxFuture<'_, Result>>;
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
@@ -400,7 +403,23 @@ pub trait GitRepository: Send + Sync {
env: Arc>,
) -> BoxFuture<'_, Result<()>>;
- fn stash_pop(&self, env: Arc>) -> BoxFuture<'_, Result<()>>;
+ fn stash_pop(
+ &self,
+ index: Option,
+ env: Arc>,
+ ) -> BoxFuture<'_, Result<()>>;
+
+ fn stash_apply(
+ &self,
+ index: Option,
+ env: Arc>,
+ ) -> BoxFuture<'_, Result<()>>;
+
+ fn stash_drop(
+ &self,
+ index: Option,
+ env: Arc>,
+ ) -> BoxFuture<'_, Result<()>>;
fn push(
&self,
@@ -975,6 +994,26 @@ impl GitRepository for RealGitRepository {
})
}
+ fn stash_entries(&self) -> BoxFuture<'_, Result> {
+ 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>> {
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>) -> BoxFuture<'_, Result<()>> {
+ fn stash_pop(
+ &self,
+ index: Option,
+ env: Arc>,
+ ) -> 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,
+ env: Arc>,
+ ) -> 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,
+ env: Arc>,
+ ) -> 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,
diff --git a/crates/git/src/stash.rs b/crates/git/src/stash.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f7379f5212332059ebe639b2dea94f9fb672b1b1
--- /dev/null
+++ b/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,
+ 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 {
+ 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\0\0"
+fn parse_stash_line(line: &str) -> Result {
+ 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::()
+ .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 {
+ 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::()
+ .with_context(|| format!("Invalid stash index number: '{}'", index_str))
+}
+
+/// Parse stash message and extract branch information if present
+///
+/// Handles the following formats:
+/// - "WIP on : " -> (Some(branch), message)
+/// - "On : " -> (Some(branch), message)
+/// - "" -> (None, message)
+fn parse_stash_message(input: &str) -> (Option<&str>, &str) {
+ // Handle "WIP on : " 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 : " 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);
+ }
+}
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index d746c1dc60e1061086e0673fc3e5675f97817cf6..f30b53faee442fdbadea1bec1f0e08148998f74d 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/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,
local_committer_task: Option>,
bulk_staging: Option,
+ 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) {
+ 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) {
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,
diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs
index fcb2be0bd4d21609161369a29d30997df2d80872..000b6639b440914f117e30cc3272bf4cc38d8be6 100644
--- a/crates/git_ui/src/git_ui.rs
+++ b/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::(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::(cx) else {
return;
diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4d142d6383a9a4e79e342420bada00c790770902
--- /dev/null
+++ b/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,
+) {
+ 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_focus_handle: FocusHandle,
+ _subscriptions: Vec,
+}
+
+impl StashList {
+ fn new(
+ repository: Option>,
+ width: Rems,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> 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.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.picker
+ .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
+ }
+}
+
+impl ModalView for StashList {}
+impl EventEmitter 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) -> 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,
+ formatted_timestamp: String,
+}
+
+pub struct StashListDelegate {
+ matches: Vec,
+ all_stash_entries: Option>,
+ repo: Option>,
+ selected_index: usize,
+ last_query: String,
+ modifiers: Modifiers,
+ focus_handle: FocusHandle,
+ timezone: UtcOffset,
+}
+
+impl StashListDelegate {
+ fn new(
+ repo: Option>,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> 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>) {
+ 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>) {
+ 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>) {
+ 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 {
+ "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>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> 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 = 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::>();
+ 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>) {
+ 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>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) -> Option {
+ 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 {
+ Some("No stashes found".into())
+ }
+
+ fn render_footer(
+ &self,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ 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(),
+ )
+ }
+}
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index 4e54cd0798fa521709da7eb619366abbea2f4d25..4b9ee462529e980c782c555157e0f1ff34029fb7 100644
--- a/crates/project/src/git_store.rs
+++ b/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,
pub remote_upstream_url: Option,
+ 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 {
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,
+ envelope: TypedEnvelope,
+ mut cx: AsyncApp,
+ ) -> Result {
+ 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,
+ envelope: TypedEnvelope,
+ mut cx: AsyncApp,
+ ) -> Result {
+ 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,
envelope: TypedEnvelope,
@@ -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 {
+ 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,
@@ -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 {
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) -> Task> {
+ pub fn stash_pop(
+ &mut self,
+ index: Option,
+ cx: &mut Context,
+ ) -> Task> {
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,
+ cx: &mut Context,
+ ) -> Task> {
+ 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,
+ cx: &mut Context,
+ ) -> oneshot::Receiver> {
+ 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))
diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto
index ef4d7aea63f620b31d20c8599bc38195776255a8..3f17f0d0c3483ade36b73e26c7207f6cf667bb63 100644
--- a/crates/proto/proto/git.proto
+++ b/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 {
diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto
index 7fdf108e122c705b0779bb5e20cf62460820bd3e..b20979081187b3dc7350b08b5c07ae700d86e02e 100644
--- a/crates/proto/proto/zed.proto
+++ b/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;
diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs
index 16bd86146c4bfb49ccf3e50a00900319661bcfa6..2985fde4d3ff4357628534f0ca3a5daf5476f813 100644
--- a/crates/proto/src/proto.rs
+++ b/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,
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 49a803e1ee7bdd247584fd218b278554598be33d..7b592cf06c06fd7e090def3db3b084024aad86cd 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -4492,6 +4492,7 @@ mod tests {
"search",
"settings_profile_selector",
"snippets",
+ "stash_picker",
"supermaven",
"svg",
"syntax_tree_view",
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
index 23bae89b680ed47da9272a75cd0bb874001f5ab1..fd979b3648b9a84aa89039386f8ac300e28d4771 100644
--- a/crates/zed_actions/src/lib.rs
+++ b/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
]
);
}