Add staged checkboxes to multibuffer headers (#24308)

Conrad Irwin and Mikayla created

Co-authored-by: Mikayla <mikayla@zed.dev>

Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/editor/src/editor.rs        | 12 +++
crates/editor/src/element.rs       | 10 +++
crates/git_ui/src/git_panel.rs     | 97 ++++++++++++++++++++++++++-----
crates/git_ui/src/project_diff.rs  | 38 +++++++++--
crates/project/src/git.rs          | 13 +++
crates/ui/src/traits/toggleable.rs | 18 ++--
6 files changed, 151 insertions(+), 37 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -124,7 +124,8 @@ pub use multi_buffer::{
     ToOffset, ToPoint,
 };
 use multi_buffer::{
-    ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16,
+    ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow,
+    ToOffsetUtf16,
 };
 use project::{
     lsp_store::{FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
@@ -580,6 +581,15 @@ struct BufferOffset(usize);
 pub trait Addon: 'static {
     fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
 
+    fn render_buffer_header_controls(
+        &self,
+        _: &ExcerptInfo,
+        _: &Window,
+        _: &App,
+    ) -> Option<AnyElement> {
+        None
+    }
+
     fn to_any(&self) -> &dyn std::any::Any;
 }
 

crates/editor/src/element.rs 🔗

@@ -2633,6 +2633,16 @@ impl EditorElement {
                                 ),
                         )
                     })
