git: Handle shift-click to stage a range of entries in the panel (#34296)

Cole Miller created

Release Notes:

- git: shift-click can now be used to stage a range of entries in the
git panel.

Change summary

assets/keymaps/default-linux.json  |   1 
assets/keymaps/default-macos.json  |   1 
assets/keymaps/vim.json            |   1 
crates/fs/src/fake_git_repo.rs     |  40 ++
crates/git/src/git.rs              |   4 
crates/git_ui/src/git_panel.rs     | 456 ++++++++++++++++++++++---------
crates/ui/src/components/toggle.rs |  19 +
7 files changed, 373 insertions(+), 149 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -856,6 +856,7 @@
       "alt-shift-y": "git::UnstageFile",
       "ctrl-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",
+      "shift-space": "git::StageRange",
       "tab": "git_panel::FocusEditor",
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",

assets/keymaps/default-macos.json 🔗

@@ -930,6 +930,7 @@
       "enter": "menu::Confirm",
       "cmd-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",
+      "shift-space": "git::StageRange",
       "cmd-y": "git::StageFile",
       "cmd-shift-y": "git::UnstageFile",
       "alt-down": "git_panel::FocusEditor",

assets/keymaps/vim.json 🔗

@@ -841,6 +841,7 @@
       "i": "git_panel::FocusEditor",
       "x": "git::ToggleStaged",
       "shift-x": "git::StageAll",
+      "g x": "git::StageRange",
       "shift-u": "git::UnstageAll"
     }
   },

crates/fs/src/fake_git_repo.rs 🔗

@@ -1,7 +1,7 @@
-use crate::FakeFs;
+use crate::{FakeFs, Fs};
 use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
