0
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-02-24 06:02:18 -05:00
denoland-deno/cli/tools/registry/pm/outdated/interactive.rs
Nathan Whitaker b440d2d4f7
feat(outdated): interactive update (#27812)
interactively select which packages to upgrade. a future improvement
could be to add a way to select the version as well, though not sure how
valuable that would be.
2025-02-04 15:41:56 -08:00

426 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Version>,
pub new_version: VersionReq,
pub name: String,
pub kind: DepKind,
}
#[derive(Debug)]
struct FormattedPackageInfo {
dep_ids: Vec<DepId>,
current_version_string: Option<String>,
new_version_highlighted: String,
formatted_name: String,
formatted_name_len: usize,
name: String,
}
#[derive(Debug)]
struct State {
packages: Vec<FormattedPackageInfo>,
currently_selected: usize,
checked: HashSet<usize>,
name_width: usize,
current_width: usize,
}
impl From<PackageInfo> 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<PackageInfo>) -> anyhow::Result<Self> {
let mut deduped_packages: HashMap<
(String, Option<Version>, 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 (<space> to select, ↑/↓/j/k to navigate, a to select all, i to invert selection, enter to accept, <Ctrl-c> to cancel)"
}
fn render(&self) -> anyhow::Result<Vec<TextItem>> {
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} {:<current_width$} -> {}",
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<Self> {
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<PackageInfo>,
) -> anyhow::Result<Option<HashSet<DepId>>> {
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)
}
}