feat: redesign cli ux progress

This commit is contained in:
stoorps 2026-03-20 17:47:32 +00:00
parent ab60ee641f
commit c63b2917da
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
21 changed files with 994 additions and 99 deletions

View file

@ -5,11 +5,12 @@ use std::env;
use std::path::{Path, PathBuf};
use aim_core::app::add::{
AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope,
AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope,
};
use aim_core::app::list::{ListRow, build_list_rows};
use aim_core::app::remove::{RemovalResult, remove_registered_app};
use aim_core::app::update::{build_update_plan, execute_updates};
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
use aim_core::domain::app::AppRecord;
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
use aim_core::registry::model::Registry;
@ -22,6 +23,14 @@ pub fn parse() -> Cli {
}
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
let mut reporter = NoopReporter;
dispatch_with_reporter(cli, &mut reporter)
}
pub fn dispatch_with_reporter(
cli: Cli,
reporter: &mut impl ProgressReporter,
) -> Result<DispatchResult, DispatchError> {
let registry_path = registry_path();
let install_home = install_home(&registry_path);
let store = RegistryStore::new(registry_path);
@ -36,21 +45,40 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
return match command {
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
cli::args::Command::Remove { query } => {
let removal = remove_registered_app(&query, &apps, &install_home)?;
let removal =
remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?;
let remaining_apps = removal.remaining_apps.clone();
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: remaining_apps,
})?;
reporter.report(&OperationEvent::Finished {
summary: format!("removed {}", removal.removed.stable_id),
});
Ok(DispatchResult::Removed(Box::new(removal)))
}
cli::args::Command::Update => {
let updates = execute_updates(&apps, &install_home)?;
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
let updated_apps = updates.apps.clone();
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: updated_apps,
})?;
reporter.report(&OperationEvent::Finished {
summary: format!(
"updated {}, failed {}",
updates.updated_count(),
updates.failed_count()
),
});
Ok(DispatchResult::Updated(Box::new(updates)))
}
};
@ -68,13 +96,21 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
}
}
let installed = install_app(&query, &plan, &install_home, requested_scope)?;
let installed =
install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?;
let mut updated_apps = registry.apps.clone();
upsert_app_record(&mut updated_apps, installed.record.clone());
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: updated_apps,
})?;
reporter.report(&OperationEvent::Finished {
summary: format!("installed {}", installed.record.stable_id),
});
return Ok(DispatchResult::Added(Box::new(installed)));
}

View file

