Synchronize modal commit editor with panel editor (#26068)

Mikayla Maki created

Release Notes:

- Git Beta: Synchronized selections between the modal editor and the
panel editor
- Git Beta: Allow opening the commit modal even if we're unable to
commit.

Change summary

crates/editor/src/editor.rs       | 37 +++++++++++++++++++++++++++++++++
crates/git_ui/src/commit_modal.rs | 32 +++++++++------------------
crates/git_ui/src/git_panel.rs    |  7 ++---
crates/git_ui/src/project_diff.rs |  5 ----
crates/gpui/src/app/context.rs    | 15 +++++++++++++
crates/gpui/src/subscription.rs   | 17 +++++++++++++++
6 files changed, 83 insertions(+), 30 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -2233,6 +2233,43 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn sync_selections(
+        &mut self,
+        other: Entity<Editor>,
+        cx: &mut Context<Self>,
+    ) -> gpui::Subscription {
+        let other_selections = other.read(cx).selections.disjoint.to_vec();
+        self.selections.change_with(cx, |selections| {
+            selections.select_anchors(other_selections);
+        });
+
+        let other_subscription =
+            cx.subscribe(&other, |this, other, other_evt, cx| match other_evt {
+                EditorEvent::SelectionsChanged { local: true } => {
+                    let other_selections = other.read(cx).selections.disjoint.to_vec();
+                    this.selections.change_with(cx, |selections| {
+                        selections.select_anchors(other_selections);
+                    });
+                }
+                _ => {}
+            });
+
+        let this_subscription =
+            cx.subscribe_self::<EditorEvent>(move |this, this_evt, cx| match this_evt {
+                EditorEvent::SelectionsChanged { local: true } => {
+                    let these_selections = this.selections.disjoint.to_vec();
+                    other.update(cx, |other_editor, cx| {
+                        other_editor.selections.change_with(cx, |selections| {
+                            selections.select_anchors(these_selections);
+                        })
+                    });
+                }
+                _ => {}
+            });
+
+        Subscription::join(other_subscription, this_subscription)
+    }
+
     pub fn change_selections<R>(
         &mut self,
         autoscroll: Option<Autoscroll>,

crates/git_ui/src/commit_modal.rs 🔗

@@ -115,27 +115,9 @@ impl CommitModal {
                 return;
             };
 
-            let (can_open_commit_editor, conflict) = git_panel.update(cx, |git_panel, cx| {
-                let can_open_commit_editor = git_panel.can_open_commit_editor();
-                let conflict = git_panel.has_unstaged_conflicts();
-                if can_open_commit_editor {
-                    git_panel.set_modal_open(true, cx);
-                }
-                (can_open_commit_editor, conflict)
+            git_panel.update(cx, |git_panel, cx| {
+                git_panel.set_modal_open(true, cx);
             });
-            if !can_open_commit_editor {
-                let message = if conflict {
-                    "There are still conflicts. You must stage these before committing."
-                } else {
-                    "No changes to commit."
-                };
-                let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
-                cx.spawn(|_, _| async move {
-                    prompt.await.ok();
-                })
-                .detach();
-                return;
-            }
 
             let dock = workspace.dock_at_position(git_panel.position(window, cx));
             let is_open = dock.read(cx).is_open();
@@ -168,8 +150,16 @@ impl CommitModal {
         let commit_editor = git_panel.update(cx, |git_panel, cx| {
             git_panel.set_modal_open(true, cx);
             let buffer = git_panel.commit_message_buffer(cx).clone();
+            let panel_editor = git_panel.commit_editor.clone();
             let project = git_panel.project.clone();
-            cx.new(|cx| commit_message_editor(buffer, None, project.clone(), false, window, cx))
+
+            cx.new(|cx| {
+                let mut editor =
+                    commit_message_editor(buffer, None, project.clone(), false, window, cx);
+                editor.sync_selections(panel_editor, cx).detach();
+
+                editor
+            })
         });
 
         let commit_message = commit_editor.read(cx).text(cx);

crates/git_ui/src/git_panel.rs 🔗

@@ -8,6 +8,7 @@ use crate::{
 use crate::{picker_prompt, project_diff, ProjectDiff};
 use db::kvp::KEY_VALUE_STORE;
 use editor::commit_tooltip::CommitTooltip;
+
 use editor::{
     scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
     ShowScrollbar,
@@ -190,7 +191,7 @@ pub struct GitPanel {
     remote_operation_id: u32,
     pending_remote_operations: RemoteOperations,
     pub(crate) active_repository: Option<Entity<Repository>>,
-    commit_editor: Entity<Editor>,
+    pub(crate) commit_editor: Entity<Editor>,
     conflicted_count: usize,
     conflicted_staged_count: usize,
     current_modifiers: Modifiers,
@@ -1934,7 +1935,7 @@ impl GitPanel {
         })
     }
 
-    pub fn can_open_commit_editor(&self) -> bool {
+    pub fn can_commit(&self) -> bool {
         (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
     }
 
@@ -2013,7 +2014,6 @@ impl GitPanel {
         cx: &mut Context<Self>,
     ) -> Option<impl IntoElement> {
         let active_repository = self.active_repository.clone()?;
-        let can_open_commit_editor = self.can_open_commit_editor();
         let (can_commit, tooltip) = self.configure_commit_button(cx);
         let project = self.project.clone().read(cx);
         let panel_editor_style = panel_editor_style(true, window, cx);
@@ -2108,7 +2108,6 @@ impl GitPanel {
                                     .icon_size(IconSize::Small)
                                     .style(ButtonStyle::Transparent)
                                     .width(expand_button_size.into())
-                                    .disabled(!can_open_commit_editor)
                                     .on_click(cx.listener({
                                         move |_, _, window, cx| {
                                             window.dispatch_action(

crates/git_ui/src/project_diff.rs 🔗

@@ -257,14 +257,12 @@ impl ProjectDiff {
                 }
             }
         }
-        let mut can_open_commit_editor = false;
         let mut stage_all = false;
         let mut unstage_all = false;
         self.workspace
             .read_with(cx, |workspace, cx| {
                 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
                     let git_panel = git_panel.read(cx);
-                    can_open_commit_editor = git_panel.can_open_commit_editor();
                     stage_all = git_panel.can_stage_all();
                     unstage_all = git_panel.can_unstage_all();
                 }
@@ -276,7 +274,6 @@ impl ProjectDiff {
             unstage: has_staged_hunks,
             prev_next,
             selection,
-            can_open_commit_editor,
             stage_all,
             unstage_all,
         };
@@ -773,7 +770,6 @@ struct ButtonStates {
     selection: bool,
     stage_all: bool,
     unstage_all: bool,
-    can_open_commit_editor: bool,
 }
 
 impl Render for ProjectDiffToolbar {
@@ -917,7 +913,6 @@ impl Render for ProjectDiffToolbar {
                     )
                     .child(
                         Button::new("commit", "Commit")
-                            .disabled(!button_states.can_open_commit_editor)
                             .tooltip(Tooltip::for_action_title_in(
                                 "Commit",
                                 &ShowCommitEditor,

crates/gpui/src/app/context.rs 🔗

@@ -90,6 +90,21 @@ impl<'a, T: 'static> Context<'a, T> {
         })
     }
 
+    /// Subscribe to an event type from ourself
+    pub fn subscribe_self<Evt>(
+        &mut self,
+        mut on_event: impl FnMut(&mut T, &Evt, &mut Context<'_, T>) + 'static,
+    ) -> Subscription
+    where
+        T: 'static + EventEmitter<Evt>,
+        Evt: 'static,
+    {
+        let this = self.entity();
+        self.app.subscribe(&this, move |this, evt, cx| {
+            this.update(cx, |this, cx| on_event(this, evt, cx))
+        })
+    }
+
     /// Register a callback to be invoked when GPUI releases this entity.
     pub fn on_release(&self, on_release: impl FnOnce(&mut T, &mut App) + 'static) -> Subscription
     where

crates/gpui/src/subscription.rs 🔗

@@ -168,6 +168,23 @@ impl Subscription {
     pub fn detach(mut self) {
         self.unsubscribe.take();
     }
+
+    /// Joins two subscriptions into a single subscription. Detach will
+    /// detach both interior subscriptions.
+    pub fn join(mut subscription_a: Self, mut subscription_b: Self) -> Self {
+        let a_unsubscribe = subscription_a.unsubscribe.take();
+        let b_unsubscribe = subscription_b.unsubscribe.take();
+        Self {
+            unsubscribe: Some(Box::new(move || {
+                if let Some(self_unsubscribe) = a_unsubscribe {
+                    self_unsubscribe();
+                }
+                if let Some(other_unsubscribe) = b_unsubscribe {
+                    other_unsubscribe();
+                }
+            })),
+        }
+    }
 }
 
 impl Drop for Subscription {