diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index d3a3458876..86cb029a6b 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -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(); diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 0cc7d66584..e592934a4a 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -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) ); } diff --git a/crates/fs/src/git_clone_progress.rs b/crates/fs/src/git_clone_progress.rs new file mode 100644 index 0000000000..4adf8fe10e --- /dev/null +++ b/crates/fs/src/git_clone_progress.rs @@ -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> { + 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, + last_message: Option, +} + +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"); + } +}