Show progress while cloning repositories (#63239)
## Summary - Reuse the existing Zed clone activity indicator, which previously showed only a static `Cloning <URL>` message. - Add filesystem job updates so the existing indicator can change while a job is running. - Stream exact `git clone --progress` messages into that indicator without maintaining a phase allowlist. - Preserve the final Git stderr message when cloning fails. https://github.com/user-attachments/assets/a9b8cbfd-b87f-4d83-a24b-992eb1c4de1f ## Testing - `cargo test -p fs git_clone_progress::tests -- --nocapture` - `CCACHE_DISABLE=1 ./script/clippy -p fs -p activity_indicator` - Manually cloned a repository and verified live Git progress in the activity indicator. Release Notes: - Improved repository cloning by showing live Git progress in the activity indicator.
This commit is contained in:
1 parent
83ffa665ed
commit
48628b1970
3 files changed
+182
-11
No files matched your search
@@ -102,11 +102,17 @@ impl ActivityIndicator {
|
||||
this.update(cx, |this: &mut ActivityIndicator, cx| {
|
||||
match job_event {
|
||||
fs::JobEvent::Started { info } => {
|
||||
this.fs_jobs.retain(|j| j.id != info.id);
|
||||
this.fs_jobs.retain(|job| job.id != info.id);
|
||||
this.fs_jobs.push(info);
|
||||
}
|
||||
fs::JobEvent::Updated { id, message } => {
|
||||
if let Some(job) = this.fs_jobs.iter_mut().find(|job| job.id == id)
|
||||
{
|
||||
job.message = message;
|
||||
}
|
||||
}
|
||||
fs::JobEvent::Completed { id } => {
|
||||
this.fs_jobs.retain(|j| j.id != id);
|
||||
this.fs_jobs.retain(|job| job.id != id);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
|
||||
+33
-9
@@ -1,4 +1,5 @@
|
||||
pub mod fs_watcher;
|
||||
mod git_clone_progress;
|
||||
|
||||
pub use fs_watcher::requires_poll_watcher;
|
||||
|
||||
@@ -18,7 +19,7 @@ use gpui::ReadGlobal as _;
|
||||
use gpui::SharedString;
|
||||
#[cfg(unix)]
|
||||
use std::ffi::CString;
|
||||
use util::command::new_command;
|
||||
use util::command::{Stdio, new_command};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
@@ -326,6 +327,7 @@ pub struct JobInfo {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum JobEvent {
|
||||
Started { info: JobInfo },
|
||||
Updated { id: JobId, message: SharedString },
|
||||
Completed { id: JobId },
|
||||
}
|
||||
|
||||
@@ -350,6 +352,18 @@ impl JobTracker {
|
||||
}
|
||||
Self { id, subscribers }
|
||||
}
|
||||
|
||||
fn update(&self, message: SharedString) {
|
||||
let mut subscribers = self.subscribers.lock();
|
||||
subscribers.retain(|sender| {
|
||||
sender
|
||||
.unbounded_send(JobEvent::Updated {
|
||||
id: self.id,
|
||||
message: message.clone(),
|
||||
})
|
||||
.is_ok()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JobTracker {
|
||||
@@ -1242,18 +1256,28 @@ impl Fs for RealFs {
|
||||
message: SharedString::from(format!("Cloning {}", repo_url)),
|
||||
};
|
||||
|
||||
let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone());
|
||||
|
||||
let output = new_command("git")
|
||||
let job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone());
|
||||
let mut child = new_command("git")
|
||||
.current_dir(abs_work_directory)
|
||||
.args(&["clone", repo_url])
|
||||
.output()
|
||||
.await?;
|
||||
.args(["clone", "--progress", repo_url])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.context("failed to read git clone progress")?;
|
||||
let stderr_output = git_clone_progress::read(stderr, |message| {
|
||||
job_tracker.update(message.into());
|
||||
})
|
||||
.await?;
|
||||
let status = child.status().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"git clone failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim_end()
|
||||
git_clone_progress::failure_message(&stderr_output)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use futures::AsyncRead;
|
||||
use smol::io::AsyncReadExt;
|
||||
use std::io;
|
||||
|
||||
pub(crate) async fn read(
|
||||
mut stderr: impl AsyncRead + Unpin,
|
||||
mut on_progress: impl FnMut(String),
|
||||
) -> io::Result<Vec<u8>> {
|
||||
let mut stderr_output = Vec::new();
|
||||
let mut read_buffer = [0; 8192];
|
||||
let mut progress = GitCloneProgress::new();
|
||||
|
||||
loop {
|
||||
let bytes_read = stderr.read(&mut read_buffer).await?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
let bytes = &read_buffer[..bytes_read];
|
||||
stderr_output.extend_from_slice(bytes);
|
||||
progress.push(bytes, &mut on_progress);
|
||||
}
|
||||
progress.finish(on_progress);
|
||||
Ok(stderr_output)
|
||||
}
|
||||
|
||||
pub(crate) fn failure_message(stderr: &[u8]) -> String {
|
||||
String::from_utf8_lossy(stderr)
|
||||
.split(['\r', '\n'])
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or("unknown error")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
struct GitCloneProgress {
|
||||
pending: Vec<u8>,
|
||||
last_message: Option<String>,
|
||||
}
|
||||
|
||||
impl GitCloneProgress {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
last_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, bytes: &[u8], mut on_progress: impl FnMut(String)) {
|
||||
for byte in bytes {
|
||||
if matches!(*byte, b'\r' | b'\n') {
|
||||
self.flush(&mut on_progress);
|
||||
} else {
|
||||
self.pending.push(*byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, mut on_progress: impl FnMut(String)) {
|
||||
self.flush(&mut on_progress);
|
||||
}
|
||||
|
||||
fn flush(&mut self, on_progress: &mut impl FnMut(String)) {
|
||||
if self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = String::from_utf8_lossy(&self.pending).trim().to_owned();
|
||||
if !message.is_empty() && self.last_message.as_ref() != Some(&message) {
|
||||
self.last_message = Some(message.clone());
|
||||
on_progress(message);
|
||||
}
|
||||
self.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_fragmented_progress_and_suppresses_duplicates() {
|
||||
let mut progress = GitCloneProgress::new();
|
||||
let mut messages = Vec::new();
|
||||
|
||||
progress.push(
|
||||
b"remote: Enumerating objects: 100, done.\rremote: Count",
|
||||
|message| messages.push(message),
|
||||
);
|
||||
progress.push(
|
||||
b"ing objects: 42% (42/100)\rReceiving objects: 100% (100/100)\rReceiving objects: 100% (100/100)\n",
|
||||
|message| messages.push(message),
|
||||
);
|
||||
progress.push(b"Updating files: 75% (3/4)", |message| {
|
||||
messages.push(message)
|
||||
});
|
||||
progress.finish(|message| messages.push(message));
|
||||
|
||||
assert_eq!(
|
||||
messages,
|
||||
[
|
||||
"remote: Enumerating objects: 100, done.",
|
||||
"remote: Counting objects: 42% (42/100)",
|
||||
"Receiving objects: 100% (100/100)",
|
||||
"Updating files: 75% (3/4)",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwards_unrecognized_git_output() {
|
||||
let mut progress = GitCloneProgress::new();
|
||||
let mut messages = Vec::new();
|
||||
|
||||
progress.push(b"Cloning into 'repository'...\n", |message| {
|
||||
messages.push(message)
|
||||
});
|
||||
progress.push(b"A future Git progress phase: 12%\r", |message| {
|
||||
messages.push(message)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
messages,
|
||||
[
|
||||
"Cloning into 'repository'...",
|
||||
"A future Git progress phase: 12%"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_the_last_failure_line() {
|
||||
assert_eq!(
|
||||
failure_message(
|
||||
b"Receiving objects: 42% (42/100)\rfatal: unable to access repository\n"
|
||||
),
|
||||
"fatal: unable to access repository"
|
||||
);
|
||||
assert_eq!(failure_message(b""), "unknown error");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user