Git context menu (#24844)

Conrad Irwin created

Adds the non-entry specific right click menu to the panel, and the
features contained therin:

* Stage all
* Discard Tracked Changes
* Trash Untracked Files

Also changes the naming from "Changes"/"New" to better match Git's
terminology (though not convinced on this, it was awkward to describe
"Discard Changes" without a way to distinguish between the changes and
the files containing them).

Release Notes:

- N/A

Change summary

Cargo.lock                     |   1 
crates/collab/src/rpc.rs       |   1 
crates/git/src/git.rs          |   3 
crates/git/src/repository.rs   |  30 ++
crates/git_ui/Cargo.toml       |   1 
crates/git_ui/src/git_panel.rs | 426 +++++++++++++++++++++++++++++------
crates/project/src/git.rs      |  82 ++++++
crates/proto/proto/zed.proto   |  11 
crates/proto/src/proto.rs      |   3 
9 files changed, 482 insertions(+), 76 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5370,6 +5370,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
+ "strum",
  "theme",
  "time",
  "ui",

crates/collab/src/rpc.rs 🔗

@@ -397,6 +397,7 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)
             .add_request_handler(forward_read_only_project_request::<proto::GitShow>)
             .add_request_handler(forward_read_only_project_request::<proto::GitReset>)
+            .add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)
             .add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)
             .add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
             .add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)

crates/git/src/git.rs 🔗

@@ -37,7 +37,8 @@ actions!(
         // editor::RevertSelectedHunks
         StageAll,
         UnstageAll,
-        RevertAll,
+        DiscardTrackedChanges,
+        TrashUntrackedFiles,
         Uncommit,
         Commit,
         ClearCommitMessage

crates/git/src/repository.rs 🔗

@@ -111,6 +111,7 @@ pub trait GitRepository: Send + Sync {
     fn branch_exits(&self, _: &str) -> Result<bool>;
 
     fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
+    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()>;
 
     fn show(&self, commit: &str) -> Result<CommitDetails>;
 
@@ -233,6 +234,31 @@ impl GitRepository for RealGitRepository {
         Ok(())
     }
 
+    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()> {
+        if paths.is_empty() {
+            return Ok(());
+        }
+        let working_directory = self
+            .repository
+            .lock()
+            .workdir()
+            .context("failed to read git work directory")?
+            .to_path_buf();
+
+        let output = new_std_command(&self.git_binary_path)
+            .current_dir(&working_directory)
+            .args(["checkout", commit, "--"])
+            .args(paths.iter().map(|path| path.as_ref()))
+            .output()?;
+        if !output.status.success() {
+            return Err(anyhow!(
+                "Failed to checkout files:\n{}",
+                String::from_utf8_lossy(&output.stderr)
+            ));
+        }
+        Ok(())
+    }
+
     fn load_index_text(&self, path: &RepoPath) -> Option<String> {
         fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
             const STAGE_NORMAL: i32 = 0;
@@ -617,6 +643,10 @@ impl GitRepository for FakeGitRepository {
         unimplemented!()
     }
 
+    fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
+        unimplemented!()
+    }
+
     fn path(&self) -> PathBuf {
         let state = self.state.lock();
         state.path.clone()

crates/git_ui/Cargo.toml 🔗

@@ -36,6 +36,7 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+strum.workspace = true
 theme.workspace = true
 time.workspace = true
 ui.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,9 +1,9 @@
 use crate::git_panel_settings::StatusStyle;
 use crate::repository_selector::RepositorySelectorPopoverMenu;
-use crate::ProjectDiff;
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
+use crate::{project_diff, ProjectDiff};
 use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
 use editor::commit_tooltip::CommitTooltip;
@@ -13,6 +13,7 @@ use editor::{
 };
 use git::repository::{CommitDetails, ResetMode};
 use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
+use git::{DiscardTrackedChanges, StageAll, TrashUntrackedFiles, UnstageAll};
 use gpui::*;
 use itertools::Itertools;
 use language::{markdown, Buffer, File, ParsedMarkdown};
@@ -26,13 +27,13 @@ use project::{
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
+use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
 use ui::{
     prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
     ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
 };
 use util::{maybe, ResultExt, TryFutureExt};
-use workspace::SaveIntent;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotificationId},
@@ -51,6 +52,21 @@ actions!(
     ]
 );
 
+fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
+where
+    T: IntoEnumIterator + VariantNames + 'static,
+{
+    let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
+    cx.spawn(|_| async move { Ok(T::iter().nth(rx.await?).unwrap()) })
+}
+
+#[derive(strum::EnumIter, strum::VariantNames)]
+#[strum(serialize_all = "title_case")]
+enum TrashCancel {
+    Trash,
+    Cancel,
+}
+
 const GIT_PANEL_KEY: &str = "GitPanel";
 
 const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -112,8 +128,8 @@ impl GitHeaderEntry {
     pub fn title(&self) -> &'static str {
         match self.header {
             Section::Conflict => "Conflicts",
-            Section::Tracked => "Changes",
-            Section::New => "New",
+            Section::Tracked => "Tracked",
+            Section::New => "Untracked",
         }
     }
 }
@@ -142,9 +158,17 @@ pub struct GitStatusEntry {
     pub(crate) is_staged: Option<bool>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TargetStatus {
+    Staged,
+    Unstaged,
+    Reverted,
+    Unchanged,
+}
+
 struct PendingOperation {
     finished: bool,
-    will_become_staged: bool,
+    target_status: TargetStatus,
     repo_paths: HashSet<RepoPath>,
     op_id: usize,
 }
@@ -599,7 +623,7 @@ impl GitPanel {
         });
     }
 
