From 4c51fffbb59e89347d3bc7e7406f484773643aa6 Mon Sep 17 00:00:00 2001 From: Coenen Benjamin Date: Thu, 4 Dec 2025 14:23:36 +0100 Subject: [PATCH] Add support for git remotes (#42819) Follow up of #42486 Closes #26559 https://github.com/user-attachments/assets/e2f54dda-a78b-4d9b-a910-16d51f98a111 Release Notes: - Added support for git remotes --------- Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> --- crates/collab/src/rpc.rs | 2 + crates/fs/src/fake_git_repo.rs | 43 +- crates/git/src/remote.rs | 3 +- crates/git/src/repository.rs | 70 +- crates/git_ui/src/branch_picker.rs | 1298 ++++++++++++++++++++++++---- crates/git_ui/src/git_panel.rs | 2 - crates/git_ui/src/remote_output.rs | 1 + crates/project/src/git_store.rs | 96 +- crates/proto/proto/git.proto | 13 + crates/proto/proto/zed.proto | 7 +- crates/proto/src/proto.rs | 6 + crates/zed_actions/src/lib.rs | 4 + 12 files changed, 1353 insertions(+), 192 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index aa77ba25bfb687b6c5cb0da84e14c843f8a2a3bc..9511087af8887a3c799357d06050ce48431b38a6 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -469,6 +469,8 @@ 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_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) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index b6beb9fc6ecb470b30c6ed4edca06be479db11c0..3bc411ff2d9b917fd409c29cca03d2191ee80978 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -50,6 +50,8 @@ pub struct FakeGitRepositoryState { pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, + /// List of remotes, keys are names and values are URLs + pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub refs: HashMap, } @@ -68,6 +70,7 @@ impl FakeGitRepositoryState { refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), + remotes: HashMap::default(), } } } @@ -432,8 +435,13 @@ impl GitRepository for FakeGitRepository { }) } - fn delete_branch(&self, _name: String) -> BoxFuture<'_, Result<()>> { - unimplemented!() + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&name) { + bail!("no such branch: {name}"); + } + Ok(()) + }) } fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { @@ -598,15 +606,24 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { - unimplemented!() + fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + self.with_state_async(false, move |state| { + let remotes = state + .remotes + .keys() + .map(|r| Remote { + name: r.clone().into(), + }) + .collect::>(); + Ok(remotes) + }) } - fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { + fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } - fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } @@ -683,6 +700,20 @@ impl GitRepository for FakeGitRepository { fn default_branch(&self) -> BoxFuture<'_, Result>> { async { Ok(Some("main".into())) }.boxed() } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.insert(name, url); + Ok(()) + }) + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.remove(&name); + Ok(()) + }) + } } #[cfg(test)] diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index e9814afc51a4a24fd154d74d0be2387c28c59fa3..8fb44839848278a3a698d7f2562741f682f38e24 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use std::sync::LazyLock; use derive_more::Deref; @@ -11,7 +12,7 @@ pub struct RemoteUrl(Url); static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); -impl std::str::FromStr for RemoteUrl { +impl FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e49b1715901f3dcc463bee0e7870d69073fa0561..f79bade2d6bc12553b173c4f4e86989a961e6d31 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -7,13 +7,15 @@ use collections::HashMap; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; -use git2::BranchType; +use git2::{BranchType, ErrorCode}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; + +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::process::{ExitStatus, Stdio}; use std::{ @@ -55,6 +57,12 @@ impl Branch { self.ref_name.starts_with("refs/remotes/") } + pub fn remote_name(&self) -> Option<&str> { + self.ref_name + .strip_prefix("refs/remotes/") + .and_then(|stripped| stripped.split("/").next()) + } + pub fn tracking_status(&self) -> Option { self.upstream .as_ref() @@ -590,6 +598,10 @@ pub trait GitRepository: Send + Sync { fn get_all_remotes(&self) -> BoxFuture<'_, Result>>; + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>; + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>; + /// returns a list of remote branches that contain HEAD fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>>; @@ -1385,9 +1397,19 @@ impl GitRepository for RealGitRepository { branch } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) { let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; + let revision = revision.get(); let branch_commit = revision.peel_to_commit()?; - let mut branch = repo.branch(&branch_name, &branch_commit, false)?; + let mut branch = match repo.branch(&branch_name, &branch_commit, false) { + Ok(branch) => branch, + Err(err) if err.code() == ErrorCode::Exists => { + repo.find_branch(&branch_name, BranchType::Local)? + } + Err(err) => { + return Err(err.into()); + } + }; + branch.set_upstream(Some(&name))?; branch } else { @@ -1403,7 +1425,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let branch = branch.await?; - GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; @@ -1993,7 +2014,7 @@ impl GitRepository for RealGitRepository { let working_directory = working_directory?; let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) - .args(["remote"]) + .args(["remote", "-v"]) .output() .await?; @@ -2002,14 +2023,43 @@ impl GitRepository for RealGitRepository { "Failed to get all remotes:\n{}", String::from_utf8_lossy(&output.stderr) ); - let remote_names = String::from_utf8_lossy(&output.stdout) - .split('\n') - .filter(|name| !name.is_empty()) - .map(|name| Remote { - name: name.trim().to_string().into(), + let remote_names: HashSet = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut split_line = line.split_whitespace(); + let remote_name = split_line.next()?; + + Some(Remote { + name: remote_name.trim().to_string().into(), + }) }) .collect(); - Ok(remote_names) + + Ok(remote_names.into_iter().collect()) + }) + .boxed() + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote_delete(&name)?; + + Ok(()) + }) + .boxed() + } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote(&name, url.as_ref())?; + Ok(()) }) .boxed() } diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 92c2f92ca342be270aa25f9e1a7ee96f5e06a585..42e043cada2813126af3489c9769aca9c675999f 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,10 +1,12 @@ use anyhow::Context as _; +use editor::Editor; use fuzzy::StringMatchCandidate; use collections::HashSet; use git::repository::Branch; +use gpui::http_client::Url; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -14,7 +16,10 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, + prelude::*, +}; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; @@ -24,8 +29,10 @@ use crate::{branch_picker, git_panel::show_error_toast}; actions!( branch_picker, [ - /// Deletes the selected git branch. - DeleteBranch + /// Deletes the selected git branch or remote. + DeleteBranch, + /// Filter the list of remotes + FilterRemotes ] ); @@ -206,7 +213,7 @@ impl BranchList { .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) } - fn handle_delete_branch( + fn handle_delete( &mut self, _: &branch_picker::DeleteBranch, window: &mut Window, @@ -215,9 +222,32 @@ impl BranchList { self.picker.update(cx, |picker, cx| { picker .delegate - .delete_branch_at(picker.delegate.selected_index, window, cx) + .delete_at(picker.delegate.selected_index, window, cx) }) } + + fn handle_filter( + &mut self, + _: &branch_picker::FilterRemotes, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |this, cx| { + this.delegate.display_remotes = !this.delegate.display_remotes; + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |picker, window, cx| { + let last_query = picker.delegate.last_query.clone(); + picker.delegate.update_matches(last_query, window, cx) + })? + .await; + + Result::Ok::<_, anyhow::Error>(()) + }) + .detach_and_log_err(cx); + }); + + cx.notify(); + } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} @@ -234,7 +264,8 @@ impl Render for BranchList { .key_context("GitBranchSelector") .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) - .on_action(cx.listener(Self::handle_delete_branch)) + .on_action(cx.listener(Self::handle_delete)) + .on_action(cx.listener(Self::handle_filter)) .child(self.picker.clone()) .on_mouse_down_out({ cx.listener(move |this, _, window, cx| { @@ -246,16 +277,50 @@ impl Render for BranchList { } } -#[derive(Debug, Clone)] -struct BranchEntry { - branch: Branch, - positions: Vec, - is_new: bool, +#[derive(Debug, Clone, PartialEq)] +enum Entry { + Branch { + branch: Branch, + positions: Vec, + }, + NewUrl { + url: String, + }, + NewBranch { + name: String, + }, +} + +impl Entry { + fn as_branch(&self) -> Option<&Branch> { + match self { + Entry::Branch { branch, .. } => Some(branch), + _ => None, + } + } + + fn name(&self) -> &str { + match self { + Entry::Branch { branch, .. } => branch.name(), + Entry::NewUrl { url, .. } => url.as_str(), + Entry::NewBranch { name, .. } => name.as_str(), + } + } + + #[cfg(test)] + fn is_new_url(&self) -> bool { + matches!(self, Self::NewUrl { .. }) + } + + #[cfg(test)] + fn is_new_branch(&self) -> bool { + matches!(self, Self::NewBranch { .. }) + } } pub struct BranchListDelegate { workspace: Option>, - matches: Vec, + matches: Vec, all_branches: Option>, default_branch: Option, repo: Option>, @@ -263,9 +328,24 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, + display_remotes: bool, + state: PickerState, + loading: bool, focus_handle: FocusHandle, } +#[derive(Debug)] +enum PickerState { + /// When we display list of branches/remotes + List, + /// When we set an url to create a new remote + NewRemote, + /// When we confirm the new remote url (after NewRemote) + CreateRemote(SharedString), + /// When we set a new branch to create + NewBranch, +} + impl BranchListDelegate { fn new( workspace: Option>, @@ -283,6 +363,9 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), + display_remotes: false, + state: PickerState::List, + loading: false, focus_handle: cx.focus_handle(), } } @@ -313,8 +396,59 @@ impl BranchListDelegate { cx.emit(DismissEvent); } - fn delete_branch_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { - let Some(branch_entry) = self.matches.get(idx) else { + fn create_remote( + &self, + remote_name: String, + remote_url: String, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(repo) = self.repo.clone() else { + return; + }; + cx.spawn(async move |this, cx| { + this.update(cx, |picker, cx| { + picker.delegate.loading = true; + cx.notify(); + }) + .log_err(); + + let stop_loader = |this: &WeakEntity>, cx: &mut AsyncApp| { + this.update(cx, |picker, cx| { + picker.delegate.loading = false; + cx.notify(); + }) + .log_err(); + }; + repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)) + .inspect_err(|_err| { + stop_loader(&this, cx); + })? + .await + .inspect_err(|_err| { + stop_loader(&this, cx); + })? + .inspect_err(|_err| { + stop_loader(&this, cx); + })?; + stop_loader(&this, cx); + Ok(()) + }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } + + fn loader(&self) -> AnyElement { + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .with_rotate_animation(3) + .into_any_element() + } + + fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(idx).cloned() else { return; }; let Some(repo) = self.repo.clone() else { @@ -322,20 +456,51 @@ impl BranchListDelegate { }; let workspace = self.workspace.clone(); - let branch_name = branch_entry.branch.name().to_string(); - let branch_ref = branch_entry.branch.ref_name.clone(); cx.spawn_in(window, async move |picker, cx| { - let result = repo - .update(cx, |repo, _| repo.delete_branch(branch_name.clone()))? - .await?; + let mut is_remote = false; + let result = match &entry { + Entry::Branch { branch, .. } => match branch.remote_name() { + Some(remote_name) => { + is_remote = true; + repo.update(cx, |repo, _| repo.remove_remote(remote_name.to_string()))? + .await? + } + None => { + repo.update(cx, |repo, _| repo.delete_branch(branch.name().to_string()))? + .await? + } + }, + _ => { + log::error!("Failed to delete remote: wrong entry to delete"); + return Ok(()); + } + }; if let Err(e) = result { - log::error!("Failed to delete branch: {}", e); + if is_remote { + log::error!("Failed to delete remote: {}", e); + } else { + log::error!("Failed to delete branch: {}", e); + } if let Some(workspace) = workspace.and_then(|w| w.upgrade()) { cx.update(|_window, cx| { - show_error_toast(workspace, format!("branch -d {branch_name}"), e, cx) + if is_remote { + show_error_toast( + workspace, + format!("remote remove {}", entry.name()), + e, + cx, + ) + } else { + show_error_toast( + workspace, + format!("branch -d {}", entry.name()), + e, + cx, + ) + } })?; } @@ -343,13 +508,12 @@ impl BranchListDelegate { } picker.update_in(cx, |picker, _, cx| { - picker - .delegate - .matches - .retain(|entry| entry.branch.ref_name != branch_ref); + picker.delegate.matches.retain(|e| e != &entry); - if let Some(all_branches) = &mut picker.delegate.all_branches { - all_branches.retain(|branch| branch.ref_name != branch_ref); + if let Entry::Branch { branch, .. } = &entry { + if let Some(all_branches) = &mut picker.delegate.all_branches { + all_branches.retain(|e| e.ref_name != branch.ref_name); + } } if picker.delegate.matches.is_empty() { @@ -374,6 +538,45 @@ impl PickerDelegate for BranchListDelegate { "Select branch…".into() } + fn render_editor( + &self, + editor: &Entity, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + cx.update_entity(editor, move |editor, cx| { + let placeholder = match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + if self.display_remotes { + "Select remote…" + } else { + "Select branch…" + } + } + PickerState::CreateRemote(_) => "Choose a name…", + }; + editor.set_placeholder_text(placeholder, window, cx); + }); + + v_flex() + .when( + self.editor_position() == PickerEditorPosition::End, + |this| this.child(Divider::horizontal()), + ) + .child( + h_flex() + .overflow_hidden() + .flex_none() + .h_9() + .px_2p5() + .child(editor.clone()), + ) + .when( + self.editor_position() == PickerEditorPosition::Start, + |this| this.child(Divider::horizontal()), + ) + } + fn editor_position(&self) -> PickerEditorPosition { match self.style { BranchListStyle::Modal => PickerEditorPosition::Start, @@ -409,20 +612,36 @@ impl PickerDelegate for BranchListDelegate { }; const RECENT_BRANCHES_COUNT: usize = 10; + let display_remotes = self.display_remotes; cx.spawn_in(window, async move |picker, cx| { - let mut matches: Vec = if query.is_empty() { + let mut matches: Vec = if query.is_empty() { all_branches .into_iter() - .filter(|branch| !branch.is_remote()) + .filter(|branch| { + if display_remotes { + branch.is_remote() + } else { + !branch.is_remote() + } + }) .take(RECENT_BRANCHES_COUNT) - .map(|branch| BranchEntry { + .map(|branch| Entry::Branch { branch, positions: Vec::new(), - is_new: false, }) .collect() } else { - let candidates = all_branches + let branches = all_branches + .iter() + .filter(|branch| { + if display_remotes { + branch.is_remote() + } else { + !branch.is_remote() + } + }) + .collect::>(); + let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) @@ -438,31 +657,40 @@ impl PickerDelegate for BranchListDelegate { ) .await .into_iter() - .map(|candidate| BranchEntry { - branch: all_branches[candidate.candidate_id].clone(), + .map(|candidate| Entry::Branch { + branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, - is_new: false, }) .collect() }; picker .update(cx, |picker, _| { + if matches!(picker.delegate.state, PickerState::CreateRemote(_)) { + picker.delegate.last_query = query; + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + + return; + } + if !query.is_empty() - && !matches - .first() - .is_some_and(|entry| entry.branch.name() == query) + && !matches.first().is_some_and(|entry| entry.name() == query) { let query = query.replace(' ', "-"); - matches.push(BranchEntry { - branch: Branch { - ref_name: format!("refs/heads/{query}").into(), - is_head: false, - upstream: None, - most_recent_commit: None, - }, - positions: Vec::new(), - is_new: true, - }) + let is_url = query.trim_start_matches("git@").parse::().is_ok(); + let entry = if is_url { + Entry::NewUrl { url: query } + } else { + Entry::NewBranch { name: query } + }; + picker.delegate.state = if is_url { + PickerState::NewRemote + } else { + PickerState::NewBranch + }; + matches.push(entry); + } else { + picker.delegate.state = PickerState::List; } let delegate = &mut picker.delegate; delegate.matches = matches; @@ -479,56 +707,78 @@ impl PickerDelegate for BranchListDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index()) else { - return; - }; - - if entry.is_new { - let from_branch = if secondary { - self.default_branch.clone() - } else { - None - }; - self.create_branch( - from_branch, - entry.branch.name().to_owned().into(), - window, - cx, - ); - return; - } - - let current_branch = self.repo.as_ref().map(|repo| { - repo.read_with(cx, |repo, _| { - repo.branch.as_ref().map(|branch| branch.ref_name.clone()) - }) - }); - - if current_branch - .flatten() - .is_some_and(|current_branch| current_branch == entry.branch.ref_name) - { - cx.emit(DismissEvent); + if let PickerState::CreateRemote(remote_url) = &self.state { + self.create_remote(self.last_query.clone(), remote_url.to_string(), window, cx); + self.state = PickerState::List; + cx.notify(); return; } - let Some(repo) = self.repo.clone() else { + let Some(entry) = self.matches.get(self.selected_index()) else { return; }; - let branch = entry.branch.clone(); - cx.spawn(async move |_, cx| { - repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? - .await??; + match entry { + Entry::Branch { branch, .. } => { + let current_branch = self.repo.as_ref().map(|repo| { + repo.read_with(cx, |repo, _| { + repo.branch.as_ref().map(|branch| branch.ref_name.clone()) + }) + }); + + if current_branch + .flatten() + .is_some_and(|current_branch| current_branch == branch.ref_name) + { + cx.emit(DismissEvent); + return; + } - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None); + let Some(repo) = self.repo.clone() else { + return; + }; + + let branch = branch.clone(); + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? + .await??; + + anyhow::Ok(()) + }) + .detach_and_prompt_err( + "Failed to change branch", + window, + cx, + |_, _, _| None, + ); + } + Entry::NewUrl { url } => { + self.state = PickerState::CreateRemote(url.clone().into()); + self.matches = Vec::new(); + self.selected_index = 0; + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |picker, window, cx| { + picker.set_query("", window, cx); + }) + }) + .detach_and_log_err(cx); + cx.notify(); + } + Entry::NewBranch { name } => { + let from_branch = if secondary { + self.default_branch.clone() + } else { + None + }; + self.create_branch(from_branch, format!("refs/heads/{name}").into(), window, cx); + } + } cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.state = PickerState::List; cx.emit(DismissEvent); } @@ -542,49 +792,60 @@ impl PickerDelegate for BranchListDelegate { let entry = &self.matches.get(ix)?; let (commit_time, author_name, subject) = entry - .branch - .most_recent_commit - .as_ref() - .map(|commit| { - let subject = commit.subject.clone(); - let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) - .unwrap_or_else(|_| OffsetDateTime::now_utc()); - let local_offset = - time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); - let formatted_time = time_format::format_localized_timestamp( - commit_time, - OffsetDateTime::now_utc(), - local_offset, - time_format::TimestampFormat::Relative, - ); - let author = commit.author_name.clone(); - (Some(formatted_time), Some(author), Some(subject)) + .as_branch() + .and_then(|branch| { + branch.most_recent_commit.as_ref().map(|commit| { + let subject = commit.subject.clone(); + let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let formatted_time = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::Relative, + ); + let author = commit.author_name.clone(); + (Some(formatted_time), Some(author), Some(subject)) + }) }) .unwrap_or_else(|| (None, None, None)); - let icon = if let Some(default_branch) = self.default_branch.clone() - && entry.is_new - { - Some( - IconButton::new("branch-from-default", IconName::GitBranchAlt) + let icon = if let Some(default_branch) = self.default_branch.clone() { + let icon = match &entry { + Entry::Branch { .. } => Some(( + IconName::GitBranchAlt, + format!("Create branch based off default: {default_branch}"), + )), + Entry::NewUrl { url } => { + Some((IconName::Screen, format!("Create remote based off {url}"))) + } + Entry::NewBranch { .. } => None, + }; + + icon.map(|(icon, tooltip_text)| { + IconButton::new("branch-from-default", icon) .on_click(cx.listener(move |this, _, window, cx| { this.delegate.set_selected_index(ix, window, cx); this.delegate.confirm(true, window, cx); })) .tooltip(move |_window, cx| { - Tooltip::for_action( - format!("Create branch based off default: {default_branch}"), - &menu::SecondaryConfirm, - cx, - ) - }), - ) + Tooltip::for_action(tooltip_text.clone(), &menu::SecondaryConfirm, cx) + }) + }) } else { None }; - let branch_name = if entry.is_new { - h_flex() + let icon_element = if self.display_remotes { + Icon::new(IconName::Screen) + } else { + Icon::new(IconName::GitBranchAlt) + }; + + let entry_name = match entry { + Entry::NewUrl { .. } => h_flex() .gap_1() .child( Icon::new(IconName::Plus) @@ -592,19 +853,31 @@ impl PickerDelegate for BranchListDelegate { .color(Color::Muted), ) .child( - Label::new(format!("Create branch \"{}\"…", entry.branch.name())) + Label::new("Create remote repository".to_string()) .single_line() .truncate(), ) - .into_any_element() - } else { - h_flex() - .max_w_48() + .into_any_element(), + Entry::NewBranch { name } => h_flex() + .gap_1() .child( - HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!("Create branch \"{name}\"…")) + .single_line() .truncate(), ) - .into_any_element() + .into_any_element(), + Entry::Branch { branch, positions } => h_flex() + .max_w_48() + .child(h_flex().mr_1().child(icon_element)) + .child( + HighlightedLabel::new(branch.name().to_string(), positions.clone()).truncate(), + ) + .into_any_element(), }; Some( @@ -613,11 +886,14 @@ impl PickerDelegate for BranchListDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .tooltip({ - let branch_name = entry.branch.name().to_string(); - if entry.is_new { - Tooltip::text(format!("Create branch \"{}\"", branch_name)) - } else { - Tooltip::text(branch_name) + match entry { + Entry::Branch { branch, .. } => Tooltip::text(branch.name().to_string()), + Entry::NewUrl { .. } => { + Tooltip::text("Create remote repository".to_string()) + } + Entry::NewBranch { name } => { + Tooltip::text(format!("Create branch \"{name}\"")) + } } }) .child( @@ -629,7 +905,7 @@ impl PickerDelegate for BranchListDelegate { .gap_6() .justify_between() .overflow_x_hidden() - .child(branch_name) + .child(entry_name) .when_some(commit_time, |label, commit_time| { label.child( Label::new(commit_time) @@ -641,30 +917,35 @@ impl PickerDelegate for BranchListDelegate { ) .when(self.style == BranchListStyle::Modal, |el| { el.child(div().max_w_96().child({ - let message = if entry.is_new { - if let Some(current_branch) = - self.repo.as_ref().and_then(|repo| { - repo.read(cx).branch.as_ref().map(|b| b.name()) - }) - { - format!("based off {}", current_branch) - } else { - "based off the current branch".to_string() - } - } else { - let show_author_name = ProjectSettings::get_global(cx) - .git - .branch_picker - .show_author_name; - - subject.map_or("no commits found".into(), |subject| { - if show_author_name && author_name.is_some() { - format!("{} • {}", author_name.unwrap(), subject) + let message = match entry { + Entry::NewUrl { url } => format!("based off {url}"), + Entry::NewBranch { .. } => { + if let Some(current_branch) = + self.repo.as_ref().and_then(|repo| { + repo.read(cx).branch.as_ref().map(|b| b.name()) + }) + { + format!("based off {}", current_branch) } else { - subject.to_string() + "based off the current branch".to_string() } - }) + } + Entry::Branch { .. } => { + let show_author_name = ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; + + subject.map_or("no commits found".into(), |subject| { + if show_author_name && author_name.is_some() { + format!("{} • {}", author_name.unwrap(), subject) + } else { + subject.to_string() + } + }) + } }; + Label::new(message) .size(LabelSize::Small) .truncate() @@ -676,40 +957,715 @@ impl PickerDelegate for BranchListDelegate { ) } - fn render_footer( + fn render_header( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { - let focus_handle = self.focus_handle.clone(); - + if matches!( + self.state, + PickerState::CreateRemote(_) | PickerState::NewRemote | PickerState::NewBranch + ) { + return None; + } + let label = if self.display_remotes { + "Remote" + } else { + "Local" + }; Some( h_flex() .w_full() .p_1p5() - .gap_0p5() - .justify_end() + .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), - ) + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) .into_any(), ) } + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + + if self.loading { + return Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(self.loader()) + .into_any(), + ); + } + match self.state { + PickerState::List => Some( + h_flex() + .w_full() + .p_1p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child( + Button::new("filter-remotes", "Filter remotes") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::FilterRemotes, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_click, window, cx| { + window.dispatch_action( + branch_picker::FilterRemotes.boxed_clone(), + cx, + ); + }) + .disabled(self.loading) + .style(ButtonStyle::Subtle) + .toggle_state(self.display_remotes), + ) + .child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .disabled(self.loading) + .on_click(|_, window, cx| { + window + .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); + }), + ) + .when(self.loading, |this| this.child(self.loader())) + .into_any(), + ), + PickerState::CreateRemote(_) => Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Choose a name for this remote repository") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex().w_full().justify_end().child( + Label::new("Save") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any(), + ), + PickerState::NewRemote | PickerState::NewBranch => None, + } + } + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { None } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use git::repository::{CommitSummary, Remote}; + use gpui::{TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn create_test_branch( + name: &str, + is_head: bool, + remote_name: Option<&str>, + timestamp: Option, + ) -> Branch { + let ref_name = match remote_name { + Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"), + None => format!("refs/heads/{name}"), + }; + + Branch { + is_head, + ref_name: ref_name.into(), + upstream: None, + most_recent_commit: timestamp.map(|ts| CommitSummary { + sha: "abc123".into(), + commit_timestamp: ts, + author_name: "Test Author".into(), + subject: "Test commit".into(), + has_parent: true, + }), + } + } + + fn create_test_branches() -> Vec { + vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature-auth", false, None, Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ] + } + + fn init_branch_list_test( + cx: &mut TestAppContext, + repository: Option>, + branches: Vec, + ) -> (VisualTestContext, Entity) { + let window = cx.add_window(|window, cx| { + let mut delegate = + BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx); + delegate.all_branches = Some(branches); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }); + + let branch_list = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(*window, cx); + + (cx, branch_list) + } + + async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": "buffer_text".to_string() + }), + ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "test".to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "index_text".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let repository = cx.read(|cx| project.read(cx).active_repository(cx)); + + repository.unwrap() + } + + #[gpui::test] + async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = create_test_branches(); + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + let query = "feature".to_string(); + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 2 existing branches + 1 "create new branch" entry = 3 total + assert_eq!(picker.delegate.matches.len(), 3); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-auth") + ); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-ui") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + async fn update_branch_list_matches_with_empty_query( + branch_list: &Entity, + cx: &mut VisualTestContext, + ) { + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_delete_branch(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + + let branches = create_test_branches(); + + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["main", "feature-auth", "feature-ui", "develop"] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_delete_remote(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("origin"), Some(900)), + create_test_branch("feature-ui", false, Some("fork"), Some(800)), + create_test_branch("develop", false, Some("private"), Some(700)), + ]; + + let remote_names = branches + .iter() + .filter_map(|branch| branch.remote_name().map(|r| r.to_string())) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in remote_names { + repo.update(&mut cx, |repo, _| { + repo.create_remote(branch, String::from("test")) + }) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + // Enable remote filter + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.display_remotes = true; + }); + }); + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + // Check matches, it should match one less branch than before + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + [ + "origin/main", + "origin/feature-auth", + "fork/feature-ui", + "private/develop" + ] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("fork"), Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ]; + + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["feature-ui", "develop"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_branch()); + assert!(!last_match.is_new_url()); + picker.delegate.display_remotes = true; + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_url()); + picker.delegate.display_remotes = true; + picker + .delegate + .update_matches(String::from("fork"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "fork/feature-auth") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + init_test(test_cx); + let repository = init_fake_repository(test_cx).await; + + let branches = vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature", false, None, Some(900)), + ]; + + let (mut ctx, branch_list) = init_branch_list_test(test_cx, repository.into(), branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "new-feature-branch".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + assert_eq!(last_match.name(), "new-feature-branch"); + assert!(matches!(picker.delegate.state, PickerState::NewBranch)); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + let branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + + assert!( + branches + .into_iter() + .any(|branch| branch.name() == "new-feature-branch") + ); + } + + #[gpui::test] + async fn test_remote_url_detection_https(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + if let PickerState::CreateRemote(remote_url) = &picker.delegate.state + && remote_url.as_ref() == "https://github.com/user/repo.git" + { + } else { + panic!("wrong picker state"); + } + picker + .delegate + .update_matches("my_new_remote".to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + }) + }); + cx.run_until_parked(); + + // List remotes + let remotes = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.get_remotes(None, false)) + }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + remotes, + vec![Remote { + name: SharedString::from("my_new_remote".to_string()) + }] + ); + } + + #[gpui::test] + async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to create a new remote but cancel in the middle of the process + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = picker.delegate.matches.len() - 1; + picker.delegate.confirm(false, window, cx); + + assert!(matches!( + picker.delegate.state, + PickerState::CreateRemote(_) + )); + if let PickerState::CreateRemote(ref url) = picker.delegate.state { + assert_eq!(url.as_ref(), "https://github.com/user/repo.git"); + } + assert_eq!(picker.delegate.matches.len(), 0); + picker.delegate.dismissed(window, cx); + assert!(matches!(picker.delegate.state, PickerState::List)); + let query = "main".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to search a branch again to see if the state is restored properly + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "main_branch") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 62bd118daf1751e32dd0b805a773be47e19e4357..c6895f4c15d5afd3ef50ce796059956dd8653f8b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3463,7 +3463,6 @@ impl GitPanel { ) -> Option { let active_repository = self.active_repository.clone()?; let panel_editor_style = panel_editor_style(true, window, cx); - let enable_coauthors = self.render_co_authors(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx); @@ -4772,7 +4771,6 @@ impl RenderOnce for PanelRepoFooter { const MAX_REPO_LEN: usize = 16; const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN; const MAX_SHORT_SHA_LEN: usize = 8; - let branch_name = self .branch .as_ref() diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 8437bf0d0d37c2b2767624110fed056bbae25d05..7fe863ee29df20ca0f61cef5bf64cdae4b198c7a 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; + use git::repository::{Remote, RemoteCommandOutput}; use linkify::{LinkFinder, LinkKind}; use ui::SharedString; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5bc3f4ee43493ee9d07ab2c3a1025214007a653d..81511b21be3599b4686b9fd11aac5118711f11fa 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -472,6 +472,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_change_branch); client.add_entity_request_handler(Self::handle_create_branch); client.add_entity_request_handler(Self::handle_rename_branch); + client.add_entity_request_handler(Self::handle_create_remote); + client.add_entity_request_handler(Self::handle_remove_remote); client.add_entity_request_handler(Self::handle_delete_branch); client.add_entity_request_handler(Self::handle_git_init); client.add_entity_request_handler(Self::handle_push); @@ -2274,6 +2276,25 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_create_remote( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let remote_name = envelope.payload.remote_name; + let remote_url = envelope.payload.remote_url; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.create_remote(remote_name, remote_url) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_delete_branch( this: Entity, envelope: TypedEnvelope, @@ -2292,6 +2313,24 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_remove_remote( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let remote_name = envelope.payload.remote_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remove_remote(remote_name) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_show( this: Entity, envelope: TypedEnvelope, @@ -4865,6 +4904,61 @@ impl Repository { ) } + pub fn create_remote( + &mut self, + remote_name: String, + remote_url: String, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git remote add {remote_name} {remote_url}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_remote(remote_name, remote_url).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateRemote { + project_id: project_id.0, + repository_id: id.to_proto(), + remote_name, + remote_url, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn remove_remote(&mut self, remote_name: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git remove remote {remote_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.remove_remote(remote_name).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRemoveRemote { + project_id: project_id.0, + repository_id: id.to_proto(), + remote_name, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn get_remotes( &mut self, branch_name: Option, @@ -4902,7 +4996,7 @@ impl Repository { let remotes = response .remotes .into_iter() - .map(|remotes| git::repository::Remote { + .map(|remotes| Remote { name: remotes.name.into(), }) .collect(); diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index de6a5f676df7332d0673d4e5bd75130bf7f0c400..aa0668ceabddc7627fcc3593b86ad2f4e40a6ac7 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -190,6 +190,19 @@ message GitRenameBranch { string new_name = 4; } +message GitCreateRemote { + uint64 project_id = 1; + uint64 repository_id = 2; + string remote_name = 3; + string remote_url = 4; +} + +message GitRemoveRemote { + uint64 project_id = 1; + uint64 repository_id = 2; + string remote_name = 3; +} + message GitDeleteBranch { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 39faeeac88cfc49cbaba4a777da3fb8daa015a66..8e26a26a43ff8af5c1b676f5dc7f8fe49e67e19f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -437,13 +437,18 @@ message Envelope { OpenImageResponse open_image_response = 392; CreateImageForPeer create_image_for_peer = 393; + GitFileHistory git_file_history = 397; GitFileHistoryResponse git_file_history_response = 398; RunGitHook run_git_hook = 399; GitDeleteBranch git_delete_branch = 400; - ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; // current max + + ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; + + GitCreateRemote git_create_remote = 402; + GitRemoveRemote git_remove_remote = 403;// current max } reserved 87 to 88, 396; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 38a994a37b6c62f7a1f078eb287f120c49b0ce82..455f94704663dcd96e37487b1a4243850634c18e 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -305,6 +305,8 @@ messages!( (RemoteMessageResponse, Background), (AskPassRequest, Background), (AskPassResponse, Background), + (GitCreateRemote, Background), + (GitRemoveRemote, Background), (GitCreateBranch, Background), (GitChangeBranch, Background), (GitRenameBranch, Background), @@ -504,6 +506,8 @@ request_messages!( (GetRemotes, GetRemotesResponse), (Pull, RemoteMessageResponse), (AskPassRequest, AskPassResponse), + (GitCreateRemote, Ack), + (GitRemoveRemote, Ack), (GitCreateBranch, Ack), (GitChangeBranch, Ack), (GitRenameBranch, Ack), @@ -676,6 +680,8 @@ entity_messages!( GitChangeBranch, GitRenameBranch, GitCreateBranch, + GitCreateRemote, + GitRemoveRemote, CheckForPushedCommits, GitDiff, GitInit, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 803fde3f8787b4f6489bd6390d289c35b1c96199..d4d28433d4c76dcab3df627789df82e99854fbc1 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -215,6 +215,10 @@ pub mod git { Switch, /// Selects a different repository. SelectRepo, + /// Filter remotes. + FilterRemotes, + /// Create a git remote. + CreateRemote, /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch,