git: Implement a basic repository selector (#23419)

This PR adds a rough-and-ready picker for selecting which of the
project's repositories the git panel should display.

Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <iamnbutler@gmail.com>
Co-authored-by: Nate <nate@zed.dev>
This commit is contained in:
Cole Miller 2025-01-21 18:11:53 -05:00 committed by GitHub
parent 417760ade7
commit 31909bf334
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 661 additions and 356 deletions

View file

@ -1,25 +1,55 @@
use anyhow::{anyhow, Context as _};
use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
use crate::{Project, ProjectPath};
use anyhow::anyhow;
use futures::channel::mpsc;
use futures::{SinkExt as _, StreamExt as _};
use git::{
repository::{GitRepository, RepoPath},
status::{GitSummary, TrackedSummary},
};
use gpui::{AppContext, Context as _, Model};
use gpui::{
AppContext, Context as _, EventEmitter, Model, ModelContext, SharedString, Subscription,
WeakModel,
};
use language::{Buffer, LanguageRegistry};
use settings::WorktreeId;
use std::sync::Arc;
use text::Rope;
use worktree::RepositoryEntry;
use util::maybe;
use worktree::{RepositoryEntry, StatusEntry};
pub struct GitState {
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.
pub active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
repositories: Vec<RepositoryHandle>,
active_index: Option<usize>,
update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
languages: Arc<LanguageRegistry>,
_subscription: Subscription,
}
#[derive(Clone)]
pub struct RepositoryHandle {
git_state: WeakModel<GitState>,
worktree_id: WorktreeId,
repository_entry: RepositoryEntry,
git_repo: Arc<dyn GitRepository>,
commit_message: Model<Buffer>,
update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
}
impl PartialEq<Self> for RepositoryHandle {
fn eq(&self, other: &Self) -> bool {
self.worktree_id == other.worktree_id
&& self.repository_entry.work_directory_id()
== other.repository_entry.work_directory_id()
}
}
impl Eq for RepositoryHandle {}
impl PartialEq<RepositoryEntry> for RepositoryHandle {
fn eq(&self, other: &RepositoryEntry) -> bool {
self.repository_entry.work_directory_id() == other.work_directory_id()
}
}
enum Message {
@ -29,11 +59,21 @@ enum Message {
Unstage(Arc<dyn GitRepository>, Vec<RepoPath>),
}
pub enum Event {
RepositoriesUpdated,
}
impl EventEmitter<Event> for GitState {}
impl GitState {
pub fn new(languages: Arc<LanguageRegistry>, cx: &mut AppContext) -> Self {
pub fn new(
worktree_store: &Model<WorktreeStore>,
languages: Arc<LanguageRegistry>,
cx: &mut ModelContext<'_, Self>,
) -> Self {
let (update_sender, mut update_receiver) =
mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
cx.spawn(|cx| async move {
cx.spawn(|_, cx| async move {
while let Some((msg, mut err_sender)) = update_receiver.next().await {
let result = cx
.background_executor()
@ -57,39 +97,147 @@ 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);
let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
GitState {
commit_message,
active_repository: None,
languages,
repositories: vec![],
active_index: None,
update_sender,
_subscription,
}
}
pub fn activate_repository(
&mut self,
worktree_id: WorktreeId,
active_repository: RepositoryEntry,
git_repo: Arc<dyn GitRepository>,
) {
self.active_repository = Some((worktree_id, active_repository, git_repo));
pub fn active_repository(&self) -> Option<RepositoryHandle> {
self.active_index
.map(|index| self.repositories[index].clone())
}
pub fn active_repository(
&self,
) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
self.active_repository.as_ref()
fn on_worktree_store_event(
&mut self,
worktree_store: Model<WorktreeStore>,
_event: &WorktreeStoreEvent,
cx: &mut ModelContext<'_, Self>,
) {
// TODO inspect the event
let mut new_repositories = Vec::new();
let mut new_active_index = None;
let this = cx.weak_model();
worktree_store.update(cx, |worktree_store, cx| {
for worktree in worktree_store.worktrees() {
worktree.update(cx, |worktree, cx| {
let snapshot = worktree.snapshot();
let Some(local) = worktree.as_local() else {
return;
};
for repo in snapshot.repositories().iter() {
let Some(local_repo) = local.get_local_repo(repo) else {
continue;
};
let existing = self
.repositories
.iter()
.enumerate()
.find(|(_, existing_handle)| existing_handle == &repo);
let handle = if let Some((index, handle)) = existing {
if self.active_index == Some(index) {
new_active_index = Some(new_repositories.len());
}
// Update the statuses but keep everything else.
let mut existing_handle = handle.clone();
existing_handle.repository_entry = repo.clone();
existing_handle
} else {
let commit_message = cx.new_model(|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: local_repo.repo().clone(),
commit_message,
update_sender: self.update_sender.clone(),
}
};
new_repositories.push(handle);
}
})
}
});
if new_active_index == None && new_repositories.len() > 0 {
new_active_index = Some(0);
}
self.repositories = new_repositories;
self.active_index = new_active_index;
cx.emit(Event::RepositoriesUpdated);
}
pub fn all_repositories(&self) -> Vec<RepositoryHandle> {
self.repositories.clone()
}
}
impl RepositoryHandle {
pub fn display_name(&self, project: &Project, cx: &AppContext) -> SharedString {
maybe!({
let path = self.unrelativize(&"".into())?;
Some(
project
.absolute_path(&path, cx)?
.file_name()?
.to_string_lossy()
.to_string()
.into(),
)
})
.unwrap_or("".into())
}
pub fn activate(&self, cx: &mut AppContext) {
let Some(git_state) = self.git_state.upgrade() else {
return;
};
git_state.update(cx, |git_state, cx| {
let Some((index, _)) = git_state
.repositories
.iter()
.enumerate()
.find(|(_, handle)| handle == &self)
else {
return;
};
git_state.active_index = Some(index);
cx.emit(Event::RepositoriesUpdated);
});
}
pub fn status(&self) -> impl '_ + Iterator<Item = StatusEntry> {
self.repository_entry.status()
}
pub fn unrelativize(&self, path: &RepoPath) -> Option<ProjectPath> {
let path = self.repository_entry.unrelativize(path)?;
Some((self.worktree_id, path).into())
}
pub fn commit_message(&self) -> Model<Buffer> {
self.commit_message.clone()
}
pub fn stage_entries(
@ -100,11 +248,8 @@ impl GitState {
if entries.is_empty() {
return Ok(());
}
let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
return Err(anyhow!("No active repository"));
};
self.update_sender
.unbounded_send((Message::Stage(git_repo.clone(), entries), err_sender))
.unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender))
.map_err(|_| anyhow!("Failed to submit stage operation"))?;
Ok(())
}
@ -117,20 +262,15 @@ impl GitState {
if entries.is_empty() {
return Ok(());
}
let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
return Err(anyhow!("No active repository"));
};
self.update_sender
.unbounded_send((Message::Unstage(git_repo.clone(), entries), err_sender))
.unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender))
.map_err(|_| anyhow!("Failed to submit unstage operation"))?;
Ok(())
}
pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
let Some((_, entry, _)) = self.active_repository.as_ref() else {
return Err(anyhow!("No active repository"));
};
let to_stage = entry
let to_stage = self
.repository_entry
.status()
.filter(|entry| !entry.status.is_staged().unwrap_or(false))
.map(|entry| entry.repo_path.clone())
@ -140,10 +280,8 @@ impl GitState {
}
pub fn unstage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
let Some((_, entry, _)) = self.active_repository.as_ref() else {
return Err(anyhow!("No active repository"));
};
let to_unstage = entry
let to_unstage = self
.repository_entry
.status()
.filter(|entry| entry.status.is_staged().unwrap_or(true))
.map(|entry| entry.repo_path.clone())
@ -155,23 +293,15 @@ impl GitState {
/// Get a count of all entries in the active repository, including
/// untracked files.
pub fn entry_count(&self) -> usize {
self.active_repository
.as_ref()
.map_or(0, |(_, entry, _)| entry.status_len())
self.repository_entry.status_len()
}
fn have_changes(&self) -> bool {
let Some((_, entry, _)) = self.active_repository.as_ref() else {
return false;
};
entry.status_summary() != GitSummary::UNCHANGED
self.repository_entry.status_summary() != GitSummary::UNCHANGED
}
fn have_staged_changes(&self) -> bool {
let Some((_, entry, _)) = self.active_repository.as_ref() else {
return false;
};
entry.status_summary().index != TrackedSummary::UNCHANGED
self.repository_entry.status_summary().index != TrackedSummary::UNCHANGED
}
pub fn can_commit(&self, commit_all: bool, cx: &AppContext) -> bool {
@ -185,36 +315,33 @@ impl GitState {
}
pub fn commit(
&mut self,
&self,
err_sender: mpsc::Sender<anyhow::Error>,
cx: &AppContext,
cx: &mut 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 = self.commit_message.read(cx).as_rope().clone();
self.update_sender
.unbounded_send((Message::Commit(git_repo, message), err_sender))
.unbounded_send((Message::Commit(self.git_repo.clone(), message), err_sender))
.map_err(|_| anyhow!("Failed to submit commit operation"))?;
self.commit_message.update(cx, |commit_message, cx| {
commit_message.set_text("", cx);
});
Ok(())
}
pub fn commit_all(
&mut self,
&self,
err_sender: mpsc::Sender<anyhow::Error>,
cx: &AppContext,
cx: &mut 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 {
return Err(anyhow!("No active repository"));
};
let to_stage = entry
let to_stage = self
.repository_entry
.status()
.filter(|entry| !entry.status.is_staged().unwrap_or(false))
.map(|entry| entry.repo_path.clone())
@ -222,10 +349,13 @@ impl GitState {
let message = self.commit_message.read(cx).as_rope().clone();
self.update_sender
.unbounded_send((
Message::StageAndCommit(git_repo.clone(), message, to_stage),
Message::StageAndCommit(self.git_repo.clone(), message, to_stage),
err_sender,
))
.map_err(|_| anyhow!("Failed to submit commit operation"))?;
self.commit_message.update(cx, |commit_message, cx| {
commit_message.set_text("", cx);
});
Ok(())
}
}

View file

@ -3128,12 +3128,15 @@ impl LspStore {
})
.detach()
}
WorktreeStoreEvent::WorktreeReleased(..) => {}
WorktreeStoreEvent::WorktreeRemoved(_, id) => self.remove_worktree(*id, cx),
WorktreeStoreEvent::WorktreeOrderChanged => {}
WorktreeStoreEvent::WorktreeUpdateSent(worktree) => {
worktree.update(cx, |worktree, _cx| self.send_diagnostic_summaries(worktree));
}
WorktreeStoreEvent::WorktreeReleased(..)
| WorktreeStoreEvent::WorktreeOrderChanged
| WorktreeStoreEvent::WorktreeUpdatedEntries(..)
| WorktreeStoreEvent::WorktreeUpdatedGitRepositories(..)
| WorktreeStoreEvent::WorktreeDeletedEntry(..) => {}
}
}

View file

@ -22,6 +22,7 @@ mod project_tests;
mod direnv;
mod environment;
pub use environment::EnvironmentErrorMessage;
use git::RepositoryHandle;
pub mod search_history;
mod yarn;
@ -691,7 +692,8 @@ impl Project {
)
});
let git_state = Some(cx.new_model(|cx| GitState::new(languages.clone(), cx)));
let git_state =
Some(cx.new_model(|cx| GitState::new(&worktree_store, languages.clone(), cx)));
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
@ -2324,6 +2326,18 @@ impl Project {
}
WorktreeStoreEvent::WorktreeOrderChanged => cx.emit(Event::WorktreeOrderChanged),
WorktreeStoreEvent::WorktreeUpdateSent(_) => {}
WorktreeStoreEvent::WorktreeUpdatedEntries(worktree_id, changes) => {
self.client()
.telemetry()
.report_discovered_project_events(*worktree_id, changes);
cx.emit(Event::WorktreeUpdatedEntries(*worktree_id, changes.clone()))
}
WorktreeStoreEvent::WorktreeUpdatedGitRepositories(worktree_id) => {
cx.emit(Event::WorktreeUpdatedGitRepositories(*worktree_id))
}
WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, id) => {
cx.emit(Event::DeletedEntry(*worktree_id, *id))
}
}
}
@ -2335,27 +2349,6 @@ impl Project {
}
}
cx.observe(worktree, |_, _, cx| cx.notify()).detach();
cx.subscribe(worktree, |project, worktree, event, cx| {
let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
match event {
worktree::Event::UpdatedEntries(changes) => {
cx.emit(Event::WorktreeUpdatedEntries(
worktree.read(cx).id(),
changes.clone(),
));
project
.client()
.telemetry()
.report_discovered_project_events(worktree_id, changes);
}
worktree::Event::UpdatedGitRepositories(_) => {
cx.emit(Event::WorktreeUpdatedGitRepositories(worktree_id));
}
worktree::Event::DeletedEntry(id) => cx.emit(Event::DeletedEntry(worktree_id, *id)),
}
})
.detach();
cx.notify();
}
@ -4169,6 +4162,17 @@ impl Project {
pub fn git_state(&self) -> Option<&Model<GitState>> {
self.git_state.as_ref()
}
pub fn active_repository(&self, cx: &AppContext) -> Option<RepositoryHandle> {
self.git_state()
.and_then(|git_state| git_state.read(cx).active_repository())
}
pub fn all_repositories(&self, cx: &AppContext) -> Vec<RepositoryHandle> {
self.git_state()
.map(|git_state| git_state.read(cx).all_repositories())
.unwrap_or_default()
}
}
fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {

View file

@ -25,7 +25,7 @@ use smol::{
};
use text::ReplicaId;
use util::{paths::SanitizedPath, ResultExt};
use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings};
use worktree::{Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, WorktreeSettings};
use crate::{search::SearchQuery, ProjectPath};
@ -63,6 +63,9 @@ pub enum WorktreeStoreEvent {
WorktreeReleased(EntityId, WorktreeId),
WorktreeOrderChanged,
WorktreeUpdateSent(Model<Worktree>),
WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet),
WorktreeUpdatedGitRepositories(WorktreeId),
WorktreeDeletedEntry(WorktreeId, ProjectEntryId),
}
impl EventEmitter<WorktreeStoreEvent> for WorktreeStore {}
@ -364,6 +367,26 @@ impl WorktreeStore {
self.send_project_updates(cx);
let handle_id = worktree.entity_id();
cx.subscribe(worktree, |_, worktree, event, cx| {
let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
match event {
worktree::Event::UpdatedEntries(changes) => {
cx.emit(WorktreeStoreEvent::WorktreeUpdatedEntries(
worktree.read(cx).id(),
changes.clone(),
));
}
worktree::Event::UpdatedGitRepositories(_) => {
cx.emit(WorktreeStoreEvent::WorktreeUpdatedGitRepositories(
worktree_id,
));
}
worktree::Event::DeletedEntry(id) => {
cx.emit(WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, *id))
}
}
})
.detach();
cx.observe_release(worktree, move |this, worktree, cx| {
cx.emit(WorktreeStoreEvent::WorktreeReleased(
handle_id,