git: Use a buffer for the panel's commit message (#23308)

Cole Miller and Max created

This PR changes the `GitPanel` and `GitState` to use a
`language::Buffer` for the commit message. This is a small initial step
toward remote editing and collaboration support.

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                        |   1 
crates/editor/src/editor_tests.rs |   1 
crates/git_ui/Cargo.toml          |   1 
crates/git_ui/src/git_panel.rs    | 224 ++++++++++++++------------------
crates/project/src/git.rs         |  66 ++++++--
crates/project/src/project.rs     |   2 
6 files changed, 146 insertions(+), 149 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5196,7 +5196,6 @@ dependencies = [
  "futures 0.3.31",
  "git",
  "gpui",
- "language",
  "menu",
  "project",
  "schemars",

crates/editor/src/editor_tests.rs 🔗

@@ -10488,6 +10488,7 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) {
     let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await;
     let mut assert = |before, after| {
         let _state_context = cx.set_state(before);
+        cx.run_until_parked();
         cx.update_editor(|editor, cx| {
             editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, cx)
         });

crates/git_ui/Cargo.toml 🔗

@@ -20,7 +20,6 @@ editor.workspace = true
 futures.workspace = true
 git.workspace = true
 gpui.workspace = true
-language.workspace = true
 menu.workspace = true
 project.workspace = true
 schemars.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,17 +1,16 @@
 use crate::git_panel_settings::StatusStyle;
 use crate::{git_panel_settings::GitPanelSettings, git_status_icon};
-use anyhow::{Context as _, Result};
+use anyhow::Result;
 use db::kvp::KEY_VALUE_STORE;
 use editor::actions::MoveToEnd;
 use editor::scroll::ScrollbarAutoHide;
-use editor::{Editor, EditorSettings, ShowScrollbar};
+use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
 use futures::channel::mpsc;
 use futures::StreamExt as _;
 use git::repository::{GitRepository, RepoPath};
 use git::status::FileStatus;
 use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
 use gpui::*;
-use language::Buffer;
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
 use project::git::GitState;
 use project::{Fs, Project, ProjectPath, WorktreeId};
@@ -153,10 +152,6 @@ impl GitPanel {
         let project = workspace.project().clone();
         let weak_workspace = cx.view().downgrade();
         let git_state = project.read(cx).git_state().cloned();
-        let language_registry = workspace.app_state().languages.clone();
-        let current_commit_message = git_state
-            .as_ref()
-            .map(|git_state| git_state.read(cx).commit_message.clone());
         let (err_sender, mut err_receiver) = mpsc::channel(1);
         let workspace = cx.view().downgrade();
 
@@ -167,81 +162,85 @@ impl GitPanel {
                 this.hide_scrollbar(cx);
             })
             .detach();
-            cx.subscribe(&project, move |this, project, event, cx| {
-                use project::Event;
-
-                let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
-                    let snapshot = worktree.read(cx).snapshot();
-                    snapshot.id()
-                });
-                let first_repo_in_project = first_repository_in_project(&project, cx);
+            cx.subscribe(&project, {
+                let git_state = git_state.clone();
+                move |this, project, event, cx| {
+                    use project::Event;
+
+                    let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
+                        let snapshot = worktree.read(cx).snapshot();
+                        snapshot.id()
+                    });
+                    let first_repo_in_project = first_repository_in_project(&project, cx);
 
-                let Some(git_state) = project.read(cx).git_state().cloned() else {
-                    return;
-                };
-                git_state.update(cx, |git_state, _| {
-                    match event {
-                        project::Event::WorktreeRemoved(id) => {
-                            let Some((worktree_id, _, _)) = git_state.active_repository.as_ref()
-                            else {
-                                return;
-                            };
-                            if worktree_id == id {
-                                git_state.active_repository = first_repo_in_project;
-                                this.schedule_update();
+                    let Some(git_state) = git_state.clone() else {
+                        return;
+                    };
+                    git_state.update(cx, |git_state, _| {
+                        match event {
+                            project::Event::WorktreeRemoved(id) => {
+                                let Some((worktree_id, _, _)) =
+                                    git_state.active_repository.as_ref()
+                                else {
+                                    return;
+                                };
+                                if worktree_id == id {
+                                    git_state.active_repository = first_repo_in_project;
+                                    this.schedule_update();
+                                }
                             }
-                        }
-                        project::Event::WorktreeOrderChanged => {
-                            // activate the new first worktree if the first was moved
-                            let Some(first_id) = first_worktree_id else {
-                                return;
-                            };
-                            if !git_state
-                                .active_repository
-                                .as_ref()
-                                .is_some_and(|(id, _, _)| id == &first_id)
-                            {
-                                git_state.active_repository = first_repo_in_project;
-                                this.schedule_update();
+                            project::Event::WorktreeOrderChanged => {
+                                // activate the new first worktree if the first was moved
+                                let Some(first_id) = first_worktree_id else {
+                                    return;
+                                };
+                                if !git_state
+                                    .active_repository
+                                    .as_ref()
+                                    .is_some_and(|(id, _, _)| id == &first_id)
+                                {
+                                    git_state.active_repository = first_repo_in_project;
+                                    this.schedule_update();
+                                }
                             }
-                        }
-                        Event::WorktreeAdded(_) => {
-                            let Some(first_id) = first_worktree_id else {
-                                return;
-                            };
-                            if !git_state
-                                .active_repository
-                                .as_ref()
-                                .is_some_and(|(id, _, _)| id == &first_id)
-                            {
-                                git_state.active_repository = first_repo_in_project;
-                                this.schedule_update();
+                            Event::WorktreeAdded(_) => {
+                                let Some(first_id) = first_worktree_id else {
+                                    return;
+                                };
+                                if !git_state
+                                    .active_repository
+                                    .as_ref()
+                                    .is_some_and(|(id, _, _)| id == &first_id)
+                                {
+                                    git_state.active_repository = first_repo_in_project;
+                                    this.schedule_update();
+                                }
                             }
-                        }
-                        project::Event::WorktreeUpdatedEntries(id, _) => {
-                            if git_state
-                                .active_repository
-                                .as_ref()
-                                .is_some_and(|(active_id, _, _)| active_id == id)
-                            {
-                                git_state.active_repository = first_repo_in_project;
+                            project::Event::WorktreeUpdatedEntries(id, _) => {
+                                if git_state
+                                    .active_repository
+                                    .as_ref()
+                                    .is_some_and(|(active_id, _, _)| active_id == id)
+                                {
+                                    git_state.active_repository = first_repo_in_project;
+                                    this.schedule_update();
+                                }
+                            }
+                            project::Event::WorktreeUpdatedGitRepositories(_) => {
+                                let Some(first) = first_repo_in_project else {
+                                    return;
+                                };
+                                git_state.active_repository = Some(first);
                                 this.schedule_update();
                             }
-                        }
-                        project::Event::WorktreeUpdatedGitRepositories(_) => {
-                            let Some(first) = first_repo_in_project else {
-                                return;
-                            };
-                            git_state.active_repository = Some(first);
-                            this.schedule_update();
-                        }
-                        project::Event::Closed => {
-                            this.reveal_in_editor = Task::ready(());
-                            this.visible_entries.clear();
-                        }
-                        _ => {}
-                    };
-                });
+                            project::Event::Closed => {
+                                this.reveal_in_editor = Task::ready(());
+                                this.visible_entries.clear();
+                            }
+                            _ => {}
+                        };
+                    });
+                }
             })
             .detach();
 
@@ -257,15 +256,23 @@ impl GitPanel {
                     background_color: Some(gpui::transparent_black()),
                     ..Default::default()
                 };
-
                 text_style.refine(&refinement);
 
-                let mut commit_editor = Editor::auto_height(10, cx);
-                if let Some(message) = current_commit_message {
-                    commit_editor.set_text(message, cx);
+                let mut commit_editor = if let Some(git_state) = git_state.as_ref() {
+                    let buffer = cx.new_model(|cx| {
+                        MultiBuffer::singleton(git_state.read(cx).commit_message.clone(), cx)
+                    });
+                    // TODO should we attach the project?
+                    Editor::new(
+                        EditorMode::AutoHeight { max_lines: 10 },
+                        buffer,
+                        None,
+                        false,
+                        cx,
+                    )
                 } else {
-                    commit_editor.set_text("", cx);
-                }
+                    Editor::auto_height(10, cx)
+                };
                 commit_editor.set_use_autoclose(false);
                 commit_editor.set_show_gutter(false, cx);
                 commit_editor.set_show_wrap_guides(false, cx);
@@ -275,24 +282,6 @@ impl GitPanel {
                 commit_editor
             });
 
-            let buffer = commit_editor
-                .read(cx)
-                .buffer()
-                .read(cx)
-                .as_singleton()
-                .expect("commit editor must be singleton");
-
-            cx.subscribe(&buffer, Self::on_buffer_event).detach();
-
-            let markdown = language_registry.language_for_name("Markdown");
-            cx.spawn(|_, mut cx| async move {
-                let markdown = markdown.await.context("failed to load Markdown language")?;
-                buffer.update(&mut cx, |buffer, cx| {
-                    buffer.set_language(Some(markdown), cx)
-                })
-            })
-            .detach_and_log_err(cx);
-
             let scroll_handle = UniformListScrollHandle::new();
 
             let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
@@ -713,9 +702,9 @@ impl GitPanel {
         let Some(git_state) = self.git_state(cx) else {
             return;
         };
-        if let Err(e) =
-            git_state.update(cx, |git_state, _| git_state.commit(self.err_sender.clone()))
-        {
+        if let Err(e) = git_state.update(cx, |git_state, cx| {
+            git_state.commit(self.err_sender.clone(), cx)
+        }) {
             self.show_err_toast("commit error", e, cx);
         };
         self.commit_editor
@@ -727,8 +716,8 @@ impl GitPanel {
         let Some(git_state) = self.git_state(cx) else {
             return;
         };
-        if let Err(e) = git_state.update(cx, |git_state, _| {
-            git_state.commit_all(self.err_sender.clone())
+        if let Err(e) = git_state.update(cx, |git_state, cx| {
+            git_state.commit_all(self.err_sender.clone(), cx)
         }) {
             self.show_err_toast("commit all error", e, cx);
         };
@@ -911,26 +900,6 @@ impl GitPanel {
         cx.notify();
     }
 
-    fn on_buffer_event(
-        &mut self,
-        _buffer: Model<Buffer>,
-        event: &language::BufferEvent,
-        cx: &mut ViewContext<Self>,
-    ) {
-        if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
-            let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx));
-
-            let Some(git_state) = self.git_state(cx) else {
-                return;
-            };
-            git_state.update(cx, |git_state, _| {
-                git_state.commit_message = commit_message.into();
-            });
-
-            cx.notify();
-        }
-    }
-
     fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut ViewContext<Self>) {
         let Some(workspace) = self.weak_workspace.upgrade() else {
             return;
@@ -1089,7 +1058,10 @@ impl GitPanel {
         let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
         let (can_commit, can_commit_all) = self.git_state(cx).map_or((false, false), |git_state| {
             let git_state = git_state.read(cx);
-            (git_state.can_commit(false), git_state.can_commit(true))
+            (
+                git_state.can_commit(false, cx),
+                git_state.can_commit(true, cx),
+            )
         });
 
         let focus_handle_1 = self.focus_handle(cx).clone();

crates/project/src/git.rs 🔗

@@ -1,19 +1,19 @@
-use std::sync::Arc;
-
-use anyhow::anyhow;
+use anyhow::{anyhow, Context as _};
 use futures::channel::mpsc;
 use futures::{SinkExt as _, StreamExt as _};
 use git::{
     repository::{GitRepository, RepoPath},
     status::{GitSummary, TrackedSummary},
 };
-use gpui::{AppContext, SharedString};
+use gpui::{AppContext, Context as _, Model};
+use language::{Buffer, LanguageRegistry};
 use settings::WorktreeId;
+use std::sync::Arc;
+use text::Rope;
 use worktree::RepositoryEntry;
 
 pub struct GitState {
-    /// The current commit message being composed.
-    pub commit_message: SharedString,
+    pub commit_message: Model<Buffer>,
 
     /// When a git repository is selected, this is used to track which repository's changes
     /// are currently being viewed or modified in the UI.
@@ -23,14 +23,14 @@ pub struct GitState {
 }
 
 enum Message {
-    StageAndCommit(Arc<dyn GitRepository>, SharedString, Vec<RepoPath>),
-    Commit(Arc<dyn GitRepository>, SharedString),
+    StageAndCommit(Arc<dyn GitRepository>, Rope, Vec<RepoPath>),
+    Commit(Arc<dyn GitRepository>, Rope),
     Stage(Arc<dyn GitRepository>, Vec<RepoPath>),
     Unstage(Arc<dyn GitRepository>, Vec<RepoPath>),
 }
 
 impl GitState {
-    pub fn new(cx: &AppContext) -> Self {
+    pub fn new(languages: Arc<LanguageRegistry>, cx: &mut AppContext) -> Self {
         let (update_sender, mut update_receiver) =
             mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
         cx.spawn(|cx| async move {
@@ -41,12 +41,12 @@ impl GitState {
                         match msg {
                             Message::StageAndCommit(repo, message, paths) => {
                                 repo.stage_paths(&paths)?;
-                                repo.commit(&message)?;
+                                repo.commit(&message.to_string())?;
                                 Ok(())
                             }
                             Message::Stage(repo, paths) => repo.stage_paths(&paths),
                             Message::Unstage(repo, paths) => repo.unstage_paths(&paths),
-                            Message::Commit(repo, message) => repo.commit(&message),
+                            Message::Commit(repo, message) => repo.commit(&message.to_string()),
                         }
                     })
                     .await;
@@ -56,8 +56,22 @@ impl GitState {
             }
         })
         .detach();
+
+        let commit_message = cx.new_model(|cx| Buffer::local("", cx));
+        let markdown = languages.language_for_name("Markdown");
+        cx.spawn({
+            let commit_message = commit_message.clone();
+            |mut cx| async move {
+                let markdown = markdown.await.context("failed to load Markdown language")?;
+                commit_message.update(&mut cx, |commit_message, cx| {
+                    commit_message.set_language(Some(markdown), cx)
+                })
+            }
+        })
+        .detach_and_log_err(cx);
+
         GitState {
-            commit_message: SharedString::default(),
+            commit_message,
             active_repository: None,
             update_sender,
         }
@@ -160,29 +174,41 @@ impl GitState {
         entry.status_summary().index != TrackedSummary::UNCHANGED
     }
 
-    pub fn can_commit(&self, commit_all: bool) -> bool {
-        return !self.commit_message.trim().is_empty()
+    pub fn can_commit(&self, commit_all: bool, cx: &AppContext) -> bool {
+        return self
+            .commit_message
+            .read(cx)
+            .chars()
+            .any(|c| !c.is_ascii_whitespace())
             && self.have_changes()
             && (commit_all || self.have_staged_changes());
     }
 
-    pub fn commit(&mut self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
-        if !self.can_commit(false) {
+    pub fn commit(
+        &mut self,
+        err_sender: mpsc::Sender<anyhow::Error>,
+        cx: &AppContext,
+    ) -> anyhow::Result<()> {
+        if !self.can_commit(false, cx) {
             return Err(anyhow!("Unable to commit"));
         }
         let Some((_, _, git_repo)) = self.active_repository() else {
             return Err(anyhow!("No active repository"));
         };
         let git_repo = git_repo.clone();
-        let message = std::mem::take(&mut self.commit_message);
+        let message = self.commit_message.read(cx).as_rope().clone();
         self.update_sender
             .unbounded_send((Message::Commit(git_repo, message), err_sender))
             .map_err(|_| anyhow!("Failed to submit commit operation"))?;
         Ok(())
     }
 
-    pub fn commit_all(&mut self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
-        if !self.can_commit(true) {
+    pub fn commit_all(
+        &mut self,
+        err_sender: mpsc::Sender<anyhow::Error>,
+        cx: &AppContext,
+    ) -> anyhow::Result<()> {
+        if !self.can_commit(true, cx) {
             return Err(anyhow!("Unable to commit"));
         }
         let Some((_, entry, git_repo)) = self.active_repository.as_ref() else {
@@ -193,7 +219,7 @@ impl GitState {
             .filter(|entry| !entry.status.is_staged().unwrap_or(false))
             .map(|entry| entry.repo_path.clone())
             .collect::<Vec<_>>();
-        let message = std::mem::take(&mut self.commit_message);
+        let message = self.commit_message.read(cx).as_rope().clone();
         self.update_sender
             .unbounded_send((
                 Message::StageAndCommit(git_repo.clone(), message, to_stage),

crates/project/src/project.rs 🔗

@@ -691,7 +691,7 @@ impl Project {
                 )
             });
 
-            let git_state = Some(cx.new_model(|cx| GitState::new(cx)));
+            let git_state = Some(cx.new_model(|cx| GitState::new(languages.clone(), cx)));
 
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();