Git Panel: separate new and changed (#24181)

Mikayla Maki , conrad , and nate created

Release Notes:

- N/A

---------

Co-authored-by: conrad <conrad@zed.dev>
Co-authored-by: nate <nate@zed.dev>

Change summary

crates/git/src/status.rs                     |   1 
crates/git_ui/src/git_panel.rs               | 795 +++++++++++++--------
crates/project/src/git.rs                    | 354 +++------
crates/project/src/project.rs                |  37 
crates/remote_server/src/headless_project.rs |  40 
crates/ui/src/components/toggle.rs           |  11 
crates/ui/src/traits/toggleable.rs           |  17 
7 files changed, 666 insertions(+), 589 deletions(-)

Detailed changes

crates/git/src/status.rs 🔗

@@ -153,6 +153,7 @@ impl FileStatus {
                 (StatusCode::Added, _) | (_, StatusCode::Added) => true,
                 _ => false,
             },
+            FileStatus::Untracked => true,
             _ => false,
         }
     }

crates/git_ui/src/git_panel.rs 🔗

@@ -5,12 +5,11 @@ use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
 use anyhow::{Context as _, Result};
+use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
 use editor::actions::MoveToEnd;
 use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
-use futures::channel::mpsc;
-use futures::{SinkExt, StreamExt as _};
 use git::repository::RepoPath;
 use git::status::FileStatus;
 use git::{
@@ -24,13 +23,13 @@ use project::{CreateOptions, Fs, Project, ProjectPath};
 use rpc::proto;
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
-use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
+use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
 use theme::ThemeSettings;
 use ui::{
-    prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, ListItem,
-    ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
+    prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors,
+    ListHeader, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
 };
-use util::{ResultExt, TryFutureExt};
+use util::{maybe, ResultExt, TryFutureExt};
 use workspace::notifications::{DetachAndPromptErr, NotificationId};
 use workspace::Toast;
 use workspace::{
@@ -78,7 +77,55 @@ struct SerializedGitPanel {
 }
 
 #[derive(Debug, PartialEq, Eq, Clone)]
-pub struct GitListEntry {
+enum Section {
+    Changed,
+    New,
+}
+
+impl Section {
+    pub fn contains(&self, status: FileStatus) -> bool {
+        match self {
+            Section::Changed => !status.is_created(),
+            Section::New => status.is_created(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+struct GitHeaderEntry {
+    header: Section,
+    all_staged: ToggleState,
+}
+
+impl GitHeaderEntry {
+    pub fn contains(&self, status_entry: &GitStatusEntry) -> bool {
+        self.header.contains(status_entry.status)
+    }
+    pub fn title(&self) -> &'static str {
+        match self.header {
+            Section::Changed => "Changed",
+            Section::New => "New",
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+enum GitListEntry {
+    GitStatusEntry(GitStatusEntry),
+    Header(GitHeaderEntry),
+}
+
+impl GitListEntry {
+    fn status_entry(&self) -> Option<GitStatusEntry> {
+        match self {
+            GitListEntry::GitStatusEntry(entry) => Some(entry.clone()),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct GitStatusEntry {
     depth: usize,
     display_name: String,
     repo_path: RepoPath,
@@ -102,11 +149,11 @@ pub struct GitPanel {
     update_visible_entries_task: Task<()>,
     repository_selector: Entity<RepositorySelector>,
     commit_editor: Entity<Editor>,
-    visible_entries: Vec<GitListEntry>,
-    all_staged: Option<bool>,
+    entries: Vec<GitListEntry>,
+    entries_by_path: collections::HashMap<RepoPath, usize>,
     width: Option<Pixels>,
-    err_sender: mpsc::Sender<anyhow::Error>,
-    commit_task: Task<()>,
+    pending: HashMap<RepoPath, bool>,
+    commit_task: Task<Result<()>>,
     commit_pending: bool,
 }
 
@@ -237,7 +284,6 @@ impl GitPanel {
         let project = workspace.project().clone();
         let git_state = project.read(cx).git_state().clone();
         let active_repository = project.read(cx).active_repository(cx);
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
         let workspace = cx.entity().downgrade();
 
         let git_panel = cx.new(|cx| {
@@ -269,8 +315,9 @@ impl GitPanel {
             let mut git_panel = Self {
                 focus_handle: cx.focus_handle(),
                 pending_serialization: Task::ready(None),
-                visible_entries: Vec::new(),
-                all_staged: None,
+                entries: Vec::new(),
+                entries_by_path: HashMap::default(),
+                pending: HashMap::default(),
                 current_modifiers: window.modifiers(),
                 width: Some(px(360.)),
                 scrollbar_state: ScrollbarState::new(scroll_handle.clone())
@@ -280,14 +327,13 @@ impl GitPanel {
                 show_scrollbar: false,
                 hide_scrollbar_task: None,
                 update_visible_entries_task: Task::ready(()),
-                commit_task: Task::ready(()),
+                commit_task: Task::ready(Ok(())),
                 commit_pending: false,
                 active_repository,
                 scroll_handle,
                 fs,
                 commit_editor,
                 project,
-                err_sender,
                 workspace,
             };
             git_panel.schedule_update(window, cx);
@@ -295,24 +341,6 @@ impl GitPanel {
             git_panel
         });
 
-        let handle = git_panel.downgrade();
-        cx.spawn(|_, mut cx| async move {
-            while let Some(e) = err_receiver.next().await {
-                let Some(this) = handle.upgrade() else {
-                    break;
-                };
-                if this
-                    .update(&mut cx, |this, cx| {
-                        this.show_err_toast("git operation error", e, cx);
-                    })
-                    .is_err()
-                {
-                    break;
-                }
-            }
-        })
-        .detach();
-
         cx.subscribe_in(
             &git_panel,
             window,
@@ -339,13 +367,11 @@ impl GitPanel {
         let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else {
             return;
         };
-        let Ok(ix) = self
-            .visible_entries
-            .binary_search_by_key(&&repo_path, |entry| &entry.repo_path)
-        else {
+        let Some(ix) = self.entries_by_path.get(&repo_path) else {
             return;
         };
-        self.selected_entry = Some(ix);
+
+        self.selected_entry = Some(*ix);
         cx.notify();
     }
 
@@ -489,14 +515,14 @@ impl GitPanel {
     }
 
     fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
-        if self.visible_entries.first().is_some() {
+        if self.entries.first().is_some() {
             self.selected_entry = Some(0);
             self.scroll_to_selected_entry(cx);
         }
     }
 
     fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
-        let item_count = self.visible_entries.len();
+        let item_count = self.entries.len();
         if item_count == 0 {
             return;
         }
@@ -517,7 +543,7 @@ impl GitPanel {
     }
 
     fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
-        let item_count = self.visible_entries.len();
+        let item_count = self.entries.len();
         if item_count == 0 {
             return;
         }
@@ -538,8 +564,8 @@ impl GitPanel {
     }
 
     fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
-        if self.visible_entries.last().is_some() {
-            self.selected_entry = Some(self.visible_entries.len() - 1);
+        if self.entries.last().is_some() {
+            self.selected_entry = Some(self.entries.len() - 1);
             self.scroll_to_selected_entry(cx);
         }
     }
@@ -578,15 +604,11 @@ impl GitPanel {
     }
 
     fn get_selected_entry(&self) -> Option<&GitListEntry> {
-        self.selected_entry
-            .and_then(|i| self.visible_entries.get(i))
+        self.selected_entry.and_then(|i| self.entries.get(i))
     }
 
     fn open_selected(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(entry) = self
-            .selected_entry
-            .and_then(|i| self.visible_entries.get(i))
-        {
+        if let Some(entry) = self.selected_entry.and_then(|i| self.entries.get(i)) {
             self.open_entry(entry, cx);
         }
     }
@@ -600,16 +622,60 @@ impl GitPanel {
         let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        let result = if entry.status.is_staged().unwrap_or(false) {
-            active_repository
-                .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
-        } else {
-            active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
+        let (stage, repo_paths) = match entry {
+            GitListEntry::GitStatusEntry(status_entry) => {
+                if status_entry.status.is_staged().unwrap_or(false) {
+                    (false, vec![status_entry.repo_path.clone()])
+                } else {
+                    (true, vec![status_entry.repo_path.clone()])
+                }
+            }
+            GitListEntry::Header(section) => {
+                let goal_staged_state = !section.all_staged.selected();
+                let entries = self
+                    .entries
+                    .iter()
+                    .filter_map(|entry| entry.status_entry())
+                    .filter(|status_entry| {
+                        section.contains(&status_entry)
+                            && status_entry.is_staged != Some(goal_staged_state)
+                    })
+                    .map(|status_entry| status_entry.repo_path)
+                    .collect::<Vec<_>>();
+
+                (!section.all_staged.selected(), entries)
+            }
         };
-        if let Err(e) = result {
-            self.show_err_toast("toggle staged error", e, cx);
+        for repo_path in repo_paths.iter() {
+            self.pending.insert(repo_path.clone(), stage);
         }
-        cx.notify();
+
+        cx.spawn({
+            let repo_paths = repo_paths.clone();
+            let active_repository = active_repository.clone();
+            |this, mut cx| async move {
+                let result = if stage {
+                    active_repository.stage_entries(repo_paths.clone()).await
+                } else {
+                    active_repository.unstage_entries(repo_paths.clone()).await
+                };
+
+                this.update(&mut cx, |this, cx| {
+                    for repo_path in repo_paths {
+                        if this.pending.get(&repo_path) == Some(&stage) {
+                            this.pending.remove(&repo_path);
+                        }
+                    }
+                    result
+                        .map_err(|e| {
+                            this.show_err_toast(e, cx);
+                        })
+                        .ok();
+                    cx.notify();
+                })
+            }
+        })
+        .detach();
     }
 
     fn toggle_staged_for_selected(
@@ -624,10 +690,14 @@ impl GitPanel {
     }
 
     fn open_entry(&self, entry: &GitListEntry, cx: &mut Context<Self>) {
+        let Some(status_entry) = entry.status_entry() else {
+            return;
+        };
         let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else {
+        let Some(path) = active_repository.repo_path_to_project_path(&status_entry.repo_path)
+        else {
             return;
         };
         let path_exists = self.project.update(cx, |project, cx| {
@@ -641,30 +711,59 @@ impl GitPanel {
     }
 
     fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
-        let Some(active_repository) = self.active_repository.as_ref() else {
+        let Some(active_repository) = self.active_repository.as_ref().cloned() else {
             return;
         };
-        for entry in &mut self.visible_entries {
-            entry.is_staged = Some(true);
+        let mut pending_paths = Vec::new();
+        for entry in self.entries.iter() {
+            if let Some(status_entry) = entry.status_entry() {
+                self.pending.insert(status_entry.repo_path.clone(), true);
+                pending_paths.push(status_entry.repo_path.clone());
+            }
         }
-        self.all_staged = Some(true);
 
-        if let Err(e) = active_repository.stage_all(self.err_sender.clone()) {
-            self.show_err_toast("stage all error", e, cx);
-        };
+        cx.spawn(|this, mut cx| async move {
+            if let Err(e) = active_repository.stage_all().await {
+                this.update(&mut cx, |this, cx| {
+                    this.show_err_toast(e, cx);
+                })
+                .ok();
+            };
+            this.update(&mut cx, |this, _cx| {
+                for repo_path in pending_paths {
+                    this.pending.remove(&repo_path);
+                }
+            })
+        })
+        .detach();
     }
 
     fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
-        let Some(active_repository) = self.active_repository.as_ref() else {
+        let Some(active_repository) = self.active_repository.as_ref().cloned() else {
             return;
         };
-        for entry in &mut self.visible_entries {
-            entry.is_staged = Some(false);
+        let mut pending_paths = Vec::new();
+        for entry in self.entries.iter() {
+            if let Some(status_entry) = entry.status_entry() {
+                self.pending.insert(status_entry.repo_path.clone(), false);
+                pending_paths.push(status_entry.repo_path.clone());
+            }
         }
-        self.all_staged = Some(false);
-        if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) {
-            self.show_err_toast("unstage all error", e, cx);
-        };
+
+        cx.spawn(|this, mut cx| async move {
+            if let Err(e) = active_repository.unstage_all().await {
+                this.update(&mut cx, |this, cx| {
+                    this.show_err_toast(e, cx);
+                })
+                .ok();
+            };
+            this.update(&mut cx, |this, _cx| {
+                for repo_path in pending_paths {
+                    this.pending.remove(&repo_path);
+                }
+            })
+        })
+        .detach();
     }
 
     fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context<Self>) {
@@ -693,37 +792,30 @@ impl GitPanel {
         let save_task = self.commit_editor.update(cx, |editor, cx| {
             editor.save(false, self.project.clone(), window, cx)
         });
-        let mut err_sender = self.err_sender.clone();
         let commit_editor = self.commit_editor.clone();
         self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
-            match save_task.await {
-                Ok(()) => {
-                    if let Some(Ok(())) = cx
-                        .update(|_, cx| {
-                            active_repository.commit(name_and_email, err_sender.clone(), cx)
-                        })
-                        .ok()
-                    {
-                        cx.update(|window, cx| {
-                            commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
-                        })
-                        .ok();
-                    }
-                }
-                Err(e) => {
-                    err_sender.send(e).await.ok();
-                }
-            }
-            git_panel
-                .update(&mut cx, |git_panel, _| {
-                    git_panel.commit_pending = false;
+            let result = maybe!(async {
+                save_task.await?;
+                active_repository.commit(name_and_email).await?;
+                cx.update(|window, cx| {
+                    commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
                 })
-                .ok();
+            })
+            .await;
+
+            git_panel.update(&mut cx, |git_panel, cx| {
+                git_panel.commit_pending = false;
+                result
+                    .map_err(|e| {
+                        git_panel.show_err_toast(e, cx);
+                    })
+                    .ok();
+            })
         });
     }
 
     /// Commit all changes, regardless of whether they are staged or not
-    fn commit_all_changes(
+    fn commit_tracked_changes(
         &mut self,
         _: &git::CommitAllChanges,
         name_and_email: Option<(SharedString, SharedString)>,
@@ -743,32 +835,43 @@ impl GitPanel {
         let save_task = self.commit_editor.update(cx, |editor, cx| {
             editor.save(false, self.project.clone(), window, cx)
         });
-        let mut err_sender = self.err_sender.clone();
+
         let commit_editor = self.commit_editor.clone();
+        let tracked_files = self
+            .entries
+            .iter()
+            .filter_map(|entry| entry.status_entry())
+            .filter(|status_entry| {
+                Section::Changed.contains(status_entry.status)
+                    && !status_entry.is_staged.unwrap_or(false)
+            })
+            .map(|status_entry| status_entry.repo_path)
+            .collect::<Vec<_>>();
+
         self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
-            match save_task.await {
-                Ok(()) => {
-                    if let Some(Ok(())) = cx
-                        .update(|_, cx| {
-                            active_repository.commit_all(name_and_email, err_sender.clone(), cx)
-                        })
-                        .ok()
-                    {
-                        cx.update(|window, cx| {
-                            commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
+            let result = maybe!(async {
+                save_task.await?;
+                active_repository.stage_entries(tracked_files).await?;
+                active_repository.commit(name_and_email).await
+            })
+            .await;
+            cx.update(|window, cx| match result {
+                Ok(_) => commit_editor.update(cx, |editor, cx| {
+                    editor.clear(window, cx);
+                }),
+
+                Err(e) => {
+                    git_panel
+                        .update(cx, |git_panel, cx| {
+                            git_panel.show_err_toast(e, cx);
                         })
                         .ok();
-                    }
                 }
-                Err(e) => {
-                    err_sender.send(e).await.ok();
-                }
-            }
-            git_panel
-                .update(&mut cx, |git_panel, _| {
-                    git_panel.commit_pending = false;
-                })
-                .ok();
+            })?;
+
+            git_panel.update(&mut cx, |git_panel, _| {
+                git_panel.commit_pending = false;
+            })
         });
     }
 
@@ -837,39 +940,6 @@ impl GitPanel {
         });
     }
 
-    fn for_each_visible_entry(
-        &self,
-        range: Range<usize>,
-        cx: &mut Context<Self>,
-        mut callback: impl FnMut(usize, GitListEntry, &mut Context<Self>),
-    ) {
-        let visible_entries = &self.visible_entries;
-
-        for (ix, entry) in visible_entries
-            .iter()
-            .enumerate()
-            .skip(range.start)
-            .take(range.end - range.start)
-        {
-            let status = entry.status;
-            let filename = entry
-                .repo_path
-                .file_name()
-                .map(|name| name.to_string_lossy().into_owned())
-                .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
-
-            let details = GitListEntry {
-                repo_path: entry.repo_path.clone(),
-                status,
-                depth: 0,
-                display_name: filename,
-                is_staged: entry.is_staged,
-            };
-
-            callback(ix, details, cx);
-        }
-    }
-
     fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let project = self.project.clone();
         let handle = cx.entity().downgrade();
@@ -910,7 +980,10 @@ impl GitPanel {
     }
 
     fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
-        self.visible_entries.clear();
+        self.entries.clear();
+        self.entries_by_path.clear();
+        let mut changed_entries = Vec::new();
+        let mut new_entries = Vec::new();
 
         let Some(repo) = self.active_repository.as_ref() else {
             // Just clear entries if no repository is active.
@@ -922,19 +995,26 @@ impl GitPanel {
         let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 
         // Second pass - create entries with proper depth calculation
-        let mut all_staged = None;
-        for (ix, entry) in repo.status().enumerate() {
+        let mut new_any_staged = false;
+        let mut new_all_staged = true;
+        let mut changed_any_staged = false;
+        let mut changed_all_staged = true;
+
+        for entry in repo.status() {
             let (depth, difference) =
                 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
+
+            let is_new = entry.status.is_created();
             let is_staged = entry.status.is_staged();
-            all_staged = if ix == 0 {
-                is_staged
+
+            let new_is_staged = is_staged.unwrap_or(false);
+            if is_new {
+                new_any_staged |= new_is_staged;
+                new_all_staged &= new_is_staged;
             } else {
-                match (all_staged, is_staged) {
-                    (None, _) | (_, None) => None,
-                    (Some(a), Some(b)) => (a == b).then_some(a),
-                }
-            };
+                changed_any_staged |= new_is_staged;
+                changed_all_staged &= new_is_staged;
+            }
 
             let display_name = if difference > 1 {
                 // Show partial path for deeply nested files
@@ -955,7 +1035,7 @@ impl GitPanel {
                     .unwrap_or_default()
             };
 
-            let entry = GitListEntry {
+            let entry = GitStatusEntry {
                 depth,
                 display_name,
                 repo_path: entry.repo_path.clone(),
@@ -963,24 +1043,56 @@ impl GitPanel {
                 is_staged,
             };
 
-            self.visible_entries.push(entry);
+            if is_new {
+                new_entries.push(entry);
+            } else {
+                changed_entries.push(entry);
+            }
         }
-        self.all_staged = all_staged;
 
         // Sort entries by path to maintain consistent order
-        self.visible_entries
-            .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+        changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+        new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+
+        if changed_entries.len() > 0 {
+            let toggle_state =
+                ToggleState::from_any_and_all(changed_any_staged, changed_all_staged);
+            self.entries.push(GitListEntry::Header(GitHeaderEntry {
+                header: Section::Changed,
+                all_staged: toggle_state,
+            }));
+            self.entries.extend(
+                changed_entries
+                    .into_iter()
+                    .map(GitListEntry::GitStatusEntry),
+            );
+        }
+        if new_entries.len() > 0 {
+            let toggle_state = ToggleState::from_any_and_all(new_any_staged, new_all_staged);
+            self.entries.push(GitListEntry::Header(GitHeaderEntry {
+                header: Section::New,
+                all_staged: toggle_state,
+            }));
+            self.entries
+                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
+        }
+
+        for (ix, entry) in self.entries.iter().enumerate() {
+            if let Some(status_entry) = entry.status_entry() {
+                self.entries_by_path.insert(status_entry.repo_path, ix);
+            }
+        }
 
         self.select_first_entry_if_none(cx);
 
         cx.notify();
     }
 
-    fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context<Self>) {
+    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
-        let notif_id = NotificationId::Named(id.into());
+        let notif_id = NotificationId::Named("git-operation-error".into());
         let message = e.to_string();
         workspace.update(cx, |workspace, cx| {
             let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
@@ -1020,7 +1132,6 @@ impl GitPanel {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let focus_handle = self.focus_handle(cx).clone();
         let all_repositories = self
             .project
             .read(cx)
@@ -1058,43 +1169,6 @@ impl GitPanel {
                 self.render_repository_selector(cx).into_any_element()
             }))
             .child(div().flex_grow())
-            .child(h_flex().gap_2().child(if self.all_staged.unwrap_or(false) {
-                self.panel_button("unstage-all", "Unstage All")
-                    .tooltip({
-                        let focus_handle = focus_handle.clone();
-                        move |window, cx| {
-                            Tooltip::for_action_in(
-                                "Unstage all changes",
-                                &UnstageAll,
-                                &focus_handle,
-                                window,
-                                cx,
-                            )
-                        }
-                    })
-                    .on_click(cx.listener(move |this, _, window, cx| {
-                        this.unstage_all(&UnstageAll, window, cx)
-                    }))
-            } else {
-                self.panel_button("stage-all", "Stage All")
-                    .tooltip({
-                        let focus_handle = focus_handle.clone();
-                        move |window, cx| {
-                            Tooltip::for_action_in(
-                                "Stage all changes",
-                                &StageAll,
-                                &focus_handle,
-                                window,
-                                cx,
-                            )
-                        }
-                    })
-                    .on_click(
-                        cx.listener(move |this, _, window, cx| {
-                            this.stage_all(&StageAll, window, cx)
-                        }),
-                    )
-            }))
     }
 
     pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -1104,7 +1178,7 @@ impl GitPanel {
             .map(|repo| repo.display_name(self.project.read(cx), cx))
             .unwrap_or_default();
 
-        let entry_count = self.visible_entries.len();
+        let entry_count = self.entries.len();
 
         RepositorySelectorPopoverMenu::new(
             self.repository_selector.clone(),
@@ -1194,7 +1268,12 @@ impl GitPanel {
             .on_click({
                 let name_and_email = name_and_email.clone();
                 cx.listener(move |this, _: &ClickEvent, window, cx| {
-                    this.commit_all_changes(&CommitAllChanges, name_and_email.clone(), window, cx)
+                    this.commit_tracked_changes(
+                        &CommitAllChanges,
+                        name_and_email.clone(),
+                        window,
+                        cx,
+                    )
                 })
             });
 
@@ -1294,21 +1373,94 @@ impl GitPanel {
     }
 
     fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
-        let entry_count = self.visible_entries.len();
+        let entry_count = self.entries.len();
 
         v_flex()
             .size_full()
             .overflow_hidden()
             .child(
                 uniform_list(cx.entity().clone(), "entries", entry_count, {
-                    move |git_panel, range, _window, cx| {
+                    move |this, range, _window, cx| {
                         let mut items = Vec::with_capacity(range.end - range.start);
-                        git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
-                            items.push(git_panel.render_entry(ix, details, has_write_access, cx));
-                        });
+
+                        for ix in range {
+                            match &this.entries.get(ix) {
+                                Some(GitListEntry::GitStatusEntry(entry)) => {
+                                    items.push(this.render_entry(ix, entry, has_write_access, cx));
+                                }
+                                Some(GitListEntry::Header(header)) => {
+                                    items.push(this.render_header(
+                                        ix,
+                                        header,
+                                        has_write_access,
+                                        cx,
+                                    ));
+                                }
+                                None => {}
+                            }
+                        }
+
                         items
                     }
                 })
+                .with_decoration(
+                    ui::indent_guides(
+                        cx.entity().clone(),
+                        px(10.0),
+                        IndentGuideColors::panel(cx),
+                        |this, range, _windows, _cx| {
+                            this.entries
+                                .iter()
+                                .skip(range.start)
+                                .map(|entry| match entry {
+                                    GitListEntry::GitStatusEntry(_) => 1,
+                                    GitListEntry::Header(_) => 0,
+                                })
+                                .collect()
+                        },
+                    )
+                    .with_render_fn(
+                        cx.entity().clone(),
+                        move |_, params, window, cx| {
+                            let left_offset = Checkbox::container_size(cx)
+                                .to_pixels(window.rem_size())
+                                .half();
+                            const PADDING_Y: f32 = 4.;
+                            let indent_size = params.indent_size;
+                            let item_height = params.item_height;
+
+                            params
+                                .indent_guides
+                                .into_iter()
+                                .enumerate()
+                                .map(|(_, layout)| {
+                                    let offset = if layout.continues_offscreen {
+                                        px(0.)
+                                    } else {
+                                        px(PADDING_Y)
+                                    };
+                                    let bounds = Bounds::new(
+                                        point(
+                                            px(layout.offset.x as f32) * indent_size + left_offset,
+                                            px(layout.offset.y as f32) * item_height + offset,
+                                        ),
+                                        size(
+                                            px(1.),
+                                            px(layout.length as f32) * item_height
+                                                - px(offset.0 * 2.),
+                                        ),
+                                    );
+                                    ui::RenderedIndentGuide {
+                                        bounds,
+                                        layout,
+                                        is_active: false,
+                                        hitbox: None,
+                                    }
+                                })
+                                .collect()
+                        },
+                    ),
+                )
                 .size_full()
                 .with_sizing_behavior(ListSizingBehavior::Infer)
                 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
@@ -1321,17 +1473,62 @@ impl GitPanel {
         Label::new(label.into()).color(color).single_line()
     }
 
+    fn render_header(
+        &self,
+        ix: usize,
+        header: &GitHeaderEntry,
+        has_write_access: bool,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let checkbox = Checkbox::new(header.title(), header.all_staged)
+            .disabled(!has_write_access)
+            .fill()
+            .elevation(ElevationIndex::Surface);
+        let selected = self.selected_entry == Some(ix);
+
+        div()
+            .w_full()
+            .px_0p5()
+            .child(
+                ListHeader::new(header.title())
+                    .start_slot(checkbox)
+                    .toggle_state(selected)
+                    .on_toggle({
+                        let header = header.clone();
+                        cx.listener(move |this, _, window, cx| {
+                            if !has_write_access {
+                                return;
+                            }
+                            this.selected_entry = Some(ix);
+                            this.toggle_staged_for_entry(
+                                &GitListEntry::Header(header.clone()),
+                                window,
+                                cx,
+                            )
+                        })
+                    }),
+            )
+            .into_any_element()
+    }
+
     fn render_entry(
         &self,
         ix: usize,
-        entry_details: GitListEntry,
+        entry: &GitStatusEntry,
         has_write_access: bool,
         cx: &Context<Self>,
-    ) -> impl IntoElement {
-        let repo_path = entry_details.repo_path.clone();
+    ) -> AnyElement {
+        let display_name = entry
+            .repo_path
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
+
+        let pending = self.pending.get(&entry.repo_path).copied();
+        let repo_path = entry.repo_path.clone();
         let selected = self.selected_entry == Some(ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
-        let status = entry_details.status;
+        let status = entry.status;
         let has_conflict = status.is_conflicted();
         let is_modified = status.is_modified();
         let is_deleted = status.is_deleted();
@@ -1357,101 +1554,93 @@ impl GitPanel {
             Color::Muted
         };
 
-        let id: ElementId = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
+        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
 
-        let checkbox = Checkbox::new(
-            id,
-            entry_details
-                .is_staged
-                .map_or(ToggleState::Indeterminate, ToggleState::from),
-        )
-        .disabled(!has_write_access)
-        .fill()
-        .elevation(ElevationIndex::Surface)
-        .on_click({
-            let handle = cx.entity().downgrade();
-            let repo_path = repo_path.clone();
-            move |toggle, _window, cx| {
-                let Some(this) = handle.upgrade() else {
-                    return;
-                };
-                this.update(cx, |this, cx| {
-                    this.visible_entries[ix].is_staged = match *toggle {
-                        ToggleState::Selected => Some(true),
-                        ToggleState::Unselected => Some(false),
-                        ToggleState::Indeterminate => None,
-                    };
-                    let repo_path = repo_path.clone();
-                    let Some(active_repository) = this.active_repository.as_ref() else {
-                        return;
-                    };
-                    let result = match toggle {
-                        ToggleState::Selected | ToggleState::Indeterminate => active_repository
-                            .stage_entries(vec![repo_path], this.err_sender.clone()),
-                        ToggleState::Unselected => active_repository
-                            .unstage_entries(vec![repo_path], this.err_sender.clone()),
-                    };
-                    if let Err(e) = result {
-                        this.show_err_toast("toggle staged error", e, cx);
-                    }
-                });
-            }
-        });
+        let is_staged = pending
+            .or_else(|| entry.is_staged)
+            .map(ToggleState::from)
+            .unwrap_or(ToggleState::Indeterminate);
+
+        let checkbox = Checkbox::new(id, is_staged)
+            .disabled(!has_write_access)
+            .fill()
+            .elevation(ElevationIndex::Surface)
+            .on_click({
+                let entry = entry.clone();
+                cx.listener(move |this, _, window, cx| {
+                    this.toggle_staged_for_entry(
+                        &GitListEntry::GitStatusEntry(entry.clone()),
+                        window,
+                        cx,
+                    );
+                })
+            });
 
         let start_slot = h_flex()
             .gap(DynamicSpacing::Base04.rems(cx))
             .child(checkbox)
             .child(git_status_icon(status, cx));
 
-        let id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
-
-        div().w_full().px_0p5().child(
-            ListItem::new(id)
-                .spacing(ListItemSpacing::Sparse)
-                .start_slot(start_slot)
-                .toggle_state(selected)
-                .disabled(!has_write_access)
-                .on_click({
-                    let repo_path = entry_details.repo_path.clone();
-                    cx.listener(move |this, _, window, cx| {
-                        this.selected_entry = Some(ix);
-                        window.dispatch_action(Box::new(OpenSelected), cx);
-                        cx.notify();
-                        let Some(workspace) = this.workspace.upgrade() else {
-                            return;
-                        };
-                        let Some(git_repo) = this.active_repository.as_ref() else {
-                            return;
-                        };
-                        let Some(path) = git_repo.repo_path_to_project_path(&repo_path).and_then(
-                            |project_path| this.project.read(cx).absolute_path(&project_path, cx),
-                        ) else {
-                            return;
-                        };
-                        workspace.update(cx, |workspace, cx| {
-                            ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
+        let id = ElementId::Name(format!("entry_{}", display_name).into());
+
+        div()
+            .w_full()
+            .px_0p5()
+            .child(
+                ListItem::new(id)
+                    .indent_level(1)
+                    .indent_step_size(px(10.0))
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot(start_slot)
+                    .toggle_state(selected)
+                    .disabled(!has_write_access)
+                    .on_click({
+                        let repo_path = entry.repo_path.clone();
+                        cx.listener(move |this, _, window, cx| {
+                            this.selected_entry = Some(ix);
+                            window.dispatch_action(Box::new(OpenSelected), cx);
+                            cx.notify();
+                            let Some(workspace) = this.workspace.upgrade() else {
+                                return;
+                            };
+                            let Some(git_repo) = this.active_repository.as_ref() else {
+                                return;
+                            };
+                            let Some(path) = git_repo
+                                .repo_path_to_project_path(&repo_path)
+                                .and_then(|project_path| {
+                                    this.project.read(cx).absolute_path(&project_path, cx)
+                                })
+                            else {
+                                return;
+                            };
+                            workspace.update(cx, |workspace, cx| {
+                                ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
+                            })
                         })
                     })
-                })
-                .child(
-                    h_flex()
-                        .when_some(repo_path.parent(), |this, parent| {
-                            let parent_str = parent.to_string_lossy();
-                            if !parent_str.is_empty() {
-                                this.child(
-                                    self.entry_label(format!("{}/", parent_str), path_color)
-                                        .when(status.is_deleted(), |this| this.strikethrough(true)),
-                                )
-                            } else {
-                                this
-                            }
-                        })
-                        .child(
-                            self.entry_label(entry_details.display_name.clone(), label_color)
-                                .when(status.is_deleted(), |this| this.strikethrough(true)),
-                        ),
-                ),
-        )
+                    .child(
+                        h_flex()
+                            .when_some(repo_path.parent(), |this, parent| {
+                                let parent_str = parent.to_string_lossy();
+                                if !parent_str.is_empty() {
+                                    this.child(
+                                        self.entry_label(format!("{}/", parent_str), path_color)
+                                            .when(status.is_deleted(), |this| {
+                                                this.strikethrough(true)
+                                            }),
+                                    )
+                                } else {
+                                    this
+                                }
+                            })
+                            .child(
+                                self.entry_label(display_name.clone(), label_color)
+                                    .when(status.is_deleted(), |this| this.strikethrough(true)),
+                            ),
+                    ),
+            )
+            .into_any_element()
     }
 }
 

crates/project/src/git.rs 🔗

@@ -2,8 +2,8 @@ use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
 use crate::{Project, ProjectPath};
 use anyhow::{anyhow, Context as _};
 use client::ProjectId;
-use futures::channel::mpsc;
-use futures::{SinkExt as _, StreamExt as _};
+use futures::channel::{mpsc, oneshot};
+use futures::StreamExt as _;
 use git::{
     repository::{GitRepository, RepoPath},
     status::{GitSummary, TrackedSummary},
@@ -20,7 +20,7 @@ pub struct GitState {
     client: Option<AnyProtoClient>,
     repositories: Vec<RepositoryHandle>,
     active_index: Option<usize>,
-    update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+    update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
     _subscription: Subscription,
 }
 
@@ -30,7 +30,7 @@ pub struct RepositoryHandle {
     pub worktree_id: WorktreeId,
     pub repository_entry: RepositoryEntry,
     pub git_repo: GitRepo,
-    update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+    update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
 }
 
 #[derive(Clone)]
@@ -61,11 +61,6 @@ impl PartialEq<RepositoryEntry> for RepositoryHandle {
 }
 
 enum Message {
-    StageAndCommit {
-        git_repo: GitRepo,
-        paths: Vec<RepoPath>,
-        name_and_email: Option<(SharedString, SharedString)>,
-    },
     Commit {
         git_repo: GitRepo,
         name_and_email: Option<(SharedString, SharedString)>,
@@ -87,151 +82,7 @@ impl GitState {
         project_id: Option<ProjectId>,
         cx: &mut Context<'_, Self>,
     ) -> Self {
-        let (update_sender, mut update_receiver) =
-            mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
-        cx.spawn(|_, cx| async move {
-            while let Some((msg, mut err_sender)) = update_receiver.next().await {
-                let result =
-                    cx.background_executor()
-                        .spawn(async move {
-                            match msg {
-                                Message::StageAndCommit {
-                                    git_repo,
-                                    name_and_email,
-                                    paths,
-                                } => {
-                                    match git_repo {
-                                        GitRepo::Local(repo) => {
-                                            repo.stage_paths(&paths)?;
-                                            repo.commit(name_and_email.as_ref().map(
-                                                |(name, email)| (name.as_ref(), email.as_ref()),
-                                            ))?;
-                                        }
-                                        GitRepo::Remote {
-                                            project_id,
-                                            client,
-                                            worktree_id,
-                                            work_directory_id,
-                                        } => {
-                                            client
-                                                .request(proto::Stage {
-                                                    project_id: project_id.0,
-                                                    worktree_id: worktree_id.to_proto(),
-                                                    work_directory_id: work_directory_id.to_proto(),
-                                                    paths: paths
-                                                        .into_iter()
-                                                        .map(|repo_path| repo_path.to_proto())
-                                                        .collect(),
-                                                })
-                                                .await
-                                                .context("sending stage request")?;
-                                            let (name, email) = name_and_email.unzip();
-                                            client
-                                                .request(proto::Commit {
-                                                    project_id: project_id.0,
-                                                    worktree_id: worktree_id.to_proto(),
-                                                    work_directory_id: work_directory_id.to_proto(),
-                                                    name: name.map(String::from),
-                                                    email: email.map(String::from),
-                                                })
-                                                .await
-                                                .context("sending commit request")?;
-                                        }
-                                    }
-
-                                    Ok(())
-                                }
-                                Message::Stage(repo, paths) => {
-                                    match repo {
-                                        GitRepo::Local(repo) => repo.stage_paths(&paths)?,
-                                        GitRepo::Remote {
-                                            project_id,
-                                            client,
-                                            worktree_id,
-                                            work_directory_id,
-                                        } => {
-                                            client
-                                                .request(proto::Stage {
-                                                    project_id: project_id.0,
-                                                    worktree_id: worktree_id.to_proto(),
-                                                    work_directory_id: work_directory_id.to_proto(),
-                                                    paths: paths
-                                                        .into_iter()
-                                                        .map(|repo_path| repo_path.to_proto())
-                                                        .collect(),
-                                                })
-                                                .await
-                                                .context("sending stage request")?;
-                                        }
-                                    }
-                                    Ok(())
-                                }
-                                Message::Unstage(repo, paths) => {
-                                    match repo {
-                                        GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
-                                        GitRepo::Remote {
-                                            project_id,
-                                            client,
-                                            worktree_id,
-                                            work_directory_id,
-                                        } => {
-                                            client
-                                                .request(proto::Unstage {
-                                                    project_id: project_id.0,
-                                                    worktree_id: worktree_id.to_proto(),
-                                                    work_directory_id: work_directory_id.to_proto(),
-                                                    paths: paths
-                                                        .into_iter()
-                                                        .map(|repo_path| repo_path.to_proto())
-                                                        .collect(),
-                                                })
-                                                .await
-                                                .context("sending unstage request")?;
-                                        }
-                                    }
-                                    Ok(())
-                                }
-                                Message::Commit {
-                                    git_repo,
-                                    name_and_email,
-                                } => {
-                                    match git_repo {
-                                        GitRepo::Local(repo) => {
-                                            repo.commit(name_and_email.as_ref().map(
-                                                |(name, email)| (name.as_ref(), email.as_ref()),
-                                            ))?
-                                        }
-                                        GitRepo::Remote {
-                                            project_id,
-                                            client,
-                                            worktree_id,
-                                            work_directory_id,
-                                        } => {
-                                            let (name, email) = name_and_email.unzip();
-                                            client
-                                                .request(proto::Commit {
-                                                    project_id: project_id.0,
-                                                    worktree_id: worktree_id.to_proto(),
-                                                    work_directory_id: work_directory_id.to_proto(),
-                                                    name: name.map(String::from),
-                                                    email: email.map(String::from),
-                                                })
-                                                .await
-                                                .context("sending commit request")?;
-                                        }
-                                    }
-                                    Ok(())
-                                }
-                            }
-                        })
-                        .await;
-                if let Err(e) = result {
-                    err_sender.send(e).await.ok();
-                }
-            }
-        })
-        .detach();
-
+        let update_sender = Self::spawn_git_worker(cx);
         let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
 
         GitState {
@@ -327,6 +178,110 @@ impl GitState {
     pub fn all_repositories(&self) -> Vec<RepositoryHandle> {
         self.repositories.clone()
     }
+
+    fn spawn_git_worker(
+        cx: &mut Context<'_, GitState>,
+    ) -> mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)> {
+        let (update_sender, mut update_receiver) =
+            mpsc::unbounded::<(Message, oneshot::Sender<anyhow::Result<()>>)>();
+        cx.spawn(|_, cx| async move {
+            while let Some((msg, respond)) = update_receiver.next().await {
+                let result = cx
+                    .background_executor()
+                    .spawn(Self::process_git_msg(msg))
+                    .await;
+                respond.send(result).ok();
+            }
+        })
+        .detach();
+        update_sender
+    }
+
+    async fn process_git_msg(msg: Message) -> Result<(), anyhow::Error> {
+        match msg {
+            Message::Stage(repo, paths) => {
+                match repo {
+                    GitRepo::Local(repo) => repo.stage_paths(&paths)?,
+                    GitRepo::Remote {
+                        project_id,
+                        client,
+                        worktree_id,
+                        work_directory_id,
+                    } => {
+                        client
+                            .request(proto::Stage {
+                                project_id: project_id.0,
+                                worktree_id: worktree_id.to_proto(),
+                                work_directory_id: work_directory_id.to_proto(),
+                                paths: paths
+                                    .into_iter()
+                                    .map(|repo_path| repo_path.to_proto())
+                                    .collect(),
+                            })
+                            .await
+                            .context("sending stage request")?;
+                    }
+                }
+                Ok(())
+            }
+            Message::Unstage(repo, paths) => {
+                match repo {
+                    GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
+                    GitRepo::Remote {
+                        project_id,
+                        client,
+                        worktree_id,
+                        work_directory_id,
+                    } => {
+                        client
+                            .request(proto::Unstage {
+                                project_id: project_id.0,
+                                worktree_id: worktree_id.to_proto(),
+                                work_directory_id: work_directory_id.to_proto(),
+                                paths: paths
+                                    .into_iter()
+                                    .map(|repo_path| repo_path.to_proto())
+                                    .collect(),
+                            })
+                            .await
+                            .context("sending unstage request")?;
+                    }
+                }
+                Ok(())
+            }
+            Message::Commit {
+                git_repo,
+                name_and_email,
+            } => {
+                match git_repo {
+                    GitRepo::Local(repo) => repo.commit(
+                        name_and_email
+                            .as_ref()
+                            .map(|(name, email)| (name.as_ref(), email.as_ref())),
+                    )?,
+                    GitRepo::Remote {
+                        project_id,
+                        client,
+                        worktree_id,
+                        work_directory_id,
+                    } => {
+                        let (name, email) = name_and_email.unzip();
+                        client
+                            .request(proto::Commit {
+                                project_id: project_id.0,
+                                worktree_id: worktree_id.to_proto(),
+                                work_directory_id: work_directory_id.to_proto(),
+                                name: name.map(String::from),
+                                email: email.map(String::from),
+                            })
+                            .await
+                            .context("sending commit request")?;
+                    }
+                }
+                Ok(())
+            }
+        }
+    }
 }
 
 impl RepositoryHandle {
@@ -379,54 +334,47 @@ impl RepositoryHandle {
         self.repository_entry.relativize(&path.path).log_err()
     }
 
-    pub fn stage_entries(
-        &self,
-        entries: Vec<RepoPath>,
-        err_sender: mpsc::Sender<anyhow::Error>,
-    ) -> anyhow::Result<()> {
+    pub async fn stage_entries(&self, entries: Vec<RepoPath>) -> anyhow::Result<()> {
         if entries.is_empty() {
             return Ok(());
         }
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
         self.update_sender
-            .unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender))
+            .unbounded_send((Message::Stage(self.git_repo.clone(), entries), result_tx))
             .map_err(|_| anyhow!("Failed to submit stage operation"))?;
-        Ok(())
+
+        result_rx.await?
     }
 
-    pub fn unstage_entries(
-        &self,
-        entries: Vec<RepoPath>,
-        err_sender: mpsc::Sender<anyhow::Error>,
-    ) -> anyhow::Result<()> {
+    pub async fn unstage_entries(&self, entries: Vec<RepoPath>) -> anyhow::Result<()> {
         if entries.is_empty() {
             return Ok(());
         }
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
         self.update_sender
-            .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender))
+            .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), result_tx))
             .map_err(|_| anyhow!("Failed to submit unstage operation"))?;
-        Ok(())
+        result_rx.await?
     }
 
-    pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+    pub async fn stage_all(&self) -> anyhow::Result<()> {
         let to_stage = self
             .repository_entry
             .status()
             .filter(|entry| !entry.status.is_staged().unwrap_or(false))
             .map(|entry| entry.repo_path.clone())
             .collect();
-        self.stage_entries(to_stage, err_sender)?;
-        Ok(())
+        self.stage_entries(to_stage).await
     }
 
-    pub fn unstage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+    pub async fn unstage_all(&self) -> anyhow::Result<()> {
         let to_unstage = self
             .repository_entry
             .status()
             .filter(|entry| entry.status.is_staged().unwrap_or(true))
             .map(|entry| entry.repo_path.clone())
             .collect();
-        self.unstage_entries(to_unstage, err_sender)?;
-        Ok(())
+        self.unstage_entries(to_unstage).await
     }
 
     /// Get a count of all entries in the active repository, including
@@ -447,64 +395,18 @@ impl RepositoryHandle {
         return self.have_changes() && (commit_all || self.have_staged_changes());
     }
 
-    pub fn commit(
+    pub async fn commit(
         &self,
         name_and_email: Option<(SharedString, SharedString)>,
-        mut err_sender: mpsc::Sender<anyhow::Error>,
-        cx: &mut App,
     ) -> anyhow::Result<()> {
-        let result = self.update_sender.unbounded_send((
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
+        self.update_sender.unbounded_send((
             Message::Commit {
                 git_repo: self.git_repo.clone(),
                 name_and_email,
             },
-            err_sender.clone(),
-        ));
-        if result.is_err() {
-            cx.spawn(|_| async move {
-                err_sender
-                    .send(anyhow!("Failed to submit commit operation"))
-                    .await
-                    .ok();
-            })
-            .detach();
-            anyhow::bail!("Failed to submit commit operation");
-        } else {
-            Ok(())
-        }
-    }
-
-    pub fn commit_all(
-        &self,
-        name_and_email: Option<(SharedString, SharedString)>,
-        mut err_sender: mpsc::Sender<anyhow::Error>,
-        cx: &mut App,
-    ) -> anyhow::Result<()> {
-        let to_stage = self
-            .repository_entry
-            .status()
-            .filter(|entry| !entry.status.is_staged().unwrap_or(false))
-            .map(|entry| entry.repo_path.clone())
-            .collect();
-        let result = self.update_sender.unbounded_send((
-            Message::StageAndCommit {
-                git_repo: self.git_repo.clone(),
-                paths: to_stage,
-                name_and_email,
-            },
-            err_sender.clone(),
-        ));
-        if result.is_err() {
-            cx.spawn(|_| async move {
-                err_sender
-                    .send(anyhow!("Failed to submit commit all operation"))
-                    .await
-                    .ok();
-            })
-            .detach();
-            anyhow::bail!("Failed to submit commit all operation");
-        } else {
-            Ok(())
-        }
+            result_tx,
+        ))?;
+        result_rx.await?
     }
 }

crates/project/src/project.rs 🔗

@@ -4006,15 +4006,9 @@ impl Project {
             .map(PathBuf::from)
             .map(RepoPath::new)
             .collect();
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        repository_handle
-            .stage_entries(entries, err_sender)
-            .context("staging entries")?;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during staging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+
+        repository_handle.stage_entries(entries).await?;
+        Ok(proto::Ack {})
     }
 
     async fn handle_unstage(
@@ -4034,15 +4028,9 @@ impl Project {
             .map(PathBuf::from)
             .map(RepoPath::new)
             .collect();
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        repository_handle
-            .unstage_entries(entries, err_sender)
-            .context("unstaging entries")?;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during unstaging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+
+        repository_handle.unstage_entries(entries).await?;
+        Ok(proto::Ack {})
     }
 
     async fn handle_commit(
@@ -4057,17 +4045,8 @@ impl Project {
 
         let name = envelope.payload.name.map(SharedString::from);
         let email = envelope.payload.email.map(SharedString::from);
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        cx.update(|cx| {
-            repository_handle
-                .commit(name.zip(email), err_sender, cx)
-                .context("unstaging entries")
-        })??;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during unstaging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+        repository_handle.commit(name.zip(email)).await?;
+        Ok(proto::Ack {})
     }
 
     async fn handle_open_commit_message_buffer(

crates/remote_server/src/headless_project.rs 🔗

@@ -2,7 +2,6 @@ use anyhow::{anyhow, Context as _, Result};
 use extension::ExtensionHostProxy;
 use extension_host::headless_host::HeadlessExtensionStore;
 use fs::{CreateOptions, Fs};
-use futures::channel::mpsc;
 use git::{repository::RepoPath, COMMIT_MESSAGE};
 use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString};
 use http_client::HttpClient;
@@ -637,15 +636,9 @@ impl HeadlessProject {
             .map(PathBuf::from)
             .map(RepoPath::new)
             .collect();
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        repository_handle
-            .stage_entries(entries, err_sender)
-            .context("staging entries")?;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during staging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+
+        repository_handle.stage_entries(entries).await?;
+        Ok(proto::Ack {})
     }
 
     async fn handle_unstage(
@@ -665,15 +658,10 @@ impl HeadlessProject {
             .map(PathBuf::from)
             .map(RepoPath::new)
             .collect();
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        repository_handle
-            .unstage_entries(entries, err_sender)
-            .context("unstaging entries")?;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during unstaging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+
+        repository_handle.unstage_entries(entries).await?;
+
+        Ok(proto::Ack {})
     }
 
     async fn handle_commit(
@@ -688,17 +676,9 @@ impl HeadlessProject {
 
         let name = envelope.payload.name.map(SharedString::from);
         let email = envelope.payload.email.map(SharedString::from);
-        let (err_sender, mut err_receiver) = mpsc::channel(1);
-        cx.update(|cx| {
-            repository_handle
-                .commit(name.zip(email), err_sender, cx)
-                .context("unstaging entries")
-        })??;
-        if let Some(error) = err_receiver.next().await {
-            Err(error.context("error during unstaging"))
-        } else {
-            Ok(proto::Ack {})
-        }
+
+        repository_handle.commit(name.zip(email)).await?;
+        Ok(proto::Ack {})
     }
 
     async fn handle_open_commit_message_buffer(

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

@@ -135,6 +135,11 @@ impl Checkbox {
             ToggleStyle::Custom(color) => color.opacity(0.3),
         }
     }
+
+    /// container size
+    pub fn container_size(cx: &App) -> Rems {
+        DynamicSpacing::Base20.rems(cx)
+    }
 }
 
 impl RenderOnce for Checkbox {
@@ -163,9 +168,13 @@ impl RenderOnce for Checkbox {
         let bg_color = self.bg_color(cx);
         let border_color = self.border_color(cx);
 
+        let size = Self::container_size(cx);
+
         let checkbox = h_flex()
+            .id(self.id.clone())
             .justify_center()
-            .size(DynamicSpacing::Base20.rems(cx))
+            .items_center()
+            .size(size)
             .group(group_id.clone())
             .child(
                 div()

crates/ui/src/traits/toggleable.rs 🔗

@@ -29,6 +29,23 @@ impl ToggleState {
             Self::Selected => Self::Unselected,
         }
     }
+
+    /// Creates a `ToggleState` from the given `any_checked` and `all_checked` flags.
+    pub fn from_any_and_all(any_checked: bool, all_checked: bool) -> Self {
+        match (any_checked, all_checked) {
+            (true, true) => Self::Selected,
+            (false, false) => Self::Unselected,
+            _ => Self::Indeterminate,
+        }
+    }
+
+    /// Returns whether this toggle state is selected
+    pub fn selected(&self) -> bool {
+        match self {
+            ToggleState::Indeterminate | ToggleState::Unselected => false,
+            ToggleState::Selected => true,
+        }
+    }
 }
 
 impl From<bool> for ToggleState {