use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use ratatui::{
Terminal,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap},
};
use std::{
collections::{HashMap, HashSet, VecDeque},
io,
time::Duration,
};
use tokio::{sync::mpsc, task::JoinSet};
use crate::checks::{CheckResult, check_cargo_quiet, merge_check_result, run_single_check};
use crate::command::run_inherit_tty_handoff;
use crate::events::{OutputStream, UpgradeEvent, with_tui_active};
use crate::flow::check::{
any_check_failed, build_upgradable_targets, cargo_binstall_missing, cargo_update_missing,
resolve_check_targets,
};
use crate::profile::parse_profile;
use crate::selection::resolve_cli_selection_quiet;
use crate::state::{
AppState, TargetStateFlags, profile_name, target_label, target_state_flags,
updatable_items_for_target,
};
use crate::ui::{AppTerminal, TerminalGuard};
use crate::upgrade::upgrade_selected_with_events;
const INPUT_TIMEOUT: Duration = Duration::from_millis(80);
const MAX_LOG_LINES: usize = 4_000;
const THEME: Theme = Theme {
background: Color::Rgb(14, 20, 27),
surface: Color::Rgb(22, 33, 43),
surface_alt: Color::Rgb(27, 42, 53),
border: Color::Rgb(51, 69, 82),
accent: Color::Rgb(91, 211, 197),
info: Color::Rgb(104, 169, 255),
success: Color::Rgb(105, 211, 155),
warning: Color::Rgb(245, 184, 75),
error: Color::Rgb(240, 120, 120),
muted: Color::Rgb(135, 150, 165),
text: Color::Rgb(224, 232, 238),
};
#[derive(Clone, Copy)]
struct Theme {
background: Color,
surface: Color,
surface_alt: Color,
border: Color,
accent: Color,
info: Color,
success: Color,
warning: Color,
error: Color,
muted: Color,
text: Color,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stage {
Checking,
Dashboard,
Confirming,
Running,
Completed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Targets,
Details,
Summary,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RowState {
Waiting,
Running,
Ok,
Update,
Warn,
Error,
}
#[derive(Clone, Debug)]
struct CheckRow {
target: String,
state: RowState,
detail: String,
}
#[derive(Clone, Debug)]
struct LogLine {
text: String,
color: Color,
}
#[derive(Clone, Debug)]
struct Modal {
title: String,
lines: Vec<String>,
confirm_selected: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct HitZones {
targets: Rect,
details: Rect,
summary: Rect,
modal_confirm: Rect,
modal_cancel: Rect,
}
#[derive(Clone, Debug)]
enum InputEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Resize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DashboardAction {
None,
Confirm,
Cancel,
Exit,
}
pub async fn run(
state: &mut AppState,
requested_updates: &[String],
start_time: &str,
) -> io::Result<i32> {
with_tui_active(async {
let _guard = TerminalGuard::enter().await?;
let backend = ratatui::backend::CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let targets = resolve_check_targets(state, requested_updates);
let mut app = DashboardApp::new(targets, start_time);
app.run(&mut terminal, state, requested_updates).await
})
.await
}
struct DashboardApp {
stage: Stage,
focus: Focus,
start_time: String,
targets: Vec<String>,
upgradable_targets: Vec<String>,
selected: HashSet<String>,
target_cursor: usize,
expanded_target: Option<String>,
detail_scroll: usize,
log_scroll: usize,
check_rows: Vec<CheckRow>,
checks_done: usize,
current_target: Option<String>,
current_command: String,
target_results: HashMap<String, bool>,
execution_success: Option<bool>,
logs: VecDeque<LogLine>,
logs_truncated: bool,
pending_stdout: String,
pending_stderr: String,
needs_full_redraw: bool,
notice: Option<String>,
modal: Option<Modal>,
hit_zones: HitZones,
}
impl DashboardApp {
fn new(targets: Vec<String>, start_time: &str) -> Self {
let check_rows = targets
.iter()
.map(|target| CheckRow {
target: target.clone(),
state: RowState::Waiting,
detail: "等待检查".to_string(),
})
.collect();
Self {
stage: Stage::Checking,
focus: Focus::Targets,
start_time: start_time.to_string(),
targets,
upgradable_targets: Vec::new(),
selected: HashSet::new(),
target_cursor: 0,
expanded_target: None,
detail_scroll: 0,
log_scroll: 0,
check_rows,
checks_done: 0,
current_target: None,
current_command: String::new(),
target_results: HashMap::new(),
execution_success: None,
logs: VecDeque::new(),
logs_truncated: false,
pending_stdout: String::new(),
pending_stderr: String::new(),
needs_full_redraw: false,
notice: None,
modal: None,
hit_zones: HitZones::default(),
}
}
async fn run(
&mut self,
terminal: &mut AppTerminal,
state: &mut AppState,
requested_updates: &[String],
) -> io::Result<i32> {
self.run_checks(terminal, state).await?;
self.run_dependency_prompts(terminal, state).await?;
self.upgradable_targets = build_upgradable_targets(state);
self.selected = self.upgradable_targets.iter().cloned().collect();
if self.upgradable_targets.is_empty() {
let code = if any_check_failed(state) { 1 } else { 0 };
self.stage = Stage::Completed;
self.execution_success = Some(code == 0);
self.notice = Some(if code == 0 {
"没有可升级项".to_string()
} else {
"没有可升级项, 但有检查失败".to_string()
});
self.wait_for_exit(terminal, state).await?;
return Ok(code);
}
let selected_targets = if requested_updates.is_empty() {
self.selection_loop(terminal, state).await?
} else {
let (selected, skipped) =
resolve_cli_selection_quiet(requested_updates, &self.upgradable_targets);
if !skipped.is_empty() {
self.notice = Some(format!(
"请求目标暂无更新: {}",
skipped
.iter()
.map(|target| target_label(target))
.collect::<Vec<_>>()
.join(", ")
));
}
selected
};
if selected_targets.is_empty() {
self.stage = Stage::Completed;
self.execution_success = Some(true);
self.notice = Some("未选择任何升级项".to_string());
self.wait_for_exit(terminal, state).await?;
return Ok(0);
}
self.selected = selected_targets.iter().cloned().collect();
if requested_updates.is_empty()
&& !self
.confirm_loop(terminal, state, &selected_targets)
.await?
{
self.stage = Stage::Completed;
self.execution_success = Some(true);
self.notice = Some("已取消升级".to_string());
self.wait_for_exit(terminal, state).await?;
return Ok(0);
}
let success = self.run_upgrade(terminal, state, &selected_targets).await?;
self.stage = Stage::Completed;
self.execution_success = Some(success);
self.wait_for_exit(terminal, state).await?;
Ok(if success { 0 } else { 1 })
}
async fn run_checks(
&mut self,
terminal: &mut AppTerminal,
state: &mut AppState,
) -> io::Result<()> {
let (sender, mut receiver) = mpsc::unbounded_channel();
let mut jobs = JoinSet::new();
for target in self.targets.clone() {
let tx = sender.clone();
jobs.spawn(async move {
let _ = tx.send(CheckEvent::Started(target.clone()));
let result = run_single_check(&target).await;
let _ = tx.send(CheckEvent::Finished(Box::new(result)));
});
}
drop(sender);
while self.checks_done < self.targets.len() {
self.draw(terminal, state)?;
tokio::select! {
Some(event) = receiver.recv() => self.apply_check_event(state, event),
input = read_input(INPUT_TIMEOUT) => {
match input? {
Some(InputEvent::Key(key)) if is_exit_key(&key) => return Err(interrupted_error()),
Some(_) | None => {}
}
}
}
}
while jobs.join_next().await.is_some() {}
self.append_check_logs();
Ok(())
}
fn apply_check_event(&mut self, state: &mut AppState, event: CheckEvent) {
match event {
CheckEvent::Started(target) => {
if let Some(row) = self.check_rows.iter_mut().find(|row| row.target == target) {
row.state = RowState::Running;
row.detail = "正在检查".to_string();
}
}
CheckEvent::Finished(result) => {
let target = result.target.clone();
merge_check_result(state, &target, result.state);
let (row_state, detail) = check_row_status(&target, state);
if let Some(row) = self.check_rows.iter_mut().find(|row| row.target == target) {
row.state = row_state;
row.detail = detail;
}
self.checks_done += 1;
self.logs
.extend(result.logs.into_iter().map(|line| LogLine {
text: strip_log_line(&line),
color: THEME.muted,
}));
}
}
}
fn append_check_logs(&mut self) {
if self.logs.is_empty() {
self.push_log("检查完成, 等待选择升级目标".to_string(), THEME.success);
}
}
async fn run_dependency_prompts(
&mut self,
terminal: &mut AppTerminal,
state: &mut AppState,
) -> io::Result<()> {
if cargo_update_missing(state).await {
let confirmed = self
.prompt(
terminal,
state,
"安装 cargo-update",
vec![
"当前无法检查已安装 crate 的更新".to_string(),
"是否执行 cargo install cargo-update?".to_string(),
],
)
.await?;
if confirmed {
self.push_log("切换到原生终端安装 cargo-update".to_string(), THEME.info);
let installed = run_inherit_tty_handoff(
"cargo",
&["install", "cargo-update"],
"cargo install cargo-update",
)
.await
.unwrap_or(false);
terminal.clear()?;
if installed {
self.recheck_cargo(state).await;
} else {
self.push_log("cargo-update 安装失败".to_string(), THEME.error);
}
}
}
if cargo_binstall_missing(state).await {
let confirmed = self
.prompt(
terminal,
state,
"安装 cargo-binstall",
vec![
"cargo-binstall 可优先使用预编译二进制".to_string(),
"是否执行 cargo install cargo-binstall?".to_string(),
],
)
.await?;
if confirmed {
self.push_log("切换到原生终端安装 cargo-binstall".to_string(), THEME.info);
let installed = run_inherit_tty_handoff(
"cargo",
&["install", "cargo-binstall"],
"cargo install cargo-binstall",
)
.await
.unwrap_or(false);
terminal.clear()?;
self.push_log(
if installed {
"cargo-binstall 安装完成".to_string()
} else {
"cargo-binstall 安装失败".to_string()
},
if installed {
THEME.success
} else {
THEME.error
},
);
}
}
Ok(())
}
async fn recheck_cargo(&mut self, state: &mut AppState) {
let mut local = AppState::default();
parse_profile(&mut local).await;
let mut logs = Vec::new();
check_cargo_quiet(&mut local, &mut logs).await;
merge_check_result(state, "cargo", local);
for line in logs {
self.push_log(strip_log_line(&line), THEME.muted);
}
}
async fn selection_loop(
&mut self,
terminal: &mut AppTerminal,
state: &AppState,
) -> io::Result<Vec<String>> {
self.stage = Stage::Dashboard;
self.focus = Focus::Targets;
loop {
self.draw(terminal, state)?;
let Some(input) = read_input(INPUT_TIMEOUT).await? else {
continue;
};
match self.handle_selection_input(input) {
DashboardAction::Confirm => return Ok(self.selected_targets()),
DashboardAction::Exit => return Ok(Vec::new()),
DashboardAction::None | DashboardAction::Cancel => {}
}
}
}
async fn confirm_loop(
&mut self,
terminal: &mut AppTerminal,
state: &AppState,
selected: &[String],
) -> io::Result<bool> {
self.stage = Stage::Confirming;
self.modal = Some(Modal {
title: "确认执行升级".to_string(),
lines: vec![
format!("将升级 {} 个目标", selected.len()),
selected
.iter()
.map(|target| target_label(target))
.collect::<Vec<_>>()
.join(" · "),
"升级命令可能需要网络或管理员权限".to_string(),
],
confirm_selected: true,
});
loop {
self.draw(terminal, state)?;
let Some(input) = read_input(INPUT_TIMEOUT).await? else {
continue;
};
match self.handle_modal_input(input) {
DashboardAction::Confirm => {
self.modal = None;
return Ok(true);
}
DashboardAction::Cancel | DashboardAction::Exit => {
self.modal = None;
return Ok(false);
}
DashboardAction::None => {}
}
}
}
async fn prompt(
&mut self,
terminal: &mut AppTerminal,
state: &AppState,
title: &str,
lines: Vec<String>,
) -> io::Result<bool> {
self.modal = Some(Modal {
title: title.to_string(),
lines,
confirm_selected: true,
});
loop {
self.draw(terminal, state)?;
let Some(input) = read_input(INPUT_TIMEOUT).await? else {
continue;
};
match self.handle_modal_input(input) {
DashboardAction::Confirm => {
self.modal = None;
return Ok(true);
}
DashboardAction::Cancel | DashboardAction::Exit => {
self.modal = None;
return Ok(false);
}
DashboardAction::None => {}
}
}
}
async fn run_upgrade(
&mut self,
terminal: &mut AppTerminal,
state: &AppState,
selected: &[String],
) -> io::Result<bool> {
self.stage = Stage::Running;
self.focus = Focus::Details;
let (sender, mut receiver) = mpsc::unbounded_channel();
let state_snapshot = state.clone();
let selected_targets = selected.to_vec();
let mut upgrade = Box::pin(upgrade_selected_with_events(
&state_snapshot,
&selected_targets,
sender,
));
let result = loop {
self.draw(terminal, state)?;
tokio::select! {
completed = &mut upgrade => {
while let Ok(event) = receiver.try_recv() {
self.apply_upgrade_event(event);
}
break completed;
}
event = receiver.recv() => {
if let Some(event) = event {
self.apply_upgrade_event(event);
}
}
input = read_input(INPUT_TIMEOUT) => {
match input? {
Some(InputEvent::Key(key)) if is_exit_key(&key) => return Err(interrupted_error()),
Some(InputEvent::Key(key)) => self.handle_log_key(key),
Some(InputEvent::Mouse(mouse)) => self.handle_log_mouse(mouse),
Some(InputEvent::Resize) | None => {}
}
}
}
};
Ok(result)
}
fn apply_upgrade_event(&mut self, event: UpgradeEvent) {
match event {
UpgradeEvent::TargetStarted { target } => {
self.current_target = Some(target.clone());
self.target_results.remove(&target);
}
UpgradeEvent::TargetFinished { target, success } => {
self.target_results.insert(target.clone(), success);
self.current_target = Some(target);
}
UpgradeEvent::CommandStarted { program, args } => {
self.current_command = format!("{program} {}", args.join(" "));
self.pending_stdout.clear();
self.pending_stderr.clear();
self.push_log(format!("> {}", self.current_command), THEME.info);
}
UpgradeEvent::Output { stream, text } => {
let color = if matches!(stream, OutputStream::Stderr) {
THEME.warning
} else {
THEME.text
};
self.push_event_output(stream, &text, color);
}
UpgradeEvent::CommandFinished { success, code } => {
self.flush_pending_output(OutputStream::Stdout, THEME.text);
self.flush_pending_output(OutputStream::Stderr, THEME.warning);
self.push_log(
format!(
"命令{} (exit {code})",
if success { "完成" } else { "失败" }
),
if success { THEME.success } else { THEME.error },
);
}
UpgradeEvent::Message { text } => self.push_output(&text, THEME.text),
UpgradeEvent::HandoffStarted { label } => {
self.push_log(format!("切换到原生终端: {label}"), THEME.warning);
}
UpgradeEvent::HandoffFinished { success } => {
self.needs_full_redraw = true;
self.push_log(
if success {
"已返回 TUI".to_string()
} else {
"原生终端命令失败, 已返回 TUI".to_string()
},
if success { THEME.success } else { THEME.error },
);
}
UpgradeEvent::Finished { success } => self.execution_success = Some(success),
}
}
async fn wait_for_exit(
&mut self,
terminal: &mut AppTerminal,
state: &AppState,
) -> io::Result<()> {
loop {
self.draw(terminal, state)?;
let Some(input) = read_input(INPUT_TIMEOUT).await? else {
continue;
};
match input {
InputEvent::Key(key)
if is_exit_key(&key)
|| matches!(
key.code,
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')
) =>
{
return Ok(());
}
InputEvent::Key(key) => self.handle_log_key(key),
InputEvent::Mouse(mouse) => self.handle_log_mouse(mouse),
InputEvent::Resize => {}
}
}
}
fn handle_selection_input(&mut self, input: InputEvent) -> DashboardAction {
match input {
InputEvent::Key(key) => self.handle_selection_key(key),
InputEvent::Mouse(mouse) => self.handle_selection_mouse(mouse),
InputEvent::Resize => DashboardAction::None,
}
}
fn handle_selection_key(&mut self, key: KeyEvent) -> DashboardAction {
if is_exit_key(&key) {
return DashboardAction::Exit;
}
match key.code {
KeyCode::Tab => self.focus = next_focus(self.focus, false),
KeyCode::BackTab => self.focus = next_focus(self.focus, true),
KeyCode::Left => self.expanded_target = None,
KeyCode::Right => self.expanded_target = self.current_target_id(),
KeyCode::Up | KeyCode::Char('k') => self.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_cursor(1),
KeyCode::PageUp => self.detail_scroll = self.detail_scroll.saturating_sub(5),
KeyCode::PageDown => self.detail_scroll = self.detail_scroll.saturating_add(5),
KeyCode::Home if matches!(self.focus, Focus::Details) => self.detail_scroll = 0,
KeyCode::End if matches!(self.focus, Focus::Details) => self.detail_scroll = usize::MAX,
KeyCode::Home => self.target_cursor = 0,
KeyCode::End => self.target_cursor = self.targets.len().saturating_sub(1),
KeyCode::Char(' ') if matches!(self.focus, Focus::Targets) => self.toggle_current(),
KeyCode::Char('a') => self.selected = self.upgradable_targets.iter().cloned().collect(),
KeyCode::Char('n') => self.selected.clear(),
KeyCode::Enter if matches!(self.focus, Focus::Summary) => {
return DashboardAction::Confirm;
}
KeyCode::Enter if matches!(self.focus, Focus::Details) => {
self.expanded_target = if self.expanded_target.is_some() {
None
} else {
self.current_target_id()
};
}
KeyCode::Char('l') => self.focus = Focus::Details,
_ => {}
}
DashboardAction::None
}
fn handle_selection_mouse(&mut self, mouse: MouseEvent) -> DashboardAction {
match mouse.kind {
MouseEventKind::ScrollUp => self.move_cursor(-1),
MouseEventKind::ScrollDown => self.move_cursor(1),
MouseEventKind::Down(MouseButton::Left) => {
if self
.hit_zones
.targets
.contains((mouse.column, mouse.row).into())
{
let row = mouse.row.saturating_sub(self.hit_zones.targets.y + 1) as usize;
if row < self.targets.len() {
self.target_cursor = row;
self.focus = Focus::Targets;
if mouse.column <= self.hit_zones.targets.x.saturating_add(6) {
self.toggle_current();
}
}
} else if self
.hit_zones
.details
.contains((mouse.column, mouse.row).into())
{
self.focus = Focus::Details;
self.expanded_target = self.current_target_id();
} else if self
.hit_zones
.summary
.contains((mouse.column, mouse.row).into())
{
self.focus = Focus::Summary;
if mouse.row >= self.hit_zones.summary.bottom().saturating_sub(3) {
return DashboardAction::Confirm;
}
}
}
_ => {}
}
DashboardAction::None
}
fn handle_modal_input(&mut self, input: InputEvent) -> DashboardAction {
let Some(modal) = self.modal.as_mut() else {
return DashboardAction::None;
};
match input {
InputEvent::Key(key) => {
if is_exit_key(&key) || matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
return DashboardAction::Cancel;
}
match key.code {
KeyCode::Left | KeyCode::Char('h') => modal.confirm_selected = true,
KeyCode::Right | KeyCode::Tab | KeyCode::Char('l') => {
modal.confirm_selected = false
}
KeyCode::Enter => {
return if modal.confirm_selected {
DashboardAction::Confirm
} else {
DashboardAction::Cancel
};
}
_ => {}
}
}
InputEvent::Mouse(mouse) => {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
if self
.hit_zones
.modal_confirm
.contains((mouse.column, mouse.row).into())
{
return DashboardAction::Confirm;
}
if self
.hit_zones
.modal_cancel
.contains((mouse.column, mouse.row).into())
{
return DashboardAction::Cancel;
}
}
}
InputEvent::Resize => {}
}
DashboardAction::None
}
fn handle_log_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => self.log_scroll = self.log_scroll.saturating_add(3),
MouseEventKind::ScrollDown => self.log_scroll = self.log_scroll.saturating_sub(3),
_ => {}
}
}
fn handle_log_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::PageUp => self.log_scroll = self.log_scroll.saturating_add(5),
KeyCode::PageDown => self.log_scroll = self.log_scroll.saturating_sub(5),
KeyCode::Home => self.log_scroll = usize::MAX,
KeyCode::End | KeyCode::Char('l') => self.log_scroll = 0,
_ => {}
}
}
fn move_cursor(&mut self, delta: i32) {
if matches!(self.focus, Focus::Details) {
self.detail_scroll = if delta < 0 {
self.detail_scroll.saturating_sub(1)
} else {
self.detail_scroll.saturating_add(1)
};
return;
}
let max = self.targets.len().saturating_sub(1);
self.target_cursor = if delta < 0 {
self.target_cursor.saturating_sub(1)
} else {
self.target_cursor.saturating_add(1).min(max)
};
}
fn toggle_current(&mut self) {
let Some(target) = self.current_target_id() else {
return;
};
if !self.upgradable_targets.iter().any(|item| item == &target) {
return;
}
if !self.selected.remove(&target) {
self.selected.insert(target);
}
}
fn current_target_id(&self) -> Option<String> {
self.targets.get(self.target_cursor).cloned()
}
fn selected_targets(&self) -> Vec<String> {
self.upgradable_targets
.iter()
.filter(|target| self.selected.contains(*target))
.cloned()
.collect()
}
fn push_log(&mut self, text: String, color: Color) {
if self.logs.len() >= MAX_LOG_LINES {
self.logs.pop_front();
self.logs_truncated = true;
}
self.logs.push_back(LogLine { text, color });
}
fn push_output(&mut self, text: &str, color: Color) {
let normalized = text.replace('\r', "\n");
if normalized.trim().is_empty() {
return;
}
for line in normalized.lines() {
self.push_log(strip_log_line(line), color);
}
}
fn push_event_output(&mut self, stream: OutputStream, text: &str, color: Color) {
let mut pending = match stream {
OutputStream::Stdout => std::mem::take(&mut self.pending_stdout),
OutputStream::Stderr => std::mem::take(&mut self.pending_stderr),
};
pending.push_str(text);
let normalized = pending.replace('\r', "\n");
let has_trailing_newline = normalized.ends_with('\n');
let mut lines = normalized.split('\n').collect::<Vec<_>>();
let remainder = if has_trailing_newline {
""
} else {
lines.pop().unwrap_or("")
};
for line in lines {
if !line.trim().is_empty() {
self.push_log(strip_log_line(line), color);
}
}
match stream {
OutputStream::Stdout => self.pending_stdout = remainder.to_string(),
OutputStream::Stderr => self.pending_stderr = remainder.to_string(),
}
}
fn flush_pending_output(&mut self, stream: OutputStream, color: Color) {
let pending = match stream {
OutputStream::Stdout => std::mem::take(&mut self.pending_stdout),
OutputStream::Stderr => std::mem::take(&mut self.pending_stderr),
};
if !pending.trim().is_empty() {
self.push_log(strip_log_line(&pending), color);
}
}
fn draw(&mut self, terminal: &mut AppTerminal, state: &AppState) -> io::Result<()> {
if self.needs_full_redraw {
terminal.clear()?;
self.needs_full_redraw = false;
}
terminal.draw(|frame| self.render(frame, state)).map(|_| ())
}
fn render(&mut self, frame: &mut ratatui::Frame<'_>, state: &AppState) {
let area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(THEME.background)),
area,
);
let shell = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
render_header(frame, shell[0], self, state);
self.render_body(frame, shell[1], state);
render_footer(frame, shell[2], self);
if self.modal.is_some() {
self.render_modal(frame, area);
}
}
fn render_body(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let layout = body_layout(area);
self.hit_zones.targets = layout.targets;
self.hit_zones.details = layout.details;
self.hit_zones.summary = layout.summary;
render_target_rail(frame, layout.targets, self, state);
match self.stage {
Stage::Checking => render_check_detail(frame, layout.details, self),
Stage::Running => render_log_detail(frame, layout.details, self),
Stage::Completed => render_completed_detail(frame, layout.details, self),
Stage::Dashboard | Stage::Confirming => {
render_target_detail(frame, layout.details, self, state)
}
}
render_summary(frame, layout.summary, self, state);
}
fn render_modal(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let Some(modal) = self.modal.as_ref() else {
return;
};
let width = area.width.saturating_mul(65) / 100;
let height = ((modal.lines.len() as u16) + 7).clamp(9, area.height.saturating_sub(2));
let modal_area = centered_rect(width.max(40).min(area.width), height, area);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(Span::styled(
format!(" {} ", modal.title),
Style::default()
.fg(THEME.accent)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(THEME.accent))
.style(Style::default().bg(THEME.surface_alt));
let inner = block.inner(modal_area);
frame.render_widget(block, modal_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2)])
.split(inner);
let body = modal.lines.join("\n");
frame.render_widget(
Paragraph::new(body)
.style(Style::default().fg(THEME.text))
.wrap(Wrap { trim: false }),
chunks[0],
);
let yes = if modal.confirm_selected {
Style::default()
.fg(THEME.background)
.bg(THEME.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(THEME.muted)
};
let no = if modal.confirm_selected {
Style::default().fg(THEME.muted)
} else {
Style::default()
.fg(THEME.background)
.bg(THEME.warning)
.add_modifier(Modifier::BOLD)
};
let buttons = Line::from(vec![
Span::styled(" [ 确认 ] ", yes),
Span::raw(" "),
Span::styled(" [ 取消 ] ", no),
]);
frame.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
chunks[1],
);
self.hit_zones.modal_confirm = Rect {
x: chunks[1].x,
y: chunks[1].y,
width: chunks[1].width / 2,
height: chunks[1].height,
};
self.hit_zones.modal_cancel = Rect {
x: chunks[1].x + chunks[1].width / 2,
y: chunks[1].y,
width: chunks[1].width - chunks[1].width / 2,
height: chunks[1].height,
};
}
}
enum CheckEvent {
Started(String),
Finished(Box<CheckResult>),
}
struct BodyLayout {
targets: Rect,
details: Rect,
summary: Rect,
}
fn body_layout(area: Rect) -> BodyLayout {
let columns = if area.width >= 112 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(26),
Constraint::Percentage(52),
Constraint::Percentage(22),
])
.split(area)
} else if area.width >= 90 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(area)
};
match columns.len() {
3 => BodyLayout {
targets: columns[0],
details: columns[1],
summary: columns[2],
},
2 => BodyLayout {
targets: columns[0],
details: columns[1],
summary: Rect::default(),
},
_ => BodyLayout {
targets: Rect::default(),
details: columns[0],
summary: Rect::default(),
},
}
}
fn render_header(frame: &mut ratatui::Frame<'_>, area: Rect, app: &DashboardApp, state: &AppState) {
let title = Line::from(vec![
Span::styled(
" updt ",
Style::default()
.fg(THEME.background)
.bg(THEME.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
stage_title(app.stage),
Style::default().fg(THEME.text).add_modifier(Modifier::BOLD),
),
]);
let profile = format!(
"profile: {} | scan: {} | targets: {}/{}",
profile_name(state.system_profile),
app.start_time,
app.checks_done,
app.targets.len()
);
let header = Paragraph::new(Text::from(vec![
title,
Line::from(Span::styled(profile, Style::default().fg(THEME.muted))),
]))
.block(panel_block("UPDT / PACKAGE WORKSPACE"));
frame.render_widget(header, area);
}
fn render_footer(frame: &mut ratatui::Frame<'_>, area: Rect, app: &DashboardApp) {
let hint = match app.stage {
Stage::Checking => "Ctrl-C exit · live check events",
Stage::Dashboard => {
"↑↓/jk move Tab focus Space toggle → details a all n none Enter continue q back"
}
Stage::Confirming => "←→ select Enter confirm Esc cancel",
Stage::Running => "PageUp/PageDown scroll l logs Ctrl-C interrupt",
Stage::Completed => "Enter/q/Esc close PageUp/PageDown scroll logs",
};
let footer = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {} ", focus_label(app.focus)),
Style::default().fg(THEME.background).bg(THEME.accent),
),
Span::raw(" "),
Span::styled(hint, Style::default().fg(THEME.muted)),
]));
frame.render_widget(footer, area);
}
fn render_target_rail(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &DashboardApp,
state: &AppState,
) {
if area.width == 0 || area.height == 0 {
return;
}
let items = app
.targets
.iter()
.enumerate()
.map(|(index, target)| {
let (badge, badge_color) = if app.stage == Stage::Checking {
let row = &app.check_rows[index];
row_badge(row.state)
} else {
target_badge(target, state)
};
let selectable = app.upgradable_targets.iter().any(|item| item == target);
let mark = if selectable && app.selected.contains(target) {
"[x]"
} else if selectable {
"[ ]"
} else {
" "
};
let line = Line::from(vec![
Span::styled(format!("{mark} "), Style::default().fg(THEME.muted)),
Span::styled(
format!("{badge:<4}"),
Style::default()
.fg(badge_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", target_label(target)),
Style::default().fg(THEME.text),
),
]);
let mut item = ListItem::new(line);
if index == app.target_cursor {
item = item.style(Style::default().bg(THEME.surface_alt));
}
item
})
.collect::<Vec<_>>();
let list = List::new(items)
.block(panel_block("TARGET RAIL"))
.highlight_style(Style::default().bg(THEME.surface_alt));
let mut list_state = ListState::default();
list_state.select(Some(
app.target_cursor.min(app.targets.len().saturating_sub(1)),
));
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_check_detail(frame: &mut ratatui::Frame<'_>, area: Rect, app: &DashboardApp) {
let progress = if app.targets.is_empty() {
0.0
} else {
app.checks_done as f64 / app.targets.len() as f64
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(1)])
.split(area);
let gauge = Gauge::default()
.block(panel_block("CHECK ENGINE"))
.gauge_style(Style::default().fg(THEME.info).bg(THEME.surface))
.label(format!("{} / {}", app.checks_done, app.targets.len()))
.ratio(progress);
frame.render_widget(gauge, chunks[0]);
let rows = app
.check_rows
.iter()
.map(|row| {
ListItem::new(Line::from(vec![
Span::styled(
format!("{:<5} ", row_badge(row.state).0),
Style::default().fg(row_badge(row.state).1),
),
Span::styled(
format!("{:<12} ", target_label(&row.target)),
Style::default().fg(THEME.text),
),
Span::styled(row.detail.clone(), Style::default().fg(THEME.muted)),
]))
})
.collect::<Vec<_>>();
frame.render_widget(
List::new(rows).block(panel_block("CHECK STATUS")),
chunks[1],
);
}
fn render_target_detail(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &DashboardApp,
state: &AppState,
) {
let Some(target) = app.current_target_id() else {
frame.render_widget(
Paragraph::new("没有目标").block(panel_block("DETAIL")),
area,
);
return;
};
let items = updatable_items_for_target(state, &target);
let (badge, color) = target_badge(&target, state);
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{badge} "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
target_label(&target),
Style::default().fg(THEME.text).add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::styled(
format!(
"{} updatable item(s) · Enter/→ expand details",
items.len()
),
Style::default().fg(THEME.muted),
)),
Line::raw(""),
];
if items.is_empty() {
lines.push(Line::from(Span::styled(
"当前没有可升级明细",
Style::default().fg(THEME.muted),
)));
} else if app.expanded_target.as_deref() == Some(target.as_str()) {
lines.extend(items.into_iter().map(|item| {
Line::from(Span::styled(
format!(" - {item}"),
Style::default().fg(THEME.text),
))
}));
} else {
lines.push(Line::from(Span::styled(
format!("已折叠 {} 个包", items.len()),
Style::default().fg(THEME.accent),
)));
}
let inner_height = area.height.saturating_sub(2) as usize;
let offset = app
.detail_scroll
.min(lines.len().saturating_sub(inner_height));
let visible = lines
.into_iter()
.skip(offset)
.take(inner_height)
.collect::<Vec<_>>();
let detail = Paragraph::new(Text::from(visible))
.block(panel_block(if app.stage == Stage::Confirming {
"REVIEW"
} else {
"DETAIL PANE"
}))
.wrap(Wrap { trim: false });
frame.render_widget(detail, area);
}
fn render_log_detail(frame: &mut ratatui::Frame<'_>, area: Rect, app: &DashboardApp) {
let inner_height = area.height.saturating_sub(2) as usize;
let total = app.logs.len();
let end = total.saturating_sub(app.log_scroll);
let start = end.saturating_sub(inner_height);
let lines = app
.logs
.iter()
.skip(start)
.take(end.saturating_sub(start))
.map(|line| {
Line::from(Span::styled(
line.text.clone(),
Style::default().fg(line.color),
))
})
.collect::<Vec<_>>();
let title = if app.current_command.is_empty() {
"LIVE COMMAND LOG".to_string()
} else {
format!("LIVE COMMAND LOG · {}", app.current_command)
};
let paragraph = Paragraph::new(Text::from(lines))
.block(panel_block(&title))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_completed_detail(frame: &mut ratatui::Frame<'_>, area: Rect, app: &DashboardApp) {
let success = app.execution_success.unwrap_or(false);
let mut lines = vec![
Line::from(Span::styled(
if success {
"UPDATE COMPLETE"
} else {
"UPDATE FINISHED WITH ERRORS"
},
Style::default()
.fg(if success { THEME.success } else { THEME.error })
.add_modifier(Modifier::BOLD),
)),
Line::raw(""),
];
for (target, result) in &app.target_results {
lines.push(Line::from(vec![
Span::styled(
if *result { "OK " } else { "ERR " },
Style::default()
.fg(if *result { THEME.success } else { THEME.error })
.add_modifier(Modifier::BOLD),
),
Span::styled(target_label(target), Style::default().fg(THEME.text)),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
app.notice
.clone()
.unwrap_or_else(|| "按 Enter 关闭".to_string()),
Style::default().fg(THEME.muted),
)));
frame.render_widget(
Paragraph::new(Text::from(lines)).block(panel_block("RESULT")),
area,
);
}
fn render_summary(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &DashboardApp,
state: &AppState,
) {
if area.width == 0 || area.height == 0 {
return;
}
let selected = app.selected_targets();
let updates = app
.targets
.iter()
.filter(|target| target_state_flags(state, target).is_some_and(|flags| flags.has_updates))
.count();
let errors = app
.targets
.iter()
.filter(|target| target_state_flags(state, target).is_some_and(|flags| flags.check_failed))
.count();
let mut lines = vec![
Line::from(vec![
Span::styled("UPDATES ", Style::default().fg(THEME.muted)),
Span::styled(
updates.to_string(),
Style::default()
.fg(THEME.warning)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("SELECTED ", Style::default().fg(THEME.muted)),
Span::styled(
selected.len().to_string(),
Style::default()
.fg(THEME.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("ERRORS ", Style::default().fg(THEME.muted)),
Span::styled(
errors.to_string(),
Style::default()
.fg(if errors == 0 {
THEME.success
} else {
THEME.error
})
.add_modifier(Modifier::BOLD),
),
]),
Line::raw(""),
Line::from(Span::styled(
"SELECTED TARGETS",
Style::default()
.fg(THEME.accent)
.add_modifier(Modifier::BOLD),
)),
];
if selected.is_empty() {
lines.push(Line::from(Span::styled(
"none",
Style::default().fg(THEME.muted),
)));
} else {
lines.extend(selected.iter().map(|target| {
Line::from(Span::styled(
format!(" · {}", target_label(target)),
Style::default().fg(THEME.text),
))
}));
}
if let Some(target) = &app.current_target {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"ACTIVE",
Style::default()
.fg(THEME.accent)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
target_label(target),
Style::default().fg(THEME.text),
)));
}
if app.logs_truncated {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"log buffer truncated",
Style::default().fg(THEME.warning),
)));
}
frame.render_widget(
Paragraph::new(Text::from(lines)).block(panel_block("SUMMARY")),
area,
);
}
fn panel_block(title: &str) -> Block<'static> {
Block::default()
.title(Span::styled(
format!(" {title} "),
Style::default()
.fg(THEME.accent)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(THEME.border))
.style(Style::default().bg(THEME.surface))
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn stage_title(stage: Stage) -> &'static str {
match stage {
Stage::Checking => "Checking package managers",
Stage::Dashboard => "Choose upgrade targets",
Stage::Confirming => "Review upgrade plan",
Stage::Running => "Running upgrades",
Stage::Completed => "Upgrade summary",
}
}
fn focus_label(focus: Focus) -> &'static str {
match focus {
Focus::Targets => "TARGETS",
Focus::Details => "DETAILS",
Focus::Summary => "SUMMARY",
}
}
fn next_focus(focus: Focus, reverse: bool) -> Focus {
match (focus, reverse) {
(Focus::Targets, false) => Focus::Details,
(Focus::Details, false) => Focus::Summary,
(Focus::Summary, false) => Focus::Targets,
(Focus::Targets, true) => Focus::Summary,
(Focus::Details, true) => Focus::Targets,
(Focus::Summary, true) => Focus::Details,
}
}
fn target_badge(target: &str, state: &AppState) -> (&'static str, Color) {
let Some(flags) = target_state_flags(state, target) else {
return ("ERR", THEME.error);
};
checked_badge(flags)
}
fn checked_badge(flags: TargetStateFlags) -> (&'static str, Color) {
if !flags.enabled || !flags.installed {
("SKIP", THEME.muted)
} else if flags.check_failed {
("ERR", THEME.error)
} else if flags.has_updates {
("UPD", THEME.warning)
} else {
("OK", THEME.success)
}
}
fn row_badge(state: RowState) -> (&'static str, Color) {
match state {
RowState::Waiting => ("WAIT", THEME.muted),
RowState::Running => ("RUN", THEME.info),
RowState::Ok => ("OK", THEME.success),
RowState::Update => ("UPD", THEME.warning),
RowState::Warn => ("WARN", THEME.warning),
RowState::Error => ("ERR", THEME.error),
}
}
fn check_row_status(target: &str, state: &AppState) -> (RowState, String) {
let Some(flags) = target_state_flags(state, target) else {
return (RowState::Error, "未知目标".to_string());
};
if !flags.enabled || !flags.installed {
(RowState::Warn, "按策略跳过".to_string())
} else if flags.check_failed {
(RowState::Error, "检查失败".to_string())
} else if flags.has_updates {
(
RowState::Update,
format!(
"{} 个可升级项",
updatable_items_for_target(state, target).len()
),
)
} else {
(RowState::Ok, "当前最新".to_string())
}
}
fn strip_log_line(line: &str) -> String {
crate::parse::strip_ansi_control_sequences(line)
.trim_end()
.to_string()
}
async fn read_input(timeout: Duration) -> io::Result<Option<InputEvent>> {
tokio::task::block_in_place(|| {
if !event::poll(timeout)? {
return Ok(None);
}
let event = match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => InputEvent::Key(key),
Event::Mouse(mouse) => InputEvent::Mouse(mouse),
Event::Resize(_, _) => InputEvent::Resize,
_ => return Ok(None),
};
Ok(Some(event))
})
}
fn is_exit_key(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))
}
fn interrupted_error() -> io::Error {
io::Error::new(io::ErrorKind::Interrupted, "user requested exit")
}
#[cfg(test)]
mod tests {
use super::{
DashboardAction, DashboardApp, Focus, InputEvent, Modal, RowState, Stage, THEME,
body_layout, checked_badge, next_focus, row_badge, strip_log_line,
};
use crate::events::{OutputStream, UpgradeEvent};
use crate::state::AppState;
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
#[test]
fn responsive_layout_hides_summary_on_narrow_terminals() {
let wide = body_layout(Rect::new(0, 0, 120, 30));
let medium = body_layout(Rect::new(0, 0, 100, 30));
let narrow = body_layout(Rect::new(0, 0, 79, 24));
assert!(wide.summary.width > 0);
assert_eq!(medium.summary, Rect::default());
assert_eq!(narrow.targets, Rect::default());
assert_eq!(narrow.details.width, 79);
}
#[test]
fn focus_cycles_in_both_directions() {
assert_eq!(next_focus(Focus::Targets, false), Focus::Details);
assert_eq!(next_focus(Focus::Details, false), Focus::Summary);
assert_eq!(next_focus(Focus::Targets, true), Focus::Summary);
}
#[test]
fn status_badges_use_semantic_colors() {
assert_eq!(row_badge(RowState::Running).0, "RUN");
assert_eq!(row_badge(RowState::Error).0, "ERR");
assert_eq!(Stage::Completed, Stage::Completed);
assert_eq!(
checked_badge(crate::state::TargetStateFlags {
enabled: true,
installed: true,
check_failed: false,
has_updates: true,
needs_cargo_updater: false,
})
.0,
"UPD"
);
}
#[test]
fn test_backend_renders_all_supported_dashboard_sizes() {
for (width, height) in [(120, 36), (100, 30), (80, 24)] {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
let state = AppState::default();
let mut app = DashboardApp::new(
vec!["npm".to_string(), "cargo".to_string()],
"2026-08-05 12:00:00",
);
terminal
.draw(|frame| app.render(frame, &state))
.expect("dashboard render");
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("updt"));
assert!(app.hit_zones.details.width > 0);
if width >= 120 {
assert!(app.hit_zones.summary.width > 0);
assert!(rendered.contains("SUMMARY"));
} else if width >= 100 {
assert_eq!(app.hit_zones.summary, Rect::default());
} else {
assert_eq!(app.hit_zones.targets, Rect::default());
assert_eq!(app.hit_zones.details.width, width);
}
}
}
#[test]
fn test_backend_renders_confirmation_overlay_without_overflow() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
let state = AppState::default();
let mut app = DashboardApp::new(vec!["npm".to_string()], "scan");
app.stage = Stage::Confirming;
app.modal = Some(Modal {
title: "确认执行升级".to_string(),
lines: vec!["npm".to_string(), "管理员权限可能需要输入".to_string()],
confirm_selected: true,
});
terminal
.draw(|frame| app.render(frame, &state))
.expect("confirmation render");
assert!(app.hit_zones.modal_confirm.width > 0);
assert!(app.hit_zones.modal_cancel.width > 0);
}
#[test]
fn selection_actions_cover_toggle_select_clear_expand_and_confirm() {
let mut app = DashboardApp::new(vec!["npm".to_string(), "cargo".to_string()], "scan");
app.upgradable_targets = app.targets.clone();
app.selected = app.upgradable_targets.iter().cloned().collect();
assert_eq!(
app.handle_selection_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)),
DashboardAction::None
);
assert_eq!(app.selected.len(), 1);
app.handle_selection_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
assert!(app.selected.is_empty());
app.handle_selection_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(app.selected.len(), 2);
app.handle_selection_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(app.expanded_target.as_deref(), Some("npm"));
app.focus = Focus::Summary;
assert_eq!(
app.handle_selection_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
DashboardAction::Confirm
);
}
#[test]
fn mouse_target_click_selects_and_toggles_current_row() {
let mut app = DashboardApp::new(vec!["npm".to_string(), "cargo".to_string()], "scan");
app.upgradable_targets = app.targets.clone();
app.selected = app.upgradable_targets.iter().cloned().collect();
app.hit_zones.targets = Rect::new(0, 0, 24, 6);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 2,
modifiers: KeyModifiers::NONE,
};
assert_eq!(
app.handle_selection_input(InputEvent::Mouse(mouse)),
DashboardAction::None
);
assert_eq!(app.target_cursor, 1);
assert!(!app.selected.contains("cargo"));
}
#[test]
fn output_fragments_are_joined_and_ansi_is_removed() {
let mut app = DashboardApp::new(vec!["npm".to_string()], "scan");
app.apply_upgrade_event(UpgradeEvent::CommandStarted {
program: "npm".to_string(),
args: vec!["update".to_string()],
});
app.apply_upgrade_event(UpgradeEvent::Output {
stream: OutputStream::Stdout,
text: "part".to_string(),
});
app.apply_upgrade_event(UpgradeEvent::Output {
stream: OutputStream::Stdout,
text: "ial\n".to_string(),
});
app.apply_upgrade_event(UpgradeEvent::CommandFinished {
success: true,
code: 0,
});
assert!(app.logs.iter().any(|line| line.text == "partial"));
assert_eq!(strip_log_line("\x1b[31mfailed\x1b[0m"), "failed");
}
#[test]
fn log_buffer_is_bounded() {
let mut app = DashboardApp::new(Vec::new(), "scan");
for index in 0..(super::MAX_LOG_LINES + 1) {
app.push_log(format!("line {index}"), THEME.text);
}
assert_eq!(app.logs.len(), super::MAX_LOG_LINES);
assert!(app.logs_truncated);
}
}