+                    .children(
+                        self.editor
+                            .read(cx)
+                            .addons
+                            .values()
+                            .filter_map(|addon| {
+                                addon.render_buffer_header_controls(for_excerpt, window, cx)
+                            })
+                            .take(1),
+                    )
                     .child(
                         h_flex()
                             .cursor_pointer()

crates/git_ui/src/git_panel.rs 🔗

@@ -14,8 +14,9 @@ use git::repository::RepoPath;
 use git::status::FileStatus;
 use git::{CommitAllChanges, CommitChanges, ToggleStaged};
 use gpui::*;
-use language::Buffer;
+use language::{Buffer, File};
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
+use multi_buffer::ExcerptInfo;
 use panel::PanelHeader;
 use project::git::{GitEvent, Repository};
 use project::{Fs, Project, ProjectPath};
@@ -1007,19 +1008,19 @@ impl GitPanel {
             };
             if status_entry.status.is_created() {
                 self.new_count += 1;
-                if self.entry_appears_staged(status_entry) != Some(false) {
+                if self.entry_is_staged(status_entry) != Some(false) {
                     self.new_staged_count += 1;
                 }
             } else {
                 self.tracked_count += 1;
-                if self.entry_appears_staged(status_entry) != Some(false) {
+                if self.entry_is_staged(status_entry) != Some(false) {
                     self.tracked_staged_count += 1;
                 }
             }
         }
     }
 
-    fn entry_appears_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
+    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
         for pending in self.pending.iter().rev() {
             if pending.repo_paths.contains(&entry.repo_path) {
                 return Some(pending.will_become_staged);
@@ -1301,6 +1302,49 @@ impl GitPanel {
         )
     }
 
+    pub fn render_buffer_header_controls(
+        &self,
+        entity: &Entity<Self>,
+        file: &Arc<dyn File>,
+        _: &Window,
+        cx: &App,
+    ) -> Option<AnyElement> {
+        let repo = self.active_repository.as_ref()?.read(cx);
+        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
+        let ix = self.entries_by_path.get(&repo_path)?;
+        let entry = self.entries.get(*ix)?;
+
+        let is_staged = self.entry_is_staged(entry.status_entry()?);
+
+        let checkbox = Checkbox::new("stage-file", is_staged.into())
+            .disabled(!self.has_write_access(cx))
+            .fill()
+            .elevation(ElevationIndex::Surface)
+            .on_click({
+                let entry = entry.clone();
+                let git_panel = entity.downgrade();
+                move |_, window, cx| {
+                    git_panel
+                        .update(cx, |this, cx| {
+                            this.toggle_staged_for_entry(&entry, window, cx);
+                            cx.stop_propagation();
+                        })
+                        .ok();
+                }
+            });
+        Some(
+            h_flex()
+                .id("start-slot")
+                .child(checkbox)
+                .child(git_status_icon(entry.status_entry()?.status, cx))
+                .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                    // prevent the list item active state triggering when toggling checkbox
+                    cx.stop_propagation();
+                })
+                .into_any_element(),
+        )
+    }
+
     fn render_entries(
         &self,
         has_write_access: bool,
@@ -1473,14 +1517,6 @@ impl GitPanel {
             .map(|name| name.to_string_lossy().into_owned())
             .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
 
-        let pending = self.pending.iter().rev().find_map(|pending| {
-            if pending.repo_paths.contains(&entry.repo_path) {
-                Some(pending.will_become_staged)
-            } else {
-                None
-            }
-        });
-
         let repo_path = entry.repo_path.clone();
         let selected = self.selected_entry == Some(ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -1512,10 +1548,7 @@ impl GitPanel {
 
         let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
 
-        let mut is_staged = pending
-            .or_else(|| entry.is_staged)
-            .map(ToggleState::from)
-            .unwrap_or(ToggleState::Indeterminate);
+        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
 
         if !self.has_staged_changes() && !entry.status.is_created() {
             is_staged = ToggleState::Selected;
@@ -1597,6 +1630,16 @@ impl GitPanel {
             )
             .into_any_element()
     }
+
+    fn has_write_access(&self, cx: &App) -> bool {
+        let room = self
+            .workspace
+            .upgrade()
+            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
+
+        room.as_ref()
+            .map_or(true, |room| room.read(cx).local_participant().can_write())
+    }
 }
 
 impl Render for GitPanel {
@@ -1734,6 +1777,28 @@ impl EventEmitter<Event> for GitPanel {}
 
 impl EventEmitter<PanelEvent> for GitPanel {}
 
+pub(crate) struct GitPanelAddon {
+    pub(crate) git_panel: Entity<GitPanel>,
+}
+
+impl editor::Addon for GitPanelAddon {
+    fn to_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn render_buffer_header_controls(
+        &self,
+        excerpt_info: &ExcerptInfo,
+        window: &Window,
+        cx: &App,
+    ) -> Option<AnyElement> {
+        let file = excerpt_info.buffer.file()?;
+        let git_panel = self.git_panel.read(cx);
+
+        git_panel.render_buffer_header_controls(&self.git_panel, &file, window, cx)
+    }
+}
+
 impl Panel for GitPanel {
     fn persistent_name() -> &'static str {
         "GitPanel"

crates/git_ui/src/project_diff.rs 🔗

@@ -21,7 +21,7 @@ use workspace::{
     ItemNavHistory, ToolbarItemLocation, Workspace,
 };
 
-use crate::git_panel::{GitPanel, GitStatusEntry};
+use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
 
 actions!(git, [Diff]);
 
@@ -29,6 +29,7 @@ pub(crate) struct ProjectDiff {
     multibuffer: Entity<MultiBuffer>,
     editor: Entity<Editor>,
     project: Entity<Project>,
+    git_panel: Entity<GitPanel>,
     git_state: Entity<GitState>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
@@ -79,9 +80,16 @@ impl ProjectDiff {
             workspace.activate_item(&existing, true, true, window, cx);
             existing
         } else {
-            let workspace_handle = cx.entity().downgrade();
-            let project_diff =
-                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
+            let workspace_handle = cx.entity();
+            let project_diff = cx.new(|cx| {
+                Self::new(
+                    workspace.project().clone(),
+                    workspace_handle,
+                    workspace.panel::<GitPanel>(cx).unwrap(),
+                    window,
+                    cx,
+                )
+            });
             workspace.add_item_to_active_pane(
                 Box::new(project_diff.clone()),
                 None,
@@ -100,7 +108,8 @@ impl ProjectDiff {
 
     fn new(
         project: Entity<Project>,
-        workspace: WeakEntity<Workspace>,
+        workspace: Entity<Workspace>,
+        git_panel: Entity<GitPanel>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -116,6 +125,9 @@ impl ProjectDiff {
                 cx,
             );
             diff_display_editor.set_expand_all_diff_hunks(cx);
+            diff_display_editor.register_addon(GitPanelAddon {
+                git_panel: git_panel.clone(),
+            });
             diff_display_editor
         });
         cx.subscribe_in(&editor, window, Self::handle_editor_event)
@@ -141,7 +153,8 @@ impl ProjectDiff {
         Self {
             project,
             git_state: git_state.clone(),
-            workspace,
+            git_panel: git_panel.clone(),
+            workspace: workspace.downgrade(),
             focus_handle,
             editor,
             multibuffer,
@@ -423,9 +436,16 @@ impl Item for ProjectDiff {
     where
         Self: Sized,
     {
-        Some(
-            cx.new(|cx| ProjectDiff::new(self.project.clone(), self.workspace.clone(), window, cx)),
-        )
+        let workspace = self.workspace.upgrade()?;
+        Some(cx.new(|cx| {
+            ProjectDiff::new(
+                self.project.clone(),
+                workspace,
+                self.git_panel.clone(),
+                window,
+                cx,
+            )
+        }))
     }
 
     fn is_dirty(&self, cx: &App) -> bool {

crates/project/src/git.rs 🔗

@@ -15,6 +15,7 @@ use gpui::{
 use language::{Buffer, LanguageRegistry};
 use rpc::{proto, AnyProtoClient};
 use settings::WorktreeId;
+use std::path::Path;
 use std::sync::Arc;
 use text::BufferId;
 use util::{maybe, ResultExt};
@@ -341,10 +342,18 @@ impl Repository {
     }
 
     pub fn project_path_to_repo_path(&self, path: &ProjectPath) -> Option<RepoPath> {
-        if path.worktree_id != self.worktree_id {
+        self.worktree_id_path_to_repo_path(path.worktree_id, &path.path)
+    }
+
+    pub fn worktree_id_path_to_repo_path(
+        &self,
+        worktree_id: WorktreeId,
+        path: &Path,
+    ) -> Option<RepoPath> {
+        if worktree_id != self.worktree_id {
             return None;
         }
-        self.repository_entry.relativize(&path.path).log_err()
+        self.repository_entry.relativize(path).log_err()
     }
 
     pub fn open_commit_buffer(

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

@@ -58,12 +58,12 @@ impl From<bool> for ToggleState {
     }
 }
 
-// impl From<Option<bool>> for ToggleState {
-//     fn from(selected: Option<bool>) -> Self {
-//         match selected {
-//             Some(true) => Self::Selected,
-//             Some(false) => Self::Unselected,
-//             None => Self::Unselected,
-//         }
-//     }
-// }
+impl From<Option<bool>> for ToggleState {
+    fn from(selected: Option<bool>) -> Self {
+        match selected {
+            Some(true) => Self::Selected,
+            Some(false) => Self::Unselected,
+            None => Self::Indeterminate,
+        }
+    }
+}