feat: redesign cli ux progress
This commit is contained in:
parent
ab60ee641f
commit
c63b2917da
21 changed files with 994 additions and 99 deletions
|
|
@ -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(®istry_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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
pub mod progress;
|
||||
pub mod prompt;
|
||||
pub mod render;
|
||||
pub mod theme;
|
||||
|
|
|
|||
154
crates/aim-cli/src/ui/progress.rs
Normal file
154
crates/aim-cli/src/ui/progress.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
22
crates/aim-cli/src/ui/theme.rs
Normal file
22
crates/aim-cli/src/ui/theme.rs
Normal 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}")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue