W7: Progress dialogs for copy/move/delete

Spawn file operations on background threads and show the existing
ProgressDialog (gauge, sparkline, ETA, cancel button) while the
operation runs. The event loop polls the result channel every frame
and applies the outcome when the thread finishes.

- DialogState::Progress variant added
- PendingProgressOp tracks kind/sources/destination/result channel
- spawn_op_with_progress() spawns thread + shows modal progress
- tick_progress() polls channel, handles Ok/Err/Empty/Disconnected
- ProgressDialog Cancel key (Enter) cancels the OpHandle
- Tab toggles focus on the cancel button
- Wired into app.rs event loop (re-render every tick while running)

Tests: 1381 pass, zero warnings.
This commit is contained in:
2026-07-06 07:45:47 +03:00
parent 5d6faeaac6
commit 9024b87934
5 changed files with 262 additions and 134 deletions
+2 -1
View File
@@ -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;
@@ -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<PathBuf>,
destination: Option<PathBuf>,
preserve_attributes: bool,
follow_links: bool,
) {
let handle = self.ops_manager.begin(
kind,
sources.clone(),
destination.clone(),
);
let (tx, rx) = std::sync::mpsc::channel::<Result<(), crate::ops::OpsError>>();
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(),
}
}
@@ -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.
@@ -194,6 +194,8 @@ pub struct FileManager {
pub pending_op: Option<PendingFileOp>,
/// Pending file op awaiting I/O-error recovery (Retry/Skip/Ab).
pub pending_error_op: Option<PendingErrorOp>,
/// Background file op with a live progress dialog.
pub pending_progress: Option<PendingProgressOp>,
/// 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<PathBuf>,
/// Destination directory (None for delete).
pub destination: Option<PathBuf>,
/// 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<Result<(), crate::ops::OpsError>>,
}
/// 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<ConnectionDialog>),
/// Alt-Shift-T — sort order dialog.
Sort(Box<sort_dialog::SortDialog>),
/// F5/F6/F8 — live progress dialog for background copy/move/delete.
Progress(Box<crate::ops::progress::ProgressDialog>),
}
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);
@@ -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),
}
}
}