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

@ -12,7 +12,7 @@ use gpui::BackgroundExecutor;
use gpui::Global; use gpui::Global;
use gpui::ReadGlobal as _; use gpui::ReadGlobal as _;
use std::borrow::Cow; use std::borrow::Cow;
use util::command::new_std_command; use util::command::{new_smol_command, new_std_command};
#[cfg(unix)] #[cfg(unix)]
use std::os::fd::{AsFd, AsRawFd}; use std::os::fd::{AsFd, AsRawFd};
@ -134,6 +134,7 @@ pub trait Fs: Send + Sync {
fn home_dir(&self) -> Option<PathBuf>; fn home_dir(&self) -> Option<PathBuf>;
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>; 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<()>; 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; fn is_fake(&self) -> bool;
async fn is_case_sensitive(&self) -> Result<bool>; async fn is_case_sensitive(&self) -> Result<bool>;
@ -839,6 +840,23 @@ impl Fs for RealFs {
Ok(()) 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 { fn is_fake(&self) -> bool {
false false
} }
@ -2352,6 +2370,10 @@ impl Fs for FakeFs {
smol::block_on(self.create_dir(&abs_work_directory_path.join(".git"))) 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 { fn is_fake(&self) -> bool {
true true
} }

View file

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

View file

@ -2081,6 +2081,99 @@ impl GitPanel {
.detach_and_log_err(cx); .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>) { pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let worktrees = self let worktrees = self
.project .project

View file

@ -3,21 +3,25 @@ use std::any::Any;
use ::settings::Settings; use ::settings::Settings;
use command_palette_hooks::CommandPaletteFilter; use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal; use commit_modal::CommitModal;
use editor::{Editor, actions::DiffClipboardWithSelectionData}; use editor::{Editor, EditorElement, EditorStyle, actions::DiffClipboardWithSelectionData};
mod blame_ui; mod blame_ui;
use git::{ use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
}; };
use git_panel_settings::GitPanelSettings; 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 onboarding::GitOnboardingModal;
use project_diff::ProjectDiff; use project_diff::ProjectDiff;
use theme::ThemeSettings;
use ui::prelude::*; use ui::prelude::*;
use workspace::Workspace; use workspace::{ModalView, Workspace};
use zed_actions; use zed_actions;
use crate::text_diff_view::TextDiffView; use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
mod askpass_modal; mod askpass_modal;
pub mod branch_picker; pub mod branch_picker;
@ -169,6 +173,19 @@ pub fn init(cx: &mut App) {
panel.git_init(window, cx); 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| { workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
open_modified_files(workspace, 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 {}

View file

@ -987,7 +987,7 @@ pub struct InlayHintSettings {
/// Default: false /// Default: false
#[serde(default)] #[serde(default)]
pub enabled: bool, 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 /// Default: true
#[serde(default = "default_true")] #[serde(default = "default_true")]

View file

@ -441,6 +441,7 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_blame_buffer); 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_update_repository);
client.add_entity_message_handler(Self::handle_remove_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 { 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( async fn handle_update_repository(
this: Entity<Self>, this: Entity<Self>,
envelope: TypedEnvelope<proto::UpdateRepository>, envelope: TypedEnvelope<proto::UpdateRepository>,
@ -1550,6 +1590,22 @@ impl GitStore {
Ok(proto::Ack {}) 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( async fn handle_fetch(
this: Entity<Self>, this: Entity<Self>,
envelope: TypedEnvelope<proto::Fetch>, envelope: TypedEnvelope<proto::Fetch>,

View file

@ -202,6 +202,16 @@ message GitInit {
string fallback_branch_name = 3; 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 { message CheckForPushedCommits {
uint64 project_id = 1; uint64 project_id = 1;
reserved 2; reserved 2;

View file

@ -399,7 +399,10 @@ message Envelope {
GetDefaultBranchResponse get_default_branch_response = 360; GetDefaultBranchResponse get_default_branch_response = 360;
GetCrashFiles get_crash_files = 361; 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; reserved 87 to 88;

View file

@ -316,6 +316,8 @@ messages!(
(PullWorkspaceDiagnostics, Background), (PullWorkspaceDiagnostics, Background),
(GetDefaultBranch, Background), (GetDefaultBranch, Background),
(GetDefaultBranchResponse, Background), (GetDefaultBranchResponse, Background),
(GitClone, Background),
(GitCloneResponse, Background)
); );
request_messages!( request_messages!(
@ -484,6 +486,7 @@ request_messages!(
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse), (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
(PullWorkspaceDiagnostics, Ack), (PullWorkspaceDiagnostics, Ack),
(GetDefaultBranch, GetDefaultBranchResponse), (GetDefaultBranch, GetDefaultBranchResponse),
(GitClone, GitCloneResponse)
); );
entity_messages!( entity_messages!(
@ -615,7 +618,8 @@ entity_messages!(
LogToDebugConsole, LogToDebugConsole,
GetDocumentDiagnostics, GetDocumentDiagnostics,
PullWorkspaceDiagnostics, PullWorkspaceDiagnostics,
GetDefaultBranch GetDefaultBranch,
GitClone
); );
entity_messages!( entity_messages!(