diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index adaf3fec8d..e4374ea502 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -84,6 +84,7 @@ impl Application { fm.sync_animations(); fm.spinner.tick(); let toast_active = fm.toasts.tick(); + let progress_done = fm.tick_progress(); // Advance any in-flight editor smooth-scroll // animation by one tick. The handler returns true // while the animation is in flight; we treat that @@ -93,7 +94,7 @@ impl Application { } else { false }; - if fm.spinner.is_active() || toast_active || fm.panel_switch_anim < 100 || editor_scrolling { + if fm.spinner.is_active() || toast_active || fm.panel_switch_anim < 100 || editor_scrolling || progress_done || fm.pending_progress.is_some() { render(&mut tui, &mut fm)?; } continue; diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index 0ab771bc4c..07638688aa 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -9,7 +9,7 @@ //! themselves via a `confirmed`/`cancelled` flag. use anyhow::Result; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::filemanager::{ config_dialog, edit_history, error_dialog, external_panelize, @@ -782,6 +782,177 @@ impl FileManager { self.status.set_message(format!("Found {}", path.display())); } + /// Spawn a file operation on a background thread and show a + /// modal progress dialog. The tick loop polls `result_rx` each + /// frame and applies the outcome when the thread finishes. + fn spawn_op_with_progress( + &mut self, + kind: crate::ops::OpKind, + sources: Vec, + destination: Option, + preserve_attributes: bool, + follow_links: bool, + ) { + let handle = self.ops_manager.begin( + kind, + sources.clone(), + destination.clone(), + ); + let (tx, rx) = std::sync::mpsc::channel::>(); + let thread_handle = handle.clone(); + let thread_sources = sources.clone(); + let thread_dest = destination.clone(); + std::thread::spawn(move || { + let result = match kind { + crate::ops::OpKind::Copy => { + let dst = thread_dest.expect("copy needs a destination"); + crate::ops::copy::copy_many( + &thread_sources, + &dst, + &thread_handle, + false, + preserve_attributes, + follow_links, + ) + } + crate::ops::OpKind::Move => { + let dst = thread_dest.expect("move needs a destination"); + crate::ops::move_op::move_many( + &thread_sources, + &dst, + &thread_handle, + false, + preserve_attributes, + follow_links, + ) + } + crate::ops::OpKind::Delete => { + crate::ops::delete::delete_many(&thread_sources, &thread_handle) + } + crate::ops::OpKind::MkDir => unreachable!("mkdir not spawned with progress"), + }; + let _ = tx.send(result); + }); + + let title = match kind { + crate::ops::OpKind::Copy => "Copying...".to_string(), + crate::ops::OpKind::Move => "Moving...".to_string(), + crate::ops::OpKind::Delete => "Deleting...".to_string(), + crate::ops::OpKind::MkDir => unreachable!("mkdir not spawned with progress"), + }; + let mut pd = crate::ops::progress::ProgressDialog::new(); + pd.title = title; + pd.set_handle(handle.clone()); + + self.pending_progress = Some(super::PendingProgressOp { + kind, + sources, + destination, + preserve_attributes, + follow_links, + handle, + result_rx: rx, + }); + self.dialog = Some(DialogState::Progress(Box::new(pd))); + } + + /// Poll the background operation (if any) and handle completion. + /// Called from the event loop every frame. + pub fn tick_progress(&mut self) -> bool { + let pending = match self.pending_progress.take() { + None => return false, + Some(p) => p, + }; + let recv_result = pending.result_rx.try_recv(); + match recv_result { + Err(std::sync::mpsc::TryRecvError::Empty) => { + self.pending_progress = Some(pending); + false + } + Ok(Ok(())) => { + let kind = pending.kind; + let count = pending.sources.len(); + let dest = pending.destination; + self.ops_manager.finish(); + self.dialog = None; + let _ = self.active_panel_mut().refresh(); + if let Some(dst) = dest { + let verb = match kind { + crate::ops::OpKind::Copy => "Copied", + crate::ops::OpKind::Move => "Moved", + crate::ops::OpKind::Delete => "Deleted", + crate::ops::OpKind::MkDir => "Created", + }; + self.status.set_message(format!( + "{verb} {count} item(s) to {}", + dst.display() + )); + } else { + self.status + .set_message(format!("Deleted {count} item(s)")); + } + true + } + Ok(Err(e)) => { + let sources = pending.sources.clone(); + let dst = pending.destination; + let preserve = pending.preserve_attributes; + let follow = pending.follow_links; + let kind = pending.kind; + self.ops_manager.finish(); + self.dialog = None; + + match e { + crate::ops::OpsError::DestExists(ref p) => { + self.pending_op = Some(PendingFileOp { + is_move: kind == crate::ops::OpKind::Move, + sources, + dst: dst.unwrap_or_default(), + preserve_attributes: preserve, + follow_links: follow, + }); + self.dialog = Some(DialogState::Overwrite(Box::new( + overwrite_dialog::OverwriteDialog::new_copy( + p.display().to_string(), + ), + ))); + } + other => { + self.pending_error_op = Some(PendingErrorOp { + kind: match kind { + crate::ops::OpKind::Copy => "copy".to_string(), + crate::ops::OpKind::Move => "move".to_string(), + crate::ops::OpKind::Delete => "delete".to_string(), + crate::ops::OpKind::MkDir => "mkdir".to_string(), + }, + sources: sources.clone(), + dst, + preserve_attributes: preserve, + follow_links: follow, + }); + self.dialog = Some(DialogState::Error(Box::new( + error_dialog::ErrorDialog::new( + sources + .first() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + other.to_string(), + ), + ))); + } + } + true + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.ops_manager.finish(); + self.dialog = None; + self.status + .set_message("Operation thread panicked".to_string()); + true + } + } + } + /// Apply the result of a finished dialog (chmod, chown, link) and /// then clear `self.dialog`. pub fn apply_finished_dialog(&mut self) { @@ -813,7 +984,8 @@ impl FileManager { | Some(DialogState::PanelFilter(_)) | Some(DialogState::Encoding(_)) | Some(DialogState::Connection(_)) - | Some(DialogState::Sort(_)) => { + | Some(DialogState::Sort(_)) + | Some(DialogState::Progress(_)) => { // These are closed by their own apply_*_outcome // helpers before this function is called; they // should never appear here. @@ -963,60 +1135,13 @@ impl FileManager { } } } else { - let preserve_attributes = d.preserve_attributes; - let follow_links = d.follow_links; - let handle = self.ops_manager.begin( + self.spawn_op_with_progress( crate::ops::OpKind::Copy, - sources.clone(), - Some(dst.clone()), + sources, + Some(dst), + d.preserve_attributes, + d.follow_links, ); - match crate::ops::copy::copy_many( - &sources, - &dst, - &handle, - false, - preserve_attributes, - follow_links, - ) { - Ok(()) => { - self.status.set_message(format!( - "Copied {} item(s) to {}", - sources.len(), - dst.display() - )); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(crate::ops::OpsError::DestExists(p)) => { - self.pending_op = Some(PendingFileOp { - is_move: false, - sources, - dst, - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Overwrite(Box::new( - overwrite_dialog::OverwriteDialog::new_copy( - p.display().to_string(), - ), - ))); - } - Err(e) => { - self.pending_error_op = Some(PendingErrorOp { - kind: "copy".to_string(), - sources: sources.clone(), - dst: Some(dst.clone()), - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Error(Box::new( - error_dialog::ErrorDialog::new( - sources.first().map(|p| p.display().to_string()).unwrap_or_default(), - e.to_string(), - ), - ))); - } - } } } } @@ -1072,93 +1197,26 @@ impl FileManager { } } } else { - let preserve_attributes = d.preserve_attributes; - let follow_links = d.follow_links; - let handle = self.ops_manager.begin( + self.spawn_op_with_progress( crate::ops::OpKind::Move, - sources.clone(), - Some(dst.clone()), + sources, + Some(dst), + d.preserve_attributes, + d.follow_links, ); - match crate::ops::move_op::move_many( - &sources, - &dst, - &handle, - false, - preserve_attributes, - follow_links, - ) { - Ok(()) => { - self.status.set_message(format!( - "Moved {} item(s) to {}", - sources.len(), - dst.display() - )); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(crate::ops::OpsError::DestExists(p)) => { - self.pending_op = Some(PendingFileOp { - is_move: true, - sources, - dst, - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Overwrite(Box::new( - overwrite_dialog::OverwriteDialog::new_move( - p.display().to_string(), - ), - ))); - } - Err(e) => { - self.pending_error_op = Some(PendingErrorOp { - kind: "move".to_string(), - sources: sources.clone(), - dst: Some(dst.clone()), - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Error(Box::new( - error_dialog::ErrorDialog::new( - sources.first().map(|p| p.display().to_string()).unwrap_or_default(), - e.to_string(), - ), - ))); - } - } } } } Some(DialogState::Delete(d)) => { #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] if d.is_confirmed() { - let paths = d.paths.clone(); - let handle = - self.ops_manager - .begin(crate::ops::OpKind::Delete, paths.clone(), None); - match crate::ops::delete::delete_many(&paths, &handle) { - Ok(()) => { - self.status - .set_message(format!("Deleted {} item(s)", paths.len())); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.pending_error_op = Some(PendingErrorOp { - kind: "delete".to_string(), - sources: paths.clone(), - dst: None, - preserve_attributes: false, - follow_links: false, - }); - self.dialog = Some(DialogState::Error(Box::new( - error_dialog::ErrorDialog::new( - paths.first().map(|p| p.display().to_string()).unwrap_or_default(), - e.to_string(), - ), - ))); - } - } + self.spawn_op_with_progress( + crate::ops::OpKind::Delete, + d.paths.clone(), + None, + false, + false, + ); } } // Info dialog: just close. @@ -1362,5 +1420,6 @@ fn dialog_label(d: &DialogState) -> String { DialogState::Encoding(_) => "Display encoding".to_string(), DialogState::Connection(_) => "Network connection".to_string(), DialogState::Sort(_) => "Sort order".to_string(), + DialogState::Progress(_) => "Progress".to_string(), } } \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs index d68277f4b4..858f39a6ea 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -865,6 +865,23 @@ impl FileManager { } consumed = true; } + Some(DialogState::Progress(d)) => { + use crate::key::Key; + match key { + Key::ENTER => { + if d.cancel_focused { + if let Some(h) = d.handle.as_ref() { + h.cancel(); + } + } + } + Key::TAB => { + d.cancel_focused = !d.cancel_focused; + } + _ => {} + } + consumed = true; + } None => return false, } // Apply captured outcomes. diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 191ac5bf9f..31f05fdf44 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -194,6 +194,8 @@ pub struct FileManager { pub pending_op: Option, /// Pending file op awaiting I/O-error recovery (Retry/Skip/Ab). pub pending_error_op: Option, + /// Background file op with a live progress dialog. + pub pending_progress: Option, /// True when the user has pressed Ctrl-X and the next key /// event should be interpreted as the second key of a /// Ctrl-X-prefixed binding (e.g. `Ctrl-X, d` for Compare @@ -256,6 +258,25 @@ pub struct PendingErrorOp { pub follow_links: bool, } +/// A background file operation (copy/move/delete) spawned with a +/// progress dialog. The result arrives via `result_rx`; the tick +/// loop polls it every frame. +pub struct PendingProgressOp { + /// Operation kind (Copy, Move, or Delete). + pub kind: crate::ops::OpKind, + /// Source paths being processed. + pub sources: Vec, + /// Destination directory (None for delete). + pub destination: Option, + /// Preserve file attributes during copy/move. + pub preserve_attributes: bool, + /// Follow symlinks during copy/move. + pub follow_links: bool, + /// Handle for progress queries and cancellation. + pub handle: crate::ops::OpHandle, + /// Channel receiver for the background thread's completion result. + pub result_rx: std::sync::mpsc::Receiver>, +} /// A modal dialog currently displayed on top of the panels. #[allow(clippy::large_enum_variant)] pub enum DialogState { @@ -335,6 +356,8 @@ pub enum DialogState { Connection(Box), /// Alt-Shift-T — sort order dialog. Sort(Box), + /// F5/F6/F8 — live progress dialog for background copy/move/delete. + Progress(Box), } impl DialogState { @@ -395,6 +418,7 @@ impl DialogState { DialogState::Encoding(_) => false, DialogState::Connection(_) => false, DialogState::Sort(_) => false, + DialogState::Progress(_) => false, } } } @@ -443,6 +467,7 @@ impl FileManager { want_exec: None, pending_op: None, pending_error_op: None, + pending_progress: None, pending_ctrl_x: false, runtime: crate::config::RuntimeConfig::default(), spinner: crate::widget::Spinner::new(), @@ -1046,6 +1071,8 @@ mod tests { fn copy_dialog_enter_copies_single_file() { let dir = std::env::temp_dir().join("tlc-fm-copy-enter"); let dst = std::env::temp_dir().join("tlc-fm-copy-dst"); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dst); let _ = fs::create_dir_all(&dir); let _ = fs::create_dir_all(&dst); fs::write(dir.join("a.txt"), b"data").unwrap(); @@ -1058,6 +1085,13 @@ mod tests { } let consumed = fm.handle_dialog_key(Key::ENTER); assert!(consumed); + assert!(matches!(fm.dialog, Some(DialogState::Progress(_)))); + for _ in 0..200 { + if fm.tick_progress() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } assert!(fm.dialog.is_none()); assert!(dst.join("a.txt").is_file()); assert_eq!(fs::read(dst.join("a.txt")).unwrap(), b"data"); @@ -1104,6 +1138,8 @@ mod tests { fn move_dialog_enter_moves_single_file() { let dir = std::env::temp_dir().join("tlc-fm-mv-enter"); let dst = std::env::temp_dir().join("tlc-fm-mv-dst"); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dst); let _ = fs::create_dir_all(&dir); let _ = fs::create_dir_all(&dst); fs::write(dir.join("a.txt"), b"data").unwrap(); @@ -1116,6 +1152,13 @@ mod tests { } let consumed = fm.handle_dialog_key(Key::ENTER); assert!(consumed); + assert!(matches!(fm.dialog, Some(DialogState::Progress(_)))); + for _ in 0..100 { + if fm.tick_progress() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } assert!(fm.dialog.is_none()); assert!(!dir.join("a.txt").exists()); assert!(dst.join("a.txt").is_file()); @@ -1153,6 +1196,13 @@ mod tests { mods: crate::key::Modifiers::empty(), }); assert!(consumed); + assert!(matches!(fm.dialog, Some(DialogState::Progress(_)))); + for _ in 0..100 { + if fm.tick_progress() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } assert!(fm.dialog.is_none()); assert!(!dir.join("a.txt").exists()); let _ = fs::remove_dir_all(&dir); diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index f15b4b583b..4a1425818a 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -180,6 +180,7 @@ impl FileManager { DialogState::Encoding(d) => d.render(frame, dialog_area, &self.theme), DialogState::Connection(d) => d.render(frame, dialog_area, &self.theme), DialogState::Sort(d) => d.render(frame, dialog_area, &self.theme), + DialogState::Progress(d) => d.render(frame, dialog_area, &self.theme), } } }