git: Add git clone open listener (#41669)

Alvaro Parker created

Change summary

crates/git_ui/src/clone.rs          | 155 +++++++++++++++++++++++++++++++
crates/git_ui/src/git_panel.rs      |  92 +-----------------
crates/git_ui/src/git_ui.rs         |   1 
crates/zed/src/main.rs              |  39 +++++++
crates/zed/src/zed/open_listener.rs | 102 ++++++++++++++++++++
5 files changed, 304 insertions(+), 85 deletions(-)

Detailed changes

crates/git_ui/src/clone.rs 🔗

@@ -0,0 +1,155 @@
+use gpui::{App, Context, WeakEntity, Window};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use std::sync::Arc;
+use ui::{Color, IconName, SharedString};
+use util::ResultExt;
+use workspace::{self, Workspace};
+
+pub fn clone_and_open(
+    repo_url: SharedString,
+    workspace: WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+    on_success: Arc<
+        dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
+    >,
+) {
+    let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
+        files: false,
+        directories: true,
+        multiple: false,
+        prompt: Some("Select as Repository Destination".into()),
+    });
+
+    window
+        .spawn(cx, async move |cx| {
+            let mut paths = destination_prompt.await.ok()?.ok()??;
+            let mut destination_dir = paths.pop()?;
+
+            let repo_name = repo_url
+                .split('/')
+                .next_back()
+                .map(|name| name.strip_suffix(".git").unwrap_or(name))
+                .unwrap_or("repository")
+                .to_owned();
+
+            let clone_task = workspace
+                .update(cx, |workspace, cx| {
+                    let fs = workspace.app_state().fs.clone();
+                    let destination_dir = destination_dir.clone();
+                    let repo_url = repo_url.clone();
+                    cx.spawn(async move |_workspace, _cx| {
+                        fs.git_clone(&repo_url, destination_dir.as_path()).await
+                    })
+                })
+                .ok()?;
+
+            if let Err(error) = clone_task.await {
+                workspace
+                    .update(cx, |workspace, cx| {
+                        let toast = StatusToast::new(error.to_string(), cx, |this, _| {
+                            this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                                .dismiss_button(true)
+                        });
+                        workspace.toggle_status_toast(toast, cx);
+                    })
+                    .log_err();
+                return None;
+            }
+
+            let has_worktrees = workspace
+                .read_with(cx, |workspace, cx| {
+                    workspace.project().read(cx).worktrees(cx).next().is_some()
+                })
+                .ok()?;
+
+            let prompt_answer = if has_worktrees {
+                cx.update(|window, cx| {
+                    window.prompt(
+                        gpui::PromptLevel::Info,
+                        &format!("Git Clone: {}", repo_name),
+                        None,
+                        &["Add repo to project", "Open repo in new project"],
+                        cx,
+                    )
+                })
+                .ok()?
+                .await
+                .ok()?
+            } else {
+                // Don't ask if project is empty
+                0
+            };
+
+            destination_dir.push(&repo_name);
+
+            match prompt_answer {
+                0 => {
+                    workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            let create_task = workspace.project().update(cx, |project, cx| {
+                                project.create_worktree(destination_dir.as_path(), true, cx)
+                            });
+
+                            let workspace_weak = cx.weak_entity();
+                            let on_success = on_success.clone();
+                            cx.spawn_in(window, async move |_window, cx| {
+                                if create_task.await.log_err().is_some() {
+                                    workspace_weak
+                                        .update_in(cx, |workspace, window, cx| {
+                                            (on_success)(workspace, window, cx);
+                                        })
+                                        .ok();
+                                }
+                            })
+                            .detach();
+                        })
+                        .ok()?;
+                }
+                1 => {
+                    workspace
+                        .update(cx, move |workspace, cx| {
+                            let app_state = workspace.app_state().clone();
+                            let destination_path = destination_dir.clone();
+                            let on_success = on_success.clone();
+
+                            workspace::open_new(
+                                Default::default(),
+                                app_state,
+                                cx,
+                                move |workspace, window, cx| {
+                                    cx.activate(true);
+
+                                    let create_task =
+                                        workspace.project().update(cx, |project, cx| {
+                                            project.create_worktree(
+                                                destination_path.as_path(),
+                                                true,
+                                                cx,
+                                            )
+                                        });
+
+                                    let workspace_weak = cx.weak_entity();
+                                    cx.spawn_in(window, async move |_window, cx| {
+                                        if create_task.await.log_err().is_some() {
+                                            workspace_weak
+                                                .update_in(cx, |workspace, window, cx| {
+                                                    (on_success)(workspace, window, cx);
+                                                })
+                                                .ok();
+                                        }
+                                    })
+                                    .detach();
+                                },
+                            )
+                            .detach();
+                        })
+                        .ok();
+                }
+                _ => {}
+            }
+
+            Some(())
+        })
+        .detach();
+}

