Added Tracked , Staged options to stash (#62254)
# Objective Closes #62252 The Git Panel could only stash *everything* — `Stash All` runs `git stash push --include-untracked`, sweeping tracked edits and untracked files into a single entry. There was no way to stash a subset, so the common workflows of "park my tracked edits but keep my new scratch files" and "park what I've staged and keep working on the rest" required dropping to the terminal. ## Images <img width="389" height="358" alt="Screenshot 2026-08-10 at 3 10 50 PM" src="https://github.com/user-attachments/assets/18e4c943-e320-4802-ada8-59e54bf4cefd" /> <img width="504" height="462" alt="Screenshot 2026-08-10 at 3 10 37 PM" src="https://github.com/user-attachments/assets/783237eb-980d-47bc-a0f5-17b03a23a60c" /> ## Solution Add two stash variants alongside `Stash All`, surfaced in the Git Panel's overflow menu based on how the list is currently grouped, so the menu mirrors the sections the user can actually see: | Group By | Stash entries offered | | --- | --- | | None | Stash All | | Tracked & Untracked | Stash All, **Stash Tracked** | | Staged & Unstaged | Stash All, **Stash Staged** | - **`git::StashTracked`** stashes tracked changes and leaves untracked files in place. It reuses the existing pathspec plumbing (`Repository::stash_entries`), filtering the status list down to the paths to stash. - **`git::StashStaged`** stashes the index only, leaving unstaged changes in place. This *cannot* be expressed as a pathspec — a partially staged file would have its unstaged hunks stashed too — so it needs git's own `--staged` flag. That meant a new `GitRepository::stash_staged` backend method and an `optional bool staged` field on `proto::Stash` so remote projects work too. Both actions are unbound by default and are dispatchable from the command palette when the panel is focused. One subtlety worth calling out for review: `Stash Tracked` filters on `FileStatus::is_created()`, not `is_untracked()`. Staging a new file flips it from `Untracked` to `Tracked { Added }`, but the panel still lists it under **Untracked** — using `is_untracked()` meant staged-new files were silently stashed. `is_created()` is the same predicate the panel uses to build that section (`git_panel.rs`), so the menu item and the list can no longer disagree. This branch also includes a separate commit adding **per-section staging** (`git::StageSection` / `git::UnstageSection`) — right-click a file to stage or unstage every entry in its section. Happy to split that into its own PR if preferred. ## Testing Manually tested on macOS against a scratch repo with a mix of states: modified tracked files, untracked files, and untracked files that had been staged. - `Stash Tracked` with tracked edits + untracked files → only tracked edits stashed; untracked files remain. - `Stash Tracked` with untracked files **staged** → they remain, staged. This was broken in an earlier revision and drove the `is_created()` fix above. - `Stash Staged` with one file staged and another modified-but-unstaged → only the staged file is stashed; the unstaged edit and untracked files survive. - `Stash Pop` round-trips both cases back to the original state, with no conflicts. - Menu contents and disabled states verified in all three Group By modes. - Per-section staging covered by a new unit test, `test_stage_section_scopes_to_selected_section`. Not covered by automated tests: the stash actions themselves. `FakeGitRepository` leaves every stash method `unimplemented!()`, so stash behavior isn't reachable from GPUI tests today — consistent with the existing untested `StashAll`. Adding fake-repo stash support looks like a worthwhile follow-up but felt out of scope here. Reviewers on non-macOS platforms: nothing here is platform-specific. Note that `Stash Staged` requires **git 2.35+** (Jan 2022) for `git stash push --staged`; older git surfaces a clear error toast rather than failing opaquely. The remote path (`proto::Stash.staged`) has not been exercised against a live collab session. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added `Stash Tracked` and `Stash Staged` options to the Git Panel, letting you stash only tracked changes or only staged changes. --------- Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
This commit is contained in:
1 parent
52f9567fb4
commit
f652da59e8
8 files changed
+520
-14
No files matched your search
@@ -1070,6 +1070,14 @@ impl GitRepository for FakeGitRepository {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn stash_staged(
|
||||
&self,
|
||||
_message: Option<String>,
|
||||
_env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn stash_pop(
|
||||
&self,
|
||||
_index: Option<usize>,
|
||||
|
||||
@@ -67,6 +67,11 @@ actions!(
|
||||
StageFile,
|
||||
/// Unstages the current file.
|
||||
UnstageFile,
|
||||
// per-section
|
||||
/// Stages every entry in the section containing the selected entry.
|
||||
StageSection,
|
||||
/// Unstages every entry in the section containing the selected entry.
|
||||
UnstageSection,
|
||||
// repo-wide
|
||||
/// Stages all changes in the repository.
|
||||
StageAll,
|
||||
@@ -74,6 +79,10 @@ actions!(
|
||||
UnstageAll,
|
||||
/// Stashes all changes in the repository, including untracked files.
|
||||
StashAll,
|
||||
/// Stashes tracked changes in the repository, leaving untracked files in place.
|
||||
StashTracked,
|
||||
/// Stashes staged changes in the repository, leaving unstaged changes in place.
|
||||
StashStaged,
|
||||
/// Pops the most recent stash.
|
||||
StashPop,
|
||||
/// Apply the most recent stash.
|
||||
|
||||
@@ -941,6 +941,12 @@ pub trait GitRepository: Send + Sync {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stash_staged(
|
||||
&self,
|
||||
message: Option<String>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stash_pop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
@@ -2561,6 +2567,33 @@ impl GitRepository for RealGitRepository {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stash_staged(
|
||||
&self,
|
||||
message: Option<String>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let git = self.git_binary_in_worktree();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let git = git?;
|
||||
// `--staged` cannot be expressed as a pathspec: a partially staged
|
||||
// file would otherwise have its unstaged hunks stashed too.
|
||||
let mut args = vec!["stash", "push", "--quiet", "--staged"];
|
||||
if let Some(message) = message.as_deref() {
|
||||
args.extend_from_slice(&["--message", message]);
|
||||
}
|
||||
let output = git.build_command(&args).envs(env.iter()).output().await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Failed to stash staged changes (requires git 2.35 or newer):\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stash_pop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
|
||||
+373
-12
@@ -43,8 +43,8 @@ use git::{
|
||||
};
|
||||
use git::{
|
||||
ExpandCommitEditor, GitHostingProviderRegistry, GitRemote, RestoreTrackedFiles, StageAll,
|
||||
StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
|
||||
ViewFile, parse_git_remote_url,
|
||||
StashAll, StashApply, StashPop, StashStaged, StashTracked, ToggleFillCommitEditor,
|
||||
TrashUntrackedFiles, UnstageAll, ViewFile, parse_git_remote_url,
|
||||
};
|
||||
use gpui::{
|
||||
AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent,
|
||||
@@ -201,21 +201,56 @@ struct GitPanelViewOptionsMenuState {
|
||||
tree_view: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum StashKind {
|
||||
All,
|
||||
Tracked,
|
||||
Staged,
|
||||
}
|
||||
|
||||
impl StashKind {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
StashKind::All => "Stash All",
|
||||
StashKind::Tracked => "Stash Tracked",
|
||||
StashKind::Staged => "Stash Staged",
|
||||
}
|
||||
}
|
||||
|
||||
fn error_action(self) -> &'static str {
|
||||
match self {
|
||||
StashKind::All => "stash",
|
||||
StashKind::Tracked => "stash tracked",
|
||||
StashKind::Staged => "stash staged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for an optional stash name. Confirming with an empty editor stashes
|
||||
/// without `--message`, letting git generate its usual "WIP on ..." description.
|
||||
struct StashMessageModal {
|
||||
editor: Entity<Editor>,
|
||||
panel: WeakEntity<GitPanel>,
|
||||
kind: StashKind,
|
||||
}
|
||||
|
||||
impl StashMessageModal {
|
||||
fn new(panel: WeakEntity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(
|
||||
panel: WeakEntity<GitPanel>,
|
||||
kind: StashKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
editor.set_placeholder_text("Optionally provide a stash message", window, cx);
|
||||
editor
|
||||
});
|
||||
Self { editor, panel }
|
||||
Self {
|
||||
editor,
|
||||
panel,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -225,8 +260,9 @@ impl StashMessageModal {
|
||||
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let message = self.editor.read(cx).text(cx).trim().to_owned();
|
||||
let message = (!message.is_empty()).then_some(message);
|
||||
let kind = self.kind;
|
||||
self.panel
|
||||
.update(cx, |panel, cx| panel.perform_stash(message, cx))
|
||||
.update(cx, |panel, cx| panel.perform_stash(kind, message, cx))
|
||||
.ok();
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
@@ -256,7 +292,7 @@ impl Render for StashMessageModal {
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
|
||||
.child(Headline::new("Stash All").size(HeadlineSize::XSmall)),
|
||||
.child(Headline::new(self.kind.title()).size(HeadlineSize::XSmall)),
|
||||
)
|
||||
.child(div().px_3().pb_3().w_full().child(self.editor.clone()))
|
||||
}
|
||||
@@ -268,6 +304,7 @@ fn git_panel_context_menu(
|
||||
has_unstaged_changes: bool,
|
||||
has_new_changes: bool,
|
||||
has_stash_items: bool,
|
||||
group_by: GitPanelGroupBy,
|
||||
include_copy_paths: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -289,6 +326,22 @@ fn git_panel_context_menu(
|
||||
"Stash All",
|
||||
StashAll.boxed_clone(),
|
||||
)
|
||||
// Offer the stash variant that matches how the list is currently grouped,
|
||||
// so the menu mirrors the sections the user can actually see.
|
||||
.when(group_by == GitPanelGroupBy::Status, |context_menu| {
|
||||
context_menu.action_disabled_when(
|
||||
!has_tracked_changes,
|
||||
"Stash Tracked",
|
||||
StashTracked.boxed_clone(),
|
||||
)
|
||||
})
|
||||
.when(group_by == GitPanelGroupBy::Staging, |context_menu| {
|
||||
context_menu.action_disabled_when(
|
||||
!has_staged_changes,
|
||||
"Stash Staged",
|
||||
StashStaged.boxed_clone(),
|
||||
)
|
||||
})
|
||||
.action_disabled_when(!has_stash_items, "Stash Pop", StashPop.boxed_clone())
|
||||
.action("View Stash", zed_actions::git::ViewStash.boxed_clone())
|
||||
.when(include_copy_paths, |context_menu| {
|
||||
@@ -2800,24 +2853,41 @@ impl GitPanel {
|
||||
}
|
||||
|
||||
pub fn stash_all(&mut self, _: &StashAll, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.prompt_for_stash_message(StashKind::All, window, cx);
|
||||
}
|
||||
|
||||
pub fn stash_tracked(&mut self, _: &StashTracked, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.prompt_for_stash_message(StashKind::Tracked, window, cx);
|
||||
}
|
||||
|
||||
pub fn stash_staged(&mut self, _: &StashStaged, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.prompt_for_stash_message(StashKind::Staged, window, cx);
|
||||
}
|
||||
|
||||
fn prompt_for_stash_message(
|
||||
&mut self,
|
||||
kind: StashKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.active_repository.is_none() {
|
||||
return;
|
||||
}
|
||||
// `git::StashAll` is also registered on the workspace, which dispatches it while
|
||||
// `Workspace` is leased, so opening the modal inline would re-enter that update.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let panel = cx.entity().downgrade();
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
StashMessageModal::new(panel, window, cx)
|
||||
StashMessageModal::new(panel, kind, window, cx)
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn perform_stash(&mut self, message: Option<String>, cx: &mut Context<Self>) {
|
||||
fn perform_stash(&mut self, kind: StashKind, message: Option<String>, cx: &mut Context<Self>) {
|
||||
let Some(active_repository) = self.active_repository.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -2825,12 +2895,16 @@ impl GitPanel {
|
||||
cx.spawn({
|
||||
async move |this, cx| {
|
||||
let stash_task = active_repository
|
||||
.update(cx, |repo, cx| repo.stash_all(message, cx))
|
||||
.update(cx, |repo, cx| match kind {
|
||||
StashKind::All => repo.stash_all(message, cx),
|
||||
StashKind::Tracked => repo.stash_tracked(message, cx),
|
||||
StashKind::Staged => repo.stash_staged(message, cx),
|
||||
})
|
||||
.await;
|
||||
this.update(cx, |this, cx| {
|
||||
stash_task
|
||||
.map_err(|e| {
|
||||
this.show_error_toast("stash", e, cx);
|
||||
this.show_error_toast(kind.error_action(), e, cx);
|
||||
})
|
||||
.ok();
|
||||
cx.notify();
|
||||
@@ -2907,6 +2981,44 @@ impl GitPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_section(
|
||||
&mut self,
|
||||
_: &git::StageSection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.change_selected_section_stage(StageIntent::Stage, window, cx);
|
||||
}
|
||||
|
||||
fn unstage_section(
|
||||
&mut self,
|
||||
_: &git::UnstageSection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.change_selected_section_stage(StageIntent::Unstage, window, cx);
|
||||
}
|
||||
|
||||
fn change_selected_section_stage(
|
||||
&mut self,
|
||||
intent: StageIntent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(selected_index) = self.selected_entry else {
|
||||
return;
|
||||
};
|
||||
let Some(section) = self.section_for_entry_index(selected_index) else {
|
||||
return;
|
||||
};
|
||||
self.toggle_staged_for_entry(
|
||||
&GitListEntry::Header(GitHeaderEntry { header: section }),
|
||||
intent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let is_amend = self.amend_pending;
|
||||
if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
|
||||
@@ -5706,13 +5818,14 @@ impl GitPanel {
|
||||
fn render_git_changes_actions_menu(
|
||||
&self,
|
||||
id: impl Into<ElementId>,
|
||||
_cx: &mut Context<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let has_tracked_changes = self.has_tracked_changes();
|
||||
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;
|
||||
let group_by = GitPanelSettings::get_global(cx).group_by;
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let menu_open = self.changes_actions_menu_handle.is_deployed();
|
||||
@@ -5730,6 +5843,7 @@ impl GitPanel {
|
||||
has_unstaged_changes,
|
||||
has_new_changes,
|
||||
has_stash_items,
|
||||
group_by,
|
||||
false,
|
||||
focus_handle.clone(),
|
||||
window,
|
||||
@@ -7498,6 +7612,7 @@ impl GitPanel {
|
||||
has_unstaged_changes,
|
||||
has_new_changes,
|
||||
has_stash_items,
|
||||
GitPanelSettings::get_global(cx).group_by,
|
||||
include_copy_paths,
|
||||
self.focus_handle.clone(),
|
||||
window,
|
||||
@@ -8287,6 +8402,8 @@ impl Render for GitPanel {
|
||||
.on_action(cx.listener(Self::unstage_all))
|
||||
.on_action(cx.listener(Self::stage_selected))
|
||||
.on_action(cx.listener(Self::unstage_selected))
|
||||
.on_action(cx.listener(Self::stage_section))
|
||||
.on_action(cx.listener(Self::unstage_section))
|
||||
.on_action(cx.listener(Self::restore_tracked_files))
|
||||
.on_action(cx.listener(Self::revert_selected))
|
||||
.on_action(cx.listener(Self::add_to_gitignore))
|
||||
@@ -8294,6 +8411,8 @@ impl Render for GitPanel {
|
||||
.on_action(cx.listener(Self::clean_all))
|
||||
.on_action(cx.listener(Self::generate_commit_message_action))
|
||||
.on_action(cx.listener(Self::stash_all))
|
||||
.on_action(cx.listener(Self::stash_tracked))
|
||||
.on_action(cx.listener(Self::stash_staged))
|
||||
.on_action(cx.listener(Self::stash_pop))
|
||||
})
|
||||
.on_action(cx.listener(Self::collapse_selected_entry))
|
||||
@@ -12874,4 +12993,246 @@ mod tests {
|
||||
"prompt should NOT list unstaged.rs, got: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_stage_section_scopes_to_selected_section(cx: &mut TestAppContext) {
|
||||
use GitListEntry::*;
|
||||
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"project": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
"lib.rs": "pub fn hello() {}",
|
||||
},
|
||||
"new_file.txt": "new content",
|
||||
"another_new.rs": "// new file",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.set_status_for_repo(
|
||||
Path::new(path!("/root/project/.git")),
|
||||
&[
|
||||
("src/main.rs", StatusCode::Modified.worktree()),
|
||||
("src/lib.rs", StatusCode::Modified.worktree()),
|
||||
("new_file.txt", FileStatus::Untracked),
|
||||
("another_new.rs", FileStatus::Untracked),
|
||||
],
|
||||
);
|
||||
|
||||
let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
|
||||
let window_handle =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace = window_handle
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
|
||||
|
||||
cx.read(|cx| {
|
||||
project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.as_local()
|
||||
.unwrap()
|
||||
.scan_complete()
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let panel = workspace.update_in(cx, GitPanel::new);
|
||||
|
||||
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
||||
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
||||
});
|
||||
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
||||
handle.await;
|
||||
|
||||
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
||||
#[rustfmt::skip]
|
||||
pretty_assertions::assert_matches!(
|
||||
entries.as_slice(),
|
||||
&[
|
||||
Header(GitHeaderEntry { header: Section::Tracked }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Header(GitHeaderEntry { header: Section::New }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
],
|
||||
);
|
||||
|
||||
// Staging from an untracked row must leave the tracked section alone.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.selected_entry = Some(4);
|
||||
panel.stage_section(&git::StageSection, window, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| {
|
||||
project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.as_local()
|
||||
.unwrap()
|
||||
.scan_complete()
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
||||
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
||||
});
|
||||
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
||||
handle.await;
|
||||
|
||||
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
||||
#[rustfmt::skip]
|
||||
pretty_assertions::assert_matches!(
|
||||
entries.as_slice(),
|
||||
&[
|
||||
Header(GitHeaderEntry { header: Section::Tracked }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Header(GitHeaderEntry { header: Section::New }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
],
|
||||
);
|
||||
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.selected_entry = Some(1);
|
||||
panel.stage_section(&git::StageSection, window, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| {
|
||||
project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.as_local()
|
||||
.unwrap()
|
||||
.scan_complete()
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
||||
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
||||
});
|
||||
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
||||
handle.await;
|
||||
|
||||
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
||||
#[rustfmt::skip]
|
||||
pretty_assertions::assert_matches!(
|
||||
entries.as_slice(),
|
||||
&[
|
||||
Header(GitHeaderEntry { header: Section::Tracked }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Header(GitHeaderEntry { header: Section::New }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
],
|
||||
);
|
||||
|
||||
// Unstaging is likewise section-scoped, and unlike the header checkbox it
|
||||
// has a fixed direction rather than toggling.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.selected_entry = Some(4);
|
||||
panel.unstage_section(&git::UnstageSection, window, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| {
|
||||
project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.as_local()
|
||||
.unwrap()
|
||||
.scan_complete()
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
||||
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
||||
});
|
||||
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
||||
handle.await;
|
||||
|
||||
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
||||
#[rustfmt::skip]
|
||||
pretty_assertions::assert_matches!(
|
||||
entries.as_slice(),
|
||||
&[
|
||||
Header(GitHeaderEntry { header: Section::Tracked }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Header(GitHeaderEntry { header: Section::New }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
],
|
||||
);
|
||||
|
||||
// Re-issuing the same direction is idempotent, where a toggle would flip back.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.selected_entry = Some(4);
|
||||
panel.unstage_section(&git::UnstageSection, window, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| {
|
||||
project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.as_local()
|
||||
.unwrap()
|
||||
.scan_complete()
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
||||
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
||||
});
|
||||
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
||||
handle.await;
|
||||
|
||||
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
||||
#[rustfmt::skip]
|
||||
pretty_assertions::assert_matches!(
|
||||
entries.as_slice(),
|
||||
&[
|
||||
Header(GitHeaderEntry { header: Section::Tracked }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
||||
Header(GitHeaderEntry { header: Section::New }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,22 @@ pub fn init(cx: &mut App) {
|
||||
panel.stash_all(action, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, action: &git::StashStaged, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.stash_staged(action, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, action: &git::StashTracked, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.stash_tracked(action, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
|
||||
@@ -3446,6 +3446,15 @@ impl GitStore {
|
||||
|
||||
let message = envelope.payload.message;
|
||||
|
||||
if envelope.payload.staged.unwrap_or(false) {
|
||||
repository_handle
|
||||
.update(&mut cx, |repository_handle, cx| {
|
||||
repository_handle.stash_staged(message, cx)
|
||||
})
|
||||
.await?;
|
||||
return Ok(proto::Ack {});
|
||||
}
|
||||
|
||||
let entries = envelope
|
||||
.payload
|
||||
.paths
|
||||
@@ -7921,6 +7930,27 @@ impl Repository {
|
||||
self.stash_entries(to_stash, message, cx)
|
||||
}
|
||||
|
||||
pub fn stash_tracked(
|
||||
&mut self,
|
||||
message: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
// `is_created` rather than `is_untracked`, so that staging a new file does not
|
||||
// move it out of the untracked set: `git add` makes it `Tracked { Added }`, but
|
||||
// the panel still lists it under "Untracked" and the user expects it left alone.
|
||||
let to_stash: Vec<_> = self
|
||||
.cached_status()
|
||||
.filter(|entry| !entry.status.is_created())
|
||||
.map(|entry| entry.repo_path)
|
||||
.collect();
|
||||
|
||||
// An empty pathspec would make `git stash push` stash everything.
|
||||
if to_stash.is_empty() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
self.stash_entries(to_stash, message, cx)
|
||||
}
|
||||
|
||||
pub fn stash_entries(
|
||||
&mut self,
|
||||
entries: Vec<RepoPath>,
|
||||
@@ -7948,6 +7978,43 @@ impl Repository {
|
||||
.map(|repo_path| repo_path.as_unix_str().to_owned())
|
||||
.collect(),
|
||||
message,
|
||||
staged: None,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
})?
|
||||
.await??;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stash_staged(
|
||||
&mut self,
|
||||
message: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let id = self.id;
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
this.update(cx, |this, _| {
|
||||
this.send_job("stash_staged", None, move |git_repo, _cx| async move {
|
||||
match git_repo {
|
||||
RepositoryState::Local(LocalRepositoryState {
|
||||
backend,
|
||||
environment,
|
||||
..
|
||||
}) => backend.stash_staged(message, environment).await,
|
||||
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
|
||||
client
|
||||
.request(proto::Stash {
|
||||
project_id: project_id.0,
|
||||
repository_id: id.to_proto(),
|
||||
paths: Vec::new(),
|
||||
message,
|
||||
staged: Some(true),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -379,6 +379,8 @@ message Stash {
|
||||
uint64 repository_id = 2;
|
||||
repeated string paths = 3;
|
||||
optional string message = 4;
|
||||
// When set, stash the index only (`git stash push --staged`) and ignore `paths`.
|
||||
optional bool staged = 5;
|
||||
}
|
||||
|
||||
message StashPop {
|
||||
|
||||
+12
-2
@@ -155,6 +155,8 @@ From the panel, you can simply type a commit message and hit the commit button,
|
||||
|
||||
Entries can be staged using each individual entry's checkbox. All changes can be staged using the button at the top of the panel, or {#action git::StageAll}.
|
||||
|
||||
Entries are grouped into sections (**Tracked** and **Untracked** by default), and each section can be staged on its own without touching the others. Click the checkbox on a section header, or use {#action git::StageSection} / {#action git::UnstageSection}, which act on the section containing the selected entry. These are also available by right-clicking an entry. Neither is bound by default; bind them in your keymap under the `GitPanel` context if you use them often.
|
||||
|
||||
To open a changed file in the editor without a diff view, right-click on the file in the Git Panel and select **View File**. Use **Open Diff** ({#kb menu::Confirm}) or **Open Diff (File)** to review changes in a diff view instead.
|
||||
|
||||
<!-- Add media -->
|
||||
@@ -255,9 +257,13 @@ Git stash allows you to temporarily save your uncommitted changes and revert you
|
||||
|
||||
### Creating Stashes
|
||||
|
||||
To stash all your current changes, use the {#action git::StashAll} action. This will save both staged and unstaged changes to a new stash entry and clean your working directory.
|
||||
To stash all your current changes, use the {#action git::StashAll} action. This will save both staged and unstaged changes, including untracked files, to a new stash entry and clean your working directory.
|
||||
|
||||
The action first prompts for an optional stash name. Confirming with an empty prompt lets git generate its usual `WIP on ...` description.
|
||||
To stash only tracked changes and leave untracked files in place, use the {#action git::StashTracked} action. To stash only staged changes and leave unstaged changes in place, use the {#action git::StashStaged} action; this requires git 2.35 or newer.
|
||||
|
||||
The Git Panel's overflow menu offers whichever of these matches the current **Group By** mode: **Stash Tracked** when grouping by Tracked & Untracked, and **Stash Staged** when grouping by Staged & Unstaged. **Stash All** is always available.
|
||||
|
||||
Each of these actions first prompts for an optional stash name. Confirming with an empty prompt lets git generate its usual `WIP on ...` description.
|
||||
|
||||
### Managing Stashes
|
||||
|
||||
@@ -385,6 +391,8 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or
|
||||
| {#action git::Add} | {#kb git::Add} |
|
||||
| {#action git::StageAll} | {#kb git::StageAll} |
|
||||
| {#action git::UnstageAll} | {#kb git::UnstageAll} |
|
||||
| {#action git::StageSection} | {#kb git::StageSection} |
|
||||
| {#action git::UnstageSection} | {#kb git::UnstageSection} |
|
||||
| {#action git::ToggleStaged} | {#kb git::ToggleStaged} |
|
||||
| {#action git::StageAndNext} | {#kb git::StageAndNext} |
|
||||
| {#action git::UnstageAndNext} | {#kb git::UnstageAndNext} |
|
||||
@@ -404,6 +412,8 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or
|
||||
| {#action git::Worktree} | {#kb git::Worktree} |
|
||||
| {#action git::Blame} | {#kb git::Blame} |
|
||||
| {#action git::StashAll} | {#kb git::StashAll} |
|
||||
| {#action git::StashTracked} | {#kb git::StashTracked} |
|
||||
| {#action git::StashStaged} | {#kb git::StashStaged} |
|
||||
| {#action git::StashPop} | {#kb git::StashPop} |
|
||||
| {#action git::StashApply} | {#kb git::StashApply} |
|
||||
| {#action git::ViewStash} | {#kb git::ViewStash} |
|
||||
|
||||
Reference in new issue
Block a user