use super::*;
pub struct TransferJob {
pub kind: TransferKind,
pub total: Option<u64>,
pub done: u64,
pub current: PathBuf,
pub started: Instant,
pub(super) cancel: Arc<AtomicBool>,
updates: Receiver<JobUpdate>,
pub problem: Option<JobProblem>,
pub clash: Option<JobClash>,
answers: Sender<Answer>,
pub skipped: u64,
after: Option<AfterJob>,
checksums: Option<Box<crate::checksum::Report>>,
}
#[derive(Debug)]
pub struct ChecksumView {
pub report: crate::checksum::Report,
pub scroll: usize,
pub room: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AfterJob {
View(PathBuf),
Open(PathBuf),
OpenWith(PathBuf, Box<crate::opener::Opener>),
Compare {
files: (PathBuf, PathBuf),
shown: (PathBuf, PathBuf),
then: Option<(PathBuf, Vec<(PathBuf, PathBuf)>)>,
},
Land(PathBuf),
}
impl AfterJob {
fn to_scratch(&self) -> bool {
!matches!(self, AfterJob::Land(_))
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TransferKind {
Copy,
Move,
Delete,
Trash,
Extract,
Pack,
Checksum,
}
impl TransferKind {
pub fn title(self) -> &'static str {
match self {
TransferKind::Copy => "Copy",
TransferKind::Move => "Move",
TransferKind::Delete => "Delete",
TransferKind::Trash => "Move to trash",
TransferKind::Extract => "Extract",
TransferKind::Pack => "Pack",
TransferKind::Checksum => "Checksum",
}
}
}
impl TransferJob {
pub fn fraction(&self) -> Option<f64> {
match self.total {
Some(total) if total > 0 => Some((self.done as f64 / total as f64).min(1.0)),
_ => None,
}
}
pub fn is_cancelling(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
pub fn answer(&mut self, answer: Answer) {
if self.problem.take().is_some() || self.clash.take().is_some() {
let _ = self.answers.send(answer);
}
}
}
pub struct JobClash {
pub source: PathBuf,
pub dest: PathBuf,
pub source_size: Option<u64>,
pub source_modified: Option<SystemTime>,
pub dest_size: Option<u64>,
pub dest_modified: Option<SystemTime>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Clashes {
Ask,
ReplaceAll,
KeepAll,
KeepBothAll,
ReplaceOlder,
}
pub struct JobProblem {
pub path: PathBuf,
pub message: String,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Answer {
Retry,
Skip,
SkipAll,
Abort,
Delete,
Overwrite,
OverwriteAll,
OverwriteOlder,
KeepBoth,
KeepBothAll,
}
enum JobUpdate {
Total(u64),
Starting(PathBuf),
Advanced(u64),
Problem(JobProblem),
Clash(JobClash),
Skipped,
Checksums(Box<crate::checksum::Report>),
Finished(Result<Transfer, String>),
}
struct JobProgress<'a> {
pub(super) updates: &'a Sender<JobUpdate>,
pub(super) cancel: &'a AtomicBool,
answers: &'a Receiver<Answer>,
skip_all: bool,
pub(super) clashes: Clashes,
aborted: bool,
}
impl<'a> JobProgress<'a> {
fn new(updates: &'a Sender<JobUpdate>, cancel: &'a AtomicBool, answers: &'a Receiver<Answer>, clashes: Clashes) -> Self {
JobProgress {
updates,
cancel,
answers,
skip_all: false,
clashes,
aborted: false,
}
}
fn finish(&self, outcome: Result<Transfer, std::io::Error>) {
let outcome = match outcome {
Err(_) if self.aborted => Ok(Transfer::Cancelled),
outcome => outcome.map_err(|e| e.to_string()),
};
let _ = self.updates.send(JobUpdate::Finished(outcome));
}
}
impl Progress for JobProgress<'_> {
fn step(&mut self, step: Step<'_>) -> bool {
let update = match step {
Step::Starting(path) => JobUpdate::Starting(path.to_path_buf()),
Step::Advanced(amount) => JobUpdate::Advanced(amount),
};
self.updates.send(update).is_ok() && !self.cancel.load(Ordering::Relaxed)
}
fn failed(&mut self, path: &Path, error: &std::io::Error) -> Reply {
if self.cancel.load(Ordering::Relaxed) {
self.aborted = true;
return Reply::Abort;
}
let answer = if self.skip_all {
Answer::Skip
} else {
let problem = JobProblem {
path: path.to_path_buf(),
message: error.to_string(),
};
if self.updates.send(JobUpdate::Problem(problem)).is_err() { Answer::Abort } else { self.answers.recv().unwrap_or(Answer::Abort) }
};
match answer {
Answer::Retry => Reply::Retry,
Answer::Skip | Answer::SkipAll => {
self.skip_all |= answer == Answer::SkipAll;
let _ = self.updates.send(JobUpdate::Skipped);
Reply::Skip
}
Answer::Abort => {
self.aborted = true;
Reply::Abort
}
Answer::Delete => Reply::Delete,
Answer::Overwrite | Answer::OverwriteAll | Answer::OverwriteOlder | Answer::KeepBoth | Answer::KeepBothAll => Reply::Skip,
}
}
fn exists(&mut self, source: &Path, dest: &Path) -> Clash {
if self.cancel.load(Ordering::Relaxed) {
return Clash::Abort;
}
let described = |path: &Path| {
std::fs::metadata(path).or_else(|_| path.symlink_metadata()).ok().map(|metadata| (metadata.len(), metadata.modified().ok())).or_else(|| {
crate::archive::entry_at(path).map(|(index, at)| {
let entry = index.entry(at);
(entry.size, entry.modified)
})
})
};
let modified = |path: &Path| described(path).and_then(|(_, modified)| modified);
let clash = match self.clashes {
Clashes::Ask => {
let size = |path: &Path| described(path).map(|(size, _)| size);
let question = JobClash {
source: source.to_path_buf(),
dest: dest.to_path_buf(),
source_size: size(source),
source_modified: modified(source),
dest_size: size(dest),
dest_modified: modified(dest),
};
let answer = if self.updates.send(JobUpdate::Clash(question)).is_err() { Answer::Abort } else { self.answers.recv().unwrap_or(Answer::Abort) };
match answer {
Answer::Overwrite => Clash::Replace,
Answer::Skip => Clash::Keep,
Answer::OverwriteAll => {
self.clashes = Clashes::ReplaceAll;
Clash::Replace
}
Answer::SkipAll => {
self.clashes = Clashes::KeepAll;
Clash::Keep
}
Answer::KeepBoth => Clash::KeepBoth,
Answer::KeepBothAll => {
self.clashes = Clashes::KeepBothAll;
Clash::KeepBoth
}
Answer::OverwriteOlder => {
self.clashes = Clashes::ReplaceOlder;
replace_if_older(modified(source), modified(dest))
}
Answer::Abort | Answer::Retry | Answer::Delete => Clash::Abort,
}
}
Clashes::ReplaceAll => Clash::Replace,
Clashes::KeepAll => Clash::Keep,
Clashes::KeepBothAll => Clash::KeepBoth,
Clashes::ReplaceOlder => replace_if_older(modified(source), modified(dest)),
};
if clash == Clash::Keep {
let _ = self.updates.send(JobUpdate::Skipped);
}
clash
}
}
fn replace_if_older(source: Option<SystemTime>, dest: Option<SystemTime>) -> Clash {
match (source, dest) {
(Some(source), Some(dest)) if dest < source => Clash::Replace,
_ => Clash::Keep,
}
}
fn run_delete(items: Vec<(PathBuf, bool)>, mut progress: JobProgress<'_>) {
let _ = progress.updates.send(JobUpdate::Total(count_entries(&items)));
for (path, is_dir) in items {
match delete_path(path, is_dir, &mut progress) {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
fn run_trash(items: Vec<(PathBuf, bool)>, mut progress: JobProgress<'_>) {
let _ = progress.updates.send(JobUpdate::Total(items.len() as u64));
for (path, is_dir) in items {
match crate::fs_ops::trash_path(path, is_dir, &mut progress) {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
fn run_extract(archive: PathBuf, items: Vec<(PathBuf, PathBuf)>, mut progress: JobProgress<'_>) {
let inners: Vec<PathBuf> = items.iter().map(|(inner, _)| inner.clone()).collect();
let _ = progress.updates.send(JobUpdate::Total(crate::fs_ops::measure_extract(&archive, &inners)));
let outcome = crate::fs_ops::extract(&archive, &items, &mut progress);
progress.finish(outcome);
}
fn run_compute(sources: Vec<(PathBuf, bool)>, base: PathBuf, mut progress: JobProgress<'_>) {
let (targets, unreadable) = crate::checksum::targets(&sources, &base);
let _ = progress.updates.send(JobUpdate::Total(targets.iter().map(|target| target.size).sum()));
let Some(mut sums) = crate::checksum::compute(&targets, &mut progress) else {
return progress.finish(Ok(Transfer::Cancelled));
};
sums.extend(unreadable.into_iter().map(|(name, error)| (name, Err(error))));
let _ = progress.updates.send(JobUpdate::Checksums(Box::new(crate::checksum::Report::Computed {
algorithm: crate::checksum::Algorithm::Sha256,
base,
sums,
})));
progress.finish(Ok(Transfer::Done));
}
fn run_check(file: PathBuf, mut progress: JobProgress<'_>) {
let text = match std::fs::read(&file) {
Ok(text) => text,
Err(error) => return progress.finish(Err(error)),
};
let (expected, not_understood) = crate::checksum::parse_sums(&text);
let base = file.parent().map(Path::to_path_buf).unwrap_or_default();
let total = expected.iter().filter_map(|line| std::fs::metadata(base.join(&line.name)).ok()).map(|metadata| metadata.len()).sum();
let _ = progress.updates.send(JobUpdate::Total(total));
let Some(results) = crate::checksum::check(&expected, &base, &mut progress) else {
return progress.finish(Ok(Transfer::Cancelled));
};
let _ = progress.updates.send(JobUpdate::Checksums(Box::new(crate::checksum::Report::Checked { file, results, not_understood })));
progress.finish(Ok(Transfer::Done));
}
fn run_pack(sources: Vec<(PathBuf, bool)>, dest: PathBuf, format: crate::fs_ops::PackFormat, replacing: bool, mut progress: JobProgress<'_>) {
let outcome = match crate::fs_ops::plan_pack(&sources, &dest, format, &mut progress) {
Ok(Some(plan)) => {
let _ = progress.updates.send(JobUpdate::Total(plan.bytes));
crate::fs_ops::pack(&plan, &dest, format, replacing, &mut progress)
}
Ok(None) => Ok(Transfer::Cancelled),
Err(error) => Err(error),
};
progress.finish(outcome);
}
fn run_transfer(items: Vec<(PathBuf, PathBuf, bool)>, is_copy: bool, mut progress: JobProgress<'_>) {
let mut remaining = Vec::new();
for (source, dest, is_dir) in items {
if !is_copy && !path_exists(&dest) && rename_in_place(&source, &dest) {
continue;
}
remaining.push((source, dest, is_dir));
}
let _ = progress.updates.send(JobUpdate::Total(measure(&remaining)));
for (source, dest, is_dir) in remaining {
let result = if is_copy { copy_path(source, dest, is_dir, &mut progress) } else { move_path(source, dest, &mut progress) };
match result {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
impl AppState {
fn start_job<F>(&mut self, kind: TransferKind, clashes: Clashes, work: F)
where
F: FnOnce(JobProgress<'_>) + Send + 'static,
{
let cancel = Arc::new(AtomicBool::new(false));
let (sender, updates) = mpsc::channel();
let (answers, worker_answers) = mpsc::channel();
let worker_cancel = Arc::clone(&cancel);
std::thread::spawn(move || work(JobProgress::new(&sender, &worker_cancel, &worker_answers, clashes)));
self.job = Some(TransferJob {
kind,
total: None,
done: 0,
current: PathBuf::new(),
started: Instant::now(),
cancel,
updates,
problem: None,
clash: None,
answers,
skipped: 0,
after: None,
checksums: None,
});
}
pub fn start_checksums(&mut self, sources: Vec<(PathBuf, bool)>, base: PathBuf) {
self.start_job(TransferKind::Checksum, Clashes::Ask, move |progress| run_compute(sources, base, progress));
}
pub fn start_check(&mut self, file: PathBuf) {
self.start_job(TransferKind::Checksum, Clashes::Ask, move |progress| run_check(file, progress));
}
pub fn start_pack(&mut self, sources: Vec<(PathBuf, bool)>, dest: PathBuf, format: crate::fs_ops::PackFormat, replacing: bool) {
let land = dest.clone();
self.start_job(TransferKind::Pack, Clashes::Ask, move |progress| run_pack(sources, dest, format, replacing, progress));
if let Some(job) = &mut self.job {
job.after = Some(AfterJob::Land(land));
}
}
pub fn start_extract(&mut self, archive: PathBuf, items: Vec<(PathBuf, PathBuf)>, clashes: Clashes, after: Option<AfterJob>) {
self.start_job(TransferKind::Extract, clashes, move |progress| run_extract(archive, items, progress));
if let Some(job) = &mut self.job {
job.after = after;
}
}
pub fn start_transfer(&mut self, items: Vec<(PathBuf, PathBuf, bool)>, is_copy: bool, clashes: Clashes) {
let kind = if is_copy { TransferKind::Copy } else { TransferKind::Move };
self.start_job(kind, clashes, move |progress| run_transfer(items, is_copy, progress));
}
pub fn start_delete(&mut self, items: Vec<(PathBuf, bool)>) {
self.start_job(TransferKind::Delete, Clashes::Ask, move |progress| run_delete(items, progress));
}
pub fn start_trash(&mut self, items: Vec<(PathBuf, bool)>) {
self.start_job(TransferKind::Trash, Clashes::Ask, move |progress| run_trash(items, progress));
}
pub fn cancel_transfer(&mut self) {
if let Some(job) = &mut self.job {
job.cancel.store(true, Ordering::Relaxed);
job.answer(Answer::Abort);
}
}
pub fn poll_transfer(&mut self) {
let Some(job) = &mut self.job else {
return;
};
let mut finished = None;
loop {
match job.updates.try_recv() {
Ok(JobUpdate::Total(amount)) => job.total = Some(amount),
Ok(JobUpdate::Starting(path)) => job.current = path,
Ok(JobUpdate::Advanced(amount)) => job.done += amount,
Ok(JobUpdate::Problem(_) | JobUpdate::Clash(_)) if job.is_cancelling() => {
let _ = job.answers.send(Answer::Abort);
}
Ok(JobUpdate::Problem(problem)) => job.problem = Some(problem),
Ok(JobUpdate::Clash(clash)) => job.clash = Some(clash),
Ok(JobUpdate::Skipped) => job.skipped += 1,
Ok(JobUpdate::Checksums(report)) => job.checksums = Some(report),
Ok(JobUpdate::Finished(result)) => {
finished = Some(result);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
finished = Some(Err("Transfer stopped unexpectedly".to_string()));
break;
}
}
}
let Some(result) = finished else {
return;
};
let job = self.job.take().expect("checked at the top");
if job.kind != TransferKind::Checksum && job.after.as_ref().is_none_or(|after| !after.to_scratch()) {
self.reload_panel(true, None);
self.reload_panel(false, None);
self.clear_active_selections();
self.dir_sizes.clear();
self.cancel_dir_sizes();
}
self.quit_armed = false;
if let (Ok(Transfer::Done), Some(report)) = (&result, job.checksums) {
self.dialog = Some(Dialog::Checksums(Box::new(ChecksumView { report: *report, scroll: 0, room: 1 })));
return;
}
match (&result, job.after) {
(Ok(Transfer::Done), Some(AfterJob::View(path))) if job.skipped == 0 => return self.request_open(path, false),
(Ok(Transfer::Done), Some(AfterJob::Open(path))) if job.skipped == 0 => return self.open_outside(&path, false),
(Ok(Transfer::Done), Some(AfterJob::OpenWith(path, opener))) if job.skipped == 0 => return self.launch(&opener, &path),
(Ok(Transfer::Done), Some(AfterJob::Land(archive))) => {
for is_left in [true, false] {
if archive.parent() == Some(self.panel_dir(is_left).as_path()) {
self.reload_panel(is_left, archive.file_name());
}
}
}
(Ok(Transfer::Done), Some(AfterJob::Compare { files, shown, then })) if job.skipped == 0 => {
match then {
Some((archive, items)) => self.start_extract(archive, items, Clashes::ReplaceAll, Some(AfterJob::Compare { files, shown, then: None })),
None => self.compare_shown(files, shown),
}
return;
}
_ => {}
}
match result {
Ok(Transfer::Done) if job.skipped > 0 => {
let entries = if job.skipped == 1 { "entry" } else { "entries" };
self.display_error(format!("{} finished, {} {entries} skipped", job.kind.title(), job.skipped))
}
Ok(Transfer::Done) => {}
Ok(Transfer::Cancelled) => {
let far = match job.kind {
TransferKind::Delete => format!("{} entries", job.done),
TransferKind::Trash => format!("{} items", job.done),
_ => crate::utils::format_size(job.done),
};
self.display_error(format!("{} cancelled after {far}", job.kind.title()))
}
Err(e) => self.display_error(e),
}
}
}
#[cfg(test)]
mod job_tests {
use super::AppState;
#[test]
fn a_finished_job_forgets_directory_sizes() {
let dir = std::env::temp_dir().join(format!("fm84-sizes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doomed"), vec![0u8; 100]).unwrap();
let mut app_state = AppState::new();
app_state.dir_sizes.insert(dir.clone(), 100);
app_state.start_delete(vec![(dir.join("doomed"), false)]);
let started = std::time::Instant::now();
while app_state.job.is_some() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "delete never finished");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_transfer();
}
assert!(app_state.dir_sizes.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
}