use console::style;
use hashbrown::HashMap;
use indicatif::{
MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle,
style::ProgressTracker,
};
use once_cell::sync::Lazy;
use std::{
fmt::{Display, Formatter, Result as FmtResult},
io::Write,
path::{Path, PathBuf},
sync::{
Arc, RwLock,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::{Mutex, mpsc};
use unicode_width::UnicodeWidthStr;
use crate::osc94::OSC94;
use crate::rsplug::util::truncate;
pub enum Message {
ConfigFound(PathBuf),
ConfigWalkFinish,
Cache(&'static str, Arc<str>),
CacheFetchObjectsProgress {
id: String,
total_objs_count: usize,
received_objs_count: usize,
},
CacheBuildProgress {
id: Arc<String>,
stdtype: usize,
line: String,
},
CacheBuildFinished {
id: Arc<String>,
success: bool,
},
LoadBegin {
total: usize,
},
LoadPluginDone,
LoadPluginRunning,
LoadPluginRunningDone,
LoadDone,
FetchDoneIdle {
idle_gen: u64,
},
PluginNotInstalled(Arc<str>),
MergeFinished {
total: usize,
merged: usize,
},
DetectLockFile(PathBuf),
InstallSkipped(Arc<str>),
InstallYank {
id: Arc<str>,
which: PathBuf,
},
InstallHelp {
help_dir: PathBuf,
},
InstallDone,
Error(Box<dyn std::error::Error + 'static + Send + Sync>),
}
type MessageSender = RwLock<Option<mpsc::UnboundedSender<Message>>>;
type LoggerCloser = Mutex<mpsc::UnboundedReceiver<()>>;
type Logger = (MessageSender, LoggerCloser);
static LOGGER: Lazy<Logger> = Lazy::new(init);
const CACHE_FETCH_PROGRESS_ID: &str = "cache_fetch_progress";
const CACHE_FETCH_STAGE_ID: &str = "cache_fetch_stage";
const CACHE_FETCH_DONE_ID: &str = "cache_fetch_done";
#[derive(Clone)]
struct ConfigGroup {
location: Arc<Path>,
files: Vec<Arc<Path>>,
}
impl ConfigGroup {
fn new(location: Arc<Path>, mut files: Vec<Arc<Path>>) -> Self {
files.sort();
Self { location, files }
}
fn location_label(&self) -> String {
let name = self
.location
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if name.is_empty() {
self.location.to_string_lossy().to_string()
} else {
name.to_string()
}
}
fn join_names(&self) -> String {
let names: Vec<String> = self
.files
.iter()
.map(|path| {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default()
.to_string()
})
.collect();
names.join(" ")
}
}
fn config_n_files_string(n: usize) -> String {
format!(
"{} {} file{}",
style("Config").blue().bold(),
n,
if n == 1 { "" } else { "s" }
)
}
fn summary_prefix(label: &str, ok: bool) -> String {
let mark = if ok {
style("✓").green().bold().to_string()
} else {
style("✗").red().bold().to_string()
};
format!("{} {}", mark, style(label).blue().bold())
}
struct ConfigList {
files: Vec<Arc<Path>>,
}
impl ConfigList {
fn from_files(mut files: Vec<Arc<Path>>) -> Self {
files.sort();
Self { files }
}
fn render_lines(&self) -> Vec<String> {
let total = self.files.len();
let groups = self.build_groups();
if groups.len() == total {
let mut lines = Vec::with_capacity(total + 2);
lines.push(format!(
"{} ({} locations)",
config_n_files_string(total),
total
));
for path in self.files.iter() {
lines.push(format!(" {}", path.to_string_lossy()));
}
return lines;
}
if let Some((dominant_idx, dominant_count)) = self.dominant_group(&groups) {
let main_ratio = if total == 0 {
0.0
} else {
dominant_count as f32 / total as f32
};
let external_count = total.saturating_sub(dominant_count);
if main_ratio >= 0.75 && external_count <= 5 {
let main = &groups[dominant_idx];
let mut lines = Vec::new();
lines.push(config_n_files_string(total));
lines.push(format!(
" {} ({})",
main.location.to_string_lossy(),
main.files.len()
));
lines.push(format!(" {}", main.join_names()));
if external_count != 0 {
let suffix = if external_count == 1 { "file" } else { "files" };
lines.push(format!(" +{} external {}", external_count, suffix));
}
return lines;
}
}
if groups.len() == 1 {
let group = &groups[0];
return vec![
config_n_files_string(total),
format!(" {}", group.location.to_string_lossy()),
format!(" {}", group.join_names()),
];
}
let mut lines = Vec::new();
lines.push(format!(
"{} in {} locations",
config_n_files_string(total),
groups.len()
));
for group in groups {
lines.push(format!(
" {} ({})",
group.location_label(),
group.files.len()
));
lines.push(format!(" {}", group.location.to_string_lossy()));
lines.push(format!(" {}", group.join_names()));
}
while lines.last().map(|line| line.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
fn build_groups(&self) -> Vec<ConfigGroup> {
let mut groups_map: HashMap<Arc<Path>, Vec<Arc<Path>>> = HashMap::new();
for path in self.files.iter() {
let key = path.parent().unwrap_or(Path::new("/")).into();
groups_map.entry(key).or_default().push(path.clone());
}
let mut groups: Vec<ConfigGroup> = groups_map
.into_iter()
.map(|(location, files)| ConfigGroup::new(location, files))
.collect();
groups.sort_by(|a, b| a.location.cmp(&b.location));
groups
}
fn dominant_group(&self, groups: &[ConfigGroup]) -> Option<(usize, usize)> {
let mut dominant_idx = None;
let mut dominant_count = 0;
for (idx, group) in groups.iter().enumerate() {
if group.files.len() > dominant_count {
dominant_count = group.files.len();
dominant_idx = Some(idx);
}
}
dominant_idx.map(|idx| (idx, dominant_count))
}
}
impl Display for ConfigList {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
for line in self.render_lines() {
writeln!(f, "{}", style(line).dim())?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ChildKey {
FetchStage,
FetchDone,
FetchProgress,
Updating,
Building(Arc<String>),
}
struct ProgressManager {
multipb: MultiProgress,
pb_style: ProgressStyle,
pb_style_spinner_mid: ProgressStyle,
pb_style_spinner_last: ProgressStyle,
pb_style_blank_mid: ProgressStyle,
pb_style_blank_last: ProgressStyle,
pb_style_fetch_note_child_mid: ProgressStyle,
pb_style_fetch_note_child_last: ProgressStyle,
pb_style_fetch_bar_mid: ProgressStyle,
pb_style_fetch_bar_last: ProgressStyle,
pb_style_summary: ProgressStyle,
progress_bars: HashMap<String, BarState>,
installskipped_count: usize,
yankfile_count: usize,
not_installed: Vec<Arc<str>>,
cachefetching_oids: HashMap<String, (usize, usize)>,
cache_updating_fetching: HashMap<String, ()>,
cache_updating_current: Option<String>,
updating_bar: Option<BarState>,
cache_fetching: HashMap<String, ()>,
cache_fetching_current: Option<String>,
cache_fetch_done_url: Option<String>,
fetch_stage_is_blank: bool,
fetch_done_is_blank: bool,
fetch_done_idle_gen: u64,
child_order: Vec<ChildKey>,
config_files: Vec<Arc<Path>>,
osc94: Option<OSC94>,
loading_running: Option<Arc<AtomicUsize>>,
idle_tx: Option<mpsc::UnboundedSender<Message>>,
}
struct BarState {
bar: ProgressBar,
last_message: Option<String>,
}
impl BarState {
fn new(bar: ProgressBar) -> Self {
Self {
bar,
last_message: None,
}
}
fn set_message_if_changed(&mut self, message: impl Into<String>) {
let message = message.into();
if self.last_message.as_deref() == Some(message.as_str()) {
return;
}
self.bar.set_message(message.clone());
self.last_message = Some(message);
}
}
fn sanitize_build_line(line: &str) -> Option<String> {
let mut last = "";
for part in line.split('\r') {
if !part.trim().is_empty() {
last = part;
}
}
let trimmed = last.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.replace('\n', " "))
}
fn dual_bar_cells(done: u64, running: u64, len: u64, width: usize) -> (usize, usize, usize) {
let width = width as u64;
let len = len.max(1);
let done_cells = (done * width / len).min(width) as usize;
let running_cells = ((running * width / len).min(width - done_cells as u64)) as usize;
let idle_cells = width as usize - done_cells - running_cells;
(done_cells, running_cells, idle_cells)
}
fn loading_bar_width(running: usize) -> usize {
let term_width = console::Term::stderr().size().1 as usize;
loading_bar_width_for(term_width, running)
}
fn loading_bar_width_for(term_width: usize, running: usize) -> usize {
let active_width = running.to_string().width() + " active".width();
let fixed_width = 40 + active_width;
term_width.saturating_sub(fixed_width).max(10)
}
#[derive(Clone)]
struct DualBarTracker {
running: Arc<AtomicUsize>,
}
impl ProgressTracker for DualBarTracker {
fn clone_box(&self) -> Box<dyn ProgressTracker> {
Box::new(self.clone())
}
fn tick(&mut self, _state: &ProgressState, _now: Instant) {}
fn reset(&mut self, _state: &ProgressState, _now: Instant) {}
fn write(&self, state: &ProgressState, w: &mut dyn std::fmt::Write) {
let done = state.pos();
let len = state.len().unwrap_or(done).max(1);
let running = self.running.load(Ordering::Relaxed) as u64;
let width = loading_bar_width(running as usize);
let (done_cells, running_cells, idle_cells) = dual_bar_cells(done, running, len, width);
let _ = write!(
w,
"{}{}{}",
style("■".repeat(done_cells)).cyan(),
style("□".repeat(running_cells)).yellow(),
" ".repeat(idle_cells),
);
}
}
#[derive(Clone)]
struct ActiveTracker {
running: Arc<AtomicUsize>,
}
impl ProgressTracker for ActiveTracker {
fn clone_box(&self) -> Box<dyn ProgressTracker> {
Box::new(self.clone())
}
fn tick(&mut self, _state: &ProgressState, _now: Instant) {}
fn reset(&mut self, _state: &ProgressState, _now: Instant) {}
fn write(&self, _state: &ProgressState, w: &mut dyn std::fmt::Write) {
let running = self.running.load(Ordering::Relaxed);
let _ = write!(
w,
"{} {}",
style(running).yellow().bold(),
style("active").dim(),
);
}
}
fn loading_style(running: Arc<AtomicUsize>) -> ProgressStyle {
ProgressStyle::with_template(
"{spinner} {prefix:.blue.bold} [{elapsed_precise}] [{dualbar}] {pos:>7}/{len:7} {active}",
)
.unwrap()
.with_key(
"dualbar",
DualBarTracker {
running: running.clone(),
},
)
.with_key("active", ActiveTracker { running })
.tick_strings(&["◒", "◐", "◓", "◑", " "])
}
impl ProgressManager {
#[cfg(test)]
fn new() -> Self {
Self::build(ProgressDrawTarget::stderr(), None)
}
#[cfg(test)]
fn with_draw_target(draw_target: ProgressDrawTarget) -> Self {
Self::build(draw_target, None)
}
fn build(
draw_target: ProgressDrawTarget,
idle_tx: Option<mpsc::UnboundedSender<Message>>,
) -> Self {
let pb_style = ProgressStyle::with_template("{prefix:.blue.bold} {wide_msg}").unwrap();
let pb_style_spinner_mid =
ProgressStyle::with_template("{spinner} ├─ {prefix:.blue.bold} {wide_msg}")
.unwrap()
.tick_strings(&["◒", "◐", "◓", "◑", " "]);
let pb_style_spinner_last =
ProgressStyle::with_template("{spinner} └─ {prefix:.blue.bold} {wide_msg}")
.unwrap()
.tick_strings(&["◒", "◐", "◓", "◑", " "]);
let pb_style_fetch_bar_mid = ProgressStyle::with_template(
"{spinner} ├─ {prefix:.blue.bold} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos:>7}/{len:7}",
)
.unwrap()
.progress_chars("■□ ")
.tick_strings(&["◒", "◐", "◓", "◑", " "]);
let pb_style_fetch_bar_last = ProgressStyle::with_template(
"{spinner} └─ {prefix:.blue.bold} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos:>7}/{len:7}",
)
.unwrap()
.progress_chars("■□ ")
.tick_strings(&["◒", "◐", "◓", "◑", " "]);
let pb_style_blank_mid = ProgressStyle::with_template("{spinner} │")
.unwrap()
.tick_strings(&[" ", " "]);
let pb_style_blank_last = ProgressStyle::with_template("{spinner} ")
.unwrap()
.tick_strings(&[" ", " "]);
let pb_style_fetch_note_child_mid =
ProgressStyle::with_template("{spinner} │ {prefix:.dim} {wide_msg}")
.unwrap()
.tick_strings(&[" ", " "]);
let pb_style_fetch_note_child_last =
ProgressStyle::with_template("{spinner} {prefix:.dim} {wide_msg}")
.unwrap()
.tick_strings(&[" ", " "]);
let pb_style_summary = ProgressStyle::with_template("{prefix} {wide_msg}").unwrap();
let multipb = MultiProgress::new();
multipb.set_draw_target(draw_target);
let mut barstate = BarState::new(
multipb.add(
ProgressBar::no_length()
.with_style(pb_style.clone())
.with_prefix("Config"),
),
);
barstate.set_message_if_changed("0 files");
Self {
multipb,
pb_style,
pb_style_spinner_mid,
pb_style_spinner_last,
pb_style_blank_mid,
pb_style_blank_last,
pb_style_fetch_note_child_mid,
pb_style_fetch_note_child_last,
pb_style_fetch_bar_mid,
pb_style_fetch_bar_last,
pb_style_summary,
progress_bars: HashMap::from([("config_files".to_string(), barstate)]),
installskipped_count: 0,
yankfile_count: 0,
not_installed: Vec::new(),
cachefetching_oids: HashMap::new(),
cache_updating_fetching: HashMap::new(),
cache_updating_current: None,
updating_bar: None,
cache_fetching: HashMap::new(),
cache_fetching_current: None,
cache_fetch_done_url: None,
fetch_stage_is_blank: false,
fetch_done_is_blank: false,
fetch_done_idle_gen: 0,
child_order: Vec::new(),
config_files: Vec::new(),
osc94: None,
loading_running: None,
idle_tx,
}
}
fn child_bar(&self, key: &ChildKey) -> Option<&BarState> {
match key {
ChildKey::FetchStage => self.progress_bars.get(CACHE_FETCH_STAGE_ID),
ChildKey::FetchDone => self.progress_bars.get(CACHE_FETCH_DONE_ID),
ChildKey::FetchProgress => self.progress_bars.get(CACHE_FETCH_PROGRESS_ID),
ChildKey::Updating => self.updating_bar.as_ref(),
ChildKey::Building(id) => self.progress_bars.get(id.as_str()),
}
}
fn category_rank(key: &ChildKey) -> u8 {
match key {
ChildKey::Updating => 0,
ChildKey::FetchStage => 1,
ChildKey::FetchDone => 2,
ChildKey::FetchProgress => 3,
ChildKey::Building(_) => 4,
}
}
fn refresh_connectors(&self) {
let mut ordered: Vec<&ChildKey> = self.child_order.iter().collect();
ordered.sort_by_key(|k| Self::category_rank(k));
let level1_last = ordered
.iter()
.copied()
.rfind(|k| !matches!(k, ChildKey::FetchDone));
let fetch_is_last = level1_last == Some(&ChildKey::FetchStage);
for key in ordered {
let Some(bs) = self.child_bar(key) else {
continue;
};
let is_level1_last = Some(key) == level1_last;
let style = match key {
ChildKey::FetchStage if self.fetch_stage_is_blank && is_level1_last => {
self.pb_style_blank_last.clone()
}
ChildKey::FetchStage if self.fetch_stage_is_blank => {
self.pb_style_blank_mid.clone()
}
ChildKey::FetchStage if is_level1_last => self.pb_style_spinner_last.clone(),
ChildKey::FetchStage => self.pb_style_spinner_mid.clone(),
ChildKey::FetchDone => {
if self.fetch_done_is_blank {
if fetch_is_last {
self.pb_style_blank_last.clone()
} else {
self.pb_style_blank_mid.clone()
}
} else if fetch_is_last {
self.pb_style_fetch_note_child_last.clone()
} else {
self.pb_style_fetch_note_child_mid.clone()
}
}
ChildKey::FetchProgress if is_level1_last => self.pb_style_fetch_bar_last.clone(),
ChildKey::FetchProgress => self.pb_style_fetch_bar_mid.clone(),
_ if is_level1_last => self.pb_style_spinner_last.clone(),
_ => self.pb_style_spinner_mid.clone(),
};
bs.bar.set_style(style);
bs.bar.tick();
}
}
fn register_child(&mut self, key: ChildKey) {
if !self.child_order.contains(&key) {
self.child_order.push(key);
}
self.refresh_connectors();
}
fn unregister_child(&mut self, key: &ChildKey) {
self.child_order.retain(|k| k != key);
self.refresh_connectors();
}
fn add_fetch_progress_bar(&self, pb: ProgressBar) -> ProgressBar {
if let Some(done) = self.progress_bars.get(CACHE_FETCH_DONE_ID) {
self.multipb.insert_after(&done.bar, pb)
} else if let Some(stage) = self.progress_bars.get(CACHE_FETCH_STAGE_ID) {
self.multipb.insert_after(&stage.bar, pb)
} else if let Some(updating) = self.updating_bar.as_ref() {
self.multipb.insert_after(&updating.bar, pb)
} else {
self.multipb.add(pb)
}
}
fn add_fetch_stage_bar(&self, pb: ProgressBar) -> ProgressBar {
if let Some(updating) = self.updating_bar.as_ref() {
self.multipb.insert_after(&updating.bar, pb)
} else if let Some(done) = self.progress_bars.get(CACHE_FETCH_DONE_ID) {
self.multipb.insert_before(&done.bar, pb)
} else if let Some(progress) = self.progress_bars.get(CACHE_FETCH_PROGRESS_ID) {
self.multipb.insert_before(&progress.bar, pb)
} else {
self.multipb.add(pb)
}
}
fn add_fetch_done_bar(&self, pb: ProgressBar) -> ProgressBar {
if let Some(stage) = self.progress_bars.get(CACHE_FETCH_STAGE_ID) {
self.multipb.insert_after(&stage.bar, pb)
} else if let Some(updating) = self.updating_bar.as_ref() {
self.multipb.insert_after(&updating.bar, pb)
} else if let Some(progress) = self.progress_bars.get(CACHE_FETCH_PROGRESS_ID) {
self.multipb.insert_before(&progress.bar, pb)
} else {
self.multipb.add(pb)
}
}
fn ensure_fetch_stage(&mut self, stage: &'static str, url: &str) {
self.cache_fetching.insert(url.to_string(), ());
let existed = self.progress_bars.contains_key(CACHE_FETCH_STAGE_ID);
if !existed {
let pb = ProgressBar::new_spinner()
.with_style(self.pb_style_spinner_mid.clone())
.with_prefix("Fetching");
pb.enable_steady_tick(Duration::from_millis(100));
let pb = self.add_fetch_stage_bar(pb);
self.progress_bars
.insert(CACHE_FETCH_STAGE_ID.to_string(), BarState::new(pb));
self.register_child(ChildKey::FetchStage);
}
self.fetch_stage_is_blank = false;
let bar_state = self.progress_bars.get_mut(CACHE_FETCH_STAGE_ID).unwrap();
bar_state.bar.set_prefix(stage);
if self.cache_fetching_current.is_none()
|| self.cache_fetching_current.as_deref() == Some(url)
{
bar_state.set_message_if_changed(url.to_string());
self.cache_fetching_current = Some(url.to_string());
}
self.refresh_connectors();
}
fn mark_fetch_done(&mut self, url: &str) {
let existed = self.progress_bars.contains_key(CACHE_FETCH_DONE_ID);
if !existed {
let pb = ProgressBar::new_spinner()
.with_style(self.pb_style_fetch_note_child_mid.clone())
.with_prefix("fetched");
let pb = self.add_fetch_done_bar(pb);
self.progress_bars
.insert(CACHE_FETCH_DONE_ID.to_string(), BarState::new(pb));
self.register_child(ChildKey::FetchDone);
}
let bar_state = self.progress_bars.get_mut(CACHE_FETCH_DONE_ID).unwrap();
bar_state.set_message_if_changed(format!("{}", style(url).dim().italic()));
self.cache_fetch_done_url = Some(url.to_string());
self.fetch_done_is_blank = false;
self.fetch_done_idle_gen = self.fetch_done_idle_gen.wrapping_add(1);
let idle_gen = self.fetch_done_idle_gen;
if let Some(tx) = self.idle_tx.clone() {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = tx.send(Message::FetchDoneIdle { idle_gen });
});
}
self.cache_fetching.remove(url);
if self.cache_fetching.is_empty() {
self.fetch_stage_is_blank = true;
if let Some(bs) = self.progress_bars.get_mut(CACHE_FETCH_STAGE_ID) {
bs.last_message = None;
}
self.cache_fetching_current = None;
} else if self.cache_fetching_current.as_deref() == Some(url) {
let next = self.cache_fetching.keys().next().cloned();
self.cache_fetching_current = next.clone();
if let Some(bs) = self.progress_bars.get_mut(CACHE_FETCH_STAGE_ID) {
bs.set_message_if_changed(next.unwrap_or_default());
}
}
self.refresh_connectors();
}
fn clear_fetch_stage(&mut self) {
if let Some(pb) = self.progress_bars.remove(CACHE_FETCH_STAGE_ID) {
pb.bar.finish_and_clear();
self.unregister_child(&ChildKey::FetchStage);
}
if let Some(pb) = self.progress_bars.remove(CACHE_FETCH_DONE_ID) {
pb.bar.finish_and_clear();
self.unregister_child(&ChildKey::FetchDone);
}
self.cache_fetching.clear();
self.cache_fetching_current = None;
self.cache_fetch_done_url = None;
self.fetch_stage_is_blank = false;
self.fetch_done_is_blank = false;
self.fetch_done_idle_gen = self.fetch_done_idle_gen.wrapping_add(1);
}
fn warn_not_installed(&self) {
let n = self.not_installed.len();
let header = format!(
"{} {} plugins not installed (run with -i to install)",
style("⚠").yellow().bold(),
n
);
const NAME_DISPLAY_LIMIT: usize = 20;
let shown: Vec<String> = self
.not_installed
.iter()
.take(3)
.map(|s| truncate(s, NAME_DISPLAY_LIMIT))
.collect();
let mut body = shown.join(" · ");
if n > shown.len() {
body.push_str(" …");
}
let block = if body.is_empty() {
header
} else {
format!("{header}\n {}", style(body).dim())
};
self.multipb.println(block).unwrap();
}
fn process(&mut self, msg: Message) {
match msg {
Message::ConfigFound(path) => {
let pb = self.progress_bars.get_mut("config_files").unwrap();
if let Some(path) = path.to_str() {
pb.set_message_if_changed(format!(
"{} files: {}",
self.config_files.len() + 1,
style(path).dim(),
));
}
self.config_files.push(path.into());
}
Message::ConfigWalkFinish => {
let mut pb = self.progress_bars.remove("config_files").unwrap();
pb.set_message_if_changed(format!("{} files", self.config_files.len()));
pb.bar.finish_and_clear();
let display = ConfigList::from_files(std::mem::take(&mut self.config_files));
self.multipb.println(display.to_string()).unwrap();
}
Message::MergeFinished { total, merged } => {
let message = if total == merged {
format!("{} plugins", style(total).green().bold())
} else {
format!(
"{} plugins {} {}",
style(total).green().bold(),
style("·").dim(),
style(format!("📦 {}", merged)).green().bold(),
)
};
if let Some(pb) = self.progress_bars.remove("loading") {
pb.bar.finish_and_clear();
}
self.multipb
.println(format!("{} {message}", summary_prefix("Loaded", true)))
.unwrap();
}
Message::Cache(r#type, url) => {
if r#type == "Fetching" || r#type == "Initializing" {
self.ensure_fetch_stage(r#type, url.as_ref());
return;
}
if r#type == "Fetching:done" {
self.mark_fetch_done(url.as_ref());
return;
}
match r#type {
"Updating" | "Updating:done" => {
let url = url.to_string();
let existed = self.updating_bar.is_some();
let pb = self.updating_bar.get_or_insert_with(|| {
let bar = ProgressBar::new_spinner()
.with_style(self.pb_style_spinner_mid.clone())
.with_prefix("Updating");
bar.enable_steady_tick(Duration::from_millis(100));
BarState::new(self.multipb.add(bar))
});
match r#type {
"Updating" => {
self.cache_updating_fetching.insert(url.clone(), ());
if self.cache_updating_current.is_none() {
self.cache_updating_current = Some(url.clone());
pb.set_message_if_changed(url);
}
}
"Updating:done" => {
self.cache_updating_fetching.remove(&url);
if self.cache_updating_fetching.is_empty() {
pb.set_message_if_changed(url);
self.cache_updating_current = None;
} else if self
.cache_updating_current
.as_deref()
.is_none_or(|c| c == url)
{
let next = self.cache_updating_fetching.keys().next().cloned();
self.cache_updating_current = next.clone();
pb.set_message_if_changed(next.unwrap_or_default());
}
}
_ => {}
}
if !existed {
self.register_child(ChildKey::Updating);
}
}
_ => {
let pb = {
let style = self.pb_style.clone();
let prefix = r#type.to_string();
self.progress_bars
.entry(r#type.to_string())
.or_insert_with(|| {
let bar = self.multipb.add(
ProgressBar::no_length()
.with_style(style)
.with_prefix(prefix),
);
BarState::new(bar)
})
};
pb.set_message_if_changed(url.to_string());
}
}
}
Message::CacheFetchObjectsProgress {
id,
total_objs_count,
received_objs_count,
} => {
let existed = self.progress_bars.contains_key(CACHE_FETCH_PROGRESS_ID);
if !existed {
let pb = ProgressBar::new(0)
.with_style(self.pb_style_fetch_bar_last.clone())
.with_prefix("Objects");
pb.enable_steady_tick(Duration::from_millis(100));
let pb = self.add_fetch_progress_bar(pb);
self.progress_bars
.insert(CACHE_FETCH_PROGRESS_ID.to_string(), BarState::new(pb));
self.register_child(ChildKey::FetchProgress);
}
let pb = self.progress_bars.get_mut(CACHE_FETCH_PROGRESS_ID).unwrap();
let (prev_total, prev_received) = self.cachefetching_oids.entry(id).or_default();
pb.bar
.inc_length(total_objs_count.saturating_sub(*prev_total) as u64);
pb.bar
.inc(received_objs_count.saturating_sub(*prev_received) as u64);
*prev_total = total_objs_count;
*prev_received = received_objs_count;
let pos = pb.bar.position();
let len = pb.bar.length().unwrap_or(pos).max(1);
if pos < len {
self.osc94
.get_or_insert_with(OSC94::new)
.progress(Some((pos * 100 / len).min(100) as u8));
} else {
self.osc94
.get_or_insert_with(OSC94::new)
.progress::<u8>(None);
}
}
Message::CacheBuildProgress { id, stdtype, line } => {
if let Some(sanitized) = sanitize_build_line(&line) {
let prefix = {
let mut prefix = format!("Building [{id}]");
const MAX_PREFIX_WIDTH: usize = 30;
prefix
.push_str(&" ".repeat(MAX_PREFIX_WIDTH.saturating_sub(prefix.width())));
prefix
};
let existed = self.progress_bars.contains_key(id.as_str());
let bar = self.progress_bars.entry(id.to_string()).or_insert_with(|| {
let style = self.pb_style_spinner_mid.clone();
let pb = ProgressBar::new_spinner().with_style(style);
pb.enable_steady_tick(Duration::from_millis(100));
BarState::new(self.multipb.add(pb.with_prefix(prefix.clone())))
});
let message = format!(
"{} {}",
style({
let mut prefix = stdtype.to_string();
prefix.push('>');
prefix
})
.dim(),
sanitized
);
bar.set_message_if_changed(message);
bar.bar.set_prefix(prefix);
if !existed {
self.register_child(ChildKey::Building(id));
}
}
}
Message::CacheBuildFinished { id, success } => {
if let Some(pb_state) = self.progress_bars.remove(id.as_str()) {
pb_state.bar.set_style(self.pb_style_summary.clone());
pb_state.bar.set_prefix(summary_prefix("Build", success));
if success {
pb_state.bar.finish_with_message(format!("[{id}]"));
} else {
pb_state.bar.finish_with_message(format!("[{id}] failed"));
}
}
self.unregister_child(&ChildKey::Building(id));
}
Message::LoadBegin { total } => {
let running = Arc::new(AtomicUsize::new(0));
let pb = ProgressBar::new(total as u64)
.with_style(loading_style(running.clone()))
.with_prefix("Loading");
pb.enable_steady_tick(Duration::from_millis(120));
let bar = BarState::new(self.multipb.add(pb));
self.progress_bars.insert("loading".to_string(), bar);
self.loading_running = Some(running);
self.osc94
.get_or_insert_with(OSC94::new)
.progress::<u8>(None);
}
Message::LoadPluginRunning => {
if let Some(running) = self.loading_running.as_ref() {
running.fetch_add(1, Ordering::Relaxed);
}
}
Message::LoadPluginRunningDone => {
if let Some(running) = self.loading_running.as_ref() {
running.fetch_sub(1, Ordering::Relaxed);
}
}
Message::LoadPluginDone => {
if let Some(state) = self.progress_bars.get_mut("loading") {
state.bar.inc(1);
}
}
Message::FetchDoneIdle { idle_gen } => {
if idle_gen != self.fetch_done_idle_gen {
return;
}
if let Some(bs) = self.progress_bars.get_mut(CACHE_FETCH_DONE_ID) {
bs.last_message = None;
}
self.fetch_done_is_blank = true;
self.refresh_connectors();
}
Message::LoadDone => {
self.clear_fetch_stage();
drop(self.osc94.take());
self.loading_running = None;
let mut pbs = std::mem::take(&mut self.progress_bars);
if let Some(pb) = pbs.remove(CACHE_FETCH_PROGRESS_ID) {
pb.bar.set_style(self.pb_style.clone());
pb.bar.finish_and_clear();
}
if let Some(pb) = self.updating_bar.take() {
pb.bar.finish_and_clear();
self.unregister_child(&ChildKey::Updating);
}
for (key, pb) in pbs {
if key == "loading" {
pb.bar.disable_steady_tick();
self.progress_bars.insert(key, pb);
continue;
}
pb.bar.finish_and_clear();
}
self.child_order.clear();
if !self.not_installed.is_empty() {
self.warn_not_installed();
}
}
Message::PluginNotInstalled(id) => {
self.not_installed.push(id);
}
Message::DetectLockFile(path) => {
self.multipb
.println(format!(
"{} {}",
summary_prefix("Lockfile", true),
style(path.to_string_lossy()).dim()
))
.unwrap();
}
Message::InstallSkipped(id) => {
self.installskipped_count += 1;
let pb = self
.progress_bars
.entry("install_skipped".to_string())
.or_insert_with(|| {
let bar = self.multipb.add(
ProgressBar::no_length()
.with_style(self.pb_style.clone())
.with_prefix("Up to date"),
);
BarState::new(bar)
});
pb.set_message_if_changed(format!("{}", style(id).italic().dim()));
}
Message::InstallYank { id, which: file } => {
self.yankfile_count += 1;
self.osc94
.get_or_insert_with(OSC94::new)
.progress::<u8>(None);
let pb = self
.progress_bars
.entry("install_yank".to_string())
.or_insert_with(|| {
let bar = self.multipb.add(
ProgressBar::no_length()
.with_style(self.pb_style.clone())
.with_prefix("Copying"),
);
BarState::new(bar)
});
pb.set_message_if_changed(format!(
"in {}: {}",
style(id).italic().dim(),
file.to_string_lossy()
));
}
Message::InstallHelp { help_dir } => {
let pb = self
.progress_bars
.entry("install_yank".to_string())
.or_insert_with(|| {
let bar = self.multipb.add(
ProgressBar::no_length()
.with_style(self.pb_style.clone())
.with_prefix(":helptags"),
);
BarState::new(bar)
});
pb.set_message_if_changed(help_dir.to_string_lossy().into_owned());
}
Message::InstallDone => {
drop(self.osc94.take());
if let Some(pb) = self.progress_bars.remove("install_skipped") {
pb.bar.set_style(self.pb_style_summary.clone());
if self.installskipped_count != 0 {
pb.bar.set_prefix(summary_prefix("Up to date", true));
pb.bar
.finish_with_message(format!("{} packages", self.installskipped_count));
} else {
pb.bar.finish_and_clear();
}
}
if let Some(pb) = self.progress_bars.remove("install_yank") {
pb.bar.set_style(self.pb_style_summary.clone());
if self.yankfile_count != 0 {
pb.bar.set_prefix(summary_prefix("Copied", true));
pb.bar
.finish_with_message(format!("{} files", self.yankfile_count));
} else {
pb.bar.finish_and_clear();
}
}
}
Message::Error(e) => {
self.multipb.suspend(|| {
eprintln!("{} {e}", style("error:").red().bold());
});
}
}
}
}
fn init() -> Logger {
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx_end, rx_end) = mpsc::unbounded_channel::<()>();
let (idle_tx, mut idle_rx) = mpsc::unbounded_channel::<Message>();
tokio::spawn(async move {
let mut manager = ProgressManager::build(ProgressDrawTarget::stderr(), Some(idle_tx));
loop {
tokio::select! {
msg = rx.recv() => match msg {
Some(msg) => manager.process(msg),
None => break,
},
msg = idle_rx.recv() => {
if let Some(msg) = msg {
manager.process(msg);
}
}
}
}
let _ = tx_end.send(());
});
(Some(tx).into(), rx_end.into())
}
pub fn msg(message: Message) {
let _ = LOGGER
.0
.read()
.unwrap()
.as_ref() .and_then(|lgr| lgr.send(message).ok());
}
pub async fn close(code: i32) -> ! {
loop {
if let Ok(mut sender) = LOGGER.0.try_write() {
let sender: &mut Option<_> = &mut sender;
drop(std::mem::take(sender));
} else {
continue;
}
LOGGER.1.lock().await.recv().await;
std::io::stdout().flush().unwrap();
std::process::exit(code);
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatif::TermLike;
use std::io;
use std::sync::{Arc, Mutex};
#[derive(Debug)]
struct Screen {
rows: Vec<String>,
row: usize,
col: usize,
}
#[derive(Debug, Clone)]
struct ScreenTermLike {
screen: Arc<Mutex<Screen>>,
width: u16,
}
impl ScreenTermLike {
fn new(width: u16) -> (Self, Arc<Mutex<Screen>>) {
let screen = Arc::new(Mutex::new(Screen {
rows: Vec::new(),
row: 0,
col: 0,
}));
(
Self {
screen: screen.clone(),
width,
},
screen,
)
}
}
impl TermLike for ScreenTermLike {
fn width(&self) -> u16 {
self.width
}
fn height(&self) -> u16 {
200
}
fn move_cursor_up(&self, n: usize) -> io::Result<()> {
let mut s = self.screen.lock().unwrap();
s.row = s.row.saturating_sub(n);
Ok(())
}
fn move_cursor_down(&self, n: usize) -> io::Result<()> {
let mut s = self.screen.lock().unwrap();
s.row += n;
while s.rows.len() <= s.row {
s.rows.push(String::new());
}
Ok(())
}
fn move_cursor_right(&self, n: usize) -> io::Result<()> {
self.screen.lock().unwrap().col += n;
Ok(())
}
fn move_cursor_left(&self, n: usize) -> io::Result<()> {
let mut s = self.screen.lock().unwrap();
s.col = s.col.saturating_sub(n);
Ok(())
}
fn write_str(&self, text: &str) -> io::Result<()> {
let mut s = self.screen.lock().unwrap();
for c in text.chars() {
match c {
'\r' => s.col = 0,
'\n' => {
s.row += 1;
s.col = 0;
while s.rows.len() <= s.row {
s.rows.push(String::new());
}
}
_ => {
let (row, col) = (s.row, s.col);
while s.rows.len() <= row {
s.rows.push(String::new());
}
let r = &mut s.rows[row];
while r.chars().count() < col {
r.push(' ');
}
let mut chars: Vec<char> = r.chars().collect();
while chars.len() <= col {
chars.push(' ');
}
chars[col] = c;
*r = chars.into_iter().collect();
s.col += 1;
}
}
}
Ok(())
}
fn write_line(&self, text: &str) -> io::Result<()> {
self.write_str(text)?;
let mut s = self.screen.lock().unwrap();
s.row += 1;
s.col = 0;
while s.rows.len() <= s.row {
s.rows.push(String::new());
}
Ok(())
}
fn clear_line(&self) -> io::Result<()> {
let mut s = self.screen.lock().unwrap();
let row = s.row;
while s.rows.len() <= row {
s.rows.push(String::new());
}
s.rows[row].clear();
s.col = 0;
Ok(())
}
fn flush(&self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn loading_bar_freezes_at_load_done_and_clears_at_merge_finished() {
let mut m = ProgressManager::new();
m.process(Message::LoadBegin { total: 2 });
let loading = m
.progress_bars
.get("loading")
.expect("LoadBegin creates the loading bar");
assert!(!loading.bar.is_finished());
m.process(Message::LoadPluginDone);
m.process(Message::LoadPluginDone);
m.process(Message::LoadDone);
let loading = m
.progress_bars
.get("loading")
.expect("LoadDone must keep the loading bar for MergeFinished");
assert!(!loading.bar.is_finished());
m.process(Message::MergeFinished {
total: 2,
merged: 1,
});
assert!(
!m.progress_bars.contains_key("loading"),
"MergeFinished must remove the loading bar"
);
}
#[test]
fn loading_bar_does_not_remain_on_screen_after_update_run() {
let (term, screen) = ScreenTermLike::new(80);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
m.process(Message::ConfigWalkFinish);
m.process(Message::LoadBegin { total: 2 });
let mk = |s: &str| Arc::<str>::from(s);
let u1 = mk("file:///x/a");
m.process(Message::Cache("Updating", u1.clone()));
m.process(Message::Cache("Updating:done", u1.clone()));
m.process(Message::Cache("Fetching", u1.clone()));
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 1,
received_objs_count: 1,
});
m.process(Message::LoadPluginDone);
let u2 = mk("file:///x/b");
m.process(Message::Cache("Updating", u2.clone()));
m.process(Message::Cache("Updating:done", u2.clone()));
m.process(Message::LoadPluginDone);
std::thread::sleep(Duration::from_millis(160));
m.process(Message::LoadDone);
std::thread::sleep(Duration::from_millis(40));
m.process(Message::MergeFinished {
total: 2,
merged: 1,
});
std::thread::sleep(Duration::from_millis(160));
let rendered = {
let s = screen.lock().unwrap();
s.rows
.iter()
.map(|r| console::strip_ansi_codes(r))
.collect::<Vec<_>>()
.join("\n")
};
assert!(
!rendered.contains("Loading"),
"loading bar must be cleared after the run; got:\n{rendered}"
);
}
#[test]
fn warn_not_installed_truncates_each_name_individually() {
let (term, screen) = ScreenTermLike::new(120);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
m.process(Message::PluginNotInstalled(Arc::from(
"abcdefghijklmnopqrstuvwxyz",
)));
m.process(Message::PluginNotInstalled(Arc::from("short")));
m.process(Message::LoadDone);
let rendered = {
let s = screen.lock().unwrap();
s.rows
.iter()
.map(|r| console::strip_ansi_codes(r))
.collect::<Vec<_>>()
.join("\n")
};
assert!(
rendered.contains("……"),
"long name should be truncated individually; got:\n{rendered}"
);
assert!(
rendered.contains("short"),
"short name should be shown as-is; got:\n{rendered}"
);
assert!(
rendered.contains("2 plugins not installed"),
"header should report the count; got:\n{rendered}"
);
}
#[test]
fn warn_not_installed_ellipsis_when_more_than_three() {
let (term, screen) = ScreenTermLike::new(120);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
for s in ["aaaa", "bbbb", "cccc", "dddd"] {
m.process(Message::PluginNotInstalled(Arc::from(s)));
}
m.process(Message::LoadDone);
let rendered = {
let s = screen.lock().unwrap();
s.rows
.iter()
.map(|r| console::strip_ansi_codes(r))
.collect::<Vec<_>>()
.join("\n")
};
assert!(
rendered.contains("4 plugins not installed"),
"header should report 4; got:\n{rendered}"
);
assert!(
rendered.contains(" …"),
"remaining plugins should be elided; got:\n{rendered}"
);
assert!(rendered.contains("aaaa"), "got:\n{rendered}");
assert!(
!rendered.contains("dddd"),
"4th should be hidden; got:\n{rendered}"
);
}
fn screen_rendered(screen: &Arc<Mutex<Screen>>) -> String {
let s = screen.lock().unwrap();
s.rows
.iter()
.map(|r| console::strip_ansi_codes(r))
.collect::<Vec<_>>()
.join("\n")
}
fn ordered_children(m: &ProgressManager) -> Vec<ChildKey> {
let mut v: Vec<ChildKey> = m.child_order.to_vec();
v.sort_by_key(ProgressManager::category_rank);
v
}
#[test]
fn dual_bar_cells_allocates_done_then_running_then_idle() {
let (done, running, idle) = dual_bar_cells(3, 2, 10, 10);
assert_eq!((done, running, idle), (3, 2, 5));
assert_eq!(done + running + idle, 10);
let (done, running, idle) = dual_bar_cells(8, 5, 10, 10);
assert_eq!((done, running, idle), (8, 2, 0));
let (done, running, idle) = dual_bar_cells(65, 8, 130, 40);
assert_eq!((done, running, idle), (20, 2, 18));
let (done, running, idle) = dual_bar_cells(0, 0, 0, 40);
assert_eq!((done, running, idle), (0, 0, 40));
}
#[test]
fn loading_bar_width_uses_remaining_terminal_width() {
assert_eq!(loading_bar_width_for(100, 3), 52);
assert_eq!(loading_bar_width_for(20, 123), 10);
}
#[test]
fn fetch_object_progress_aggregates_all_repos() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 100,
received_objs_count: 40,
});
m.process(Message::CacheFetchObjectsProgress {
id: "b".into(),
total_objs_count: 50,
received_objs_count: 10,
});
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 120,
received_objs_count: 60,
});
let pb = &m
.progress_bars
.get(CACHE_FETCH_PROGRESS_ID)
.expect("object progress bar")
.bar;
assert_eq!(pb.length(), Some(170));
assert_eq!(pb.position(), 70);
}
#[test]
fn fetch_done_note_is_below_object_progress() {
let (term, screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let url = Arc::<str>::from("file:///x/a");
m.process(Message::Cache("Updating", url.clone()));
m.process(Message::Cache("Updating:done", url.clone()));
m.process(Message::Cache("Fetching", url.clone()));
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 10,
received_objs_count: 10,
});
std::thread::sleep(Duration::from_millis(160));
let rendered = screen_rendered(&screen);
let updating = rendered.find("Updating").expect("updating bar");
let fetching = rendered.find("Fetching").expect("fetching bar");
let objects = rendered.find("Objects").expect("objects bar");
assert!(
updating < fetching,
"fetching should follow updating; got:\n{rendered}"
);
assert!(
fetching < objects,
"objects should follow fetching while fetch is running; got:\n{rendered}"
);
m.process(Message::Cache("Fetching:done", url));
std::thread::sleep(Duration::from_millis(160));
let rendered = screen_rendered(&screen);
assert!(
rendered.contains("fetched"),
"done note missing; got:\n{rendered}"
);
let updating = rendered.find("Updating").expect("updating bar");
let fetched = rendered.find("fetched").expect("done note");
let objects = rendered.find("Objects").expect("objects bar");
assert!(
updating < fetched && fetched < objects,
"done note should keep the fetching row position above object progress; got:\n{rendered}"
);
assert!(
rendered.contains("file:///x/a"),
"done url; got:\n{rendered}"
);
}
#[test]
fn fetching_and_fetched_are_separate_rows() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/a");
let b = Arc::<str>::from("file:///x/b");
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching", b.clone()));
m.process(Message::Cache("Fetching:done", a));
assert!(
m.progress_bars.contains_key(CACHE_FETCH_STAGE_ID),
"Fetching bar must exist while b is inflight"
);
assert!(
m.progress_bars.contains_key(CACHE_FETCH_DONE_ID),
"fetched bar must exist for completed a"
);
assert_eq!(
ordered_children(&m),
vec![ChildKey::FetchStage, ChildKey::FetchDone],
"Fetching and fetched must be distinct bars in order"
);
}
#[test]
fn fetching_row_order_with_updating_and_objects() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/a");
let b = Arc::<str>::from("file:///x/b");
m.process(Message::Cache("Updating", a.clone()));
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching", b.clone()));
m.process(Message::Cache("Fetching:done", a.clone()));
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 10,
received_objs_count: 5,
});
assert_eq!(
ordered_children(&m),
vec![
ChildKey::Updating,
ChildKey::FetchStage,
ChildKey::FetchDone,
ChildKey::FetchProgress,
],
"display order must be Updating<Fetching<fetched<Objects"
);
}
#[test]
fn fetching_concurrent_urls_keep_current() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/aaaaaaaa");
let b = Arc::<str>::from("file:///x/bbbbbbbb");
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching", b.clone()));
assert_eq!(m.cache_fetching.len(), 2, "both URLs are inflight");
assert_eq!(
m.cache_fetching_current.as_deref(),
Some("file:///x/aaaaaaaa"),
"current must stay as the first URL"
);
}
#[test]
fn fetching_bar_blanks_when_no_inflight() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/a");
let b = Arc::<str>::from("file:///x/b");
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching:done", a));
assert!(
m.progress_bars.contains_key(CACHE_FETCH_STAGE_ID),
"Fetching bar must remain (blank, not cleared)"
);
assert!(
m.fetch_stage_is_blank,
"Fetching bar must be blank when no inflight"
);
assert!(m.cache_fetching.is_empty(), "inflight set must be empty");
assert!(
m.progress_bars.contains_key(CACHE_FETCH_DONE_ID),
"fetched bar must remain"
);
m.process(Message::Cache("Fetching", b));
assert!(
m.progress_bars.contains_key(CACHE_FETCH_STAGE_ID),
"Fetching bar must still exist"
);
assert!(
!m.fetch_stage_is_blank,
"Fetching bar must resume from blank"
);
assert_eq!(
ordered_children(&m),
vec![ChildKey::FetchStage, ChildKey::FetchDone],
"Fetching must stay above fetched"
);
}
#[test]
fn fetch_done_idle_respects_generation() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/a");
let b = Arc::<str>::from("file:///x/b");
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching:done", a.clone()));
assert!(!m.fetch_done_is_blank, "fetched is active after done");
m.process(Message::FetchDoneIdle { idle_gen: 0 });
assert!(!m.fetch_done_is_blank, "stale idle must be ignored");
m.process(Message::Cache("Fetching", b.clone()));
m.process(Message::Cache("Fetching:done", b));
m.process(Message::FetchDoneIdle { idle_gen: 1 });
assert!(!m.fetch_done_is_blank, "idle with old gen must be ignored");
m.process(Message::FetchDoneIdle { idle_gen: 2 });
assert!(
m.fetch_done_is_blank,
"current gen idle must blank the fetched row"
);
}
#[test]
fn fetched_connector_follows_fetch_last() {
let (term, _screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
let a = Arc::<str>::from("file:///x/a");
m.process(Message::Cache("Fetching", a.clone()));
m.process(Message::Cache("Fetching:done", a.clone()));
let level1_last = ordered_children(&m)
.iter()
.rfind(|k| !matches!(k, ChildKey::FetchDone))
.cloned();
assert_eq!(
level1_last,
Some(ChildKey::FetchStage),
"Fetching is last level1 when no Objects"
);
m.process(Message::CacheFetchObjectsProgress {
id: "a".into(),
total_objs_count: 10,
received_objs_count: 5,
});
let level1_last = ordered_children(&m)
.iter()
.rfind(|k| !matches!(k, ChildKey::FetchDone))
.cloned();
assert_eq!(
level1_last,
Some(ChildKey::FetchProgress),
"Objects is last level1 when present"
);
}
#[test]
fn loading_bar_shows_running_region_even_at_zero_done() {
let (term, screen) = ScreenTermLike::new(100);
let mut m =
ProgressManager::with_draw_target(ProgressDrawTarget::term_like(Box::new(term)));
m.process(Message::LoadBegin { total: 10 });
for _ in 0..3 {
m.process(Message::LoadPluginRunning);
}
std::thread::sleep(Duration::from_millis(180));
let rendered = screen_rendered(&screen);
assert!(
rendered.contains('□'),
"running region must show before any completion; got:\n{rendered}"
);
assert!(
rendered.contains("0/10"),
"pos/len at zero done; got:\n{rendered}"
);
assert!(
rendered.contains("3 active"),
"running count in annotation; got:\n{rendered}"
);
m.process(Message::LoadPluginDone);
m.process(Message::LoadPluginDone);
std::thread::sleep(Duration::from_millis(180));
let rendered = screen_rendered(&screen);
assert!(rendered.contains('■'), "done region; got:\n{rendered}");
assert!(rendered.contains('□'), "running region; got:\n{rendered}");
assert!(
rendered.contains("2/10"),
"pos/len after completions; got:\n{rendered}"
);
m.process(Message::LoadDone);
m.process(Message::MergeFinished {
total: 2,
merged: 1,
});
std::thread::sleep(Duration::from_millis(180));
let rendered = screen_rendered(&screen);
assert!(
!rendered.contains("Loading"),
"loading bar must be cleared after merge; got:\n{rendered}"
);
}
}