// Copyright 2018-2025 the Deno authors. MIT license. use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Write as _; use std::io; use console_static_text::ConsoleSize; use console_static_text::TextItem; use crossterm::cursor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use crossterm::terminal; use crossterm::ExecutableCommand; use deno_core::anyhow; use deno_semver::Version; use deno_semver::VersionReq; use deno_terminal::colors; use unicode_width::UnicodeWidthStr; use crate::tools::registry::pm::deps::DepId; use crate::tools::registry::pm::deps::DepKind; #[derive(Debug)] pub struct PackageInfo { pub id: DepId, pub current_version: Option, pub new_version: VersionReq, pub name: String, pub kind: DepKind, } #[derive(Debug)] struct FormattedPackageInfo { dep_ids: Vec, current_version_string: Option, new_version_highlighted: String, formatted_name: String, formatted_name_len: usize, name: String, } #[derive(Debug)] struct State { packages: Vec, currently_selected: usize, checked: HashSet, name_width: usize, current_width: usize, } impl From for FormattedPackageInfo { fn from(package: PackageInfo) -> Self { let new_version_string = package.new_version.version_text().trim_start_matches('^'); let new_version_highlighted = if let (Some(current_version), Ok(new_version)) = ( &package.current_version, Version::parse_standard(new_version_string), ) { highlight_new_version(current_version, &new_version) } else { new_version_string.to_string() }; FormattedPackageInfo { dep_ids: vec![package.id], current_version_string: package .current_version .as_ref() .map(|v| v.to_string()), new_version_highlighted, formatted_name: format!( "{}{}", colors::gray(format!("{}:", package.kind.scheme())), package.name ), formatted_name_len: package.kind.scheme().len() + 1 + package.name.len(), name: package.name, } } } impl State { fn new(packages: Vec) -> anyhow::Result { let mut deduped_packages: HashMap< (String, Option, VersionReq), FormattedPackageInfo, > = HashMap::with_capacity(packages.len()); for package in packages { match deduped_packages.entry(( package.name.clone(), package.current_version.clone(), package.new_version.clone(), )) { std::collections::hash_map::Entry::Occupied(mut occupied_entry) => { occupied_entry.get_mut().dep_ids.push(package.id) } std::collections::hash_map::Entry::Vacant(vacant_entry) => { vacant_entry.insert(FormattedPackageInfo::from(package)); } } } let mut packages: Vec<_> = deduped_packages.into_values().collect(); packages.sort_by(|a, b| a.name.cmp(&b.name)); let name_width = packages .iter() .map(|p| p.formatted_name_len) .max() .unwrap_or_default(); let current_width = packages .iter() .map(|p| { p.current_version_string .as_ref() .map(|s| s.len()) .unwrap_or_default() }) .max() .unwrap_or_default(); Ok(Self { packages, currently_selected: 0, checked: HashSet::new(), name_width, current_width, }) } fn instructions_line() -> &'static str { "Select which packages to update ( to select, ↑/↓/j/k to navigate, a to select all, i to invert selection, enter to accept, to cancel)" } fn render(&self) -> anyhow::Result> { let mut items = Vec::with_capacity(self.packages.len() + 1); items.push(TextItem::new_owned(format!( "{} {}", colors::intense_blue("?"), Self::instructions_line() ))); for (i, package) in self.packages.iter().enumerate() { let mut line = String::new(); let f = &mut line; let checked = self.checked.contains(&i); write!( f, "{} {} ", if self.currently_selected == i { colors::intense_blue("❯").to_string() } else { " ".to_string() }, if checked { "●" } else { "○" } )?; let name_pad = " ".repeat(self.name_width + 2 - package.formatted_name_len); write!( f, "{formatted_name}{name_pad} {: {}", package .current_version_string .as_deref() .unwrap_or_default(), &package.new_version_highlighted, name_pad = name_pad, formatted_name = package.formatted_name, current_width = self.current_width )?; items.push(TextItem::with_hanging_indent_owned(line, 1)); } Ok(items) } } enum VersionDifference { Major, Minor, Patch, Prerelease, } fn version_diff(a: &Version, b: &Version) -> VersionDifference { if a.major != b.major { VersionDifference::Major } else if a.minor != b.minor { VersionDifference::Minor } else if a.patch != b.patch { VersionDifference::Patch } else { VersionDifference::Prerelease } } fn highlight_new_version(current: &Version, new: &Version) -> String { let diff = version_diff(current, new); let new_pre = if new.pre.is_empty() { String::new() } else { let mut s = String::new(); s.push('-'); for p in &new.pre { s.push_str(p); } s }; match diff { VersionDifference::Major => format!( "{}.{}.{}{}", colors::red_bold(new.major), colors::red_bold(new.minor), colors::red_bold(new.patch), colors::red_bold(new_pre) ), VersionDifference::Minor => format!( "{}.{}.{}{}", new.major, colors::yellow_bold(new.minor), colors::yellow_bold(new.patch), colors::yellow_bold(new_pre) ), VersionDifference::Patch => format!( "{}.{}.{}{}", new.major, new.minor, colors::green_bold(new.patch), colors::green_bold(new_pre) ), VersionDifference::Prerelease => format!( "{}.{}.{}{}", new.major, new.minor, new.patch, colors::red_bold(new_pre) ), } } struct RawMode { needs_disable: bool, } impl RawMode { fn enable() -> io::Result { terminal::enable_raw_mode()?; Ok(Self { needs_disable: true, }) } fn disable(mut self) -> io::Result<()> { self.needs_disable = false; terminal::disable_raw_mode() } } impl Drop for RawMode { fn drop(&mut self) { if self.needs_disable { let _ = terminal::disable_raw_mode(); } } } pub fn select_interactive( packages: Vec, ) -> anyhow::Result>> { let mut stderr = io::stderr(); let raw_mode = RawMode::enable()?; let mut static_text = console_static_text::ConsoleStaticText::new(move || { if let Ok((cols, rows)) = terminal::size() { ConsoleSize { cols: Some(cols), rows: Some(rows), } } else { ConsoleSize { cols: None, rows: None, } } }); static_text.keep_cursor_zero_column(true); let (_, start_row) = cursor::position().unwrap_or_default(); let (_, rows) = terminal::size()?; if rows - start_row < (packages.len() + 2) as u16 { let pad = ((packages.len() + 2) as u16) - (rows - start_row); stderr.execute(terminal::ScrollUp(pad.min(rows)))?; stderr.execute(cursor::MoveUp(pad.min(rows)))?; } let mut state = State::new(packages)?; stderr.execute(cursor::Hide)?; let instructions_width = format!("? {}", State::instructions_line()).width(); let mut do_it = false; let mut scroll_offset = 0; loop { let mut items = state.render()?; let size = static_text.console_size(); let first_line_rows = size .cols .map(|cols| (instructions_width / cols as usize) + 1) .unwrap_or(1); if let Some(rows) = size.rows { if items.len() + first_line_rows >= rows as usize { let adj = if scroll_offset == 0 { first_line_rows.saturating_sub(1) } else { 0 }; if state.currently_selected < scroll_offset { scroll_offset = state.currently_selected; } else if state.currently_selected + 1 >= scroll_offset + (rows as usize).saturating_sub(adj) { scroll_offset = (state.currently_selected + 1).saturating_sub(rows as usize) + 1; } let adj = if scroll_offset == 0 { first_line_rows.saturating_sub(1) } else { 0 }; let mut new_items = Vec::with_capacity(rows as usize); scroll_offset = scroll_offset.clamp(0, items.len() - 1); new_items.extend( items.drain( scroll_offset ..(scroll_offset + (rows as usize).saturating_sub(adj)) .min(items.len()), ), ); items = new_items; } } static_text.eprint_items(items.iter()); let event = crossterm::event::read()?; #[allow(clippy::single_match)] match event { crossterm::event::Event::Key(KeyEvent { kind: KeyEventKind::Press, code, modifiers, .. }) => match (code, modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => break, (KeyCode::Up | KeyCode::Char('k'), KeyModifiers::NONE) => { state.currently_selected = if state.currently_selected == 0 { state.packages.len() - 1 } else { state.currently_selected - 1 }; } (KeyCode::Down | KeyCode::Char('j'), KeyModifiers::NONE) => { state.currently_selected = (state.currently_selected + 1) % state.packages.len(); } (KeyCode::Char(' '), _) => { if !state.checked.insert(state.currently_selected) { state.checked.remove(&state.currently_selected); } } (KeyCode::Char('a'), _) => { if (0..state.packages.len()).all(|idx| state.checked.contains(&idx)) { state.checked.clear(); } else { state.checked.extend(0..state.packages.len()); } } (KeyCode::Char('i'), _) => { for idx in 0..state.packages.len() { if state.checked.contains(&idx) { state.checked.remove(&idx); } else { state.checked.insert(idx); } } } (KeyCode::Enter, _) => { do_it = true; break; } _ => {} }, _ => {} } } static_text.eprint_clear(); crossterm::execute!(&mut stderr, cursor::Show)?; raw_mode.disable()?; if do_it { Ok(Some( state .checked .into_iter() .flat_map(|idx| &state.packages[idx].dep_ids) .copied() .collect(), )) } else { Ok(None) } }