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 ] ); }