Compare commits

...

9 Commits

Author SHA1 Message Date
Agus Zubiaga
5cc3b3a04f wait for workspace window to show 2025-12-17 23:00:30 -03:00
Agus Zubiaga
422dc4f307 Reuse cloning code from git_panel 2025-12-17 21:50:05 -03:00
Agus Zubiaga
b1aa0e2efd Use git/commit style format 2025-12-17 21:10:05 -03:00
Agus Zubiaga
3dbfee1c47 Merge branch 'main' into git-clone 2025-12-17 20:53:10 -03:00
Alvaro Parker
4930d3aa80 Improve code 2025-11-11 08:52:04 -03:00
Alvaro Parker
5d633a3968 Remove modal indicator 2025-11-11 08:52:04 -03:00
Alvaro Parker
6967ea41e5 Open project panel on success 2025-11-11 08:52:04 -03:00
Alvaro Parker
5068581b39 Add loading modal 2025-11-11 08:52:04 -03:00
Alvaro Parker
f3bd6b88db WIP Git clone 2025-11-11 08:52:04 -03:00
5 changed files with 304 additions and 85 deletions

155
crates/git_ui/src/clone.rs Normal file
View File

@@ -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();
}

View File

@@ -2848,93 +2848,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>) {

View File

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

View File

@@ -15,11 +15,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;
@@ -33,10 +35,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,
};
@@ -893,6 +897,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.clone();
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 =

View File

@@ -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"),
}
}
}