use std::collections::VecDeque; use std::io::{self, IsTerminal, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, mpsc}; use std::thread; use avril_runtime::registry::{ProgressEvent, ProgressSink}; const CAPACITY: usize = 128; enum Message { Event { generation: u64, line: String }, Flush(mpsc::SyncSender<()>), Stop, } pub(crate) struct Reporter { sender: mpsc::SyncSender, // Odd generations accept events; even generations belong to the input prompt. generation: Arc, thread: Option>, } impl Reporter { pub(crate) fn terminal() -> Option { if !io::stderr().is_terminal() || std::env::var("TERM").is_ok_and(|term| term == "dumb") { return None; } Self::start(io::stderr(), avril_ext_console::colors(true)).ok() } fn start(mut output: impl Write + Send + 'static, colors: bool) -> io::Result { let (sender, receiver) = mpsc::sync_channel(CAPACITY); let generation = Arc::new(AtomicU64::new(1)); let current = generation.clone(); let thread = thread::Builder::new() .name("avril:progress".into()) .spawn(move || { let mut recent = VecDeque::new(); let mut seen_generation = 0; let mut failed = false; while let Ok(message) = receiver.recv() { match message { Message::Event { generation, line } => { if failed || current.load(Ordering::Acquire) != generation { continue; } if generation != seen_generation { recent.clear(); seen_generation = generation; } if recent.contains(&line) { continue; } if recent.len() == CAPACITY { recent.pop_front(); } recent.push_back(line.clone()); let line = color_action(line, colors); // Never hold the terminal lock while waiting for the next event. failed = output .write_all(format!("{line}\n").as_bytes()) .and_then(|()| output.flush()) .is_err(); } Message::Flush(done) => { let _ = done.send(()); } Message::Stop => break, } } })?; Ok(Self { sender, generation, thread: Some(thread), }) } pub(crate) fn sink(&self, registry: url::Url) -> ProgressSink { let sender = self.sender.clone(); let current = self.generation.clone(); Arc::new(move |event| { let generation = current.load(Ordering::Acquire); if generation & 1 == 0 { return; } if let Some(line) = label(event, ®istry) { let _ = sender.try_send(Message::Event { generation, line }); } }) } pub(crate) fn scope(&self, active: bool) { let current = self.generation.load(Ordering::Acquire); if (current & 1 != 0) != active { self.generation.fetch_add(1, Ordering::AcqRel); } if !active { self.flush(); } } pub(crate) fn flush(&self) { let (done, wait) = mpsc::sync_channel(0); if self.sender.send(Message::Flush(done)).is_ok() { let _ = wait.recv(); } } } impl Drop for Reporter { fn drop(&mut self) { self.scope(false); // Store-owned sink clones may outlive the reporter, so channel EOF is not shutdown. let _ = self.sender.send(Message::Stop); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } pub(crate) fn color_action(line: String, colors: bool) -> String { if !colors { return line; } let end = line.find(' ').unwrap_or(line.len()); format!("\x1b[1;36m{}\x1b[0m{}", &line[..end], &line[end..]) } fn label(event: &ProgressEvent, registry: &url::Url) -> Option { let text = match event { ProgressEvent::NpmMetadata { name } => format!("Fetching npm metadata for {name}"), ProgressEvent::NpmArchive { name, version } => { format!("Downloading npm:{name}@{version}") } ProgressEvent::NpmMaterializing { name, version } => { format!("Extracting and verifying npm:{name}@{version}") } ProgressEvent::NpmMaterialized { .. } => return None, ProgressEvent::NpmResolving => "Resolving npm dependencies".into(), ProgressEvent::SourceDownload { url } => source_label(url, registry), }; Some( text.chars() .filter(|ch| !ch.is_control()) .take(240) .collect(), ) } fn source_label(value: &str, registry: &url::Url) -> String { let Ok(mut url) = url::Url::parse(value) else { return "Downloading source".into(); }; let _ = url.set_username(""); let _ = url.set_password(None); url.set_query(None); url.set_fragment(None); let prefix = format!("{}/", registry.path().trim_end_matches('/')); if url.origin() == registry.origin() && let Some(suffix) = url.path().strip_prefix(&prefix) { let parts: Vec<_> = suffix.split('/').take(3).collect(); if let [scope, name, version] = parts.as_slice() && scope.starts_with('@') { return if *version == "meta.json" { format!("Fetching JSR metadata for {scope}/{name}") } else { let version = version.strip_suffix("_meta.json").unwrap_or(version); format!("Downloading jsr:{scope}/{name}@{version}") }; } } format!("Downloading {url}") }