diff --git a/Cargo.lock b/Cargo.lock index 75d058db38..7c81f692ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12843,6 +12843,7 @@ dependencies = [ "git", "gpui", "picker", + "project", "ui", "util", "workspace", diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 90277242f1..d091f04326 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -308,6 +308,8 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler( forward_mutating_project_request::, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 80cc2500f5..c905c440cf 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -6575,3 +6575,95 @@ async fn test_context_collaboration_with_reconnect( assert!(context.buffer().read(cx).read_only()); }); } + +#[gpui::test] +async fn test_remote_git_branches( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs() + .insert_tree("/project", serde_json::json!({ ".git":{} })) + .await; + let branches = ["main", "dev", "feature-1"]; + client_a + .fs() + .insert_branches(Path::new("/project/.git"), &branches); + + let (project_a, worktree_id) = client_a.build_local_project("/project", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let root_path = ProjectPath::root_path(worktree_id); + // Client A sees that a guest has joined. + executor.run_until_parked(); + + let branches_b = cx_b + .update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx))) + .await + .unwrap(); + + let new_branch = branches[2]; + + let branches_b = branches_b + .into_iter() + .map(|branch| branch.name) + .collect::>(); + + assert_eq!(&branches_b, &branches); + + cx_b.update(|cx| { + project_b.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx) + }) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let host_branch = cx_a.update(|cx| { + project_a.update(cx, |project, cx| { + project.worktree_store().update(cx, |worktree_store, cx| { + worktree_store + .current_branch(root_path.clone(), cx) + .unwrap() + }) + }) + }); + + assert_eq!(host_branch.as_ref(), branches[2]); + + // Also try creating a new branch + cx_b.update(|cx| { + project_b.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx) + }) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let host_branch = cx_a.update(|cx| { + project_a.update(cx, |project, cx| { + project.worktree_store().update(cx, |worktree_store, cx| { + worktree_store.current_branch(root_path, cx).unwrap() + }) + }) + }); + + assert_eq!(host_branch.as_ref(), "totally-new-branch"); +} diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 0e13c88d94..9fe546ffcd 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -1,7 +1,7 @@ use crate::tests::TestServer; use call::ActiveCall; use fs::{FakeFs, Fs as _}; -use gpui::{Context as _, TestAppContext}; +use gpui::{BackgroundExecutor, Context as _, TestAppContext}; use http_client::BlockedHttpClient; use language::{language_settings::language_settings, LanguageRegistry}; use node_runtime::NodeRuntime; @@ -174,3 +174,133 @@ async fn test_sharing_an_ssh_remote_project( ); }); } + +#[gpui::test] +async fn test_ssh_collaboration_git_branches( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + cx_a.set_name("a"); + cx_b.set_name("b"); + server_cx.set_name("server"); + + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up project on remote FS + let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree("/project", serde_json::json!({ ".git":{} })) + .await; + + let branches = ["main", "dev", "feature-1"]; + remote_fs.insert_branches(Path::new("/project/.git"), &branches); + + // User A connects to the remote project via SSH. + server_cx.update(HeadlessProject::init); + let remote_http_client = Arc::new(BlockedHttpClient); + let node = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + let headless_project = server_cx.new_model(|cx| { + client::init_settings(cx); + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: remote_http_client, + node_runtime: node, + languages, + }, + cx, + ) + }); + + let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let (project_a, worktree_id) = client_a + .build_ssh_project("/project", client_ssh, cx_a) + .await; + + // While the SSH worktree is being scanned, user A shares the remote project. + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // User B joins the project. + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + // Give client A sometime to see that B has joined, and that the headless server + // has some git repositories + executor.run_until_parked(); + + let root_path = ProjectPath::root_path(worktree_id); + + let branches_b = cx_b + .update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx))) + .await + .unwrap(); + + let new_branch = branches[2]; + + let branches_b = branches_b + .into_iter() + .map(|branch| branch.name) + .collect::>(); + + assert_eq!(&branches_b, &branches); + + cx_b.update(|cx| { + project_b.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx) + }) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let server_branch = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store + .current_branch(root_path.clone(), cx) + .unwrap() + }) + }) + }); + + assert_eq!(server_branch.as_ref(), branches[2]); + + // Also try creating a new branch + cx_b.update(|cx| { + project_b.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx) + }) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let server_branch = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store.current_branch(root_path, cx).unwrap() + }) + }) + }); + + assert_eq!(server_branch.as_ref(), "totally-new-branch"); +} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 8483e5c02a..4a84c27dfd 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -813,6 +813,7 @@ struct FakeFsState { root: Arc>, next_inode: u64, next_mtime: SystemTime, + git_event_tx: smol::channel::Sender, event_txs: Vec>>, events_paused: bool, buffered_events: Vec, @@ -969,8 +970,10 @@ impl FakeFs { const SYSTEMTIME_INTERVAL: u64 = 100; pub fn new(executor: gpui::BackgroundExecutor) -> Arc { - Arc::new(Self { - executor, + let (tx, mut rx) = smol::channel::bounded::(10); + + let this = Arc::new(Self { + executor: executor.clone(), state: Mutex::new(FakeFsState { root: Arc::new(Mutex::new(FakeFsEntry::Dir { inode: 0, @@ -979,6 +982,7 @@ impl FakeFs { entries: Default::default(), git_repo_state: None, })), + git_event_tx: tx, next_mtime: SystemTime::UNIX_EPOCH, next_inode: 1, event_txs: Default::default(), @@ -987,7 +991,22 @@ impl FakeFs { read_dir_call_count: 0, metadata_call_count: 0, }), - }) + }); + + executor.spawn({ + let this = this.clone(); + async move { + while let Some(git_event) = rx.next().await { + if let Some(mut state) = this.state.try_lock() { + state.emit_event([(git_event, None)]); + } else { + panic!("Failed to lock file system state, this execution would have caused a test hang"); + } + } + } + }).detach(); + + this } pub fn set_next_mtime(&self, next_mtime: SystemTime) { @@ -1181,7 +1200,12 @@ impl FakeFs { let mut entry = entry.lock(); if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { - let repo_state = git_repo_state.get_or_insert_with(Default::default); + let repo_state = git_repo_state.get_or_insert_with(|| { + Arc::new(Mutex::new(FakeGitRepositoryState::new( + dot_git.to_path_buf(), + state.git_event_tx.clone(), + ))) + }); let mut repo_state = repo_state.lock(); f(&mut repo_state); @@ -1196,7 +1220,22 @@ impl FakeFs { pub fn set_branch_name(&self, dot_git: &Path, branch: Option>) { self.with_git_state(dot_git, true, |state| { - state.branch_name = branch.map(Into::into) + let branch = branch.map(Into::into); + state.branches.extend(branch.clone()); + state.current_branch_name = branch.map(Into::into) + }) + } + + pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) { + self.with_git_state(dot_git, true, |state| { + if let Some(first) = branches.first() { + if state.current_branch_name.is_none() { + state.current_branch_name = Some(first.to_string()) + } + } + state + .branches + .extend(branches.iter().map(ToString::to_string)); }) } @@ -1836,7 +1875,12 @@ impl Fs for FakeFs { let mut entry = entry.lock(); if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { let state = git_repo_state - .get_or_insert_with(|| Arc::new(Mutex::new(FakeGitRepositoryState::default()))) + .get_or_insert_with(|| { + Arc::new(Mutex::new(FakeGitRepositoryState::new( + abs_dot_git.to_path_buf(), + state.git_event_tx.clone(), + ))) + }) .clone(); Some(git::repository::FakeGitRepository::open(state)) } else { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 1b3686f021..fe65816cc5 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,8 +1,9 @@ use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; use anyhow::{Context, Result}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use git2::BranchType; +use gpui::SharedString; use parking_lot::Mutex; use rope::Rope; use serde::{Deserialize, Serialize}; @@ -17,7 +18,7 @@ use util::ResultExt; #[derive(Clone, Debug, Hash, PartialEq)] pub struct Branch { pub is_head: bool, - pub name: Box, + pub name: SharedString, /// Timestamp of most recent commit, normalized to Unix Epoch format. pub unix_timestamp: Option, } @@ -41,6 +42,7 @@ pub trait GitRepository: Send + Sync { fn branches(&self) -> Result>; fn change_branch(&self, _: &str) -> Result<()>; fn create_branch(&self, _: &str) -> Result<()>; + fn branch_exits(&self, _: &str) -> Result; fn blame(&self, path: &Path, content: Rope) -> Result; } @@ -132,6 +134,18 @@ impl GitRepository for RealGitRepository { GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes) } + fn branch_exits(&self, name: &str) -> Result { + let repo = self.repository.lock(); + let branch = repo.find_branch(name, BranchType::Local); + match branch { + Ok(_) => Ok(true), + Err(e) => match e.code() { + git2::ErrorCode::NotFound => Ok(false), + _ => Err(anyhow::anyhow!(e)), + }, + } + } + fn branches(&self) -> Result> { let repo = self.repository.lock(); let local_branches = repo.branches(Some(BranchType::Local))?; @@ -139,7 +153,11 @@ impl GitRepository for RealGitRepository { .filter_map(|branch| { branch.ok().and_then(|(branch, _)| { let is_head = branch.is_head(); - let name = branch.name().ok().flatten().map(Box::from)?; + let name = branch + .name() + .ok() + .flatten() + .map(|name| name.to_string().into())?; let timestamp = branch.get().peel_to_commit().ok()?.time(); let unix_timestamp = timestamp.seconds(); let timezone_offset = timestamp.offset_minutes(); @@ -201,17 +219,20 @@ impl GitRepository for RealGitRepository { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct FakeGitRepository { state: Arc>, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct FakeGitRepositoryState { + pub path: PathBuf, + pub event_emitter: smol::channel::Sender, pub index_contents: HashMap, pub blames: HashMap, pub worktree_statuses: HashMap, - pub branch_name: Option, + pub current_branch_name: Option, + pub branches: HashSet, } impl FakeGitRepository { @@ -220,6 +241,20 @@ impl FakeGitRepository { } } +impl FakeGitRepositoryState { + pub fn new(path: PathBuf, event_emitter: smol::channel::Sender) -> Self { + FakeGitRepositoryState { + path, + event_emitter, + index_contents: Default::default(), + blames: Default::default(), + worktree_statuses: Default::default(), + current_branch_name: Default::default(), + branches: Default::default(), + } + } +} + impl GitRepository for FakeGitRepository { fn reload_index(&self) {} @@ -234,7 +269,7 @@ impl GitRepository for FakeGitRepository { fn branch_name(&self) -> Option { let state = self.state.lock(); - state.branch_name.clone() + state.current_branch_name.clone() } fn head_sha(&self) -> Option { @@ -264,18 +299,41 @@ impl GitRepository for FakeGitRepository { } fn branches(&self) -> Result> { - Ok(vec![]) + let state = self.state.lock(); + let current_branch = &state.current_branch_name; + Ok(state + .branches + .iter() + .map(|branch_name| Branch { + is_head: Some(branch_name) == current_branch.as_ref(), + name: branch_name.into(), + unix_timestamp: None, + }) + .collect()) + } + + fn branch_exits(&self, name: &str) -> Result { + let state = self.state.lock(); + Ok(state.branches.contains(name)) } fn change_branch(&self, name: &str) -> Result<()> { let mut state = self.state.lock(); - state.branch_name = Some(name.to_owned()); + state.current_branch_name = Some(name.to_owned()); + state + .event_emitter + .try_send(state.path.clone()) + .expect("Dropped repo change event"); Ok(()) } fn create_branch(&self, name: &str) -> Result<()> { let mut state = self.state.lock(); - state.branch_name = Some(name.to_owned()); + state.branches.insert(name.to_owned()); + state + .event_emitter + .try_send(state.path.clone()) + .expect("Dropped repo change event"); Ok(()) } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index f81a2092d5..096f495a88 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -256,6 +256,9 @@ pub struct AppContext { pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, pub(crate) prompt_builder: Option, + + #[cfg(any(test, feature = "test-support", debug_assertions))] + pub(crate) name: Option<&'static str>, } impl AppContext { @@ -309,6 +312,9 @@ impl AppContext { layout_id_buffer: Default::default(), propagate_event: true, prompt_builder: Some(PromptBuilder::Default), + + #[cfg(any(test, feature = "test-support", debug_assertions))] + name: None, }), }); @@ -988,6 +994,7 @@ impl AppContext { } /// Move the global of the given type to the stack. + #[track_caller] pub(crate) fn lease_global(&mut self) -> GlobalLease { GlobalLease::new( self.globals_by_type @@ -1319,6 +1326,12 @@ impl AppContext { (task, is_first) } + + /// Get the name for this App. + #[cfg(any(test, feature = "test-support", debug_assertions))] + pub fn get_name(&self) -> &'static str { + self.name.as_ref().unwrap() + } } impl Context for AppContext { diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 4d5452acc0..07aa466295 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -536,6 +536,15 @@ impl AnyWeakModel { } } +impl std::fmt::Debug for AnyWeakModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct(type_name::()) + .field("entity_id", &self.entity_id) + .field("entity_type", &self.entity_type) + .finish() + } +} + impl From> for AnyWeakModel { fn from(model: WeakModel) -> Self { model.any_model diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index f46cdc8e34..34449c91ec 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -478,6 +478,12 @@ impl TestAppContext { .await .unwrap(); } + + /// Set a name for this App. + #[cfg(any(test, feature = "test-support"))] + pub fn set_name(&mut self, name: &'static str) { + self.update(|cx| cx.name = Some(name)) + } } impl Model { diff --git a/crates/gpui/src/global.rs b/crates/gpui/src/global.rs index 05f1598364..96f5d5fed5 100644 --- a/crates/gpui/src/global.rs +++ b/crates/gpui/src/global.rs @@ -57,6 +57,7 @@ pub trait UpdateGlobal { } impl UpdateGlobal for T { + #[track_caller] fn update_global(cx: &mut C, update: F) -> R where C: BorrowAppContext, diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 7ba3ce055e..2952f4af8a 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -306,6 +306,7 @@ where self.borrow_mut().set_global(global) } + #[track_caller] fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R where G: Global, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 167d5c1d49..b2fc8c5304 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -288,6 +288,13 @@ impl ProjectPath { path: self.path.to_string_lossy().to_string(), } } + + pub fn root_path(worktree_id: WorktreeId) -> Self { + Self { + worktree_id, + path: Path::new("").into(), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -701,7 +708,7 @@ impl Project { let ssh_proto = ssh.read(cx).proto_client(); let worktree_store = - cx.new_model(|_| WorktreeStore::remote(false, ssh_proto.clone(), 0)); + cx.new_model(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID)); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); @@ -3370,6 +3377,25 @@ impl Project { worktree.get_local_repo(&root_entry)?.repo().clone().into() } + pub fn branches( + &self, + project_path: ProjectPath, + cx: &AppContext, + ) -> Task>> { + self.worktree_store().read(cx).branches(project_path, cx) + } + + pub fn update_or_create_branch( + &self, + repository: ProjectPath, + new_branch: String, + cx: &AppContext, + ) -> Task> { + self.worktree_store() + .read(cx) + .update_or_create_branch(repository, new_branch, cx) + } + pub fn blame_buffer( &self, buffer: &Model, diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index df190d03f3..dc67eedbc1 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -73,6 +73,8 @@ impl WorktreeStore { client.add_model_request_handler(Self::handle_copy_project_entry); client.add_model_request_handler(Self::handle_delete_project_entry); client.add_model_request_handler(Self::handle_expand_project_entry); + client.add_model_request_handler(Self::handle_git_branches); + client.add_model_request_handler(Self::handle_update_branch); } pub fn local(retain_worktrees: bool, fs: Arc) -> Self { @@ -127,6 +129,13 @@ impl WorktreeStore { .find(|worktree| worktree.read(cx).id() == id) } + pub fn current_branch(&self, repository: ProjectPath, cx: &AppContext) -> Option> { + self.worktree_for_id(repository.worktree_id, cx)? + .read(cx) + .git_entry(repository.path)? + .branch() + } + pub fn worktree_for_entry( &self, entry_id: ProjectEntryId, @@ -836,6 +845,131 @@ impl WorktreeStore { Ok(()) } + pub fn branches( + &self, + project_path: ProjectPath, + cx: &AppContext, + ) -> Task>> { + let Some(worktree) = self.worktree_for_id(project_path.worktree_id, cx) else { + return Task::ready(Err(anyhow!("No worktree found for ProjectPath"))); + }; + + match worktree.read(cx) { + Worktree::Local(local_worktree) => { + let branches = util::maybe!({ + let worktree_error = |error| { + format!( + "{} for worktree {}", + error, + local_worktree.abs_path().to_string_lossy() + ) + }; + + let entry = local_worktree + .git_entry(project_path.path) + .with_context(|| worktree_error("No git entry found"))?; + + let repo = local_worktree + .get_local_repo(&entry) + .with_context(|| worktree_error("No repository found"))? + .repo() + .clone(); + + repo.branches() + }); + + Task::ready(branches) + } + Worktree::Remote(remote_worktree) => { + let request = remote_worktree.client().request(proto::GitBranches { + project_id: remote_worktree.project_id(), + repository: Some(proto::ProjectPath { + worktree_id: project_path.worktree_id.to_proto(), + path: project_path.path.to_string_lossy().to_string(), // Root path + }), + }); + + cx.background_executor().spawn(async move { + let response = request.await?; + + let branches = response + .branches + .into_iter() + .map(|proto_branch| git::repository::Branch { + is_head: proto_branch.is_head, + name: proto_branch.name.into(), + unix_timestamp: proto_branch + .unix_timestamp + .map(|timestamp| timestamp as i64), + }) + .collect(); + + Ok(branches) + }) + } + } + } + + pub fn update_or_create_branch( + &self, + repository: ProjectPath, + new_branch: String, + cx: &AppContext, + ) -> Task> { + let Some(worktree) = self.worktree_for_id(repository.worktree_id, cx) else { + return Task::ready(Err(anyhow!("No worktree found for ProjectPath"))); + }; + + match worktree.read(cx) { + Worktree::Local(local_worktree) => { + let result = util::maybe!({ + let worktree_error = |error| { + format!( + "{} for worktree {}", + error, + local_worktree.abs_path().to_string_lossy() + ) + }; + + let entry = local_worktree + .git_entry(repository.path) + .with_context(|| worktree_error("No git entry found"))?; + + let repo = local_worktree + .get_local_repo(&entry) + .with_context(|| worktree_error("No repository found"))? + .repo() + .clone(); + + if !repo.branch_exits(&new_branch)? { + repo.create_branch(&new_branch)?; + } + + repo.change_branch(&new_branch)?; + + Ok(()) + }); + + Task::ready(result) + } + Worktree::Remote(remote_worktree) => { + let request = remote_worktree.client().request(proto::UpdateGitBranch { + project_id: remote_worktree.project_id(), + repository: Some(proto::ProjectPath { + worktree_id: repository.worktree_id.to_proto(), + path: repository.path.to_string_lossy().to_string(), // Root path + }), + branch_name: new_branch, + }); + + cx.background_executor().spawn(async move { + request.await?; + Ok(()) + }) + } + } + } + async fn filter_paths( fs: &Arc, mut input: Receiver, @@ -917,6 +1051,61 @@ impl WorktreeStore { .ok_or_else(|| anyhow!("invalid request"))?; Worktree::handle_expand_entry(worktree, envelope.payload, cx).await } + + pub async fn handle_git_branches( + this: Model, + branches: TypedEnvelope, + cx: AsyncAppContext, + ) -> Result { + let project_path = branches + .payload + .repository + .clone() + .context("Invalid GitBranches call")?; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_proto(project_path.worktree_id), + path: Path::new(&project_path.path).into(), + }; + + let branches = this + .read_with(&cx, |this, cx| this.branches(project_path, cx))? + .await?; + + Ok(proto::GitBranchesResponse { + branches: branches + .into_iter() + .map(|branch| proto::Branch { + is_head: branch.is_head, + name: branch.name.to_string(), + unix_timestamp: branch.unix_timestamp.map(|timestamp| timestamp as u64), + }) + .collect(), + }) + } + + pub async fn handle_update_branch( + this: Model, + update_branch: TypedEnvelope, + cx: AsyncAppContext, + ) -> Result { + let project_path = update_branch + .payload + .repository + .clone() + .context("Invalid GitBranches call")?; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_proto(project_path.worktree_id), + path: Path::new(&project_path.path).into(), + }; + let new_branch = update_branch.payload.branch_name; + + this.read_with(&cx, |this, cx| { + this.update_or_create_branch(project_path, new_branch, cx) + })? + .await?; + + Ok(proto::Ack {}) + } } #[derive(Clone, Debug)] diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 5635eb8800..c61a14cdbf 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -281,7 +281,12 @@ message Envelope { FlushBufferedMessages flush_buffered_messages = 267; LanguageServerPromptRequest language_server_prompt_request = 268; - LanguageServerPromptResponse language_server_prompt_response = 269; // current max + LanguageServerPromptResponse language_server_prompt_response = 269; + + GitBranches git_branches = 270; + GitBranchesResponse git_branches_response = 271; + + UpdateGitBranch update_git_branch = 272; // current max } @@ -2432,3 +2437,24 @@ message LanguageServerPromptRequest { message LanguageServerPromptResponse { optional uint64 action_response = 1; } + +message Branch { + bool is_head = 1; + string name = 2; + optional uint64 unix_timestamp = 3; +} + +message GitBranches { + uint64 project_id = 1; + ProjectPath repository = 2; +} + +message GitBranchesResponse { + repeated Branch branches = 1; +} + +message UpdateGitBranch { + uint64 project_id = 1; + string branch_name = 2; + ProjectPath repository = 3; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 7a31e7cc7a..3807e04bd5 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -357,6 +357,9 @@ messages!( (FlushBufferedMessages, Foreground), (LanguageServerPromptRequest, Foreground), (LanguageServerPromptResponse, Foreground), + (GitBranches, Background), + (GitBranchesResponse, Background), + (UpdateGitBranch, Background) ); request_messages!( @@ -473,6 +476,8 @@ request_messages!( (GetPermalinkToLine, GetPermalinkToLineResponse), (FlushBufferedMessages, Ack), (LanguageServerPromptRequest, LanguageServerPromptResponse), + (GitBranches, GitBranchesResponse), + (UpdateGitBranch, Ack) ); entity_messages!( @@ -550,7 +555,9 @@ entity_messages!( HideToast, OpenServerSettings, GetPermalinkToLine, - LanguageServerPromptRequest + LanguageServerPromptRequest, + GitBranches, + UpdateGitBranch ); entity_messages!( diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 55204e14b9..7dc2853650 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -631,7 +631,7 @@ impl SshClientDelegate { self.update_status( Some(&format!( - "Building remote server binary from source for {}", + "Building remote server binary from source for {} with Docker", &triple )), cx, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 4385dac1fe..81be01b6a6 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use fs::Fs; -use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext, PromptLevel}; +use gpui::{AppContext, AsyncAppContext, Context as _, Model, ModelContext, PromptLevel}; use http_client::HttpClient; use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry}; use node_runtime::NodeRuntime; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index f7420ef5b0..82e3824eb0 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -26,7 +26,29 @@ use std::{ #[gpui::test] async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + "project2": { + "README.md": "# project 2", + }, + }), + ) + .await; + fs.set_index_for_repo( + Path::new("/code/project1/.git"), + &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())], + ); + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree("/code/project1", true, cx) @@ -128,7 +150,22 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test #[gpui::test] async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, headless, _) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; project .update(cx, |project, cx| { @@ -193,7 +230,22 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes #[gpui::test] async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, headless, fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; cx.update_global(|settings_store: &mut SettingsStore, cx| { settings_store.set_user_settings( @@ -304,7 +356,22 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo #[gpui::test] async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, headless, fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; fs.insert_tree( "/code/project1/.zed", @@ -463,7 +530,22 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext #[gpui::test] async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree("/code/project1", true, cx) @@ -523,7 +605,22 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont #[gpui::test] async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, _fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree("/code/project1", true, cx) @@ -566,7 +663,22 @@ async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut #[gpui::test(iterations = 10)] async fn test_canceling_buffer_opening(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, _fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree("/code/project1", true, cx) @@ -597,7 +709,25 @@ async fn test_adding_then_removing_then_adding_worktrees( cx: &mut TestAppContext, server_cx: &mut TestAppContext, ) { - let (project, _headless, _fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + "project2": { + "README.md": "# project 2", + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (_worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree("/code/project1", true, cx) @@ -636,9 +766,25 @@ async fn test_adding_then_removing_then_adding_worktrees( #[gpui::test] async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, _fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let buffer = project.update(cx, |project, cx| project.open_server_settings(cx)); cx.executor().run_until_parked(); + let buffer = buffer.await.unwrap(); cx.update(|cx| { @@ -651,7 +797,22 @@ async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut Test #[gpui::test(iterations = 20)] async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let (project, _headless, fs) = init_test(cx, server_cx).await; + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; let (worktree, _) = project .update(cx, |project, cx| { @@ -690,19 +851,8 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) ); } -fn init_logger() { - if std::env::var("RUST_LOG").is_ok() { - env_logger::try_init().ok(); - } -} - -async fn init_test( - cx: &mut TestAppContext, - server_cx: &mut TestAppContext, -) -> (Model, Model, Arc) { - init_logger(); - - let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx); +#[gpui::test] +async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); fs.insert_tree( "/code", @@ -710,32 +860,109 @@ async fn init_test( "project1": { ".git": {}, "README.md": "# project 1", - "src": { - "lib.rs": "fn one() -> usize { 1 }" - } - }, - "project2": { - "README.md": "# project 2", }, }), ) .await; - fs.set_index_for_repo( - Path::new("/code/project1/.git"), - &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())], - ); - server_cx.update(HeadlessProject::init); + let (project, headless_project) = init_test(&fs, cx, server_cx).await; + let branches = ["main", "dev", "feature-1"]; + fs.insert_branches(Path::new("/code/project1/.git"), &branches); + + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree("/code/project1", true, cx) + }) + .await + .unwrap(); + + let worktree_id = cx.update(|cx| worktree.read(cx).id()); + let root_path = ProjectPath::root_path(worktree_id); + // Give the worktree a bit of time to index the file system + cx.run_until_parked(); + + let remote_branches = project + .update(cx, |project, cx| project.branches(root_path.clone(), cx)) + .await + .unwrap(); + + let new_branch = branches[2]; + + let remote_branches = remote_branches + .into_iter() + .map(|branch| branch.name) + .collect::>(); + + assert_eq!(&remote_branches, &branches); + + cx.update(|cx| { + project.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx) + }) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let server_branch = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store + .current_branch(root_path.clone(), cx) + .unwrap() + }) + }) + }); + + assert_eq!(server_branch.as_ref(), branches[2]); + + // Also try creating a new branch + cx.update(|cx| { + project.update(cx, |project, cx| { + project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx) + }) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let server_branch = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .worktree_store + .update(cx, |worktree_store, cx| { + worktree_store.current_branch(root_path, cx).unwrap() + }) + }) + }); + + assert_eq!(server_branch.as_ref(), "totally-new-branch"); +} + +pub async fn init_test( + server_fs: &Arc, + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) -> (Model, Model) { + let server_fs = server_fs.clone(); + init_logger(); + + let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx); let http_client = Arc::new(BlockedHttpClient); let node_runtime = NodeRuntime::unavailable(); let languages = Arc::new(LanguageRegistry::new(cx.executor())); + server_cx.update(HeadlessProject::init); let headless = server_cx.new_model(|cx| { client::init_settings(cx); HeadlessProject::new( crate::HeadlessAppState { session: ssh_server_client, - fs: fs.clone(), + fs: server_fs.clone(), http_client, node_runtime, languages, @@ -752,13 +979,21 @@ async fn init_test( |_, cx| cx.on_release(|_, _| drop(headless)) }) .detach(); - (project, headless, fs) + (project, headless) +} + +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } } fn build_project(ssh: Model, cx: &mut TestAppContext) -> Model { cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); + if !cx.has_global::() { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + } }); let client = cx.update(|cx| { @@ -773,6 +1008,7 @@ fn build_project(ssh: Model, cx: &mut TestAppContext) -> Model< let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); let languages = Arc::new(LanguageRegistry::test(cx.executor())); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { Project::init(&client, cx); language::init(cx); diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 56b13688ba..9288416d57 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -123,7 +123,6 @@ impl ProtoMessageHandlerSet { let extract_entity_id = *this.entity_id_extractors.get(&payload_type_id)?; let entity_type_id = *this.entity_types_by_message_type.get(&payload_type_id)?; let entity_id = (extract_entity_id)(message.as_ref()); - match this .entities_by_type_and_remote_id .get_mut(&(entity_type_id, entity_id))? @@ -145,6 +144,26 @@ pub enum EntityMessageSubscriber { Pending(Vec>), } +impl std::fmt::Debug for EntityMessageSubscriber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EntityMessageSubscriber::Entity { handle } => f + .debug_struct("EntityMessageSubscriber::Entity") + .field("handle", handle) + .finish(), + EntityMessageSubscriber::Pending(vec) => f + .debug_struct("EntityMessageSubscriber::Pending") + .field( + "envelopes", + &vec.iter() + .map(|envelope| envelope.payload_type_name()) + .collect::>(), + ) + .finish(), + } + } +} + impl From> for AnyProtoClient where T: ProtoClient + 'static, diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 0130adf99c..620055a971 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -61,6 +61,7 @@ pub trait Settings: 'static + Send + Sync { anyhow::anyhow!("missing default") } + #[track_caller] fn register(cx: &mut AppContext) where Self: Sized, @@ -271,6 +272,7 @@ impl SettingsStore { pub fn register_setting(&mut self, cx: &mut AppContext) { let setting_type_id = TypeId::of::(); let entry = self.setting_values.entry(setting_type_id); + if matches!(entry, hash_map::Entry::Occupied(_)) { return; } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 74c5b2812a..f58eaa89a0 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -447,7 +447,7 @@ impl TitleBar { }) .on_click(move |_, cx| { let _ = workspace.update(cx, |this, cx| { - BranchList::open(this, &Default::default(), cx) + BranchList::open(this, &Default::default(), cx); }); }), ) diff --git a/crates/util/src/arc_cow.rs b/crates/util/src/arc_cow.rs index 02ad1fa1f0..06a2fa9cd0 100644 --- a/crates/util/src/arc_cow.rs +++ b/crates/util/src/arc_cow.rs @@ -75,6 +75,12 @@ impl From for ArcCow<'_, str> { } } +impl From<&String> for ArcCow<'_, str> { + fn from(value: &String) -> Self { + Self::Owned(value.clone().into()) + } +} + impl<'a> From> for ArcCow<'a, str> { fn from(value: Cow<'a, str>) -> Self { match value { diff --git a/crates/vcs_menu/Cargo.toml b/crates/vcs_menu/Cargo.toml index 75dcad83df..11de371868 100644 --- a/crates/vcs_menu/Cargo.toml +++ b/crates/vcs_menu/Cargo.toml @@ -14,6 +14,7 @@ fuzzy.workspace = true git.workspace = true gpui.workspace = true picker.workspace = true +project.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/vcs_menu/src/lib.rs b/crates/vcs_menu/src/lib.rs index 720a427ae9..3ee289df0e 100644 --- a/crates/vcs_menu/src/lib.rs +++ b/crates/vcs_menu/src/lib.rs @@ -2,24 +2,23 @@ use anyhow::{Context, Result}; use fuzzy::{StringMatch, StringMatchCandidate}; use git::repository::Branch; use gpui::{ - actions, rems, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, View, ViewContext, VisualContext, WindowContext, + actions, rems, AnyElement, AppContext, AsyncAppContext, DismissEvent, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, ParentElement, Render, + SharedString, Styled, Subscription, Task, View, ViewContext, VisualContext, WindowContext, }; use picker::{Picker, PickerDelegate}; +use project::ProjectPath; use std::{ops::Not, sync::Arc}; use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; use util::ResultExt; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::notifications::DetachAndPromptErr; +use workspace::{ModalView, Workspace}; actions!(branches, [OpenRecent]); pub fn init(cx: &mut AppContext) { cx.observe_new_views(|workspace: &mut Workspace, _| { - workspace.register_action(|workspace, action, cx| { - BranchList::open(workspace, action, cx).log_err(); - }); + workspace.register_action(BranchList::open); }) .detach(); } @@ -31,6 +30,21 @@ pub struct BranchList { } impl BranchList { + pub fn open(_: &mut Workspace, _: &OpenRecent, cx: &mut ViewContext) { + let this = cx.view().clone(); + cx.spawn(|_, mut cx| async move { + // Modal branch picker has a longer trailoff than a popover one. + let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?; + + this.update(&mut cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx)) + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to read branches", cx, |_, _| None) + } + fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext) -> Self { let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx)); let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); @@ -40,17 +54,6 @@ impl BranchList { _subscription, } } - pub fn open( - workspace: &mut Workspace, - _: &OpenRecent, - cx: &mut ViewContext, - ) -> Result<()> { - // Modal branch picker has a longer trailoff than a popover one. - let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?; - workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx)); - - Ok(()) - } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} @@ -100,36 +103,32 @@ pub struct BranchListDelegate { } impl BranchListDelegate { - fn new( - workspace: &Workspace, - handle: View, + async fn new( + workspace: View, branch_name_trailoff_after: usize, - cx: &AppContext, + cx: &AsyncAppContext, ) -> Result { - let project = workspace.project().read(cx); - let repo = project - .get_first_worktree_root_repo(cx) - .context("failed to get root repository for first worktree")?; + let all_branches_request = cx.update(|cx| { + let project = workspace.read(cx).project().read(cx); + let first_worktree = project + .visible_worktrees(cx) + .next() + .context("No worktrees found")?; + let project_path = ProjectPath::root_path(first_worktree.read(cx).id()); + anyhow::Ok(project.branches(project_path, cx)) + })??; + + let all_branches = all_branches_request.await?; - let all_branches = repo.branches()?; Ok(Self { matches: vec![], - workspace: handle, + workspace, all_branches, selected_index: 0, last_query: Default::default(), branch_name_trailoff_after, }) } - - fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) { - self.workspace.update(cx, |model, ctx| { - struct GitCheckoutFailure; - let id = NotificationId::unique::(); - - model.show_toast(Toast::new(id, message), ctx) - }); - } } impl PickerDelegate for BranchListDelegate { @@ -235,40 +234,32 @@ impl PickerDelegate for BranchListDelegate { cx.spawn({ let branch = branch.clone(); |picker, mut cx| async move { - picker - .update(&mut cx, |this, cx| { - let project = this.delegate.workspace.read(cx).project().read(cx); - let repo = project - .get_first_worktree_root_repo(cx) - .context("failed to get root repository for first worktree")?; + let branch_change_task = picker.update(&mut cx, |this, cx| { + let project = this.delegate.workspace.read(cx).project().read(cx); - let branch_to_checkout = match branch { - BranchEntry::Branch(branch) => branch.string, - BranchEntry::NewBranch { name: branch_name } => { - let status = repo.create_branch(&branch_name); - if status.is_err() { - this.delegate.display_error_toast(format!("Failed to create branch '{branch_name}', check for conflicts or unstashed files"), cx); - status?; - } + let branch_to_checkout = match branch { + BranchEntry::Branch(branch) => branch.string, + BranchEntry::NewBranch { name: branch_name } => branch_name, + }; + let worktree = project + .worktrees(cx) + .next() + .context("worktree disappeared")?; + let repository = ProjectPath::root_path(worktree.read(cx).id()); - branch_name - } - }; + anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx)) + })??; - let status = repo.change_branch(&branch_to_checkout); - if status.is_err() { - this.delegate.display_error_toast(format!("Failed to checkout branch '{branch_to_checkout}', check for conflicts or unstashed files"), cx); - status?; - } + branch_change_task.await?; - cx.emit(DismissEvent); + picker.update(&mut cx, |_, cx| { + cx.emit(DismissEvent); - Ok::<(), anyhow::Error>(()) - }) - .log_err(); + Ok::<(), anyhow::Error>(()) + }) } }) - .detach(); + .detach_and_prompt_err("Failed to change branch", cx, |_, _| None); } fn dismissed(&mut self, cx: &mut ViewContext>) { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 722a7b3f0a..ba65eae87c 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2385,6 +2385,12 @@ impl Snapshot { .map(|entry| entry.to_owned()) } + pub fn git_entry(&self, work_directory_path: Arc) -> Option { + self.repository_entries + .get(&RepositoryWorkDirectory(work_directory_path)) + .map(|entry| entry.to_owned()) + } + pub fn git_entries(&self) -> impl Iterator { self.repository_entries.values() }