-use futures::future::{self, BoxFuture};
+use futures::future::{self, BoxFuture, join_all};
 use git::{
     blame::Blame,
     repository::{
@@ -356,18 +356,46 @@ impl GitRepository for FakeGitRepository {
 
     fn stage_paths(
         &self,
-        _paths: Vec<RepoPath>,
+        paths: Vec<RepoPath>,
         _env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>> {
-        unimplemented!()
+        Box::pin(async move {
+            let contents = paths
+                .into_iter()
+                .map(|path| {
+                    let abs_path = self.dot_git_path.parent().unwrap().join(&path);
+                    Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
+                })
+                .collect::<Vec<_>>();
+            let contents = join_all(contents).await;
+            self.with_state_async(true, move |state| {
+                for (path, content) in contents {
+                    if let Some(content) = content {
+                        state.index_contents.insert(path, content);
+                    } else {
+                        state.index_contents.remove(&path);
+                    }
+                }
+                Ok(())
+            })
+            .await
+        })
     }
 
     fn unstage_paths(
         &self,
-        _paths: Vec<RepoPath>,
+        paths: Vec<RepoPath>,
         _env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>> {
-        unimplemented!()
+        self.with_state_async(true, move |state| {
+            for path in paths {
+                match state.head_contents.get(&path) {
+                    Some(content) => state.index_contents.insert(path, content.clone()),
+                    None => state.index_contents.remove(&path),
+                };
+            }
+            Ok(())
+        })
     }
 
     fn commit(

crates/git/src/git.rs 🔗

@@ -31,8 +31,10 @@ actions!(
     git,
     [
         // per-hunk
-        /// Toggles the staged state of the hunk at cursor.
+        /// Toggles the staged state of the hunk or status entry at cursor.
         ToggleStaged,
+        /// Stage status entries between an anchor entry and the cursor.
+        StageRange,
         /// Stages the current hunk and moves to the next one.
         StageAndNext,
         /// Unstages the current hunk and moves to the next one.

crates/git_ui/src/git_panel.rs 🔗

@@ -30,10 +30,9 @@ use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles
 use gpui::{
     Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
-    ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task,
-    Transformation, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, percentage,
-    uniform_list,
+    ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
+    PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
+    WeakEntity, actions, anchored, deferred, percentage, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -48,7 +47,7 @@ use panel::{
     PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
     panel_icon_button,
 };
-use project::git_store::RepositoryEvent;
+use project::git_store::{RepositoryEvent, RepositoryId};
 use project::{
     Fs, Project, ProjectPath,
     git_store::{GitStoreEvent, Repository},
@@ -212,14 +211,14 @@ impl GitHeaderEntry {
 
 #[derive(Debug, PartialEq, Eq, Clone)]
 enum GitListEntry {
-    GitStatusEntry(GitStatusEntry),
+    Status(GitStatusEntry),
     Header(GitHeaderEntry),
 }
 
 impl GitListEntry {
     fn status_entry(&self) -> Option<&GitStatusEntry> {
         match self {
-            GitListEntry::GitStatusEntry(entry) => Some(entry),
+            GitListEntry::Status(entry) => Some(entry),
             _ => None,
         }
     }
@@ -323,7 +322,6 @@ pub struct GitPanel {
     pub(crate) commit_editor: Entity<Editor>,
     conflicted_count: usize,
     conflicted_staged_count: usize,
-    current_modifiers: Modifiers,
     add_coauthors: bool,
     generate_commit_message_task: Option<Task<Option<()>>>,
     entries: Vec<GitListEntry>,
@@ -355,9 +353,16 @@ pub struct GitPanel {
     show_placeholders: bool,
     local_committer: Option<GitCommitter>,
     local_committer_task: Option<Task<()>>,
+    bulk_staging: Option<BulkStaging>,
     _settings_subscription: Subscription,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct BulkStaging {
+    repo_id: RepositoryId,
+    anchor: RepoPath,
+}
+
 const MAX_PANEL_EDITOR_LINES: usize = 6;
 
 pub(crate) fn commit_message_editor(
@@ -497,7 +502,6 @@ impl GitPanel {
                 commit_editor,
                 conflicted_count: 0,
                 conflicted_staged_count: 0,
-                current_modifiers: window.modifiers(),
                 add_coauthors: true,
                 generate_commit_message_task: None,
                 entries: Vec::new(),
@@ -529,6 +533,7 @@ impl GitPanel {
                 entry_count: 0,
                 horizontal_scrollbar,
                 vertical_scrollbar,
+                bulk_staging: None,
                 _settings_subscription,
             };
 
@@ -735,16 +740,6 @@ impl GitPanel {
         }
     }
 
-    fn handle_modifiers_changed(
-        &mut self,
-        event: &ModifiersChangedEvent,
-        _: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.current_modifiers = event.modifiers;
-        cx.notify();
-    }
-
     fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
         if let Some(selected_entry) = self.selected_entry {
             self.scroll_handle
@@ -1265,10 +1260,18 @@ impl GitPanel {
             return;
         };
         let (stage, repo_paths) = match entry {
-            GitListEntry::GitStatusEntry(status_entry) => {
+            GitListEntry::Status(status_entry) => {
                 if status_entry.status.staging().is_fully_staged() {
+                    if let Some(op) = self.bulk_staging.clone()
+                        && op.anchor == status_entry.repo_path
+                    {
+                        self.bulk_staging = None;
+                    }
+
                     (false, vec![status_entry.clone()])
                 } else {
+                    self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
+
                     (true, vec![status_entry.clone()])
                 }
             }
@@ -1383,6 +1386,13 @@ impl GitPanel {
         }
     }
 
+    fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
+        let Some(index) = self.selected_entry else {
+            return;
+        };
+        self.stage_bulk(index, cx);
+    }
+
     fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
         let Some(selected_entry) = self.get_selected_entry() else {
             return;
@@ -2449,6 +2459,11 @@ impl GitPanel {
     }
 
     fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
+        let bulk_staging = self.bulk_staging.take();
+        let last_staged_path_prev_index = bulk_staging
+            .as_ref()
+            .and_then(|op| self.entry_by_path(&op.anchor, cx));
+
         self.entries.clear();
         self.single_staged_entry.take();
         self.single_tracked_entry.take();
@@ -2465,7 +2480,7 @@ impl GitPanel {
         let mut changed_entries = Vec::new();
         let mut new_entries = Vec::new();
         let mut conflict_entries = Vec::new();
-        let mut last_staged = None;
+        let mut single_staged_entry = None;
         let mut staged_count = 0;
         let mut max_width_item: Option<(RepoPath, usize)> = None;
 
@@ -2503,7 +2518,7 @@ impl GitPanel {
 
             if staging.has_staged() {
                 staged_count += 1;
-                last_staged = Some(entry.clone());
+                single_staged_entry = Some(entry.clone());
             }
 
             let width_estimate = Self::item_width_estimate(
@@ -2534,27 +2549,27 @@ impl GitPanel {
 
         let mut pending_staged_count = 0;
         let mut last_pending_staged = None;
-        let mut pending_status_for_last_staged = None;
+        let mut pending_status_for_single_staged = None;
         for pending in self.pending.iter() {
             if pending.target_status == TargetStatus::Staged {
                 pending_staged_count += pending.entries.len();
                 last_pending_staged = pending.entries.iter().next().cloned();
             }
-            if let Some(last_staged) = &last_staged {
+            if let Some(single_staged) = &single_staged_entry {
                 if pending
                     .entries
                     .iter()
-                    .any(|entry| entry.repo_path == last_staged.repo_path)
+                    .any(|entry| entry.repo_path == single_staged.repo_path)
                 {
-                    pending_status_for_last_staged = Some(pending.target_status);
+                    pending_status_for_single_staged = Some(pending.target_status);
                 }
             }
         }
 
         if conflict_entries.len() == 0 && staged_count == 1 && pending_staged_count == 0 {
-            match pending_status_for_last_staged {
+            match pending_status_for_single_staged {
                 Some(TargetStatus::Staged) | None => {
-                    self.single_staged_entry = last_staged;
+                    self.single_staged_entry = single_staged_entry;
                 }
                 _ => {}
             }
@@ -2570,11 +2585,8 @@ impl GitPanel {
             self.entries.push(GitListEntry::Header(GitHeaderEntry {
                 header: Section::Conflict,
             }));
-            self.entries.extend(
-                conflict_entries
-                    .into_iter()
-                    .map(GitListEntry::GitStatusEntry),
-            );
+            self.entries
+                .extend(conflict_entries.into_iter().map(GitListEntry::Status));
         }
 
         if changed_entries.len() > 0 {
@@ -2583,31 +2595,39 @@ impl GitPanel {
                     header: Section::Tracked,
                 }));
             }
-            self.entries.extend(
-                changed_entries
-                    .into_iter()
-                    .map(GitListEntry::GitStatusEntry),
-            );
+            self.entries
+                .extend(changed_entries.into_iter().map(GitListEntry::Status));
         }
         if new_entries.len() > 0 {
             self.entries.push(GitListEntry::Header(GitHeaderEntry {
                 header: Section::New,
             }));
             self.entries
-                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
+                .extend(new_entries.into_iter().map(GitListEntry::Status));
         }
 
         if let Some((repo_path, _)) = max_width_item {
             self.max_width_item_index = self.entries.iter().position(|entry| match entry {
-                GitListEntry::GitStatusEntry(git_status_entry) => {
-                    git_status_entry.repo_path == repo_path
-                }
+                GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
                 GitListEntry::Header(_) => false,
             });
         }
 
         self.update_counts(repo);
 
+        let bulk_staging_anchor_new_index = bulk_staging
+            .as_ref()
+            .filter(|op| op.repo_id == repo.id)
+            .and_then(|op| self.entry_by_path(&op.anchor, cx));
+        if bulk_staging_anchor_new_index == last_staged_path_prev_index
+            && let Some(index) = bulk_staging_anchor_new_index
+            && let Some(entry) = self.entries.get(index)
+            && let Some(entry) = entry.status_entry()
+            && self.entry_staging(entry) == StageStatus::Staged
+        {
+            self.bulk_staging = bulk_staging;
+        }
+
         self.select_first_entry_if_none(cx);
 
         let suggested_commit_message = self.suggest_commit_message(cx);
@@ -3743,7 +3763,7 @@ impl GitPanel {
 
                                 for ix in range {
                                     match &this.entries.get(ix) {
-                                        Some(GitListEntry::GitStatusEntry(entry)) => {
+                                        Some(GitListEntry::Status(entry)) => {
                                             items.push(this.render_entry(
                                                 ix,
                                                 entry,
@@ -4000,8 +4020,6 @@ impl GitPanel {
         let marked = self.marked_entries.contains(&ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
         let status = entry.status;
-        let modifiers = self.current_modifiers;
-        let shift_held = modifiers.shift;
 
         let has_conflict = status.is_conflicted();
         let is_modified = status.is_modified();
@@ -4120,12 +4138,6 @@ impl GitPanel {
                     cx.stop_propagation();
                 },
             )
-            // .on_secondary_mouse_down(cx.listener(
-            //     move |this, event: &MouseDownEvent, window, cx| {
-            //         this.deploy_entry_context_menu(event.position, ix, window, cx);
-            //         cx.stop_propagation();
-            //     },
-            // ))
             .child(
                 div()
                     .id(checkbox_wrapper_id)
@@ -4137,46 +4149,35 @@ impl GitPanel {
                             .disabled(!has_write_access)
                             .fill()
                             .elevation(ElevationIndex::Surface)
-                            .on_click({
+                            .on_click_ext({
                                 let entry = entry.clone();
-                                cx.listener(move |this, _, window, cx| {
-                                    if !has_write_access {
-                                        return;
-                                    }
-                                    this.toggle_staged_for_entry(
-                                        &GitListEntry::GitStatusEntry(entry.clone()),
-                                        window,
-                                        cx,
-                                    );
-                                    cx.stop_propagation();
-                                })
+                                let this = cx.weak_entity();
+                                move |_, click, window, cx| {
+                                    this.update(cx, |this, cx| {
+                                        if !has_write_access {
+                                            return;
+                                        }
+                                        if click.modifiers().shift {
+                                            this.stage_bulk(ix, cx);
+                                        } else {
+                                            this.toggle_staged_for_entry(
+                                                &GitListEntry::Status(entry.clone()),
+                                                window,
+                                                cx,
+                                            );
+                                        }
+                                        cx.stop_propagation();
+                                    })
+                                    .ok();
+                                }
                             })
                             .tooltip(move |window, cx| {
                                 let is_staged = entry_staging.is_fully_staged();
 
                                 let action = if is_staged { "Unstage" } else { "Stage" };
-                                let tooltip_name = if shift_held {
-                                    format!("{} section", action)
-                                } else {
-                                    action.to_string()
-                                };
-
-                                let meta = if shift_held {
-                                    format!(
-                                        "Release shift to {} single entry",
-                                        action.to_lowercase()
-                                    )
-                                } else {
-                                    format!("Shift click to {} section", action.to_lowercase())
-                                };
+                                let tooltip_name = action.to_string();
 
-                                Tooltip::with_meta(
-                                    tooltip_name,
-                                    Some(&ToggleStaged),
-                                    meta,
-                                    window,
-                                    cx,
-                                )
+                                Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
                             }),
                     ),
             )
@@ -4242,6 +4243,41 @@ impl GitPanel {
             panel
         })
     }
+
+    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
+        let Some(op) = self.bulk_staging.as_ref() else {
+            return;
+        };
+        let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
+            return;
+        };
+        if let Some(entry) = self.entries.get(index)
+            && let Some(entry) = entry.status_entry()
+        {
+            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
+        }
+        if index < anchor_index {
+            std::mem::swap(&mut index, &mut anchor_index);
+        }
+        let entries = self
+            .entries
+            .get(anchor_index..=index)
+            .unwrap_or_default()
+            .iter()
+            .filter_map(|entry| entry.status_entry().cloned())
+            .collect::<Vec<_>>();
+        self.change_file_stage(true, entries, cx);
+    }
+
+    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
+        let Some(repo) = self.active_repository.as_ref() else {
+            return;
+        };
+        self.bulk_staging = Some(BulkStaging {
+            repo_id: repo.read(cx).id,
+            anchor: path,
+        });
+    }
 }
 
 fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
@@ -4279,9 +4315,9 @@ impl Render for GitPanel {
             .id("git_panel")
             .key_context(self.dispatch_context(window, cx))
             .track_focus(&self.focus_handle)
-            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .when(has_write_access && !project.is_read_only(cx), |this| {
                 this.on_action(cx.listener(Self::toggle_staged_for_selected))
+                    .on_action(cx.listener(Self::stage_range))
                     .on_action(cx.listener(GitPanel::commit))
                     .on_action(cx.listener(GitPanel::amend))
                     .on_action(cx.listener(GitPanel::cancel))
@@ -4953,7 +4989,7 @@ impl Component for PanelRepoFooter {
 
 #[cfg(test)]
 mod tests {
-    use git::status::StatusCode;
+    use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
     use gpui::{TestAppContext, VisualTestContext};
     use project::{FakeFs, WorktreeSettings};
     use serde_json::json;
@@ -5052,13 +5088,13 @@ mod tests {
                 GitListEntry::Header(GitHeaderEntry {
                     header: Section::Tracked
                 }),
-                GitListEntry::GitStatusEntry(GitStatusEntry {
+                GitListEntry::Status(GitStatusEntry {
                     abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
                     repo_path: "crates/gpui/gpui.rs".into(),
                     status: StatusCode::Modified.worktree(),
                     staging: StageStatus::Unstaged,
                 }),
-                GitListEntry::GitStatusEntry(GitStatusEntry {
+                GitListEntry::Status(GitStatusEntry {
                     abs_path: path!("/root/zed/crates/util/util.rs").into(),
                     repo_path: "crates/util/util.rs".into(),
                     status: StatusCode::Modified.worktree(),
@@ -5067,54 +5103,6 @@ mod tests {
             ],
         );
 
-        // TODO(cole) restore this once repository deduplication is implemented properly.
-        //cx.update_window_entity(&panel, |panel, window, cx| {
-        //    panel.select_last(&Default::default(), window, cx);
-        //    assert_eq!(panel.selected_entry, Some(2));
-        //    panel.open_diff(&Default::default(), window, cx);
-        //});
-        //cx.run_until_parked();
-
-        //let worktree_roots = workspace.update(cx, |workspace, cx| {
-        //    workspace
-        //        .worktrees(cx)
-        //        .map(|worktree| worktree.read(cx).abs_path())
-        //        .collect::<Vec<_>>()
-        //});
-        //pretty_assertions::assert_eq!(
-        //    worktree_roots,
-        //    vec![
-        //        Path::new(path!("/root/zed/crates/gpui")).into(),
-        //        Path::new(path!("/root/zed/crates/util/util.rs")).into(),
-        //    ]
-        //);
-
-        //project.update(cx, |project, cx| {
-        //    let git_store = project.git_store().read(cx);
-        //    // The repo that comes from the single-file worktree can't be selected through the UI.
-        //    let filtered_entries = filtered_repository_entries(git_store, cx)
-        //        .iter()
-        //        .map(|repo| repo.read(cx).worktree_abs_path.clone())
-        //        .collect::<Vec<_>>();
-        //    assert_eq!(
-        //        filtered_entries,
-        //        [Path::new(path!("/root/zed/crates/gpui")).into()]
-        //    );
-        //    // But we can select it artificially here.
-        //    let repo_from_single_file_worktree = git_store
-        //        .repositories()
-        //        .values()
-        //        .find(|repo| {
-        //            repo.read(cx).worktree_abs_path.as_ref()
-        //                == Path::new(path!("/root/zed/crates/util/util.rs"))
-        //        })
-        //        .unwrap()
-        //        .clone();
-
-        //    // Paths still make sense when we somehow activate a repo that comes from a single-file worktree.
-        //    repo_from_single_file_worktree.update(cx, |repo, cx| repo.set_as_active_repository(cx));
-        //});
-
         let handle = cx.update_window_entity(&panel, |panel, _, _| {
             std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
         });
@@ -5127,13 +5115,13 @@ mod tests {
                 GitListEntry::Header(GitHeaderEntry {
                     header: Section::Tracked
                 }),
-                GitListEntry::GitStatusEntry(GitStatusEntry {
+                GitListEntry::Status(GitStatusEntry {
                     abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
                     repo_path: "crates/gpui/gpui.rs".into(),
                     status: StatusCode::Modified.worktree(),
                     staging: StageStatus::Unstaged,
                 }),
-                GitListEntry::GitStatusEntry(GitStatusEntry {
+                GitListEntry::Status(GitStatusEntry {
                     abs_path: path!("/root/zed/crates/util/util.rs").into(),
                     repo_path: "crates/util/util.rs".into(),
                     status: StatusCode::Modified.worktree(),
@@ -5142,4 +5130,196 @@ mod tests {
             ],
         );
     }
+
+    #[gpui::test]
+    async fn test_bulk_staging(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() {}",
+                        "utils.rs": "pub fn util() {}"
+                    },
+                    "tests": {
+                        "test.rs": "fn test() {}"
+                    },
+                    "new_file.txt": "new content",
+                    "another_new.rs": "// new file",
+                    "conflict.txt": "conflicted content"
+                }
+            }),
+        )
+        .await;
+
+        fs.set_status_for_repo(
+            Path::new(path!("/root/project/.git")),
+            &[
+                (Path::new("src/main.rs"), StatusCode::Modified.worktree()),
+                (Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
+                (Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
+                (Path::new("new_file.txt"), FileStatus::Untracked),
+                (Path::new("another_new.rs"), FileStatus::Untracked),
+                (Path::new("src/utils.rs"), FileStatus::Untracked),
+                (
+                    Path::new("conflict.txt"),
+                    UnmergedStatus {
+                        first_head: UnmergedStatusCode::Updated,
+                        second_head: UnmergedStatusCode::Updated,
+                    }
+                    .into(),
+                ),
+            ],
+        );
+
+        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .nth(0)
+                .unwrap()
+                .read(cx)
+                .as_local()
+                .unwrap()
+                .scan_complete()
+        })
+        .await;
+
+        cx.executor().run_until_parked();
+
+        let panel = workspace.update(cx, GitPanel::new).unwrap();
+
+        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::Conflict }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                Header(GitHeaderEntry { header: Section::Tracked }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                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, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+            ],
+        );
+
+        let second_status_entry = entries[3].clone();
+        panel.update_in(cx, |panel, window, cx| {
+            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
+        });
+
+        panel.update_in(cx, |panel, window, cx| {
+            panel.selected_entry = Some(7);
+            panel.stage_range(&git::StageRange, window, cx);
+        });
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .nth(0)
+                .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::Conflict }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                Header(GitHeaderEntry { header: Section::Tracked }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Header(GitHeaderEntry { header: Section::New }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+            ],
+        );
+
+        let third_status_entry = entries[4].clone();
+        panel.update_in(cx, |panel, window, cx| {
+            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
+        });
+
+        panel.update_in(cx, |panel, window, cx| {
+            panel.selected_entry = Some(9);
+            panel.stage_range(&git::StageRange, window, cx);
+        });
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .nth(0)
+                .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::Conflict }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                Header(GitHeaderEntry { header: Section::Tracked }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Header(GitHeaderEntry { header: Section::New }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+            ],
+        );
+    }
 }

crates/ui/src/components/toggle.rs 🔗

@@ -1,5 +1,6 @@
 use gpui::{
-    AnyElement, AnyView, ElementId, Hsla, IntoElement, Styled, Window, div, hsla, prelude::*,
+    AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, Styled, Window, div, hsla,
+    prelude::*,
 };
 use std::sync::Arc;
 
@@ -44,7 +45,7 @@ pub struct Checkbox {
     toggle_state: ToggleState,
     disabled: bool,
     placeholder: bool,
-    on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
+    on_click: Option<Box<dyn Fn(&ToggleState, &ClickEvent, &mut Window, &mut App) + 'static>>,
     filled: bool,
     style: ToggleStyle,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
@@ -83,6 +84,16 @@ impl Checkbox {
     pub fn on_click(
         mut self,
         handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_click = Some(Box::new(move |state, _, window, cx| {
+            handler(state, window, cx)
+        }));
+        self
+    }
+
+    pub fn on_click_ext(
+        mut self,
+        handler: impl Fn(&ToggleState, &ClickEvent, &mut Window, &mut App) + 'static,
     ) -> Self {
         self.on_click = Some(Box::new(handler));
         self
@@ -226,8 +237,8 @@ impl RenderOnce for Checkbox {
             .when_some(
                 self.on_click.filter(|_| !self.disabled),
                 |this, on_click| {
-                    this.on_click(move |_, window, cx| {
-                        on_click(&self.toggle_state.inverse(), window, cx)
+                    this.on_click(move |click, window, cx| {
+                        on_click(&self.toggle_state.inverse(), click, window, cx)
                     })
                 },
             )