deno/cli/tools/pm/outdated/interactive.rs
David Sherret ea419a6e39
Some checks are pending
ci / pre-build (push) Waiting to run
ci / bench release linux-x86_64 (push) Blocked by required conditions
ci / lint debug macos-x86_64 (push) Blocked by required conditions
ci / lint debug windows-x86_64 (push) Blocked by required conditions
ci / test debug linux-x86_64 (push) Blocked by required conditions
ci / test release linux-x86_64 (push) Blocked by required conditions
ci / test debug macos-x86_64 (push) Blocked by required conditions
ci / test release macos-x86_64 (push) Blocked by required conditions
ci / test debug windows-x86_64 (push) Blocked by required conditions
ci / test release windows-x86_64 (push) Blocked by required conditions
ci / build libs (push) Blocked by required conditions
ci / publish canary (push) Blocked by required conditions
ci / test debug linux-aarch64 (push) Blocked by required conditions
ci / test release linux-aarch64 (push) Blocked by required conditions
ci / test debug macos-aarch64 (push) Blocked by required conditions
ci / test release macos-aarch64 (push) Blocked by required conditions
ci / lint debug linux-x86_64 (push) Blocked by required conditions
feat(BREAKING/fmt): prompt or require deno fmt . when providing no files and no config files discovered (#30623)
When running `deno fmt` with no input paths with no deno.json or
package.json:

* In tty environments, prompts for confirmation.
* In non-tty environments, errors without providing the current
directory (`deno fmt .`).

The reason for this is we had too many complaints of people accidentally
running `deno fmt` in a directory that wasn't a JS project.
2025-09-08 14:28:47 -04:00

402 lines
10 KiB
Rust
Raw Permalink 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::ExecutableCommand;
use crossterm::cursor;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::terminal;
use deno_core::anyhow;
use deno_semver::Version;
use deno_semver::VersionReq;
use deno_terminal::colors;
use unicode_width::UnicodeWidthStr;
use crate::tools::pm::deps::DepId;
use crate::tools::pm::deps::DepKind;
use crate::util::console::HideCursorGuard;
use crate::util::console::RawMode;
#[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 = match (
&package.current_version,
Version::parse_standard(new_version_string),
) {
(Some(current_version), Ok(new_version)) => {
highlight_new_version(current_version, &new_version)
}
_ => 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)
),
}
}
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)?;
let hide_cursor_guard = HideCursorGuard::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
&& 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();
hide_cursor_guard.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)
}
}