crates/git_ui/src/git_panel.rs 🔗

@@ -2849,93 +2849,15 @@ impl GitPanel {
     }
 
     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,
-            prompt: Some("Select as Repository Destination".into()),
-        });
-
         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("/").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,
-                        &format!("Git Clone: {}", repo_name),
-                        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();
+        crate::clone::clone_and_open(
+            repo.into(),
+            workspace,
+            window,
+            cx,
+            Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
+        );
     }
 
     pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {

crates/git_ui/src/git_ui.rs 🔗

@@ -10,6 +10,7 @@ use ui::{
 };
 
 mod blame_ui;
+pub mod clone;
 
 use git::{
     repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},

crates/zed/src/main.rs 🔗

@@ -18,11 +18,13 @@ use extension::ExtensionHostProxy;
 use fs::{Fs, RealFs};
 use futures::{StreamExt, channel::oneshot, future};
 use git::GitHostingProviderRegistry;
+use git_ui::clone::clone_and_open;
 use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
 
 use gpui_tokio::Tokio;
 use language::LanguageRegistry;
 use onboarding::{FIRST_OPEN, show_onboarding_view};
+use project_panel::ProjectPanel;
 use prompt_store::PromptBuilder;
 use remote::RemoteConnectionOptions;
 use reqwest_client::ReqwestClient;
@@ -36,10 +38,12 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use session::{AppSession, Session};
 use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
 use std::{
+    cell::RefCell,
     env,
     io::{self, IsTerminal},
     path::{Path, PathBuf},
     process,
+    rc::Rc,
     sync::{Arc, OnceLock},
     time::Instant,
 };
@@ -896,6 +900,41 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 })
                 .detach_and_log_err(cx);
             }
+            OpenRequestKind::GitClone { repo_url } => {
+                workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
+                    if window.is_window_active() {
+                        clone_and_open(
+                            repo_url,
+                            cx.weak_entity(),
+                            window,
+                            cx,
+                            Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
+                                workspace.focus_panel::<ProjectPanel>(window, cx);
+                            }),
+                        );
+                        return;
+                    }
+
+                    let subscription = Rc::new(RefCell::new(None));
+                    subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
+                        let subscription = subscription.clone();
+                        let repo_url = repo_url;
+                        move |_, workspace_entity, window, cx| {
+                            if window.is_window_active() && subscription.take().is_some() {
+                                clone_and_open(
+                                    repo_url.clone(),
+                                    workspace_entity.downgrade(),
+                                    window,
+                                    cx,
+                                    Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
+                                        workspace.focus_panel::<ProjectPanel>(window, cx);
+                                    }),
+                                );
+                            }
+                        }
+                    })));
+                });
+            }
             OpenRequestKind::GitCommit { sha } => {
                 cx.spawn(async move |cx| {
                     let paths_with_position =

crates/zed/src/zed/open_listener.rs 🔗

@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::thread;
 use std::time::Duration;
+use ui::SharedString;
 use util::ResultExt;
 use util::paths::PathWithPosition;
 use workspace::PathList;
@@ -58,6 +59,9 @@ pub enum OpenRequestKind {
         /// `None` opens settings without navigating to a specific path.
         setting_path: Option<String>,
     },
+    GitClone {
+        repo_url: SharedString,
+    },
     GitCommit {
         sha: String,
     },
@@ -113,6 +117,8 @@ impl OpenRequest {
                 this.kind = Some(OpenRequestKind::Setting {
                     setting_path: Some(setting_path.to_string()),
                 });
+            } else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
+                this.parse_git_clone_url(clone_path)?
             } else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
                 this.parse_git_commit_url(commit_path)?
             } else if url.starts_with("ssh://") {
@@ -143,6 +149,26 @@ impl OpenRequest {
         }
     }
 
+    fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
+        // Format: /?repo=<url> or ?repo=<url>
+        let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
+
+        let query = clone_path
+            .strip_prefix('?')
+            .context("invalid git clone url: missing query string")?;
+
+        let repo_url = url::form_urlencoded::parse(query.as_bytes())
+            .find_map(|(key, value)| (key == "repo").then_some(value))
+            .filter(|s| !s.is_empty())
+            .context("invalid git clone url: missing repo query parameter")?
+            .to_string()
+            .into();
+
+        self.kind = Some(OpenRequestKind::GitClone { repo_url });
+
+        Ok(())
+    }
+
     fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
         // Format: <sha>?repo=<path>
         let (sha, query) = commit_path
@@ -1087,4 +1113,80 @@ mod tests {
 
         assert!(!errored_reuse);
     }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
+                            .into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
 }