From a864168c27624230f8ac736bb1288e76183f7f9d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 3 Feb 2025 18:11:13 +0200 Subject: [PATCH] Enable collaborating editing of the commit message input inside the git panel (#24130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/200b88b8-249a-4841-97cd-fda8365efd00 Now all users in the collab/ssh session can edit the commit input collaboratively, observing each others' changes live. A real `.git/COMMIT_EDITMSG` file is opened, which automatically enables its syntax highlight, but its original context is never used or saved on disk — this way we avoid stale commit messages from previous commits that git places there. A caveat: previous version put some effort into preserving unfinished commit messages on repo swtiches, but this version would not do that — instead, it will be blank on startup, and use whatever `.git/COMMIT_EDITMSG` contents on repo switch Release Notes: - N/A --------- Co-authored-by: Cole Miller --- Cargo.lock | 2 + crates/collab/src/rpc.rs | 1 + crates/editor/src/editor.rs | 4 + crates/git/src/repository.rs | 18 +- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/git_panel.rs | 236 ++++++++++-- crates/project/src/git.rs | 374 ++++++++----------- crates/project/src/project.rs | 147 +++++--- crates/proto/proto/zed.proto | 14 +- crates/proto/src/proto.rs | 3 + crates/remote_server/src/headless_project.rs | 158 +++++--- 11 files changed, 595 insertions(+), 364 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 534f4dbf72..24a4524c07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5264,9 +5264,11 @@ dependencies = [ "futures 0.3.31", "git", "gpui", + "language", "menu", "picker", "project", + "rpc", "schemars", "serde", "serde_derive", diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index ce5ef11e56..6b6d1d9749 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -394,6 +394,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) .add_request_handler({ diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4d9ad08c82..c288664337 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12060,6 +12060,10 @@ impl Editor { self.buffer.read(cx).read(cx).text() } + pub fn is_empty(&self, cx: &App) -> bool { + self.buffer.read(cx).read(cx).is_empty() + } + pub fn text_option(&self, cx: &App) -> Option { let text = self.text(cx); let text = text.trim(); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 7e8ecac9f0..7b82c2571d 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,6 @@ use crate::status::FileStatus; -use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; +use crate::{GitHostingProviderRegistry, COMMIT_MESSAGE}; use anyhow::{anyhow, Context as _, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; @@ -62,7 +62,7 @@ pub trait GitRepository: Send + Sync { /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index. fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>; - fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>; + fn commit(&self, name_and_email: Option<(&str, &str)>) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -283,14 +283,22 @@ impl GitRepository for RealGitRepository { Ok(()) } - fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> { + fn commit(&self, name_and_email: Option<(&str, &str)>) -> Result<()> { let working_directory = self .repository .lock() .workdir() .context("failed to read git work directory")? .to_path_buf(); - let mut args = vec!["commit", "--quiet", "-m", message]; + let commit_file = self.dot_git_dir().join(*COMMIT_MESSAGE); + let commit_file_path = commit_file.to_string_lossy(); + let mut args = vec![ + "commit", + "--quiet", + "-F", + commit_file_path.as_ref(), + "--cleanup=strip", + ]; let author = name_and_email.map(|(name, email)| format!("{name} <{email}>")); if let Some(author) = author.as_deref() { args.push("--author"); @@ -450,7 +458,7 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> { + fn commit(&self, _name_and_email: Option<(&str, &str)>) -> Result<()> { unimplemented!() } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index acc7987d80..18cad73127 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -20,8 +20,10 @@ editor.workspace = true futures.workspace = true git.workspace = true gpui.workspace = true +language.workspace = true menu.workspace = true project.workspace = true +rpc.workspace = true schemars.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index cef3155bf5..3ff84e0ddc 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3,20 +3,24 @@ use crate::repository_selector::RepositorySelectorPopoverMenu; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; -use anyhow::Result; +use anyhow::{Context as _, Result}; 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::StreamExt as _; +use futures::{SinkExt, StreamExt as _}; use git::repository::RepoPath; use git::status::FileStatus; -use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll}; +use git::{ + CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll, COMMIT_MESSAGE, +}; use gpui::*; +use language::{Buffer, BufferId}; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; -use project::git::RepositoryHandle; -use project::{Fs, Project, ProjectPath}; +use project::git::{GitRepo, RepositoryHandle}; +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}; @@ -30,7 +34,7 @@ use workspace::notifications::{DetachAndPromptErr, NotificationId}; use workspace::Toast; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - Workspace, + Item, Workspace, }; actions!( @@ -101,10 +105,69 @@ pub struct GitPanel { all_staged: Option, width: Option, err_sender: mpsc::Sender, + commit_task: Task<()>, + commit_pending: bool, +} + +fn commit_message_buffer( + project: &Entity, + active_repository: &RepositoryHandle, + cx: &mut App, +) -> Task>> { + match &active_repository.git_repo { + GitRepo::Local(repo) => { + let commit_message_file = repo.dot_git_dir().join(*COMMIT_MESSAGE); + let fs = project.read(cx).fs().clone(); + let project = project.downgrade(); + cx.spawn(|mut cx| async move { + fs.create_file( + &commit_message_file, + CreateOptions { + overwrite: false, + ignore_if_exists: true, + }, + ) + .await + .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; + let buffer = project + .update(&mut cx, |project, cx| { + project.open_local_buffer(&commit_message_file, cx) + })? + .await + .with_context(|| { + format!("opening commit message buffer at {commit_message_file:?}",) + })?; + Ok(buffer) + }) + } + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + let request = client.request(proto::OpenCommitMessageBuffer { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + }); + let project = project.downgrade(); + cx.spawn(|mut cx| async move { + let response = request.await.context("requesting to open commit buffer")?; + let buffer_id = BufferId::new(response.buffer_id)?; + let buffer = project + .update(&mut cx, { + |project, cx| project.wait_for_remote_buffer(buffer_id, cx) + })? + .await?; + Ok(buffer) + }) + } + } } fn commit_message_editor( - active_repository: Option<&RepositoryHandle>, + commit_message_buffer: Option>, window: &mut Window, cx: &mut Context<'_, Editor>, ) -> Editor { @@ -121,8 +184,8 @@ fn commit_message_editor( }; text_style.refine(&refinement); - let mut commit_editor = if let Some(active_repository) = active_repository.as_ref() { - let buffer = cx.new(|cx| MultiBuffer::singleton(active_repository.commit_message(), cx)); + let mut commit_editor = if let Some(commit_message_buffer) = commit_message_buffer { + let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx)); Editor::new( EditorMode::AutoHeight { max_lines: 10 }, buffer, @@ -148,12 +211,31 @@ impl GitPanel { workspace: WeakEntity, cx: AsyncWindowContext, ) -> Task>> { - cx.spawn(|mut cx| async move { workspace.update_in(&mut cx, Self::new) }) + cx.spawn(|mut cx| async move { + let commit_message_buffer = workspace.update(&mut cx, |workspace, cx| { + let project = workspace.project(); + let active_repository = project.read(cx).active_repository(cx); + active_repository + .map(|active_repository| commit_message_buffer(project, &active_repository, cx)) + })?; + let commit_message_buffer = match commit_message_buffer { + Some(commit_message_buffer) => Some( + commit_message_buffer + .await + .context("opening commit buffer")?, + ), + None => None, + }; + workspace.update_in(&mut cx, |workspace, window, cx| { + Self::new(workspace, window, commit_message_buffer, cx) + }) + }) } pub fn new( workspace: &mut Workspace, window: &mut Window, + commit_message_buffer: Option>, cx: &mut Context, ) -> Entity { let fs = workspace.app_state().fs.clone(); @@ -172,7 +254,10 @@ impl GitPanel { .detach(); let commit_editor = - cx.new(|cx| commit_message_editor(active_repository.as_ref(), window, cx)); + cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx)); + commit_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); let scroll_handle = UniformListScrollHandle::new(); @@ -207,6 +292,8 @@ impl GitPanel { show_scrollbar: false, hide_scrollbar_task: None, update_visible_entries_task: Task::ready(()), + commit_task: Task::ready(()), + commit_pending: false, active_repository, scroll_handle, fs, @@ -586,16 +673,49 @@ impl GitPanel { &mut self, _: &git::CommitChanges, name_and_email: Option<(SharedString, SharedString)>, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - if !active_repository.can_commit(false, cx) { + if !active_repository.can_commit(false) { return; } - active_repository.commit(name_and_email, self.err_sender.clone(), cx); + if self.commit_editor.read(cx).is_empty(cx) { + return; + } + self.commit_pending = true; + 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; + }) + .ok(); + }); } /// Commit all changes, regardless of whether they are staged or not @@ -603,16 +723,49 @@ impl GitPanel { &mut self, _: &git::CommitAllChanges, name_and_email: Option<(SharedString, SharedString)>, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - if !active_repository.can_commit(true, cx) { + if !active_repository.can_commit(true) { return; } - active_repository.commit_all(name_and_email, self.err_sender.clone(), cx); + if self.commit_editor.read(cx).is_empty(cx) { + return; + } + self.commit_pending = true; + 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_all(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; + }) + .ok(); + }); } fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context) { @@ -714,17 +867,40 @@ impl GitPanel { } fn schedule_update(&mut self, window: &mut Window, cx: &mut Context) { + let project = self.project.clone(); let handle = cx.entity().downgrade(); self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move { cx.background_executor().timer(UPDATE_DEBOUNCE).await; - if let Some(this) = handle.upgrade() { - this.update_in(&mut cx, |this, window, cx| { - this.update_visible_entries(cx); - let active_repository = this.active_repository.as_ref(); - this.commit_editor = - cx.new(|cx| commit_message_editor(active_repository, window, cx)); - }) - .ok(); + if let Some(git_panel) = handle.upgrade() { + let Ok(commit_message_buffer) = git_panel.update_in(&mut cx, |git_panel, _, cx| { + git_panel + .active_repository + .as_ref() + .map(|active_repository| { + commit_message_buffer(&project, active_repository, cx) + }) + }) else { + return; + }; + let commit_message_buffer = match commit_message_buffer { + Some(commit_message_buffer) => match commit_message_buffer + .await + .context("opening commit buffer on repo update") + .log_err() + { + Some(buffer) => Some(buffer), + None => return, + }, + None => None, + }; + + git_panel + .update_in(&mut cx, |git_panel, window, cx| { + git_panel.update_visible_entries(cx); + git_panel.commit_editor = + cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx)); + }) + .ok(); } }); } @@ -963,14 +1139,15 @@ impl GitPanel { cx: &Context, ) -> impl IntoElement { let editor = self.commit_editor.clone(); + let can_commit = can_commit && !editor.read(cx).is_empty(cx); let editor_focus_handle = editor.read(cx).focus_handle(cx).clone(); let (can_commit, can_commit_all) = self.active_repository .as_ref() .map_or((false, false), |active_repository| { ( - can_commit && active_repository.can_commit(false, cx), - can_commit && active_repository.can_commit(true, cx), + can_commit && active_repository.can_commit(false), + can_commit && active_repository.can_commit(true), ) }); @@ -1306,6 +1483,7 @@ impl Render for GitPanel { } None => (has_write_access, None), }; + let can_commit = !self.commit_pending && can_commit; let has_co_authors = can_commit && has_write_access diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index bc2bda97e0..2a6b50a27f 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -8,14 +8,10 @@ use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, }; -use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, SharedString, Subscription, WeakEntity, -}; -use language::{Buffer, LanguageRegistry}; +use gpui::{App, Context, Entity, EventEmitter, SharedString, Subscription, WeakEntity}; use rpc::{proto, AnyProtoClient}; use settings::WorktreeId; use std::sync::Arc; -use text::Rope; use util::maybe; use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry}; @@ -25,7 +21,6 @@ pub struct GitState { repositories: Vec, active_index: Option, update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender)>, - languages: Arc, _subscription: Subscription, } @@ -34,13 +29,12 @@ pub struct RepositoryHandle { git_state: WeakEntity, pub worktree_id: WorktreeId, pub repository_entry: RepositoryEntry, - git_repo: Option, - commit_message: Entity, + pub git_repo: GitRepo, update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender)>, } #[derive(Clone)] -enum GitRepo { +pub enum GitRepo { Local(Arc), Remote { project_id: ProjectId, @@ -70,12 +64,10 @@ enum Message { StageAndCommit { git_repo: GitRepo, paths: Vec, - message: Rope, name_and_email: Option<(SharedString, SharedString)>, }, Commit { git_repo: GitRepo, - message: Rope, name_and_email: Option<(SharedString, SharedString)>, }, Stage(GitRepo, Vec), @@ -91,7 +83,6 @@ impl EventEmitter for GitState {} impl GitState { pub fn new( worktree_store: &Entity, - languages: Arc, client: Option, project_id: Option, cx: &mut Context<'_, Self>, @@ -100,150 +91,140 @@ impl GitState { mpsc::unbounded::<(Message, mpsc::Sender)>(); 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, - message, - name_and_email, - paths, - } => { - match git_repo { - GitRepo::Local(repo) => { - repo.stage_paths(&paths)?; - repo.commit( - &message.to_string(), - name_and_email.as_ref().map(|(name, email)| { - (name.as_ref(), email.as_ref()) - }), - )?; + 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")?; + } } - 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(), - message: message.to_string(), - 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(()) } - 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")?; + 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(()) } - Ok(()) - } - Message::Commit { - git_repo, - message, - name_and_email, - } => { - match git_repo { - GitRepo::Local(repo) => repo.commit( - &message.to_string(), - 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(), - // TODO implement collaborative commit message buffer instead and use it - // If it works, remove `commit_with_message` method. - message: message.to_string(), - name: name.map(String::from), - email: email.map(String::from), - }) - .await - .context("sending commit request")?; + 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(()) } - Ok(()) } - } - }) - .await; + }) + .await; if let Err(e) = result { err_sender.send(e).await.ok(); } @@ -255,7 +236,6 @@ impl GitState { GitState { project_id, - languages, client, repositories: Vec::new(), active_index: None, @@ -285,7 +265,7 @@ impl GitState { worktree_store.update(cx, |worktree_store, cx| { for worktree in worktree_store.worktrees() { - worktree.update(cx, |worktree, cx| { + worktree.update(cx, |worktree, _| { let snapshot = worktree.snapshot(); for repo in snapshot.repositories().iter() { let git_repo = worktree @@ -303,6 +283,9 @@ impl GitState { work_directory_id: repo.work_directory_id(), }) }); + let Some(git_repo) = git_repo else { + continue; + }; let existing = self .repositories .iter() @@ -317,25 +300,11 @@ impl GitState { existing_handle.repository_entry = repo.clone(); existing_handle } else { - let commit_message = cx.new(|cx| Buffer::local("", cx)); - cx.spawn({ - let commit_message = commit_message.downgrade(); - let languages = self.languages.clone(); - |_, mut cx| async move { - let markdown = languages.language_for_name("Markdown").await?; - commit_message.update(&mut cx, |commit_message, cx| { - commit_message.set_language(Some(markdown), cx); - })?; - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); RepositoryHandle { git_state: this.clone(), worktree_id: worktree.id(), repository_entry: repo.clone(), git_repo, - commit_message, update_sender: self.update_sender.clone(), } }; @@ -403,10 +372,6 @@ impl RepositoryHandle { Some((self.worktree_id, path).into()) } - pub fn commit_message(&self) -> Entity { - self.commit_message.clone() - } - pub fn stage_entries( &self, entries: Vec, @@ -415,11 +380,8 @@ impl RepositoryHandle { if entries.is_empty() { return Ok(()); } - let Some(git_repo) = self.git_repo.clone() else { - return Ok(()); - }; self.update_sender - .unbounded_send((Message::Stage(git_repo, entries), err_sender)) + .unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender)) .map_err(|_| anyhow!("Failed to submit stage operation"))?; Ok(()) } @@ -432,11 +394,8 @@ impl RepositoryHandle { if entries.is_empty() { return Ok(()); } - let Some(git_repo) = self.git_repo.clone() else { - return Ok(()); - }; self.update_sender - .unbounded_send((Message::Unstage(git_repo, entries), err_sender)) + .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender)) .map_err(|_| anyhow!("Failed to submit unstage operation"))?; Ok(()) } @@ -477,14 +436,8 @@ impl RepositoryHandle { self.repository_entry.status_summary().index != TrackedSummary::UNCHANGED } - pub fn can_commit(&self, commit_all: bool, cx: &App) -> 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 can_commit(&self, commit_all: bool) -> bool { + return self.have_changes() && (commit_all || self.have_staged_changes()); } pub fn commit( @@ -492,15 +445,10 @@ impl RepositoryHandle { name_and_email: Option<(SharedString, SharedString)>, mut err_sender: mpsc::Sender, cx: &mut App, - ) { - let Some(git_repo) = self.git_repo.clone() else { - return; - }; - let message = self.commit_message.read(cx).as_rope().clone(); + ) -> anyhow::Result<()> { let result = self.update_sender.unbounded_send(( Message::Commit { - git_repo, - message, + git_repo: self.git_repo.clone(), name_and_email, }, err_sender.clone(), @@ -513,32 +461,10 @@ impl RepositoryHandle { .ok(); }) .detach(); - return; + anyhow::bail!("Failed to submit commit operation"); + } else { + Ok(()) } - self.commit_message.update(cx, |commit_message, cx| { - commit_message.set_text("", cx); - }); - } - - pub fn commit_with_message( - &self, - message: String, - name_and_email: Option<(SharedString, SharedString)>, - err_sender: mpsc::Sender, - ) -> anyhow::Result<()> { - let Some(git_repo) = self.git_repo.clone() else { - return Ok(()); - }; - let result = self.update_sender.unbounded_send(( - Message::Commit { - git_repo, - message: message.into(), - name_and_email, - }, - err_sender, - )); - anyhow::ensure!(result.is_ok(), "Failed to submit commit operation"); - Ok(()) } pub fn commit_all( @@ -546,22 +472,17 @@ impl RepositoryHandle { name_and_email: Option<(SharedString, SharedString)>, mut err_sender: mpsc::Sender, cx: &mut App, - ) { - let Some(git_repo) = self.git_repo.clone() else { - return; - }; + ) -> 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 message = self.commit_message.read(cx).as_rope().clone(); let result = self.update_sender.unbounded_send(( Message::StageAndCommit { - git_repo, + git_repo: self.git_repo.clone(), paths: to_stage, - message, name_and_email, }, err_sender.clone(), @@ -574,10 +495,9 @@ impl RepositoryHandle { .ok(); }) .detach(); - return; + anyhow::bail!("Failed to submit commit all operation"); + } else { + Ok(()) } - self.commit_message.update(cx, |commit_message, cx| { - commit_message.set_text("", cx); - }); } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 23c22df90a..4f6ae341ab 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -48,6 +48,7 @@ use ::git::{ blame::Blame, repository::{Branch, GitRepository, RepoPath}, status::FileStatus, + COMMIT_MESSAGE, }; use gpui::{ AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, @@ -609,6 +610,7 @@ impl Project { client.add_model_request_handler(Self::handle_stage); client.add_model_request_handler(Self::handle_unstage); client.add_model_request_handler(Self::handle_commit); + client.add_model_request_handler(Self::handle_open_commit_message_buffer); WorktreeStore::init(&client); BufferStore::init(&client); @@ -699,9 +701,7 @@ impl Project { ) }); - let git_state = Some( - cx.new(|cx| GitState::new(&worktree_store, languages.clone(), None, None, cx)), - ); + let git_state = Some(cx.new(|cx| GitState::new(&worktree_store, None, None, cx))); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); @@ -824,7 +824,6 @@ impl Project { let git_state = Some(cx.new(|cx| { GitState::new( &worktree_store, - languages.clone(), Some(ssh_proto.clone()), Some(ProjectId(SSH_PROJECT_ID)), cx, @@ -1030,7 +1029,6 @@ impl Project { let git_state = Some(cx.new(|cx| { GitState::new( &worktree_store, - languages.clone(), Some(client.clone().into()), Some(ProjectId(remote_id)), cx, @@ -3974,21 +3972,8 @@ impl Project { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { - let repository_handle = project - .git_state() - .context("missing git state")? - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })??; + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; let entries = envelope .payload @@ -4015,21 +4000,8 @@ impl Project { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { - let repository_handle = project - .git_state() - .context("missing git state")? - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })??; + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; let entries = envelope .payload @@ -4056,7 +4028,93 @@ impl Project { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + 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 {}) + } + } + + async fn handle_open_commit_message_buffer( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + let git_repository = match &repository_handle.git_repo { + git::GitRepo::Local(git_repository) => git_repository.clone(), + git::GitRepo::Remote { .. } => { + anyhow::bail!("Cannot handle open commit message buffer for remote git repo") + } + }; + let commit_message_file = git_repository.dot_git_dir().join(*COMMIT_MESSAGE); + let fs = this.update(&mut cx, |project, _| project.fs().clone())?; + fs.create_file( + &commit_message_file, + CreateOptions { + overwrite: false, + ignore_if_exists: true, + }, + ) + .await + .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; + + let (worktree, relative_path) = this + .update(&mut cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store.find_or_create_worktree(&commit_message_file, false, cx) + }) + })? + .await + .with_context(|| { + format!("deriving worktree for commit message file {commit_message_file:?}") + })?; + + let buffer = this + .update(&mut cx, |headless_project, cx| { + headless_project + .buffer_store + .update(cx, |buffer_store, cx| { + buffer_store.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: Arc::from(relative_path), + }, + cx, + ) + }) + }) + .with_context(|| { + format!("opening buffer for commit message file {commit_message_file:?}") + })? + .await?; + let peer_id = envelope.original_sender_id()?; + Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx) + } + + fn repository_for_request( + this: &Entity, + worktree_id: WorktreeId, + work_directory_id: ProjectEntryId, + cx: &mut AsyncApp, + ) -> Result { + this.update(cx, |project, cx| { let repository_handle = project .git_state() .context("missing git state")? @@ -4070,20 +4128,7 @@ impl Project { }) .context("missing repository handle")?; anyhow::Ok(repository_handle) - })??; - - let commit_message = envelope.payload.message; - 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); - repository_handle - .commit_with_message(commit_message, name.zip(email), err_sender) - .context("unstaging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + })? } fn respond_to_open_buffer_request( @@ -4122,7 +4167,7 @@ impl Project { buffer.read(cx).remote_id() } - fn wait_for_remote_buffer( + pub fn wait_for_remote_buffer( &mut self, id: BufferId, cx: &mut Context, diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index f4b36d5768..c719038921 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -311,7 +311,8 @@ message Envelope { Stage stage = 293; Unstage unstage = 294; - Commit commit = 295; // current max + Commit commit = 295; + OpenCommitMessageBuffer open_commit_message_buffer = 296; // current max } reserved 87 to 88; @@ -2655,7 +2656,12 @@ message Commit { uint64 project_id = 1; uint64 worktree_id = 2; uint64 work_directory_id = 3; - string message = 4; - optional string name = 5; - optional string email = 6; + optional string name = 4; + optional string email = 5; +} + +message OpenCommitMessageBuffer { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index d461994490..eabd0d3ec7 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -249,6 +249,7 @@ messages!( (OpenBufferForSymbol, Background), (OpenBufferForSymbolResponse, Background), (OpenBufferResponse, Background), + (OpenCommitMessageBuffer, Background), (PerformRename, Background), (PerformRenameResponse, Background), (Ping, Foreground), @@ -443,6 +444,7 @@ request_messages!( (OpenBufferById, OpenBufferResponse), (OpenBufferByPath, OpenBufferResponse), (OpenBufferForSymbol, OpenBufferForSymbolResponse), + (OpenCommitMessageBuffer, OpenBufferResponse), (OpenNewBuffer, OpenBufferResponse), (PerformRename, PerformRenameResponse), (Ping, Ack), @@ -554,6 +556,7 @@ entity_messages!( OpenBufferById, OpenBufferByPath, OpenBufferForSymbol, + OpenCommitMessageBuffer, PerformRename, PrepareRename, RefreshInlayHints, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index ba70e832c8..fbea10fdea 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,16 +1,16 @@ use anyhow::{anyhow, Context as _, Result}; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; -use fs::Fs; +use fs::{CreateOptions, Fs}; use futures::channel::mpsc; -use git::repository::RepoPath; +use git::{repository::RepoPath, COMMIT_MESSAGE}; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString}; use http_client::HttpClient; use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry}; use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, - git::GitState, + git::{GitRepo, GitState, RepositoryHandle}, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -83,8 +83,7 @@ impl HeadlessProject { store }); - let git_state = - cx.new(|cx| GitState::new(&worktree_store, languages.clone(), None, None, cx)); + let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx)); let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); @@ -201,6 +200,7 @@ impl HeadlessProject { client.add_model_request_handler(Self::handle_stage); client.add_model_request_handler(Self::handle_unstage); client.add_model_request_handler(Self::handle_commit); + client.add_model_request_handler(Self::handle_open_commit_message_buffer); client.add_request_handler( extensions.clone().downgrade(), @@ -625,20 +625,8 @@ impl HeadlessProject { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { - let repository_handle = project - .git_state - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })??; + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; let entries = envelope .payload @@ -665,20 +653,8 @@ impl HeadlessProject { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { - let repository_handle = project - .git_state - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })??; + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; let entries = envelope .payload @@ -705,7 +681,106 @@ impl HeadlessProject { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = this.update(&mut cx, |project, cx| { + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + 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 {}) + } + } + + async fn handle_open_commit_message_buffer( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + let git_repository = match &repository_handle.git_repo { + GitRepo::Local(git_repository) => git_repository.clone(), + GitRepo::Remote { .. } => { + anyhow::bail!("Cannot handle open commit message buffer for remote git repo") + } + }; + let commit_message_file = git_repository.dot_git_dir().join(*COMMIT_MESSAGE); + let fs = this.update(&mut cx, |headless_project, _| headless_project.fs.clone())?; + fs.create_file( + &commit_message_file, + CreateOptions { + overwrite: false, + ignore_if_exists: true, + }, + ) + .await + .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; + + let (worktree, relative_path) = this + .update(&mut cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store.find_or_create_worktree(&commit_message_file, false, cx) + }) + })? + .await + .with_context(|| { + format!("deriving worktree for commit message file {commit_message_file:?}") + })?; + + let buffer = this + .update(&mut cx, |headless_project, cx| { + headless_project + .buffer_store + .update(cx, |buffer_store, cx| { + buffer_store.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: Arc::from(relative_path), + }, + cx, + ) + }) + }) + .with_context(|| { + format!("opening buffer for commit message file {commit_message_file:?}") + })? + .await?; + + let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?; + this.update(&mut cx, |headless_project, cx| { + headless_project + .buffer_store + .update(cx, |buffer_store, cx| { + buffer_store + .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .detach_and_log_err(cx); + }) + })?; + + Ok(proto::OpenBufferResponse { + buffer_id: buffer_id.to_proto(), + }) + } + + fn repository_for_request( + this: &Entity, + worktree_id: WorktreeId, + work_directory_id: ProjectEntryId, + cx: &mut AsyncApp, + ) -> Result { + this.update(cx, |project, cx| { let repository_handle = project .git_state .read(cx) @@ -718,20 +793,7 @@ impl HeadlessProject { }) .context("missing repository handle")?; anyhow::Ok(repository_handle) - })??; - - let commit_message = envelope.payload.message; - 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); - repository_handle - .commit_with_message(commit_message, name.zip(email), err_sender) - .context("unstaging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + })? } }