@ -1,6 +1,7 @@
fn main() {
let cli = aim_cli::parse();
match aim_cli::dispatch(cli) {
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
Ok(result) => {
let output = aim_cli::render(&result);
if !output.is_empty() {

View file

@ -1,2 +1,4 @@
pub mod progress;
pub mod prompt;
pub mod render;
pub mod theme;

View file

@ -0,0 +1,154 @@
use std::io::IsTerminal;
use std::time::Duration;
use aim_core::app::progress::{OperationEvent, OperationKind, OperationStage, ProgressReporter};
use indicatif::{ProgressBar, ProgressStyle};
pub fn new_progress_bar(total: Option<u64>) -> ProgressBar {
match total {
Some(total) => ProgressBar::new(total),
None => ProgressBar::new_spinner(),
}
}
pub fn spinner_style() -> ProgressStyle {
ProgressStyle::with_template("{spinner} {msg}").expect("spinner template is valid")
}
pub fn byte_style() -> ProgressStyle {
ProgressStyle::with_template("{bar:32.cyan/blue} {bytes}/{total_bytes} {msg}")
.expect("byte progress template is valid")
}
pub fn operation_label(kind: OperationKind) -> &'static str {
match kind {
OperationKind::Add => "Installing",
OperationKind::UpdateBatch => "Updating",
OperationKind::UpdateItem => "Updating",
OperationKind::Remove => "Removing",
}
}
pub fn stage_label(stage: OperationStage) -> &'static str {
match stage {
OperationStage::ResolveQuery => "Resolving source",
OperationStage::DiscoverRelease => "Discovering release",
OperationStage::SelectArtifact => "Selecting artifact",
OperationStage::DownloadArtifact => "Downloading artifact",
OperationStage::StagePayload => "Staging payload",
OperationStage::WriteDesktopEntry => "Writing desktop entry",
OperationStage::ExtractIcon => "Extracting icon",
OperationStage::RefreshIntegration => "Refreshing desktop integration",
OperationStage::SaveRegistry => "Saving registry",
OperationStage::Finalize => "Finalizing",
}
}
pub fn event_message(event: &OperationEvent) -> Option<String> {
match event {
OperationEvent::Started { kind, label } => {
Some(format!("{} {label}", operation_label(*kind)))
}
OperationEvent::StageChanged { stage, message } => {
let title = stage_label(*stage);
if title.eq_ignore_ascii_case(message) {
Some(title.to_owned())
} else {
Some(format!("{title}: {message}"))
}
}
OperationEvent::Progress { .. } => None,
OperationEvent::Warning { message } => Some(format!("Warning: {message}")),
OperationEvent::Finished { summary } => Some(summary.clone()),
OperationEvent::Failed { stage, reason } => {
Some(format!("{} failed: {reason}", stage_label(*stage)))
}
}
}
pub struct TerminalProgressReporter {
interactive: bool,
progress_bar: Option<ProgressBar>,
byte_total: Option<u64>,
}
impl TerminalProgressReporter {
pub fn stderr() -> Self {
Self {
interactive: std::io::stderr().is_terminal(),
progress_bar: None,
byte_total: None,
}
}
fn clear_progress(&mut self) {
if let Some(progress_bar) = self.progress_bar.take() {
progress_bar.finish_and_clear();
}
self.byte_total = None;
}
fn show_spinner(&mut self, message: String) {
if !self.interactive {
eprintln!("{message}");
return;
}
let progress_bar = self.progress_bar.get_or_insert_with(|| {
let progress_bar = new_progress_bar(None);
progress_bar.set_style(spinner_style());
progress_bar.enable_steady_tick(Duration::from_millis(100));
progress_bar
});
progress_bar.set_message(message);
self.byte_total = None;
}
fn show_progress(&mut self, current: u64, total: Option<u64>) {
if !self.interactive {
return;
}
let total = total.unwrap_or_else(|| current.max(1));
let replace_progress = self.byte_total != Some(total);
if replace_progress {
self.clear_progress();
let progress_bar = new_progress_bar(Some(total));
progress_bar.set_style(byte_style());
self.progress_bar = Some(progress_bar);
self.byte_total = Some(total);
}
if let Some(progress_bar) = &self.progress_bar {
progress_bar.set_length(total);
progress_bar.set_position(current.min(total));
}
}
}
impl Default for TerminalProgressReporter {
fn default() -> Self {
Self::stderr()
}
}
impl ProgressReporter for TerminalProgressReporter {
fn report(&mut self, event: &OperationEvent) {
match event {
OperationEvent::Started { .. } | OperationEvent::StageChanged { .. } => {
if let Some(message) = event_message(event) {
self.show_spinner(message);
}
}
OperationEvent::Progress { current, total } => self.show_progress(*current, *total),
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
self.clear_progress();
if let Some(message) = event_message(event) {
eprintln!("{message}");
}
}
OperationEvent::Finished { .. } => self.clear_progress(),
}
}
}

View file

@ -3,24 +3,24 @@ use std::io::IsTerminal;
use aim_core::app::add::{AddPlan, prefer_latest_tracking};
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use dialoguer::{Select, theme::ColorfulTheme};
use dialoguer::Select;
const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE";
pub fn render_interaction(request: &InteractionRequest) -> String {
match &request.kind {
InteractionKind::SelectRegisteredApp { query, matches } => format!(
"multiple installed apps match '{query}': {}",
"Choose the installed app matching '{query}': {}",
matches.join(", ")
),
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => format!(
"tracking preference required: requested {requested_version}, latest available {latest_version}",
"Choose update tracking: requested {requested_version}, latest available {latest_version}",
),
InteractionKind::SelectArtifact { candidates } => {
format!("artifact selection required: {}", candidates.join(", "))
format!("Choose an artifact: {}", candidates.join(", "))
}
}
}
@ -100,8 +100,8 @@ fn resolve_tracking_preference(
format!("Keep tracking the requested release lineage ({requested_version})"),
format!("Track the latest release after install ({latest_version})"),
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose update tracking behavior")
let selection = Select::with_theme(&crate::ui::theme::dialog_theme())
.with_prompt("Choose update tracking")
.items(options)
.default(1)
.interact()?;

View file

@ -4,7 +4,13 @@ use aim_core::domain::update::UpdateExecutionStatus;
use crate::DispatchResult;
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
format!("updates found: {total}, selected: {selected}, failed: {failed}",)
[
crate::ui::theme::heading("Update Review"),
format!("apps with updates: {total}"),
format!("selected: {selected}"),
format!("failed: {failed}"),
]
.join("\n")
}
pub fn render_dispatch_result(result: &DispatchResult) -> String {
@ -13,9 +19,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
DispatchResult::List(rows) => render_list(rows),
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
DispatchResult::Removed(removed) => render_removed_app(removed),
DispatchResult::UpdatePlan(plan) => {
render_update_summary(plan.items.len(), plan.items.len(), 0)
}
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
DispatchResult::Updated(result) => render_updated_apps(result),
DispatchResult::Noop => String::new(),
}
@ -31,46 +35,68 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
.warnings
.iter()
.chain(added.install_outcome.warnings.iter())
.map(|warning| format!("warning: {warning}"))
.collect::<Vec<_>>()
.join("\n");
.map(|warning| format!("Warning: {warning}"))
.collect::<Vec<_>>();
let summary = format!(
"installing as {scope}\ninstalled app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]",
added.record.display_name,
added.record.stable_id,
added.source.kind.as_str(),
added.source.locator,
added.selected_artifact.url,
added.selected_artifact.selection_reason,
);
let mut lines = vec![
crate::ui::theme::heading("Installation Summary"),
format!(
"{} {} ({})",
crate::ui::theme::label("Application"),
added.record.display_name,
added.record.stable_id,
),
format!("{} {scope}", crate::ui::theme::label("Install scope")),
format!(
"{} {} {}",
crate::ui::theme::label("Source"),
added.source.kind.as_str(),
added.source.locator,
),
format!(
"{} {} [{}]",
crate::ui::theme::label("Selected artifact"),
added.selected_artifact.url,
added.selected_artifact.selection_reason,
),
];
if warning_lines.is_empty() {
summary
} else {
format!("{summary}\n{warning_lines}")
}
lines.extend(warning_lines);
lines.join("\n")
}
fn render_pending_add(plan: &AddPlan) -> String {
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
format!(
"resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}",
plan.resolution.source.kind.as_str(),
plan.resolution.source.locator,
plan.selected_artifact.url,
plan.selected_artifact.selection_reason,
)
[
crate::ui::theme::heading("Installation Review"),
format!(
"{} {} {}",
crate::ui::theme::label("Resolved source"),
plan.resolution.source.kind.as_str(),
plan.resolution.source.locator,
),
format!(
"{} {} [{}]",
crate::ui::theme::label("Selected artifact"),
plan.selected_artifact.url,
plan.selected_artifact.selection_reason,
),
prompts,
]
.join("\n")
}
fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
if rows.is_empty() {
return "installed apps: none".to_owned();
return crate::ui::theme::muted("No installed apps yet");
}
let mut output = String::from("installed apps:\n");
let mut output = format!("{}\n", crate::ui::theme::heading("Installed Apps"));
for row in rows {
output.push_str(&format!("- {} ({})\n", row.display_name, row.stable_id));
output.push_str(&format!(
"{}\n",
crate::ui::theme::bullet(&format!("{} ({})", row.display_name, row.stable_id))
));
}
output.trim_end().to_owned()
}
@ -79,36 +105,38 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
let warning_lines = removed
.warnings
.iter()
.map(|warning| format!("warning: {warning}"))
.collect::<Vec<_>>()
.join("\n");
let summary = format!("removed: {}", removed.removed.display_name);
if warning_lines.is_empty() {
summary
} else {
format!("{summary}\n{warning_lines}")
}
.map(|warning| format!("Warning: {warning}"))
.collect::<Vec<_>>();
let mut lines = vec![
crate::ui::theme::heading("Removal Summary"),
format!(
"{} {}",
crate::ui::theme::label("Removed app"),
removed.removed.display_name,
),
];
lines.extend(warning_lines);
lines.join("\n")
}
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
let mut lines = vec![format!(
"updated apps: {}, failed: {}",
result.updated_count(),
result.failed_count()
)];
let mut lines = vec![
crate::ui::theme::heading("Update Summary"),
format!("updated apps: {}", result.updated_count()),
format!("failed updates: {}", result.failed_count()),
];
for item in &result.items {
match &item.status {
UpdateExecutionStatus::Updated => lines.push(format!(
"updated: {} ({}) {} -> {}",
"Updated: {} ({}) {} -> {}",
item.display_name,
item.stable_id,
item.from_version.as_deref().unwrap_or("unknown"),
item.to_version.as_deref().unwrap_or("unknown")
)),
UpdateExecutionStatus::Failed { reason } => lines.push(format!(
"failed: {} ({}) {}",
"Failed: {} ({}) {}",
item.display_name, item.stable_id, reason
)),
}
@ -116,3 +144,16 @@ fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult)
lines.join("\n")
}
fn render_update_plan(plan: &aim_core::domain::update::UpdatePlan) -> String {
let mut lines = vec![render_update_summary(plan.items.len(), plan.items.len(), 0)];
for item in &plan.items {
lines.push(format!(
"{} ({}) via {}",
item.display_name, item.stable_id, item.selected_channel.locator
));
}
lines.join("\n")
}

View file

@ -0,0 +1,22 @@
use console::style;
use dialoguer::theme::ColorfulTheme;
pub fn dialog_theme() -> ColorfulTheme {
ColorfulTheme::default()
}
pub fn heading(title: &str) -> String {
style(title).bold().to_string()
}
pub fn label(title: &str) -> String {
style(format!("{title}:")).bold().to_string()
}
pub fn muted(message: &str) -> String {
style(message).dim().to_string()
}
pub fn bullet(message: &str) -> String {
format!("- {message}")
}