From 62270b33c24e64763c5330d69f8ebc3931d49ae8 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:09:38 -0400 Subject: [PATCH] git: Add ability to clone remote repositories from Zed (#35606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds preliminary git clone support through using the new `GitClone` action. This works with SSH connections too. - [x] Get backend working - [x] Add a UI to interact with this Future follow-ups: - Polish the UI - Have the path select prompt say "Select Repository clone target" instead of “Open” - Use Zed path prompt if the user has that as a setting - Add support for cloning from a user's GitHub repositories directly Release Notes: - Add the ability to clone remote git repositories through the `git: Clone` action --------- Co-authored-by: hpmcdona --- crates/fs/src/fs.rs | 24 ++++- crates/git/src/git.rs | 2 + crates/git_ui/src/git_panel.rs | 93 ++++++++++++++++++ crates/git_ui/src/git_ui.rs | 120 ++++++++++++++++++++++- crates/language/src/language_settings.rs | 2 +- crates/project/src/git_store.rs | 56 +++++++++++ crates/proto/proto/git.proto | 10 ++ crates/proto/proto/zed.proto | 5 +- crates/proto/src/proto.rs | 6 +- 9 files changed, 310 insertions(+), 8 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index a76ccee2bf..af8fe129ab 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -12,7 +12,7 @@ use gpui::BackgroundExecutor; use gpui::Global; use gpui::ReadGlobal as _; use std::borrow::Cow; -use util::command::new_std_command; +use util::command::{new_smol_command, new_std_command}; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; @@ -134,6 +134,7 @@ pub trait Fs: Send + Sync { fn home_dir(&self) -> Option; fn open_repo(&self, abs_dot_git: &Path) -> Option>; fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; + async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; fn is_fake(&self) -> bool; async fn is_case_sensitive(&self) -> Result; @@ -839,6 +840,23 @@ impl Fs for RealFs { Ok(()) } + async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()> { + let output = new_smol_command("git") + .current_dir(abs_work_directory) + .args(&["clone", repo_url]) + .output() + .await?; + + if !output.status.success() { + anyhow::bail!( + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + fn is_fake(&self) -> bool { false } @@ -2352,6 +2370,10 @@ impl Fs for FakeFs { smol::block_on(self.create_dir(&abs_work_directory_path.join(".git"))) } + async fn git_clone(&self, _repo_url: &str, _abs_work_directory: &Path) -> Result<()> { + anyhow::bail!("Git clone is not supported in fake Fs") + } + fn is_fake(&self) -> bool { true } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 553361e673..e6336eb656 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -93,6 +93,8 @@ actions!( Init, /// Opens all modified files in the editor. OpenModifiedFiles, + /// Clones a repository. + Clone, ] ); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e4f445858d..75fac114d2 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2081,6 +2081,99 @@ impl GitPanel { .detach_and_log_err(cx); } + pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context) { + let path = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + }); + + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |this, cx| { + let mut paths = path.await.ok()?.ok()??; + let mut path = paths.pop()?; + let repo_name = repo + .split(std::path::MAIN_SEPARATOR_STR) + .last()? + .strip_suffix(".git")? + .to_owned(); + + let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?; + + let prompt_answer = match fs.git_clone(&repo, path.as_path()).await { + Ok(_) => cx.update(|window, cx| { + window.prompt( + PromptLevel::Info, + "Git Clone", + None, + &["Add repo to project", "Open repo in new project"], + cx, + ) + }), + Err(e) => { + this.update(cx, |this: &mut GitPanel, cx| { + let toast = StatusToast::new(e.to_string(), cx, |this, _| { + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + .dismiss_button(true) + }); + + this.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(toast, cx); + }) + .ok(); + }) + .ok()?; + + return None; + } + } + .ok()?; + + path.push(repo_name); + match prompt_answer.await.ok()? { + 0 => { + workspace + .update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| { + project.create_worktree(path.as_path(), true, cx) + }) + .detach(); + }) + .ok(); + } + 1 => { + workspace + .update(cx, move |workspace, cx| { + workspace::open_new( + Default::default(), + workspace.app_state().clone(), + cx, + move |workspace, _, cx| { + cx.activate(true); + workspace + .project() + .update(cx, |project, cx| { + project.create_worktree(&path, true, cx) + }) + .detach(); + }, + ) + .detach(); + }) + .ok(); + } + _ => {} + } + + Some(()) + }) + .detach(); + } + pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context) { let worktrees = self .project diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index bde867bcd2..7d5207dfb6 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -3,21 +3,25 @@ use std::any::Any; use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; -use editor::{Editor, actions::DiffClipboardWithSelectionData}; +use editor::{Editor, EditorElement, EditorStyle, actions::DiffClipboardWithSelectionData}; mod blame_ui; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use git_panel_settings::GitPanelSettings; -use gpui::{Action, App, Context, FocusHandle, Window, actions}; +use gpui::{ + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TextStyle, + Window, actions, +}; use onboarding::GitOnboardingModal; use project_diff::ProjectDiff; +use theme::ThemeSettings; use ui::prelude::*; -use workspace::Workspace; +use workspace::{ModalView, Workspace}; use zed_actions; -use crate::text_diff_view::TextDiffView; +use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; mod askpass_modal; pub mod branch_picker; @@ -169,6 +173,19 @@ pub fn init(cx: &mut App) { panel.git_init(window, cx); }); }); + workspace.register_action(|workspace, _action: &git::Clone, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + GitCloneModal::show(panel, window, cx) + }); + + // panel.update(cx, |panel, cx| { + // panel.git_clone(window, cx); + // }); + }); workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| { open_modified_files(workspace, window, cx); }); @@ -613,3 +630,98 @@ impl Component for GitStatusIcon { ) } } + +struct GitCloneModal { + panel: Entity, + repo_input: Entity, + focus_handle: FocusHandle, +} + +impl GitCloneModal { + pub fn show(panel: Entity, window: &mut Window, cx: &mut Context) -> Self { + let repo_input = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter repository", cx); + editor + }); + let focus_handle = repo_input.focus_handle(cx); + + window.focus(&focus_handle); + + Self { + panel, + repo_input, + focus_handle, + } + } + + fn render_editor(&self, window: &Window, cx: &App) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme(); + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + background_color: Some(theme.colors().editor_background), + ..Default::default() + }; + + let element = EditorElement::new( + &self.repo_input, + EditorStyle { + background: theme.colors().editor_background, + local_player: theme.players().local(), + text: text_style, + ..Default::default() + }, + ); + + div() + .rounded_md() + .p_1() + .border_1() + .border_color(theme.colors().border_variant) + .when( + self.repo_input + .focus_handle(cx) + .contains_focused(window, cx), + |this| this.border_color(theme.colors().border_focused), + ) + .child(element) + .bg(theme.colors().editor_background) + } +} + +impl Focusable for GitCloneModal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for GitCloneModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .w(rems(34.)) + .elevation_3(cx) + .child(self.render_editor(window, cx)) + .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { + cx.emit(DismissEvent); + })) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let repo = this.repo_input.read(cx).text(cx); + this.panel.update(cx, |panel, cx| { + panel.git_clone(repo, window, cx); + }); + cx.emit(DismissEvent); + })) + } +} + +impl EventEmitter for GitCloneModal {} + +impl ModalView for GitCloneModal {} diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 9b0abb1537..1aae0b2f7e 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -987,7 +987,7 @@ pub struct InlayHintSettings { /// Default: false #[serde(default)] pub enabled: bool, - /// Global switch to toggle inline values on and off. + /// Global switch to toggle inline values on and off when debugging. /// /// Default: true #[serde(default = "default_true")] diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 01fc987816..5d48c833ab 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -441,6 +441,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_blame_buffer); client.add_entity_message_handler(Self::handle_update_repository); client.add_entity_message_handler(Self::handle_remove_repository); + client.add_entity_request_handler(Self::handle_git_clone); } pub fn is_local(&self) -> bool { @@ -1464,6 +1465,45 @@ impl GitStore { } } + pub fn git_clone( + &self, + repo: String, + path: impl Into>, + cx: &App, + ) -> Task> { + let path = path.into(); + match &self.state { + GitStoreState::Local { fs, .. } => { + let fs = fs.clone(); + cx.background_executor() + .spawn(async move { fs.git_clone(&repo, &path).await }) + } + GitStoreState::Ssh { + upstream_client, + upstream_project_id, + .. + } => { + let request = upstream_client.request(proto::GitClone { + project_id: upstream_project_id.0, + abs_path: path.to_string_lossy().to_string(), + remote_repo: repo, + }); + + cx.background_spawn(async move { + let result = request.await?; + + match result.success { + true => Ok(()), + false => Err(anyhow!("Git Clone failed")), + } + }) + } + GitStoreState::Remote { .. } => { + Task::ready(Err(anyhow!("Git Clone isn't supported for remote users"))) + } + } + } + async fn handle_update_repository( this: Entity, envelope: TypedEnvelope, @@ -1550,6 +1590,22 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_git_clone( + this: Entity, + envelope: TypedEnvelope, + cx: AsyncApp, + ) -> Result { + let path: Arc = PathBuf::from(envelope.payload.abs_path).into(); + let repo_name = envelope.payload.remote_repo; + let result = cx + .update(|cx| this.read(cx).git_clone(repo_name, path, cx))? + .await; + + Ok(proto::GitCloneResponse { + success: result.is_ok(), + }) + } + async fn handle_fetch( this: Entity, envelope: TypedEnvelope, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index c32da9b110..f2c388a3a3 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -202,6 +202,16 @@ message GitInit { string fallback_branch_name = 3; } +message GitClone { + uint64 project_id = 1; + string abs_path = 2; + string remote_repo = 3; +} + +message GitCloneResponse { + bool success = 1; +} + message CheckForPushedCommits { uint64 project_id = 1; reserved 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index bb97bd500a..856a793c2f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -399,7 +399,10 @@ message Envelope { GetDefaultBranchResponse get_default_branch_response = 360; GetCrashFiles get_crash_files = 361; - GetCrashFilesResponse get_crash_files_response = 362; // current max + GetCrashFilesResponse get_crash_files_response = 362; + + GitClone git_clone = 363; + GitCloneResponse git_clone_response = 364; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 9edb041b4b..a5dd97661f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -316,6 +316,8 @@ messages!( (PullWorkspaceDiagnostics, Background), (GetDefaultBranch, Background), (GetDefaultBranchResponse, Background), + (GitClone, Background), + (GitCloneResponse, Background) ); request_messages!( @@ -484,6 +486,7 @@ request_messages!( (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse), (PullWorkspaceDiagnostics, Ack), (GetDefaultBranch, GetDefaultBranchResponse), + (GitClone, GitCloneResponse) ); entity_messages!( @@ -615,7 +618,8 @@ entity_messages!( LogToDebugConsole, GetDocumentDiagnostics, PullWorkspaceDiagnostics, - GetDefaultBranch + GetDefaultBranch, + GitClone ); entity_messages!(