diff --git a/Cargo.lock b/Cargo.lock index 5b41c6387e4c4c..9f46d90b42e171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -990,12 +990,12 @@ dependencies = [ [[package]] name = "console_static_text" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4be93df536dfbcbd39ff7c129635da089901116b88bfc29ec1acb9b56f8ff35" +checksum = "55d8a913e62f6444b79e038be3eb09839e9cfc34d55d85f9336460710647d2f6" dependencies = [ "unicode-width", - "vte", + "vte 0.13.1", ] [[package]] @@ -1125,6 +1125,31 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.6.0", + "crossterm_winapi", + "mio 1.0.3", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1295,6 +1320,7 @@ dependencies = [ "clap_complete_fig", "color-print", "console_static_text", + "crossterm", "dashmap", "data-encoding", "deno_ast", @@ -1390,6 +1416,7 @@ dependencies = [ "tracing", "twox-hash", "typed-arena", + "unicode-width", "uuid", "walkdir", "which", @@ -5177,6 +5204,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "moka" version = "0.12.10" @@ -5359,7 +5398,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] @@ -6710,9 +6749,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.32" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.6.0", "errno", @@ -7150,6 +7189,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 1.0.3", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -7405,7 +7455,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" dependencies = [ - "vte", + "vte 0.11.1", ] [[package]] @@ -8220,7 +8270,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 0.8.11", "num_cpus", "parking_lot", "pin-project-lite", @@ -8783,6 +8833,16 @@ name = "vte" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" dependencies = [ "arrayvec", "utf8parse", diff --git a/Cargo.toml b/Cargo.toml index 1ba196d6619f93..5af9ac3a88980d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,7 @@ cbc = { version = "=0.1.2", features = ["alloc"] } # Instead use util::time::utc_now() chrono = { version = "0.4", default-features = false, features = ["std", "serde"] } color-print = "0.3.5" -console_static_text = "=0.8.1" +console_static_text = "=0.8.3" ctr = { version = "0.9.2", features = ["alloc"] } dashmap = "5.5.3" data-encoding = "2.3.3" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 54de3db642ffab..dee17bd34474a2 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -105,6 +105,7 @@ clap_complete = "=4.5.24" clap_complete_fig = "=4.5.2" color-print.workspace = true console_static_text.workspace = true +crossterm = "0.28.1" dashmap.workspace = true data-encoding.workspace = true dhat = { version = "0.3.3", optional = true } @@ -169,6 +170,7 @@ tower-lsp.workspace = true tracing = { version = "0.1", features = ["log", "default"] } twox-hash.workspace = true typed-arena = "=2.0.2" +unicode-width = "0.1.3" uuid = { workspace = true, features = ["serde"] } walkdir.workspace = true which.workspace = true diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 144d1a57b95e87..7a817486def07f 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -475,7 +475,7 @@ pub enum DenoSubcommand { #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutdatedKind { - Update { latest: bool }, + Update { latest: bool, interactive: bool }, PrintOutdated { compatible: bool }, } @@ -2660,7 +2660,7 @@ Specific version requirements to update to can be specified: .long("latest") .action(ArgAction::SetTrue) .help( - "Update to the latest version, regardless of semver constraints", + "Consider the latest version, regardless of semver constraints", ) .conflicts_with("compatible"), ) @@ -2669,15 +2669,21 @@ Specific version requirements to update to can be specified: .long("update") .short('u') .action(ArgAction::SetTrue) - .conflicts_with("compatible") .help("Update dependency versions"), ) + .arg( + Arg::new("interactive") + .long("interactive") + .short('i') + .action(ArgAction::SetTrue) + .requires("update") + .help("Interactively select which dependencies to update") + ) .arg( Arg::new("compatible") .long("compatible") .action(ArgAction::SetTrue) - .help("Only output versions that satisfy semver requirements") - .conflicts_with("update"), + .help("Only consider versions that satisfy semver requirements") ) .arg( Arg::new("recursive") @@ -4462,7 +4468,11 @@ fn outdated_parse( let update = matches.get_flag("update"); let kind = if update { let latest = matches.get_flag("latest"); - OutdatedKind::Update { latest } + let interactive = matches.get_flag("interactive"); + OutdatedKind::Update { + latest, + interactive, + } } else { let compatible = matches.get_flag("compatible"); OutdatedKind::PrintOutdated { compatible } @@ -11646,7 +11656,10 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" svec!["--update"], OutdatedFlags { filters: vec![], - kind: OutdatedKind::Update { latest: false }, + kind: OutdatedKind::Update { + latest: false, + interactive: false, + }, recursive: false, }, ), @@ -11654,7 +11667,10 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" svec!["--update", "--latest"], OutdatedFlags { filters: vec![], - kind: OutdatedKind::Update { latest: true }, + kind: OutdatedKind::Update { + latest: true, + interactive: false, + }, recursive: false, }, ), @@ -11662,7 +11678,10 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" svec!["--update", "--recursive"], OutdatedFlags { filters: vec![], - kind: OutdatedKind::Update { latest: false }, + kind: OutdatedKind::Update { + latest: false, + interactive: false, + }, recursive: true, }, ), @@ -11670,7 +11689,10 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" svec!["--update", "@foo/bar"], OutdatedFlags { filters: svec!["@foo/bar"], - kind: OutdatedKind::Update { latest: false }, + kind: OutdatedKind::Update { + latest: false, + interactive: false, + }, recursive: false, }, ), @@ -11682,6 +11704,17 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" recursive: false, }, ), + ( + svec!["--update", "--latest", "--interactive"], + OutdatedFlags { + filters: svec![], + kind: OutdatedKind::Update { + latest: true, + interactive: true, + }, + recursive: false, + }, + ), ]; for (input, expected) in cases { let mut args = svec!["deno", "outdated"]; diff --git a/cli/tools/registry/pm/deps.rs b/cli/tools/registry/pm/deps.rs index 621dd4693df8e3..849e72d3ec1754 100644 --- a/cli/tools/registry/pm/deps.rs +++ b/cli/tools/registry/pm/deps.rs @@ -194,6 +194,12 @@ pub struct Dep { pub alias: Option, } +impl Dep { + pub fn alias_or_name(&self) -> &str { + self.alias.as_deref().unwrap_or_else(|| &self.req.name) + } +} + fn import_map_entries( import_map: &ImportMap, ) -> impl Iterator)> { diff --git a/cli/tools/registry/pm/outdated.rs b/cli/tools/registry/pm/outdated.rs index 1afe7a503469be..146f654f39a198 100644 --- a/cli/tools/registry/pm/outdated.rs +++ b/cli/tools/registry/pm/outdated.rs @@ -1,5 +1,7 @@ // Copyright 2018-2025 the Deno authors. MIT license. +mod interactive; + use std::collections::HashSet; use std::sync::Arc; @@ -13,6 +15,7 @@ use deno_semver::VersionReq; use deno_terminal::colors; use super::deps::Dep; +use super::deps::DepId; use super::deps::DepManager; use super::deps::DepManagerArgs; use super::deps::PackageLatestVersion; @@ -240,8 +243,11 @@ pub async fn outdated( deps.resolve_versions().await?; match update_flags.kind { - crate::args::OutdatedKind::Update { latest } => { - update(deps, latest, &filter_set, flags).await?; + crate::args::OutdatedKind::Update { + latest, + interactive, + } => { + update(deps, latest, &filter_set, interactive, flags).await?; } crate::args::OutdatedKind::PrintOutdated { compatible } => { print_outdated(&mut deps, compatible)?; @@ -299,9 +305,10 @@ async fn update( mut deps: DepManager, update_to_latest: bool, filter_set: &filter::FilterSet, + interactive: bool, flags: Arc, ) -> Result<(), AnyError> { - let mut updated = Vec::new(); + let mut to_update = Vec::new(); for (dep_id, resolved, latest_versions) in deps .deps_with_resolved_latest_versions() @@ -320,19 +327,54 @@ async fn update( continue; }; - updated.push(( + to_update.push(( dep_id, format!("{}:{}", dep.kind.scheme(), dep.req.name), deps.resolved_version(dep.id).cloned(), new_version_req.clone(), )); + } - deps.update_dep(dep_id, new_version_req); + if interactive && !to_update.is_empty() { + let selected = interactive::select_interactive( + to_update + .iter() + .map( + |(dep_id, _, current_version, new_req): &( + DepId, + String, + Option, + VersionReq, + )| { + let dep = deps.get_dep(*dep_id); + interactive::PackageInfo { + id: *dep_id, + current_version: current_version + .as_ref() + .map(|nv| nv.version.clone()), + name: dep.alias_or_name().into(), + kind: dep.kind, + new_version: new_req.clone(), + } + }, + ) + .collect(), + )?; + if let Some(selected) = selected { + to_update.retain(|(id, _, _, _)| selected.contains(id)); + } else { + log::info!("Cancelled, not updating"); + return Ok(()); + } } - deps.commit_changes()?; + if !to_update.is_empty() { + for (dep_id, _, _, new_version_req) in &to_update { + deps.update_dep(*dep_id, new_version_req.clone()); + } + + deps.commit_changes()?; - if !updated.is_empty() { let factory = super::npm_install_after_modification( flags.clone(), Some(deps.jsr_fetch_resolver.clone()), @@ -352,7 +394,7 @@ async fn update( let mut deps = deps.reloaded_after_modification(args); deps.resolve_current_versions().await?; for (dep_id, package_name, maybe_current_version, new_version_req) in - updated + to_update { if let Some(nv) = deps.resolved_version(dep_id) { updated_to_versions.insert(( diff --git a/cli/tools/registry/pm/outdated/interactive.rs b/cli/tools/registry/pm/outdated/interactive.rs new file mode 100644 index 00000000000000..fb7bb0c69b89e3 --- /dev/null +++ b/cli/tools/registry/pm/outdated/interactive.rs @@ -0,0 +1,426 @@ +// 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) + } +}