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

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>
This commit is contained in:
Anthony Eid 2025-08-11 11:09:38 -04:00 committed by GitHub
parent 12084b6677
commit 62270b33c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 310 additions and 8 deletions

View file

@ -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

View file

@ -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 {}