git: Add ability to clone remote repositories from Zed (#35606)

Anthony Eid and hpmcdona created

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 <hayden_mcdonald@brown.edu>

Change summary

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(-)

Detailed changes

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<PathBuf>;
     fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
     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<bool>;
 
@@ -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
     }

crates/git/src/git.rs πŸ”—

@@ -93,6 +93,8 @@ actions!(
         Init,
         /// Opens all modified files in the editor.
         OpenModifiedFiles,
+        /// Clones a repository.
+        Clone,
     ]
 );
 

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<Self>) {
+        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<Self>) {
         let worktrees = self
             .project

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::<git_panel::GitPanel>(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<GitPanel>,
+    repo_input: Entity<Editor>,
+    focus_handle: FocusHandle,
+}
+
+impl GitCloneModal {
+    pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) -> 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<DismissEvent> for GitCloneModal {}
+
+impl ModalView for GitCloneModal {}

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")]

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<Arc<std::path::Path>>,
+        cx: &App,
+    ) -> Task<Result<()>> {
+        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<Self>,
         envelope: TypedEnvelope<proto::UpdateRepository>,
@@ -1550,6 +1590,22 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_git_clone(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitClone>,
+        cx: AsyncApp,
+    ) -> Result<proto::GitCloneResponse> {
+        let path: Arc<Path> = 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<Self>,
         envelope: TypedEnvelope<proto::Fetch>,

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;

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;

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!(