git: Fully implement "all staged" checkbox (#23079)

Cole Miller created

Also includes some improvements to the "stage/unstage all" actions and
buttons.

Release Notes:

- N/A

Change summary

crates/git_ui/src/git_panel.rs | 167 +++++++++++++++++++++++------------
1 file changed, 109 insertions(+), 58 deletions(-)

Detailed changes

crates/git_ui/src/git_panel.rs 🔗

@@ -69,7 +69,7 @@ pub struct GitListEntry {
     display_name: String,
     repo_path: RepoPath,
     status: GitStatusPair,
-    toggle_state: ToggleState,
+    is_staged: Option<bool>,
 }
 
 pub struct GitPanel {
@@ -91,18 +91,11 @@ pub struct GitPanel {
     /// At this point it doesn't matter what repository the entry belongs to,
     /// as only one repositories' entries are visible in the list at a time.
     visible_entries: Vec<GitListEntry>,
+    all_staged: Option<bool>,
     width: Option<Pixels>,
     reveal_in_editor: Task<()>,
 }
 
-fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState {
-    match status.is_staged() {
-        Some(true) => ToggleState::Selected,
-        Some(false) => ToggleState::Unselected,
-        None => ToggleState::Indeterminate,
-    }
-}
-
 impl GitPanel {
     pub fn load(
         workspace: WeakView<Workspace>,
@@ -314,6 +307,7 @@ impl GitPanel {
                 fs,
                 pending_serialization: Task::ready(None),
                 visible_entries: Vec::new(),
+                all_staged: None,
                 current_modifiers: cx.modifiers(),
                 width: Some(px(360.)),
                 scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
@@ -602,10 +596,26 @@ impl GitPanel {
     }
 
     fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext<Self>) {
-        self.git_state.update(cx, |state, _| state.stage_all());
+        let to_stage = self
+            .visible_entries
+            .iter_mut()
+            .filter_map(|entry| {
+                let is_unstaged = !entry.is_staged.unwrap_or(false);
+                entry.is_staged = Some(true);
+                is_unstaged.then(|| entry.repo_path.clone())
+            })
+            .collect();
+        self.all_staged = Some(true);
+        self.git_state
+            .update(cx, |state, _| state.stage_entries(to_stage));
     }
 
     fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext<Self>) {
+        // This should only be called when all entries are staged.
+        for entry in &mut self.visible_entries {
+            entry.is_staged = Some(false);
+        }
+        self.all_staged = Some(false);
         self.git_state.update(cx, |state, _| {
             state.unstage_all();
         });
@@ -639,11 +649,6 @@ impl GitPanel {
         println!("Commit all changes triggered");
     }
 
-    fn all_staged(&self) -> bool {
-        // TODO: Implement all_staged
-        true
-    }
-
     fn no_entries(&self) -> bool {
         self.visible_entries.is_empty()
     }
@@ -678,7 +683,7 @@ impl GitPanel {
                 status,
                 depth: 0,
                 display_name: filename,
-                toggle_state: entry.toggle_state,
+                is_staged: entry.is_staged,
             };
 
             callback(ix, details, cx);
@@ -705,10 +710,19 @@ impl GitPanel {
         let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 
         // Second pass - create entries with proper depth calculation
-        for entry in repo.status() {
+        let mut all_staged = None;
+        for (ix, entry) in repo.status().enumerate() {
             let (depth, difference) =
                 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
-            let toggle_state = status_to_toggle_state(&entry.status);
+            let is_staged = entry.status.is_staged();
+            all_staged = if ix == 0 {
+                is_staged
+            } else {
+                match (all_staged, is_staged) {
+                    (None, _) | (_, None) => None,
+                    (Some(a), Some(b)) => (a == b).then_some(a),
+                }
+            };
 
             let display_name = if difference > 1 {
                 // Show partial path for deeply nested files
@@ -734,11 +748,12 @@ impl GitPanel {
                 display_name,
                 repo_path: entry.repo_path,
                 status: entry.status,
-                toggle_state,
+                is_staged,
             };
 
             self.visible_entries.push(entry);
         }
+        self.all_staged = all_staged;
 
         // Sort entries by path to maintain consistent order
         self.visible_entries
@@ -805,7 +820,11 @@ impl GitPanel {
             .child(
                 h_flex()
                     .gap_2()
-                    .child(Checkbox::new("all-changes", true.into()).disabled(true))
+                    .child(Checkbox::new(
+                        "all-changes",
+                        self.all_staged
+                            .map_or(ToggleState::Indeterminate, ToggleState::from),
+                    ))
                     .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
             )
             .child(div().flex_grow())
@@ -814,27 +833,50 @@ impl GitPanel {
                     .gap_2()
                     .child(
                         IconButton::new("discard-changes", IconName::Undo)
-                            .tooltip(move |cx| {
+                            .tooltip({
                                 let focus_handle = focus_handle.clone();
-
-                                Tooltip::for_action_in(
-                                    "Discard all changes",
-                                    &RevertAll,
-                                    &focus_handle,
-                                    cx,
-                                )
+                                move |cx| {
+                                    Tooltip::for_action_in(
+                                        "Discard all changes",
+                                        &RevertAll,
+                                        &focus_handle,
+                                        cx,
+                                    )
+                                }
                             })
                             .icon_size(IconSize::Small)
                             .disabled(true),
                     )
-                    .child(if self.all_staged() {
-                        self.panel_button("unstage-all", "Unstage All").on_click(
-                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(RevertAll))),
-                        )
+                    .child(if self.all_staged.unwrap_or(false) {
+                        self.panel_button("unstage-all", "Unstage All")
+                            .tooltip({
+                                let focus_handle = focus_handle.clone();
+                                move |cx| {
+                                    Tooltip::for_action_in(
+                                        "Unstage all changes",
+                                        &UnstageAll,
+                                        &focus_handle,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .on_click(
+                                cx.listener(move |this, _, cx| this.unstage_all(&UnstageAll, cx)),
+                            )
                     } else {
-                        self.panel_button("stage-all", "Stage All").on_click(
-                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))),
-                        )
+                        self.panel_button("stage-all", "Stage All")
+                            .tooltip({
+                                let focus_handle = focus_handle.clone();
+                                move |cx| {
+                                    Tooltip::for_action_in(
+                                        "Stage all changes",
+                                        &StageAll,
+                                        &focus_handle,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .on_click(cx.listener(move |this, _, cx| this.stage_all(&StageAll, cx)))
                     }),
             )
     }
@@ -1049,30 +1091,39 @@ impl GitPanel {
 
         entry = entry
             .child(
-                Checkbox::new(checkbox_id, entry_details.toggle_state)
-                    .fill()
-                    .elevation(ElevationIndex::Surface)
-                    .on_click({
-                        let handle = handle.clone();
-                        let repo_path = repo_path.clone();
-                        move |toggle, cx| {
-                            let Some(this) = handle.upgrade() else {
-                                return;
-                            };
-                            this.update(cx, |this, _| {
-                                this.visible_entries[ix].toggle_state = *toggle;
-                            });
-                            state.update(cx, {
-                                let repo_path = repo_path.clone();
-                                move |state, _| match toggle {
-                                    ToggleState::Selected | ToggleState::Indeterminate => {
-                                        state.stage_entry(repo_path);
-                                    }
-                                    ToggleState::Unselected => state.unstage_entry(repo_path),
+                Checkbox::new(
+                    checkbox_id,
+                    entry_details
+                        .is_staged
+                        .map_or(ToggleState::Indeterminate, ToggleState::from),
+                )
+                .fill()
+                .elevation(ElevationIndex::Surface)
+                .on_click({
+                    let handle = handle.clone();
+                    let repo_path = repo_path.clone();
+                    move |toggle, cx| {
+                        let Some(this) = handle.upgrade() else {
+                            return;
+                        };
+                        this.update(cx, |this, _| {
+                            this.visible_entries[ix].is_staged = match *toggle {
+                                ToggleState::Selected => Some(true),
+                                ToggleState::Unselected => Some(false),
+                                ToggleState::Indeterminate => None,
+                            }
+                        });
+                        state.update(cx, {
+                            let repo_path = repo_path.clone();
+                            move |state, _| match toggle {
+                                ToggleState::Selected | ToggleState::Indeterminate => {
+                                    state.stage_entry(repo_path);
                                 }
-                            });
-                        }
-                    }),
+                                ToggleState::Unselected => state.unstage_entry(repo_path),
+                            }
+                        });
+                    }
+                }),
             )
             .child(git_status_icon(status))
             .child(