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:
parent
c63b2917da
commit
9d8ec1e4fd
17 changed files with 1277 additions and 74 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue