feat(cli): enhance install and removal UX with progress visibility and theming

- Introduced visible progress stages during installation, including source resolution and artifact selection.
- Improved separation between live transcript output and final summaries, ensuring clarity.
- Removed redundant recap text from installation summaries.
- Centralized terminal styling using a configurable theme system, allowing for warm defaults and user overrides.
- Added support for hex colors and named colors in the configuration.
- Updated tests to verify new behaviors and configurations.
This commit is contained in:
stoorps 2026-03-20 19:44:04 +00:00
parent c63b2917da
commit 9d8ec1e4fd
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
17 changed files with 1277 additions and 74 deletions

View file

@ -27,19 +27,37 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
build_add_plan_with(query, transport.as_ref())
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport.as_ref(), &mut reporter)
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport, &mut reporter)
}
pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
});
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
@ -60,6 +78,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
@ -89,6 +111,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
)
}
_ => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
@ -249,17 +275,6 @@ pub fn install_app_with_reporter(
)),
};
if desktop_owned.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
@ -280,6 +295,20 @@ pub fn install_app_with_reporter(
})
.map_err(InstallAppError::Install)?;
if install_outcome.desktop_entry_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
}
if install_outcome.icon_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
message: "refreshing desktop integration".to_owned(),
@ -308,6 +337,7 @@ pub fn install_app_with_reporter(
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
artifact_size_bytes: artifact_bytes.len() as u64,
source: plan.resolution.source.clone(),
install_scope: policy.scope,
integration_mode: policy.integration_mode,
@ -326,6 +356,7 @@ pub fn install_app_with_reporter(
pub struct InstalledApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub artifact_size_bytes: u64,
pub source: crate::domain::source::SourceRef,
pub install_scope: InstallScope,
pub integration_mode: IntegrationMode,

View file

@ -4,6 +4,8 @@ use crate::domain::app::AppRecord;
pub struct ListRow {
pub stable_id: String,
pub display_name: String,
pub version: Option<String>,
pub source: String,
}
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
@ -11,6 +13,13 @@ pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
.map(|app| ListRow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
version: app.installed_version.clone(),
source: app
.source
.as_ref()
.map(|source| source.locator.clone())
.or_else(|| app.source_input.clone())
.unwrap_or_else(|| "-".to_owned()),
})
.collect()
}

View file

@ -93,7 +93,7 @@ pub fn remove_registered_app_with_reporter(
stage: OperationStage::StagePayload,
message: "removing managed artifacts".to_owned(),
});
let warnings = delete_artifacts(&plan)?;
let deletion = delete_artifacts(&plan)?;
let remaining_apps = apps
.iter()
.filter(|candidate| candidate.stable_id != app.stable_id)
@ -102,8 +102,9 @@ pub fn remove_registered_app_with_reporter(
let result = RemovalResult {
removed: plan,
removed_paths: deletion.removed_paths,
remaining_apps,
warnings,
warnings: deletion.warnings,
};
reporter.report(&OperationEvent::StageChanged {
@ -120,6 +121,7 @@ pub fn remove_registered_app_with_reporter(
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalResult {
pub removed: RemovalPlan,
pub removed_paths: Vec<String>,
pub remaining_apps: Vec<AppRecord>,
pub warnings: Vec<String>,
}
@ -161,13 +163,19 @@ fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf>
]
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredAppError> {
struct DeletionOutcome {
removed_paths: Vec<String>,
warnings: Vec<String>,
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<DeletionOutcome, RemoveRegisteredAppError> {
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
let mut removed_paths = Vec::new();
for artifact_path in &plan.artifact_paths {
match fs::remove_file(artifact_path) {
Ok(()) => {}
Ok(()) => removed_paths.push(artifact_path.clone()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
}
@ -182,5 +190,8 @@ fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredA
));
}
Ok(warnings)
Ok(DeletionOutcome {
removed_paths,
warnings,
})
}

View file

@ -1,4 +1,4 @@
use aim_core::app::add::{build_add_plan_with, install_app_with_reporter};
use aim_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::domain::app::InstallScope;
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
@ -133,7 +133,6 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
#[test]
fn install_app_reports_operation_stages_in_order() {
let root = tempdir().unwrap();
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
let mut events: Vec<OperationEvent> = Vec::new();
unsafe {
@ -142,6 +141,9 @@ fn install_app_reports_operation_stages_in_order() {
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let plan = build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut reporter)
.unwrap();
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
@ -152,6 +154,18 @@ fn install_app_reports_operation_stages_in_order() {
.unwrap();
assert_eq!(installed.record.stable_id, "sharkdp-bat");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
@ -182,4 +196,33 @@ fn install_app_reports_operation_stages_in_order() {
}
)
}));
let stage_order = events
.iter()
.filter_map(|event| match event {
OperationEvent::StageChanged { stage, .. } => Some(*stage),
_ => None,
})
.collect::<Vec<_>>();
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::ResolveQuery,
OperationStage::DiscoverRelease,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::DiscoverRelease,
OperationStage::SelectArtifact,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::SelectArtifact,
OperationStage::DownloadArtifact,
]
}));
}

View file

@ -5,6 +5,7 @@ use aim_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use std::path::Path;
use tempfile::tempdir;
@ -20,9 +21,18 @@ fn list_flow_returns_display_rows_for_registered_apps() {
let rows = build_list_rows(&[AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: "sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.25.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
@ -31,6 +41,8 @@ fn list_flow_returns_display_rows_for_registered_apps() {
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].stable_id, "bat");
assert_eq!(rows[0].display_name, "Bat");
assert_eq!(rows[0].version.as_deref(), Some("0.25.0"));
assert_eq!(rows[0].source, "sharkdp/bat");
}
#[test]
@ -162,6 +174,7 @@ fn remove_flow_reports_resolution_and_cleanup_events() {
.unwrap();
assert_eq!(result.removed.stable_id, "bat");
assert_eq!(result.removed_paths.len(), 0);
assert!(events.iter().any(|event| {
matches!(
event,