diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 3cca560c00..9b67b7c5ba 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1052,6 +1052,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 e72f4174ff..296fa148ac 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1123,6 +1123,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 393f2c80f8..9c57f9a2a5 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -993,6 +993,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 9e7cabf9b2..251e1c1034 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 73f327166a..014b02adf6 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -460,6 +460,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 8a67eddcd7..549c788dfa 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 e84014129c..73d32ac9e4 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 fd12dafa98..adc1d71c04 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}; @@ -338,6 +339,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<()>>; @@ -399,7 +402,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, @@ -974,6 +993,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(); @@ -1227,14 +1266,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?; @@ -1248,6 +1295,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 0000000000..93e8aba6b9 Binary files /dev/null and b/crates/git/src/stash.rs differ diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4ecb4a8829..6988a4bb6b 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, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, @@ -119,6 +120,7 @@ struct GitMenuState { has_staged_changes: bool, has_unstaged_changes: bool, has_new_changes: bool, + has_stash_items: bool, } fn git_panel_context_menu( @@ -146,7 +148,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() @@ -369,6 +372,7 @@ pub struct GitPanel { local_committer: Option, local_committer_task: Option>, bulk_staging: Option, + stash_entries: GitStash, _settings_subscription: Subscription, } @@ -558,6 +562,7 @@ impl GitPanel { horizontal_scrollbar, vertical_scrollbar, bulk_staging: None, + stash_entries: Default::default(), _settings_subscription, }; @@ -1422,7 +1427,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 @@ -1437,6 +1442,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; @@ -2685,6 +2713,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(); @@ -3053,6 +3083,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( @@ -3068,6 +3099,7 @@ impl GitPanel { has_staged_changes, has_unstaged_changes, has_new_changes, + has_stash_items, }, window, cx, @@ -4115,6 +4147,7 @@ impl GitPanel { has_staged_changes: self.has_staged_changes(), has_unstaged_changes: self.has_unstaged_changes(), has_new_changes: self.new_count > 0, + 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 5369b8b404..b2070fba25 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 0000000000..60e58b1928 --- /dev/null +++ b/crates/git_ui/src/stash_picker.rs @@ -0,0 +1,507 @@ +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, + true, + 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).into_any_element(); + let branch_name = entry_match.entry.branch.clone().unwrap_or_default(); + let branch_label = h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new(branch_name) + .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() + .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 5cf298a8bf..6122514b7c 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, }, @@ -248,6 +249,7 @@ pub struct RepositorySnapshot { pub merge: MergeDetails, pub remote_origin_url: Option, pub remote_upstream_url: Option, + pub stash_entries: GitStash, } type JobId = u64; @@ -425,6 +427,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); @@ -1778,16 +1782,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, @@ -2744,6 +2785,7 @@ impl RepositorySnapshot { merge: Default::default(), remote_origin_url: None, remote_upstream_url: None, + stash_entries: Default::default(), } } @@ -2770,6 +2812,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(), } } @@ -2833,6 +2881,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(), } } @@ -2889,6 +2943,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, @@ -3264,6 +3338,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); @@ -3698,7 +3776,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, _| { @@ -3708,12 +3790,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")?; @@ -3727,6 +3810,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, @@ -4252,6 +4428,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 @@ -4561,6 +4744,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 { @@ -4592,18 +4776,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, @@ -4852,6 +5040,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 @@ -4902,6 +5091,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 cfb0369875..278d85897a 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -122,6 +122,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 { @@ -283,6 +284,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; @@ -306,6 +315,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 70689bcd63..b1e86a12e4 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -396,7 +396,9 @@ message Envelope { GitCloneResponse git_clone_response = 364; LspQuery lsp_query = 365; - LspQueryResponse lsp_query_response = 366; // current max + LspQueryResponse lsp_query_response = 366; + StashDrop stash_drop = 367; + StashApply stash_apply = 368; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index d38e54685f..c93fbb03c3 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -257,6 +257,8 @@ messages!( (Unstage, Background), (Stash, Background), (StashPop, Background), + (StashApply, Background), + (StashDrop, Background), (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateChannelBuffer, Foreground), @@ -417,6 +419,8 @@ request_messages!( (Unstage, Ack), (Stash, Ack), (StashPop, Ack), + (StashApply, Ack), + (StashDrop, Ack), (UpdateBuffer, Ack), (UpdateParticipantLocation, Ack), (UpdateProject, Ack), @@ -570,6 +574,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 638e1dca0e..94c7d55e24 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4491,6 +4491,7 @@ mod tests { "search", "settings_profile_selector", "snippets", + "stash_picker", "supermaven", "svg", "tab_switcher", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a5223a2cdf..be1cb0af6f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -178,7 +178,9 @@ pub mod git { SelectRepo, /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] - Branch + Branch, + /// Opens the git stash selector. + ViewStash ] ); }