diff --git a/Cargo.toml b/Cargo.toml index 89f903f..bff0c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ chrono = { version = "0.4", features = ["serde"] } # CLI clap = { version = "4.5", features = ["derive"] } indicatif = "0.17" +inquire = "0.7" # Error handling thiserror = "2.0" diff --git a/bin/Cargo.toml b/bin/Cargo.toml index ff0d8c1..f9b0363 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -31,5 +31,6 @@ tokio = { workspace = true } futures = { workspace = true } clap = { workspace = true } indicatif = { workspace = true } +inquire = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } diff --git a/bin/src/commands/status.rs b/bin/src/commands/status.rs index fdeca24..18f6b06 100644 --- a/bin/src/commands/status.rs +++ b/bin/src/commands/status.rs @@ -1,6 +1,7 @@ //! Background job status command. use anyhow::{Context, Result}; +use inquire::Select; use paracas_daemon::{DownloadJob, JobStatus, StateManager}; /// Execute the status command. @@ -15,8 +16,11 @@ pub(crate) fn status( StateManager::with_default_path().context("Failed to initialize state manager")?; // Handle cancellation request + // cancel_id is Some("") when --cancel is passed without a value + // cancel_id is Some(id) when --cancel is passed if let Some(id) = cancel_id { - return cancel_job(&state_manager, id); + let id_opt = if id.is_empty() { None } else { Some(id) }; + return cancel_job(&state_manager, id_opt); } // Handle follow/watch mode @@ -133,8 +137,63 @@ fn list_jobs(state: &StateManager, running_only: bool, show_all: bool) -> Result Ok(()) } -fn cancel_job(state: &StateManager, job_id: &str) -> Result<()> { - let id = job_id.parse().context("Invalid job ID format")?; +/// Prompt user to select a job from available cancellable jobs. +fn prompt_cancel_selection(state: &StateManager) -> Result { + let jobs = state.list_jobs()?; + + let filtered: Vec<_> = jobs + .into_iter() + .filter(|job| matches!(job.status, JobStatus::Running | JobStatus::Pending)) + .collect(); + + if filtered.is_empty() { + anyhow::bail!("No running or pending jobs found to cancel."); + } + + let options: Vec = filtered + .iter() + .map(|job| { + let instruments: Vec<_> = job.tasks.iter().map(|t| t.instrument_id.as_str()).collect(); + let instruments_display = if instruments.len() > 3 { + format!( + "{}, ... (+{} more)", + instruments[..3].join(", "), + instruments.len() - 3 + ) + } else { + instruments.join(", ") + }; + format!( + "{} | {:?} | {:.1}% | {}", + job.id, + job.status, + job.progress_percent(), + instruments_display + ) + }) + .collect(); + + let selection = Select::new("Select a job to cancel:", options) + .prompt() + .context("Job selection cancelled")?; + + // Extract the job ID from the selection (first part before " | ") + let job_id = selection + .split(" | ") + .next() + .context("Failed to parse job selection")? + .to_string(); + + Ok(job_id) +} + +fn cancel_job(state: &StateManager, job_id: Option<&str>) -> Result<()> { + let id_str = match job_id { + Some(id) => id.to_string(), + None => prompt_cancel_selection(state)?, + }; + + let id = id_str.parse().context("Invalid job ID format")?; let mut job: DownloadJob = state.load_job(id).context("Job not found")?; diff --git a/bin/src/main.rs b/bin/src/main.rs index 94aa27d..8685346 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -104,8 +104,8 @@ enum Commands { #[arg(short, long)] follow: Option, - /// Cancel a running job - #[arg(long)] + /// Cancel a running job (prompts for selection if no job ID provided) + #[arg(long, num_args = 0..=1, default_missing_value = "")] cancel: Option, }, @@ -164,20 +164,20 @@ enum Commands { enum JobAction { /// Pause a running job Pause { - /// Job ID to pause - job_id: String, + /// Job ID to pause (if omitted, prompts for selection) + job_id: Option, }, /// Resume a paused job Resume { - /// Job ID to resume - job_id: String, + /// Job ID to resume (if omitted, prompts for selection) + job_id: Option, }, /// Kill a running or paused job Kill { - /// Job ID to kill - job_id: String, + /// Job ID to kill (if omitted, prompts for selection) + job_id: Option, }, /// Clean up finished jobs from storage @@ -269,12 +269,14 @@ async fn main() -> Result<()> { } Commands::Job { action } => match action { JobAction::Pause { job_id } => { - commands::job::job_command("pause", Some(&job_id), false) + commands::job::job_command("pause", job_id.as_deref(), false) } JobAction::Resume { job_id } => { - commands::job::job_command("resume", Some(&job_id), false) + commands::job::job_command("resume", job_id.as_deref(), false) + } + JobAction::Kill { job_id } => { + commands::job::job_command("kill", job_id.as_deref(), false) } - JobAction::Kill { job_id } => commands::job::job_command("kill", Some(&job_id), false), JobAction::Clean { all } => commands::job::job_command("clean", None, all), }, }