From 5d1c56829a797a57ee69c46677eb80a85f4eb302 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 5 Feb 2025 18:32:07 -0700 Subject: [PATCH] Add staged checkboxes to multibuffer headers (#24308) Co-authored-by: Mikayla Release Notes: - N/A --------- Co-authored-by: Mikayla --- 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(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6304a56b66585f48898d1bbabf625e1fbee68754..170371164f532e8c069eaa87615ffc9f19ed804b 100644 --- a/crates/editor/src/editor.rs +++ b/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 { + None + } + fn to_any(&self) -> &dyn std::any::Any; } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4669f5d57fbba7d5e62f030f9345e0288f8c1977..9d0c50b89668dac66a8c040b64803731596d7709 100644 --- a/crates/editor/src/element.rs +++ b/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() diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 19028168dd21ba745175a1842cf4b3145429293e..1af585bafa150c7abd778bbc134c73452dd78d36 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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 { + fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option { 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, + file: &Arc, + _: &Window, + cx: &App, + ) -> Option { + 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 for GitPanel {} impl EventEmitter for GitPanel {} +pub(crate) struct GitPanelAddon { + pub(crate) git_panel: Entity, +} + +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 { + 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" diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index d35bb59d6a79e4b0e111259f0f707952ad94ed95..1581d0fc8ad943a6723ba5ce7ba938b53708a775 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/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, editor: Entity, project: Entity, + git_panel: Entity, git_state: Entity, workspace: WeakEntity, 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::(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, - workspace: WeakEntity, + workspace: Entity, + git_panel: Entity, window: &mut Window, cx: &mut Context, ) -> 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 { diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 38a891005916fba97dccc55fef45b04577555ec6..f4ab1791a7a1aa8180ab6b6e2234799898ddb879 100644 --- a/crates/project/src/git.rs +++ b/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 { - 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 { + 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( diff --git a/crates/ui/src/traits/toggleable.rs b/crates/ui/src/traits/toggleable.rs index 8771cedaa70e73ef0b8da4c92b9767afd3ab16f5..f731f9965e9c18f6e60cfadf986c68f2f9a3f122 100644 --- a/crates/ui/src/traits/toggleable.rs +++ b/crates/ui/src/traits/toggleable.rs @@ -58,12 +58,12 @@ impl From for ToggleState { } } -// impl From> for ToggleState { -// fn from(selected: Option) -> Self { -// match selected { -// Some(true) => Self::Selected, -// Some(false) => Self::Unselected, -// None => Self::Unselected, -// } -// } -// } +impl From> for ToggleState { + fn from(selected: Option) -> Self { + match selected { + Some(true) => Self::Selected, + Some(false) => Self::Unselected, + None => Self::Indeterminate, + } + } +}