diff --git a/Cargo.lock b/Cargo.lock index 1d77093..70a5518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,17 +197,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -810,10 +799,10 @@ dependencies = [ ] [[package]] -name = "dotenv" -version = "0.15.0" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "ecb" @@ -1182,15 +1171,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.5.2" @@ -2023,7 +2003,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.5.2", + "hermit-abi", "libc", ] @@ -2933,14 +2913,13 @@ version = "0.1.3" dependencies = [ "anyhow", "assert_fs", - "atty", "base64 0.22.1", "blake3", "chrono", "clap", "crossterm", "directories", - "dotenv", + "dotenvy", "futures", "hex", "image", diff --git a/Cargo.toml b/Cargo.toml index 6f8564b..a96fb95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,8 +78,7 @@ infer = "0.16" directories = "5" base64 = "0.22" hex = "0.4" -atty = "0.2" -dotenv = "0.15.0" +dotenvy = "0.15" pdf-extract = "0.10.0" zip = "2" tempfile = "3" @@ -89,7 +88,6 @@ tempfile = "3" wiremock = "0.6" tokio-test = "0.4" assert_fs = "1" -dotenv = "0.15.0" [features] diff --git a/src/ai/claude.rs b/src/ai/claude.rs index 8be5113..edab6fb 100644 --- a/src/ai/claude.rs +++ b/src/ai/claude.rs @@ -245,10 +245,7 @@ impl ClaudeProvider { /// Use a different (usually cheaper) model for per-file /// descriptions than for grouping. - pub fn with_describe_model( - self, - model: impl Into, - ) -> Self { + pub fn with_describe_model(self, model: impl Into) -> Self { let mut this = self; this.describe_model = model.into(); this @@ -878,7 +875,6 @@ mod tests { cache_control: None, }], }], - None, ); diff --git a/src/analyze/mod.rs b/src/analyze/mod.rs index 2999277..7a6a1df 100644 --- a/src/analyze/mod.rs +++ b/src/analyze/mod.rs @@ -586,9 +586,7 @@ async fn extract_document_text( } /// docx is a zip: the body text lives in `word/document.xml`. -async fn extract_docx_text( - path: &std::path::Path, -) -> Option { +async fn extract_docx_text(path: &std::path::Path) -> Option { let path = path.to_path_buf(); tokio::task::spawn_blocking(move || { let file = std::fs::File::open(&path).ok()?; @@ -638,9 +636,7 @@ fn strip_docx_xml(xml: &str) -> String { } /// RTF: drop control words and groups, keep plain text. -async fn extract_rtf_text( - path: &std::path::Path, -) -> Option { +async fn extract_rtf_text(path: &std::path::Path) -> Option { let raw = read_text_excerpt(path).await?; let text = strip_rtf(&raw); (!text.trim().is_empty()).then_some(text) diff --git a/src/cost.rs b/src/cost.rs index c28f1f5..bd7438e 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -116,10 +116,13 @@ mod tests { #[test] fn pricing_varies_by_model_family() { - let haiku = estimate_cost(100, "claude-haiku-4-5", "claude-haiku-4-5"); - let sonnet = estimate_cost(100, "claude-sonnet-5", "claude-sonnet-5"); + let haiku = + estimate_cost(100, "claude-haiku-4-5", "claude-haiku-4-5"); + let sonnet = + estimate_cost(100, "claude-sonnet-5", "claude-sonnet-5"); let opus = estimate_cost(100, OPUS, OPUS); - let fable = estimate_cost(100, "claude-fable-5", "claude-fable-5"); + let fable = + estimate_cost(100, "claude-fable-5", "claude-fable-5"); assert!(haiku.estimated_cost_usd < sonnet.estimated_cost_usd); assert!(sonnet.estimated_cost_usd < opus.estimated_cost_usd); @@ -128,7 +131,8 @@ mod tests { #[test] fn unknown_model_uses_opus_pricing() { - let unknown = estimate_cost(10, "some-future-model", "some-future-model"); + let unknown = + estimate_cost(10, "some-future-model", "some-future-model"); let opus = estimate_cost(10, OPUS, OPUS); assert_eq!( diff --git a/src/executor/journal.rs b/src/executor/journal.rs index b6d8f40..5e1a99a 100644 --- a/src/executor/journal.rs +++ b/src/executor/journal.rs @@ -87,9 +87,8 @@ pub fn load(journal_dir: &Path, run_id: &str) -> Result { std::fs::read_to_string(&path).with_context(|| { format!("No journal for run {run_id} at {}", path.display()) })?; - serde_json::from_str(&content).with_context(|| { - format!("Corrupt journal: {}", path.display()) - }) + serde_json::from_str(&content) + .with_context(|| format!("Corrupt journal: {}", path.display())) } /// Run ids of journals that have not been undone, newest first. diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 45c8a1e..492374e 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -88,8 +88,7 @@ pub fn execute_plan( let mut deletions_staged = Vec::new(); let mut bytes_staged = 0u64; for path in &plan.deletions { - let size = - std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); let dest = trash::trash_path_for(&paths.trash_dir, &run_id, path); match move_file(path, &dest) { Ok(()) => { diff --git a/src/executor/trash.rs b/src/executor/trash.rs index 442f720..4c2f95b 100644 --- a/src/executor/trash.rs +++ b/src/executor/trash.rs @@ -19,7 +19,8 @@ pub fn trash_path_for( Component::Normal(part) => dest.push(part), Component::Prefix(prefix) => { // Windows drive prefix — keep it as a plain directory name. - dest.push(prefix.as_os_str().to_string_lossy().replace(':', "")) + dest + .push(prefix.as_os_str().to_string_lossy().replace(':', "")) } _ => {} } diff --git a/src/fingerprint/text.rs b/src/fingerprint/text.rs index b8b3d1b..0613530 100644 --- a/src/fingerprint/text.rs +++ b/src/fingerprint/text.rs @@ -123,8 +123,7 @@ mod tests { fn detect(dir: &TempDir) -> Vec { let files = - fingerprint_files(scan_directory(dir.path()).unwrap()) - .unwrap(); + fingerprint_files(scan_directory(dir.path()).unwrap()).unwrap(); find_similar_text(&files) } diff --git a/src/main.rs b/src/main.rs index 807681a..5891a25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::path::Path; +use std::io::IsTerminal; + use anyhow::Result; use clap::Parser; -use dotenv::dotenv; +use dotenvy::dotenv; use tracing_subscriber::EnvFilter; use spindle::ai::ClaudeProvider; @@ -103,17 +105,37 @@ async fn main() -> Result<()> { let (tx, mut rx) = tokio::sync::mpsc::channel::(64); - let event_handle = tokio::spawn(async move { - let mut progress = PipelineProgress::new(); - while let Some(event) = rx.recv().await { - progress.handle_event(&event); - } - }); + // Full TUI progress in a real terminal; plain line output when + // piped or redirected. + let use_tui = std::io::stdout().is_terminal(); + let event_handle: tokio::task::JoinHandle> = if use_tui { + tokio::task::spawn_blocking(move || { + tui::run_pipeline_progress(rx) + }) + } else { + tokio::spawn(async move { + let mut progress = PipelineProgress::new(); + while let Some(event) = rx.recv().await { + progress.handle_event(&event); + } + Ok(()) + }) + }; - let result = pipeline::run(&provider, &pipeline_config, tx).await?; - if let Err(e) = event_handle.await { - tracing::error!(error = %e, "Event handler task panicked"); + // Join the progress task before propagating pipeline errors so the + // terminal is restored first. + let pipeline_result = + pipeline::run(&provider, &pipeline_config, tx).await; + match event_handle.await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::warn!(error = %e, "Progress display failed") + } + Err(e) => { + tracing::error!(error = %e, "Event handler task panicked") + } } + let result = pipeline_result?; let plan = &result.plan; @@ -234,8 +256,7 @@ fn run_list_undo() -> Result<()> { for run_id in runs { match journal::load(&paths.journal_dir, &run_id) { Ok(j) => { - let staged: u64 = - j.deletions.iter().map(|d| d.size).sum(); + let staged: u64 = j.deletions.iter().map(|d| d.size).sum(); println!( " {} {} moves, {} deletions ({} in trash)", run_id, @@ -599,11 +620,8 @@ fn execute_review( deletions, skipped_files: vec![], }; - let report = execute_plan( - &plan, - &exec_paths, - &config.general.output_dir, - ); + let report = + execute_plan(&plan, &exec_paths, &config.general.output_dir); println!( "\nDone! {} duplicates staged to trash ({} reclaimable \ diff --git a/src/model/group.rs b/src/model/group.rs index a280a0a..27fde94 100644 --- a/src/model/group.rs +++ b/src/model/group.rs @@ -7,16 +7,22 @@ use serde::{Deserialize, Serialize}; )] pub enum DuplicateType { Exact, - NearDuplicate { distance: u32 }, + NearDuplicate { + distance: u32, + }, /// An archive whose entries are all present, extracted, in a /// directory — the archive is redundant. ArchiveMatch, /// Text files whose normalized content is near-identical /// (simhash hamming distance). - SimilarText { distance: u32 }, + SimilarText { + distance: u32, + }, /// Audio files with matching acoustic fingerprints /// (percent bit-similarity of chromaprint streams). - SimilarAudio { score: u32 }, + SimilarAudio { + score: u32, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/pipeline.rs b/src/pipeline.rs index 1e2c663..13d588c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -152,9 +152,7 @@ pub async fn run( config.near_duplicate_threshold, ); near_dupes.extend( - crate::fingerprint::archive::find_archive_matches( - &fingerprinted, - ), + crate::fingerprint::archive::find_archive_matches(&fingerprinted), ); near_dupes.extend(crate::fingerprint::text::find_similar_text( &fingerprinted, @@ -814,19 +812,14 @@ mod tests { } } - let summaries: Vec = - (0..MAX_GROUPING_BATCH * 2 + 5) - .map(|i| summary(i, 0.9)) - .collect(); + let summaries: Vec = (0..MAX_GROUPING_BATCH * 2 + 5) + .map(|i| summary(i, 0.9)) + .collect(); - let groups = propose_groups_batched( - &OneLabelProvider, - &summaries, - &[], - &[], - ) - .await - .unwrap(); + let groups = + propose_groups_batched(&OneLabelProvider, &summaries, &[], &[]) + .await + .unwrap(); assert_eq!(groups.len(), 1); assert_eq!(groups[0].member_indices.len(), summaries.len()); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8462db8..7c2ecb9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,7 +1,7 @@ mod progress; mod review; -pub use progress::{ProgressState, Stage}; +pub use progress::{PipelineTuiState, ProgressState, Stage}; pub use review::{Mode, ReviewAction, ReviewMode, ReviewState}; use anyhow::Result; @@ -136,6 +136,62 @@ pub fn run_progress( Ok(()) } +/// Drive the pipeline phase inside the TUI: a stage checklist with +/// spinner, live analysis gauge, and cost estimate, fed by +/// `PipelineEvent`s until the plan is ready (or the sender drops). +/// Ctrl-C aborts the whole process. +pub fn run_pipeline_progress( + mut rx: tokio::sync::mpsc::Receiver, +) -> Result<()> { + enable_raw_mode()?; + io::stdout().execute(EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + let mut state = PipelineTuiState::default(); + let mut disconnected = false; + + loop { + state.tick(); + terminal + .draw(|frame| progress::render_pipeline(frame, &state))?; + + loop { + match rx.try_recv() { + Ok(event) => state.handle_event(&event), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if state.is_done() || disconnected { + // One last frame so the final ✓ is visible for a beat. + terminal + .draw(|frame| progress::render_pipeline(frame, &state))?; + break; + } + + if event::poll(std::time::Duration::from_millis(80))? { + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + disable_raw_mode()?; + io::stdout().execute(LeaveAlternateScreen)?; + std::process::exit(130); + } + } + } + } + + disable_raw_mode()?; + io::stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} + pub fn run_review( state: ReviewState, ) -> Result<(ReviewAction, ReviewState)> { @@ -317,11 +373,13 @@ mod tests { } #[test] - fn review_x_triggers_execute() { + fn review_x_confirms_then_executes() { let mut state = make_review_state(); state.handle_key(KeyCode::Char('x')); + assert_eq!(state.pending_action(), None); + state.handle_key(KeyCode::Enter); assert_eq!(state.pending_action(), Some(ReviewAction::Execute)); } diff --git a/src/tui/progress.rs b/src/tui/progress.rs index 16c9371..fa47bf3 100644 --- a/src/tui/progress.rs +++ b/src/tui/progress.rs @@ -1,10 +1,235 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, - style::{Color, Style}, - widgets::{Block, Borders, Gauge, Paragraph}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Gauge, Padding, Paragraph}, Frame, }; +use crate::pipeline::PipelineEvent; + +// Matches the review screen's theme so the two feel like one app. +const PURPLE: Color = Color::Rgb(125, 86, 244); +const GREEN: Color = Color::Rgb(4, 181, 117); +const WHITE: Color = Color::Rgb(250, 250, 250); +const SUBTLE: Color = Color::Rgb(136, 136, 136); +const CREAM: Color = Color::Rgb(202, 211, 245); +const YELLOW: Color = Color::Rgb(249, 226, 175); + +const SPINNER: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Live state for the pipeline phase of the TUI (scan → fingerprint → +/// analyze → group), fed by `PipelineEvent`s. +#[derive(Default)] +pub struct PipelineTuiState { + scanned: Option, + exact_dupes: Option, + near_dupes: Option, + estimated_usd: Option, + to_analyze: Option, + cached: usize, + analyzed: usize, + failed: usize, + current_file: Option, + analysis_done: bool, + groups: Option, + grouping_error: Option, + plan_ready: bool, + spinner: usize, +} + +impl PipelineTuiState { + pub fn tick(&mut self) { + self.spinner = (self.spinner + 1) % SPINNER.len(); + } + + pub fn is_done(&self) -> bool { + self.plan_ready + } + + pub fn handle_event(&mut self, event: &PipelineEvent) { + match event { + PipelineEvent::ScanComplete { file_count } => { + self.scanned = Some(*file_count); + } + PipelineEvent::FingerprintComplete { + exact_dupes, + near_dupes, + .. + } => { + self.exact_dupes = Some(*exact_dupes); + self.near_dupes = Some(*near_dupes); + } + PipelineEvent::CostEstimated { estimated_usd, .. } => { + self.estimated_usd = Some(*estimated_usd); + } + PipelineEvent::AnalysisStarted { file_count, cached } => { + self.to_analyze = Some(*file_count); + self.cached = *cached; + } + PipelineEvent::FileAnalyzed { filename } => { + self.analyzed += 1; + self.current_file = Some(filename.clone()); + } + PipelineEvent::AnalysisComplete { + succeeded, failed, .. + } => { + self.analyzed = succeeded + failed; + self.failed = *failed; + self.analysis_done = true; + self.current_file = None; + } + PipelineEvent::GroupingComplete { group_count } => { + self.groups = Some(*group_count); + } + PipelineEvent::GroupingFailed { error } => { + self.grouping_error = Some(error.clone()); + } + PipelineEvent::PlanReady => { + self.plan_ready = true; + } + } + } + + fn spinner_frame(&self) -> &'static str { + SPINNER[self.spinner] + } +} + +pub fn render_pipeline(frame: &mut Frame, state: &PipelineTuiState) { + let area = frame.area(); + let width = (area.width * 70 / 100) + .clamp(40, 76) + .min(area.width.saturating_sub(2)); + let height = 14.min(area.height.saturating_sub(2)); + let x = (area.width.saturating_sub(width)) / 2; + let y = (area.height.saturating_sub(height)) / 2; + let panel = Rect::new(x, y, width, height); + + let mut lines: Vec> = vec![Line::from("")]; + + let spin = state.spinner_frame(); + let stage = |done: bool, + active: bool, + label: String, + lines: &mut Vec>| { + let (icon, style) = if done { + ("✓".to_string(), Style::default().fg(GREEN)) + } else if active { + (spin.to_string(), Style::default().fg(PURPLE)) + } else { + ("·".to_string(), Style::default().fg(SUBTLE)) + }; + let text_style = if done || active { + Style::default().fg(CREAM) + } else { + Style::default().fg(SUBTLE) + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {icon} "), style), + Span::styled(label, text_style), + ])); + }; + + let scanned = state.scanned; + stage( + scanned.is_some(), + scanned.is_none(), + match scanned { + Some(n) => format!("Scanned {n} files"), + None => "Scanning…".to_string(), + }, + &mut lines, + ); + + let fingerprinted = state.exact_dupes.is_some(); + stage( + fingerprinted, + scanned.is_some() && !fingerprinted, + match (state.exact_dupes, state.near_dupes) { + (Some(exact), Some(near)) => format!( + "Fingerprinted — {exact} exact, {near} similar duplicates" + ), + _ => "Fingerprinting…".to_string(), + }, + &mut lines, + ); + + // Analysis line with gauge. + let analyzing = state.to_analyze.is_some() && !state.analysis_done; + stage( + state.analysis_done, + analyzing, + match (state.to_analyze, state.estimated_usd) { + (Some(total), Some(usd)) => format!( + "Analyzing content — {}/{} (est. ${usd:.2}, {} cached)", + state.analyzed, total, state.cached + ), + (Some(total), None) => { + format!("Analyzing content — {}/{}", state.analyzed, total) + } + _ => "Analyze content".to_string(), + }, + &mut lines, + ); + if let Some(file) = &state.current_file { + lines.push(Line::from(Span::styled( + format!(" {file}"), + Style::default().fg(SUBTLE), + ))); + } + if state.failed > 0 { + lines.push(Line::from(Span::styled( + format!(" {} files failed analysis", state.failed), + Style::default().fg(YELLOW), + ))); + } + + stage( + state.groups.is_some(), + state.analysis_done && state.groups.is_none(), + match (state.groups, &state.grouping_error) { + (Some(n), _) => format!("Grouped into {n} folders"), + (None, Some(_)) => { + "Grouping failed — falling back to one group".to_string() + } + _ => "Group by topic".to_string(), + }, + &mut lines, + ); + + lines.push(Line::from("")); + + let block = Block::bordered() + .border_type(BorderType::Rounded) + .title_top(Line::from(vec![Span::styled( + " spindle ", + Style::default() + .fg(WHITE) + .bg(PURPLE) + .add_modifier(Modifier::BOLD), + )])) + .border_style(Style::default().fg(PURPLE)) + .padding(Padding::new(1, 1, 0, 0)); + frame.render_widget(Paragraph::new(lines).block(block), panel); + + // Slim gauge under the panel while analysis runs. + if let Some(total) = state.to_analyze { + if !state.analysis_done && total > 0 && y + height < area.height { + let gauge_area = + Rect::new(x, y + height, width, 1.min(area.height)); + let ratio = + (state.analyzed as f64 / total as f64).clamp(0.0, 1.0); + let gauge = Gauge::default() + .gauge_style(Style::default().fg(GREEN).bg(Color::Black)) + .ratio(ratio) + .label(""); + frame.render_widget(gauge, gauge_area); + } + } +} + pub struct ProgressState { total_files: usize, completed: usize, diff --git a/src/tui/review.rs b/src/tui/review.rs index c706b4b..573ec7e 100644 --- a/src/tui/review.rs +++ b/src/tui/review.rs @@ -147,8 +147,10 @@ pub enum Mode { MoveToGroup { cursor: usize }, NewGroup { input: String, cursor_pos: usize }, ConfirmRemove, + ConfirmExecute, DiffView { compare_idx: usize }, Preview, + Help, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -802,12 +804,27 @@ impl ReviewState { Mode::MoveToGroup { .. } => self.handle_move_to_group_key(code), Mode::NewGroup { .. } => self.handle_new_group_key(code), Mode::ConfirmRemove => self.handle_confirm_remove_key(code), + Mode::ConfirmExecute => self.handle_confirm_execute_key(code), Mode::DiffView { .. } => self.handle_diff_view_key(code), Mode::Preview => self.handle_preview_key(code), + Mode::Help => self.mode = Mode::Normal, } self.update_image_preview(); } + fn handle_confirm_execute_key(&mut self, code: KeyCode) { + match code { + KeyCode::Enter | KeyCode::Char('y') => { + self.action = Some(ReviewAction::Execute); + self.mode = Mode::Normal; + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => { + self.mode = Mode::Normal; + } + _ => {} + } + } + fn handle_normal_key(&mut self, code: KeyCode) { match code { KeyCode::Tab => { @@ -877,7 +894,11 @@ impl ReviewState { } KeyCode::Char('x') => { - self.action = Some(ReviewAction::Execute); + self.mode = Mode::ConfirmExecute; + } + + KeyCode::Char('?') => { + self.mode = Mode::Help; } KeyCode::Char('d') @@ -1337,10 +1358,196 @@ pub fn render(frame: &mut Frame, state: &mut ReviewState) { Mode::Preview => { render_preview_modal(frame, state); } + Mode::ConfirmExecute => { + render_confirm_execute_modal(frame, state); + } + Mode::Help => { + render_help_modal(frame, state); + } _ => {} } } +/// A centered, cleared modal area sized as a fraction of the screen. +fn centered_modal(frame: &Frame, pct_w: u16, pct_h: u16) -> Rect { + let area = frame.area(); + let w = (area.width * pct_w / 100) + .max(30) + .min(area.width.saturating_sub(2)); + let h = (area.height * pct_h / 100) + .max(8) + .min(area.height.saturating_sub(2)); + let x = (area.width.saturating_sub(w)) / 2; + let y = (area.height.saturating_sub(h)) / 2; + Rect::new(x, y, w, h) +} + +fn render_confirm_execute_modal( + frame: &mut Frame, + state: &ReviewState, +) { + let modal_area = centered_modal(frame, 55, 45); + frame.render_widget(Clear, modal_area); + + let mut lines: Vec> = vec![Line::from("")]; + match state.review_mode { + ReviewMode::Organize => { + let groups = state.approved_groups(); + let moves = state.approved_moves(); + lines.push(Line::from(vec![ + Span::styled(" Move ", theme::normal()), + Span::styled( + format!("{}", moves.len()), + Style::default() + .fg(theme::BRIGHT_GREEN) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" files into ", theme::normal()), + Span::styled( + format!("{}", groups.len()), + Style::default() + .fg(theme::BRIGHT_GREEN) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" folders:", theme::normal()), + ])); + lines.push(Line::from("")); + for group in groups.iter().take(6) { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(group.label.clone(), theme::value()), + Span::styled( + format!(" ({} files)", group.members.len()), + theme::dim(), + ), + ])); + } + if groups.len() > 6 { + lines.push(Line::from(Span::styled( + format!(" … and {} more", groups.len() - 6), + theme::dim(), + ))); + } + } + ReviewMode::Dupes => { + let deletions = state.files_to_delete(); + let bytes: u64 = deletions + .iter() + .filter_map(|p| state.file_metadata.get(p)) + .map(|(_, size)| *size) + .sum(); + lines.push(Line::from(vec![ + Span::styled(" Stage ", theme::normal()), + Span::styled( + format!("{}", deletions.len()), + Style::default() + .fg(theme::BRIGHT_YELLOW) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" duplicates to trash (", theme::normal()), + Span::styled(format_size(bytes), theme::value()), + Span::styled(")", theme::normal()), + ])); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Nothing is deleted permanently — undo with", + theme::dim(), + ))); + lines.push(Line::from(Span::styled( + " 'spindle --undo', reclaim with 'spindle --purge'.", + theme::dim(), + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" \u{23ce}/y", theme::key_hint()), + Span::styled(" go ahead ", theme::dim()), + Span::styled("esc/n", theme::key_hint()), + Span::styled(" back to review", theme::dim()), + ])); + + let block = Block::bordered() + .border_type(BorderType::Rounded) + .title_top(Line::from(Span::styled( + " Ready to execute? ", + theme::title_badge(), + ))) + .border_style(Style::default().fg(theme::BORDER_PURPLE)); + frame.render_widget(Paragraph::new(lines).block(block), modal_area); +} + +fn render_help_modal(frame: &mut Frame, state: &ReviewState) { + let modal_area = centered_modal(frame, 60, 75); + frame.render_widget(Clear, modal_area); + + let mut lines: Vec> = vec![Line::from("")]; + let section = |title: &str, + keys: &[(&str, &str)], + lines: &mut Vec>| { + lines.push(Line::from(Span::styled( + format!(" {title}"), + theme::label(), + ))); + for (key, desc) in keys { + lines.push(Line::from(vec![ + Span::styled(format!(" {key:<8}"), theme::key_hint()), + Span::styled((*desc).to_string(), theme::normal()), + ])); + } + lines.push(Line::from("")); + }; + + section( + "NAVIGATE", + &[ + ("j/k \u{2191}\u{2193}", "move up/down"), + ("tab", "switch pane (groups \u{2194} files)"), + ("s", "switch organize \u{2194} dupes mode"), + ], + &mut lines, + ); + match state.review_mode { + ReviewMode::Organize => section( + "ORGANIZE", + &[ + ("space", "toggle group approval / preview file"), + ("enter", "mark file (multi-select)"), + ("m", "move file(s) to another group"), + ("n", "move file(s) to a new group"), + ("d", "remove file(s) from the plan"), + ], + &mut lines, + ), + ReviewMode::Dupes => section( + "DUPES", + &[ + ("space", "toggle keep/delete on a file"), + ("d", "side-by-side diff of the set"), + ], + &mut lines, + ), + } + section( + "ACT", + &[ + ("x", "execute (with confirmation)"), + ("q", "quit without changes"), + ("?", "this help"), + ], + &mut lines, + ); + + let block = Block::bordered() + .border_type(BorderType::Rounded) + .title_top(Line::from(Span::styled( + " Keys ", + theme::title_badge(), + ))) + .border_style(Style::default().fg(theme::BORDER_PURPLE)); + frame.render_widget(Paragraph::new(lines).block(block), modal_area); +} + fn panel_block<'a>(title: &'a str, focused: bool) -> Block<'a> { let border_color = if focused { theme::BORDER_PURPLE @@ -1478,9 +1685,10 @@ fn render_middle_panel( render_new_group_input(frame, area, state, input, *cursor_pos) } Mode::ConfirmRemove => render_confirm_remove(frame, area, state), - Mode::DiffView { .. } | Mode::Preview => { - render_file_list(frame, area, state) - } + Mode::DiffView { .. } + | Mode::Preview + | Mode::ConfirmExecute + | Mode::Help => render_file_list(frame, area, state), } } @@ -2343,7 +2551,9 @@ fn render_detail( Mode::NewGroup { input, .. } => { render_detail_new_group(state, input) } - Mode::ConfirmRemove => render_detail_group(state), + Mode::ConfirmRemove | Mode::ConfirmExecute | Mode::Help => { + render_detail_group(state) + } Mode::DiffView { .. } | Mode::Preview => { render_detail_file(state) } @@ -2917,7 +3127,12 @@ fn render_footer(frame: &mut Frame, area: Rect, state: &ReviewState) { if state.review_mode == ReviewMode::Dupes { k.push(("d", "diff")); } - k.extend([("tab", "pane"), ("x", "execute"), ("q", "quit")]); + k.extend([ + ("tab", "pane"), + ("x", "execute"), + ("?", "help"), + ("q", "quit"), + ]); k } (Pane::Files, ReviewMode::Organize) => { @@ -2960,6 +3175,10 @@ fn render_footer(frame: &mut Frame, area: Rect, state: &ReviewState) { Mode::ConfirmRemove => { vec![("y", "delete group"), ("n", "keep"), ("esc", "cancel")] } + Mode::ConfirmExecute => { + vec![("\u{23ce}/y", "confirm"), ("esc/n", "cancel")] + } + Mode::Help => vec![("any key", "close")], Mode::DiffView { .. } => { let mut k = vec![("j/k", "cycle files")]; let is_text = state @@ -3227,10 +3446,34 @@ mod tests { } #[test] - fn x_triggers_execute() { + fn x_opens_confirmation_then_enter_executes() { let mut state = make_state(); state.handle_key(KeyCode::Char('x')); + assert_eq!(*state.mode(), Mode::ConfirmExecute); + assert_eq!(state.pending_action(), None); + + state.handle_key(KeyCode::Enter); assert_eq!(state.pending_action(), Some(ReviewAction::Execute)); + assert_eq!(*state.mode(), Mode::Normal); + } + + #[test] + fn confirm_execute_can_be_cancelled() { + let mut state = make_state(); + state.handle_key(KeyCode::Char('x')); + state.handle_key(KeyCode::Esc); + assert_eq!(state.pending_action(), None); + assert_eq!(*state.mode(), Mode::Normal); + } + + #[test] + fn question_mark_opens_help_any_key_closes() { + let mut state = make_state(); + state.handle_key(KeyCode::Char('?')); + assert_eq!(*state.mode(), Mode::Help); + + state.handle_key(KeyCode::Char('j')); + assert_eq!(*state.mode(), Mode::Normal); } #[test]