-    fn revert(
+    fn revert_selected(
         &mut self,
         _: &editor::actions::RevertFile,
         window: &mut Window,
@@ -608,28 +632,37 @@ impl GitPanel {
         maybe!({
             let list_entry = self.entries.get(self.selected_entry?)?.clone();
             let entry = list_entry.status_entry()?;
-            let active_repo = self.active_repository.as_ref()?;
+            self.revert_entry(&entry, window, cx);
+            Some(())
+        });
+    }
+
+    fn revert_entry(
+        &mut self,
+        entry: &GitStatusEntry,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        maybe!({
+            let active_repo = self.active_repository.clone()?;
             let path = active_repo
                 .read(cx)
                 .repo_path_to_project_path(&entry.repo_path)?;
             let workspace = self.workspace.clone();
 
             if entry.status.is_staged() != Some(false) {
-                self.update_staging_area_for_entries(false, vec![entry.repo_path.clone()], cx);
+                self.perform_stage(false, vec![entry.repo_path.clone()], cx);
             }
+            let filename = path.path.file_name()?.to_string_lossy();
 
-            if entry.status.is_created() {
-                let prompt = window.prompt(
-                    PromptLevel::Info,
-                    "Do you want to trash this file?",
-                    None,
-                    &["Trash", "Cancel"],
-                    cx,
-                );
+            if !entry.status.is_created() {
+                self.perform_checkout(vec![entry.repo_path.clone()], cx);
+            } else {
+                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
                 cx.spawn_in(window, |_, mut cx| async move {
-                    match prompt.await {
-                        Ok(0) => {}
-                        _ => return Ok(()),
+                    match prompt.await? {
+                        TrashCancel::Trash => {}
+                        TrashCancel::Cancel => return Ok(()),
                     }
                     let task = workspace.update(&mut cx, |workspace, cx| {
                         workspace
@@ -647,45 +680,235 @@ impl GitPanel {
                     cx,
                     |e, _, _| Some(format!("{e}")),
                 );
-                return Some(());
             }
+            Some(())
+        });
+    }
 
-            let open_path = workspace.update(cx, |workspace, cx| {
-                workspace.open_path_preview(path, None, true, false, window, cx)
-            });
+    fn perform_checkout(&mut self, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
+        let workspace = self.workspace.clone();
+        let Some(active_repository) = self.active_repository.clone() else {
+            return;
+        };
 
-            cx.spawn_in(window, |_, mut cx| async move {
-                let item = open_path?.await?;
-                let editor = cx.update(|_, cx| {
-                    item.act_as::<Editor>(cx)
-                        .ok_or_else(|| anyhow::anyhow!("didn't open editor"))
-                })??;
+        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
+        self.pending.push(PendingOperation {
+            op_id,
+            target_status: TargetStatus::Reverted,
+            repo_paths: repo_paths.iter().cloned().collect(),
+            finished: false,
+        });
+        self.update_visible_entries(cx);
+        let task = cx.spawn(|_, mut cx| async move {
+            let tasks: Vec<_> = workspace.update(&mut cx, |workspace, cx| {
+                workspace.project().update(cx, |project, cx| {
+                    repo_paths
+                        .iter()
+                        .filter_map(|repo_path| {
+                            let path = active_repository
+                                .read(cx)
+                                .repo_path_to_project_path(&repo_path)?;
+                            Some(project.open_buffer(path, cx))
+                        })
+                        .collect()
+                })
+            })?;
 
-                if let Some(task) =
-                    editor.update(&mut cx, |editor, _| editor.wait_for_diff_to_load())?
-                {
-                    task.await
-                };
+            let buffers = futures::future::join_all(tasks).await;
 
-                editor.update_in(&mut cx, |editor, window, cx| {
-                    editor.revert_file(&Default::default(), window, cx);
-                })?;
+            active_repository
+                .update(&mut cx, |repo, _| repo.checkout_files("HEAD", repo_paths))?
+                .await??;
 
-                workspace
-                    .update_in(&mut cx, |workspace, window, cx| {
-                        workspace.save_active_item(SaveIntent::Save, window, cx)
-                    })?
-                    .await?;
-                Ok(())
+            let tasks: Vec<_> = cx.update(|cx| {
+                buffers
+                    .iter()
+                    .filter_map(|buffer| {
+                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
+                            buffer.is_dirty().then(|| buffer.reload(cx))
+                        })
+                    })
+                    .collect()
+            })?;
+
+            futures::future::join_all(tasks).await;
+
+            Ok(())
+        });
+
+        cx.spawn(|this, mut cx| async move {
+            let result = task.await;
+
+            this.update(&mut cx, |this, cx| {
+                for pending in this.pending.iter_mut() {
+                    if pending.op_id == op_id {
+                        pending.finished = true;
+                        if result.is_err() {
+                            pending.target_status = TargetStatus::Unchanged;
+                            this.update_visible_entries(cx);
+                        }
+                        break;
+                    }
+                }
+                result
+                    .map_err(|e| {
+                        this.show_err_toast(e, cx);
+                    })
+                    .ok();
             })
-            .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
-                Some(format!("{e}"))
-            });
+            .ok();
+        })
+        .detach();
+    }
 
-            Some(())
+    fn discard_tracked_changes(
+        &mut self,
+        _: &DiscardTrackedChanges,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let entries = self
+            .entries
+            .iter()
+            .filter_map(|entry| entry.status_entry().cloned())
+            .filter(|status_entry| !status_entry.status.is_created())
+            .collect::<Vec<_>>();
+
+        match entries.len() {
+            0 => return,
+            1 => return self.revert_entry(&entries[0], window, cx),
+            _ => {}
+        }
+        let details = entries
+            .iter()
+            .filter_map(|entry| entry.repo_path.0.file_name())
+            .map(|filename| filename.to_string_lossy())
+            .join("\n");
+
+        #[derive(strum::EnumIter, strum::VariantNames)]
+        #[strum(serialize_all = "title_case")]
+        enum DiscardCancel {
+            DiscardTrackedChanges,
+            Cancel,
+        }
+        let prompt = prompt(
+            "Discard changes to these files?",
+            Some(&details),
+            window,
+            cx,
+        );
+        cx.spawn(|this, mut cx| async move {
+            match prompt.await {
+                Ok(DiscardCancel::DiscardTrackedChanges) => {
+                    this.update(&mut cx, |this, cx| {
+                        let repo_paths = entries.into_iter().map(|entry| entry.repo_path).collect();
+                        this.perform_checkout(repo_paths, cx);
+                    })
+                    .ok();
+                }
+                _ => {
+                    return;
+                }
+            }
+        })
+        .detach();
+    }
+
+    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
+        let workspace = self.workspace.clone();
+        let Some(active_repo) = self.active_repository.clone() else {
+            return;
+        };
+        let to_delete = self
+            .entries
+            .iter()
+            .filter_map(|entry| entry.status_entry())
+            .filter(|status_entry| status_entry.status.is_created())
+            .cloned()
+            .collect::<Vec<_>>();
+
+        match to_delete.len() {
+            0 => return,
+            1 => return self.revert_entry(&to_delete[0], window, cx),
+            _ => {}
+        };
+
+        let details = to_delete
+            .iter()
+            .map(|entry| {
+                entry
+                    .repo_path
+                    .0
+                    .file_name()
+                    .map(|f| f.to_string_lossy())
+                    .unwrap_or_default()
+            })
+            .join("\n");
+
+        let prompt = prompt("Trash these files?", Some(&details), window, cx);
+        cx.spawn_in(window, |this, mut cx| async move {
+            match prompt.await? {
+                TrashCancel::Trash => {}
+                TrashCancel::Cancel => return Ok(()),
+            }
+            let tasks = workspace.update(&mut cx, |workspace, cx| {
+                to_delete
+                    .iter()
+                    .filter_map(|entry| {
+                        workspace.project().update(cx, |project, cx| {
+                            let project_path = active_repo
+                                .read(cx)
+                                .repo_path_to_project_path(&entry.repo_path)?;
+                            project.delete_file(project_path, true, cx)
+                        })
+                    })
+                    .collect::<Vec<_>>()
+            })?;
+            let to_unstage = to_delete
+                .into_iter()
+                .filter_map(|entry| {
+                    if entry.status.is_staged() != Some(false) {
+                        Some(entry.repo_path.clone())
+                    } else {
+                        None
+                    }
+                })
+                .collect();
+            this.update(&mut cx, |this, cx| {
+                this.perform_stage(false, to_unstage, cx)
+            })?;
+            for task in tasks {
+                task.await?;
+            }
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
+            Some(format!("{e}"))
         });
     }
 
+    fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
+        let repo_paths = self
+            .entries
+            .iter()
+            .filter_map(|entry| entry.status_entry())
+            .filter(|status_entry| status_entry.is_staged != Some(true))
+            .map(|status_entry| status_entry.repo_path.clone())
+            .collect::<Vec<_>>();
+        self.perform_stage(true, repo_paths, cx);
+    }
+
+    fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
+        let repo_paths = self
+            .entries
+            .iter()
+            .filter_map(|entry| entry.status_entry())
+            .filter(|status_entry| status_entry.is_staged != Some(false))
+            .map(|status_entry| status_entry.repo_path.clone())
+            .collect::<Vec<_>>();
+        self.perform_stage(false, repo_paths, cx);
+    }
+
     fn toggle_staged_for_entry(
         &mut self,
         entry: &GitListEntry,
@@ -720,22 +943,21 @@ impl GitPanel {
                 (goal_staged_state, entries)
             }
         };
-        self.update_staging_area_for_entries(stage, repo_paths, cx);
+        self.perform_stage(stage, repo_paths, cx);
     }
 
-    fn update_staging_area_for_entries(
-        &mut self,
-        stage: bool,
-        repo_paths: Vec<RepoPath>,
-        cx: &mut Context<Self>,
-    ) {
+    fn perform_stage(&mut self, stage: bool, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
         let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
         self.pending.push(PendingOperation {
             op_id,
-            will_become_staged: stage,
+            target_status: if stage {
+                TargetStatus::Staged
+            } else {
+                TargetStatus::Unstaged
+            },
             repo_paths: repo_paths.iter().cloned().collect(),
             finished: false,
         });
@@ -1105,6 +1327,14 @@ impl GitPanel {
             let is_new = entry.status.is_created();
             let is_staged = entry.status.is_staged();
 
+            if self.pending.iter().any(|pending| {
+                pending.target_status == TargetStatus::Reverted
+                    && !pending.finished
+                    && pending.repo_paths.contains(&entry.repo_path)
+            }) {
+                continue;
+            }
+
             let display_name = if difference > 1 {
                 // Show partial path for deeply nested files
                 entry
@@ -1236,7 +1466,12 @@ impl GitPanel {
     fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
         for pending in self.pending.iter().rev() {
             if pending.repo_paths.contains(&entry.repo_path) {
-                return Some(pending.will_become_staged);
+                match pending.target_status {
+                    TargetStatus::Staged => return Some(true),
+                    TargetStatus::Unstaged => return Some(false),
+                    TargetStatus::Reverted => continue,
+                    TargetStatus::Unchanged => continue,
+                }
             }
         }
         entry.is_staged
@@ -1248,6 +1483,10 @@ impl GitPanel {
             || self.conflicted_staged_count > 0
     }
 
+    fn has_conflicts(&self) -> bool {
+        self.conflicted_count > 0
+    }
+
     fn has_tracked_changes(&self) -> bool {
         self.tracked_count > 0
     }
@@ -1316,10 +1555,17 @@ impl GitPanel {
                 .is_above_project()
         });
 
-        self.panel_header_container(window, cx)
-            .when(all_repositories.len() > 1 || has_repo_above, |el| {
-                el.child(self.render_repository_selector(cx))
-            })
+        self.panel_header_container(window, cx).when(
+            all_repositories.len() > 1 || has_repo_above,
+            |el| {
+                el.child(
+                    Label::new("Repository")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .child(self.render_repository_selector(cx))
+            },
+        )
     }
 
     pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -1701,6 +1947,12 @@ impl GitPanel {
                 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
                 .track_scroll(self.scroll_handle.clone()),
             )
+            .on_mouse_down(
+                MouseButton::Right,
+                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
+                    this.deploy_panel_context_menu(event.position, window, cx)
+                }),
+            )
             .children(self.render_scrollbar(cx))
     }
 
@@ -1743,7 +1995,7 @@ impl GitPanel {
         repo.update(cx, |repo, cx| repo.show(sha, cx))
     }
 
-    fn deploy_context_menu(
+    fn deploy_entry_context_menu(
         &mut self,
         position: Point<Pixels>,
         ix: usize,
@@ -1768,7 +2020,38 @@ impl GitPanel {
                 .action("Open Diff", Confirm.boxed_clone())
                 .action("Open File", SecondaryConfirm.boxed_clone())
         });
+        self.selected_entry = Some(ix);
+        self.set_context_menu(context_menu, position, window, cx);
+    }
+
+    fn deploy_panel_context_menu(
+        &mut self,
+        position: Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
+            context_menu
+                .action("Stage All", StageAll.boxed_clone())
+                .action("Unstage All", UnstageAll.boxed_clone())
+                .action("Open Diff", project_diff::Diff.boxed_clone())
+                .separator()
+                .action(
+                    "Discard Tracked Changes",
+                    DiscardTrackedChanges.boxed_clone(),
+                )
+                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
+        });
+        self.set_context_menu(context_menu, position, window, cx);
+    }
 
+    fn set_context_menu(
+        &mut self,
+        context_menu: Entity<ContextMenu>,
+        position: Point<Pixels>,
+        window: &Window,
+        cx: &mut Context<Self>,
+    ) {
         let subscription = cx.subscribe_in(
             &context_menu,
             window,
@@ -1782,7 +2065,6 @@ impl GitPanel {
                 cx.notify();
             },
         );
-        self.selected_entry = Some(ix);
         self.context_menu = Some((context_menu, position, subscription));
         cx.notify();
     }
@@ -1834,14 +2116,14 @@ impl GitPanel {
 
         let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
 
-        if !self.has_staged_changes() && !entry.status.is_created() {
+        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
             is_staged = ToggleState::Selected;
         }
 
         let checkbox = Checkbox::new(id, is_staged)
             .disabled(!has_write_access)
             .fill()
-            .placeholder(!self.has_staged_changes())
+            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
             .elevation(ElevationIndex::Surface)
             .on_click({
                 let entry = entry.clone();
@@ -1888,7 +2170,8 @@ impl GitPanel {
                     })
                     .on_secondary_mouse_down(cx.listener(
                         move |this, event: &MouseDownEvent, window, cx| {
-                            this.deploy_context_menu(event.position, ix, window, cx)
+                            this.deploy_entry_context_menu(event.position, ix, window, cx);
+                            cx.stop_propagation();
                         },
                     ))
                     .child(
@@ -1921,12 +2204,7 @@ impl GitPanel {
 impl Render for GitPanel {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let project = self.project.read(cx);
-        let has_entries = self
-            .active_repository
-            .as_ref()
-            .map_or(false, |active_repository| {
-                active_repository.read(cx).entry_count() > 0
-            });
+        let has_entries = self.entries.len() > 0;
         let room = self
             .workspace
             .upgrade()
@@ -1959,10 +2237,14 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::close_panel))
             .on_action(cx.listener(Self::open_diff))
             .on_action(cx.listener(Self::open_file))
-            .on_action(cx.listener(Self::revert))
+            .on_action(cx.listener(Self::revert_selected))
             .on_action(cx.listener(Self::focus_changes_list))
             .on_action(cx.listener(Self::focus_editor))
             .on_action(cx.listener(Self::toggle_staged_for_selected))
+            .on_action(cx.listener(Self::stage_all))
+            .on_action(cx.listener(Self::unstage_all))
+            .on_action(cx.listener(Self::discard_tracked_changes))
+            .on_action(cx.listener(Self::clean_all))
             .when(has_write_access && has_co_authors, |git_panel| {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })

crates/project/src/git.rs 🔗

@@ -65,6 +65,11 @@ pub enum Message {
         commit: SharedString,
         reset_mode: ResetMode,
     },
+    CheckoutFiles {
+        repo: GitRepo,
+        commit: SharedString,
+        paths: Vec<RepoPath>,
+    },
     Stage(GitRepo, Vec<RepoPath>),
     Unstage(GitRepo, Vec<RepoPath>),
     SetIndexText(GitRepo, RepoPath, Option<String>),
@@ -106,6 +111,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_commit);
         client.add_entity_request_handler(Self::handle_reset);
         client.add_entity_request_handler(Self::handle_show);
+        client.add_entity_request_handler(Self::handle_checkout_files);
         client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
         client.add_entity_request_handler(Self::handle_set_index_text);
     }
@@ -121,8 +127,6 @@ impl GitStore {
         event: &WorktreeStoreEvent,
         cx: &mut Context<'_, Self>,
     ) {
-        // TODO inspect the event
-
         let mut new_repositories = Vec::new();
         let mut new_active_index = None;
         let this = cx.weak_entity();
@@ -282,6 +286,36 @@ impl GitStore {
                 }
                 Ok(())
             }
+
+            Message::CheckoutFiles {
+                repo,
+                commit,
+                paths,
+            } => {
+                match repo {
+                    GitRepo::Local(repo) => repo.checkout_files(&commit, &paths)?,
+                    GitRepo::Remote {
+                        project_id,
+                        client,
+                        worktree_id,
+                        work_directory_id,
+                    } => {
+                        client
+                            .request(proto::GitCheckoutFiles {
+                                project_id: project_id.0,
+                                worktree_id: worktree_id.to_proto(),
+                                work_directory_id: work_directory_id.to_proto(),
+                                commit: commit.into(),
+                                paths: paths
+                                    .into_iter()
+                                    .map(|p| p.to_string_lossy().to_string())
+                                    .collect(),
+                            })
+                            .await?;
+                    }
+                }
+                Ok(())
+            }
             Message::Unstage(repo, paths) => {
                 match repo {
                     GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
@@ -502,6 +536,30 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_checkout_files(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitCheckoutFiles>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+        let paths = envelope
+            .payload
+            .paths
+            .iter()
+            .map(|s| RepoPath::from_str(s))
+            .collect();
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.checkout_files(&envelope.payload.commit, paths)
+            })?
+            .await??;
+        Ok(proto::Ack {})
+    }
+
     async fn handle_open_commit_message_buffer(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::OpenCommitMessageBuffer>,
@@ -712,6 +770,26 @@ impl Repository {
         })
     }
 
+    pub fn checkout_files(
+        &self,
+        commit: &str,
+        paths: Vec<RepoPath>,
+    ) -> oneshot::Receiver<Result<()>> {
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
+        let commit = commit.to_string().into();
+        self.update_sender
+            .unbounded_send((
+                Message::CheckoutFiles {
+                    repo: self.git_repo.clone(),
+                    commit,
+                    paths,
+                },
+                result_tx,
+            ))
+            .ok();
+        result_rx
+    }
+
     pub fn reset(&self, commit: &str, reset_mode: ResetMode) -> oneshot::Receiver<Result<()>> {
         let (result_tx, result_rx) = futures::channel::oneshot::channel();
         let commit = commit.to_string().into();

crates/proto/proto/zed.proto 🔗

@@ -320,7 +320,8 @@ message Envelope {
         GitReset git_reset = 301;
         GitCommitDetails git_commit_details = 302;
 
-        SetIndexText set_index_text = 299; // current max
+        SetIndexText set_index_text = 299;
+        GitCheckoutFiles git_checkout_files = 303; // current max
     }
 
     reserved 87 to 88;
@@ -2688,6 +2689,14 @@ message GitReset {
     }
 }
 
+message GitCheckoutFiles {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    uint64 work_directory_id = 3;
+    string commit = 4;
+    repeated string paths = 5;
+}
+
 message GetPanicFilesResponse {
     repeated string file_contents = 2;
 }

crates/proto/src/proto.rs 🔗

@@ -441,6 +441,7 @@ messages!(
     (InstallExtension, Background),
     (RegisterBufferWithLanguageServers, Background),
     (GitReset, Background),
+    (GitCheckoutFiles, Background),
     (GitShow, Background),
     (GitCommitDetails, Background),
     (SetIndexText, Background),
@@ -579,6 +580,7 @@ request_messages!(
     (RegisterBufferWithLanguageServers, Ack),
     (GitShow, GitCommitDetails),
     (GitReset, Ack),
+    (GitCheckoutFiles, Ack),
     (SetIndexText, Ack),
 );
 
@@ -674,6 +676,7 @@ entity_messages!(
     RegisterBufferWithLanguageServers,
     GitShow,
     GitReset,
+    GitCheckoutFiles,
     SetIndexText,
 );