Enable collaborating editing of the commit message input inside the git panel (#24130)

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 <cole@zed.dev>
This commit is contained in:
Kirill Bulatov 2025-02-03 18:11:13 +02:00 committed by GitHub
parent 6b48a6e690
commit a864168c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 595 additions and 364 deletions

View file

@ -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<RepositoryHandle>,
active_index: Option<usize>,
update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
languages: Arc<LanguageRegistry>,
_subscription: Subscription,
}
@ -34,13 +29,12 @@ pub struct RepositoryHandle {
git_state: WeakEntity<GitState>,
pub worktree_id: WorktreeId,
pub repository_entry: RepositoryEntry,
git_repo: Option<GitRepo>,
commit_message: Entity<Buffer>,
pub git_repo: GitRepo,
update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
}
#[derive(Clone)]
enum GitRepo {
pub enum GitRepo {
Local(Arc<dyn GitRepository>),
Remote {
project_id: ProjectId,
@ -70,12 +64,10 @@ enum Message {
StageAndCommit {
git_repo: GitRepo,
paths: Vec<RepoPath>,
message: Rope,
name_and_email: Option<(SharedString, SharedString)>,
},
Commit {
git_repo: GitRepo,
message: Rope,
name_and_email: Option<(SharedString, SharedString)>,
},
Stage(GitRepo, Vec<RepoPath>),
@ -91,7 +83,6 @@ impl EventEmitter<Event> for GitState {}
impl GitState {
pub fn new(
worktree_store: &Entity<WorktreeStore>,
languages: Arc<LanguageRegistry>,
client: Option<AnyProtoClient>,
project_id: Option<ProjectId>,
cx: &mut Context<'_, Self>,
@ -100,150 +91,140 @@ impl GitState {
mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
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<Buffer> {
self.commit_message.clone()
}
pub fn stage_entries(
&self,
entries: Vec<RepoPath>,
@ -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<anyhow::Error>,
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::Error>,
) -> 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<anyhow::Error>,
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);
});
}
}