1use std::{
2 io,
3 path::{Path, PathBuf},
4};
5
6use super::App;
7use crate::FileSystem;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct OperationId(pub u64);
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum OperationMode {
16 #[default]
18 Immediate,
19 Deferred,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum FileOperation {
26 Copy {
28 sources: Vec<PathBuf>,
30 destination: PathBuf,
32 overwrite: bool,
34 },
35 Move {
37 sources: Vec<PathBuf>,
39 destination: PathBuf,
41 overwrite: bool,
43 },
44 Delete {
46 paths: Vec<PathBuf>,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct OperationRequest {
54 pub id: OperationId,
56 pub operation: FileOperation,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct OperationProgress {
63 pub id: OperationId,
65 pub completed: usize,
67 pub total: usize,
69 pub current_path: Option<PathBuf>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct OperationFailure {
76 pub path: PathBuf,
78 pub error: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct OperationResult {
85 pub id: OperationId,
87 pub succeeded: Vec<PathBuf>,
89 pub failures: Vec<OperationFailure>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct OperationApplyError {
96 pub expected: Option<OperationId>,
98 pub received: OperationId,
100}
101
102impl std::fmt::Display for OperationApplyError {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 write!(
105 f,
106 "operation result {:?} does not match pending operation {:?}",
107 self.received, self.expected
108 )
109 }
110}
111
112impl std::error::Error for OperationApplyError {}
113
114fn copy_one(
115 filesystem: &dyn FileSystem,
116 source: &Path,
117 destination: &Path,
118 overwrite: bool,
119) -> io::Result<PathBuf> {
120 let name = source
121 .file_name()
122 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "source has no filename"))?;
123 let target = destination.join(name);
124 if filesystem.exists(&target) && !overwrite {
125 return Err(io::Error::new(
126 io::ErrorKind::AlreadyExists,
127 format!("{} already exists", target.display()),
128 ));
129 }
130 if filesystem.is_dir(source) {
131 filesystem.copy_dir(source, &target)?;
132 } else {
133 filesystem.copy_file(source, &target)?;
134 }
135 Ok(target)
136}
137
138pub fn execute_operation_with_progress(
143 request: &OperationRequest,
144 on_progress: impl FnMut(OperationProgress),
145) -> OperationResult {
146 execute_operation_with(crate::std_filesystem().as_ref(), request, on_progress)
147}
148
149pub fn execute_operation_with(
151 filesystem: &dyn FileSystem,
152 request: &OperationRequest,
153 mut on_progress: impl FnMut(OperationProgress),
154) -> OperationResult {
155 let (paths, total) = match &request.operation {
156 FileOperation::Copy { sources, .. } | FileOperation::Move { sources, .. } => {
157 (sources.clone(), sources.len())
158 }
159 FileOperation::Delete { paths } => (paths.clone(), paths.len()),
160 };
161
162 let mut succeeded = Vec::new();
163 let mut failures = Vec::new();
164 for (index, path) in paths.iter().enumerate() {
165 on_progress(OperationProgress {
166 id: request.id,
167 completed: index,
168 total,
169 current_path: Some(path.clone()),
170 });
171
172 let result = match &request.operation {
173 FileOperation::Copy {
174 destination,
175 overwrite,
176 ..
177 } => copy_one(filesystem, path, destination, *overwrite),
178 FileOperation::Move {
179 destination,
180 overwrite,
181 ..
182 } => copy_one(filesystem, path, destination, *overwrite).and_then(|target| {
183 if filesystem.is_dir(path) {
184 filesystem.remove_dir_all(path)?;
185 } else {
186 filesystem.remove_file(path)?;
187 }
188 Ok(target)
189 }),
190 FileOperation::Delete { .. } => {
191 let changed = path.clone();
192 if filesystem.is_dir(path) {
193 filesystem.remove_dir_all(path).map(|()| changed)
194 } else {
195 filesystem.remove_file(path).map(|()| changed)
196 }
197 }
198 };
199
200 match result {
201 Ok(path) => succeeded.push(path),
202 Err(error) => failures.push(OperationFailure {
203 path: path.clone(),
204 error: error.to_string(),
205 }),
206 }
207 }
208
209 on_progress(OperationProgress {
210 id: request.id,
211 completed: total,
212 total,
213 current_path: None,
214 });
215
216 OperationResult {
217 id: request.id,
218 succeeded,
219 failures,
220 }
221}
222
223#[must_use]
225pub fn execute_operation(request: &OperationRequest) -> OperationResult {
226 execute_operation_with_progress(request, |_| {})
227}
228
229impl App {
230 #[must_use]
232 pub fn operation_mode(&self) -> OperationMode {
233 self.operation_mode
234 }
235
236 #[must_use]
238 pub fn operation_filesystem(&self) -> crate::SharedFileSystem {
239 self.active_pane().filesystem().clone()
240 }
241
242 pub fn set_operation_mode(&mut self, mode: OperationMode) {
244 self.operation_mode = mode;
245 }
246
247 pub(crate) fn queue_operation(&mut self, operation: FileOperation) -> OperationRequest {
248 if let Some(request) = self.pending_operation.clone() {
249 self.notify_error("A filesystem operation is already in progress");
250 return request;
251 }
252 let request = OperationRequest {
253 id: OperationId(self.next_operation_id),
254 operation,
255 };
256 self.next_operation_id = self.next_operation_id.wrapping_add(1);
257 self.pending_operation = Some(request.clone());
258 self.queued_operation = Some(request.clone());
259 self.status_msg = "Filesystem operation queued.".into();
260 request
261 }
262
263 pub fn take_operation_request(&mut self) -> Option<OperationRequest> {
265 self.queued_operation.take()
266 }
267
268 #[must_use]
270 pub fn pending_operation(&self) -> Option<&OperationRequest> {
271 self.pending_operation.as_ref()
272 }
273
274 pub fn cancel_operation(&mut self, id: OperationId) -> Result<(), OperationApplyError> {
277 let expected = self.pending_operation.as_ref().map(|request| request.id);
278 if expected != Some(id) {
279 return Err(OperationApplyError {
280 expected,
281 received: id,
282 });
283 }
284 self.pending_operation = None;
285 self.queued_operation = None;
286 self.copy_progress = None;
287 self.status_msg = "Filesystem operation cancelled.".into();
288 self.event_queue
289 .push_back(super::AppEvent::OperationCancelled { id });
290 Ok(())
291 }
292
293 pub fn apply_operation_progress(
299 &mut self,
300 progress: OperationProgress,
301 ) -> Result<(), OperationApplyError> {
302 let expected = self.pending_operation.as_ref().map(|request| request.id);
303 if expected != Some(progress.id) {
304 return Err(OperationApplyError {
305 expected,
306 received: progress.id,
307 });
308 }
309 self.copy_progress = Some(super::CopyProgress {
310 label: format!("Processing {} item(s)…", progress.total),
311 done: progress.completed,
312 total: progress.total,
313 current_item: progress
314 .current_path
315 .as_ref()
316 .and_then(|path| path.file_name())
317 .unwrap_or_default()
318 .to_string_lossy()
319 .into_owned(),
320 });
321 self.event_queue
322 .push_back(super::AppEvent::OperationProgress {
323 id: progress.id,
324 completed: progress.completed,
325 total: progress.total,
326 });
327 Ok(())
328 }
329
330 pub fn apply_operation_result(
337 &mut self,
338 result: OperationResult,
339 ) -> Result<Vec<PathBuf>, OperationApplyError> {
340 let expected = self.pending_operation.as_ref().map(|request| request.id);
341 if expected != Some(result.id) {
342 return Err(OperationApplyError {
343 expected,
344 received: result.id,
345 });
346 }
347 let succeeded_count = result.succeeded.len();
348 let failure_count = result.failures.len();
349 let was_move = self
350 .pending_operation
351 .as_ref()
352 .is_some_and(|request| matches!(request.operation, FileOperation::Move { .. }));
353 self.pending_operation = None;
354 self.queued_operation = None;
355 self.copy_progress = None;
356
357 if was_move && result.failures.is_empty() {
358 self.clipboard = None;
359 }
360 for pane in &mut self.panes {
361 pane.clear_marks();
362 pane.reload();
363 pane.clear_dir_size_cache();
364 }
365 self.preview_state.invalidate();
366
367 if result.failures.is_empty() {
368 let message = format!("Completed {} item(s).", result.succeeded.len());
369 self.status_msg = message.clone();
370 self.notify(message);
371 } else {
372 let details = result
373 .failures
374 .iter()
375 .map(|failure| format!("'{}': {}", failure.path.display(), failure.error))
376 .collect::<Vec<_>>()
377 .join("; ");
378 let message = format!(
379 "Completed {}, {} error(s): {details}",
380 result.succeeded.len(),
381 result.failures.len()
382 );
383 self.status_msg = format!("Error: {message}");
384 self.notify_error(message);
385 }
386 self.event_queue
387 .push_back(super::AppEvent::OperationCompleted {
388 id: result.id,
389 succeeded: succeeded_count,
390 failed: failure_count,
391 });
392 Ok(result.succeeded)
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use std::fs;
400
401 use crate::{AppOptions, AppOutcome, ClipOp, ClipboardItem};
402 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
403 use tempfile::tempdir;
404
405 #[test]
406 fn deferred_paste_returns_request_without_touching_destination() {
407 let source_dir = tempdir().unwrap();
408 let destination = tempdir().unwrap();
409 let source = source_dir.path().join("file.txt");
410 fs::write(&source, b"payload").unwrap();
411 let mut app = App::new(AppOptions {
412 pane_dirs: vec![destination.path().to_path_buf()],
413 ..AppOptions::default()
414 });
415 app.clipboard = Some(ClipboardItem {
416 paths: vec![source.clone()],
417 op: ClipOp::Copy,
418 });
419 app.set_operation_mode(OperationMode::Deferred);
420
421 let outcome = app
422 .dispatch_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
423 .unwrap();
424 let AppOutcome::OperationRequested(request) = outcome else {
425 panic!("expected deferred operation");
426 };
427 assert!(matches!(request.operation, FileOperation::Copy { .. }));
428 assert!(!destination.path().join("file.txt").exists());
429 }
430
431 #[test]
432 fn execute_and_apply_deferred_copy() {
433 let source_dir = tempdir().unwrap();
434 let destination = tempdir().unwrap();
435 let source = source_dir.path().join("file.txt");
436 fs::write(&source, b"payload").unwrap();
437 let mut app = App::new(AppOptions {
438 pane_dirs: vec![destination.path().to_path_buf()],
439 ..AppOptions::default()
440 });
441 app.set_operation_mode(OperationMode::Deferred);
442 let request = app.queue_operation(FileOperation::Copy {
443 sources: vec![source],
444 destination: destination.path().to_path_buf(),
445 overwrite: false,
446 });
447 let result = execute_operation(&request);
448 let changed = app.apply_operation_result(result).unwrap();
449 assert_eq!(changed, vec![destination.path().join("file.txt")]);
450 assert!(destination.path().join("file.txt").exists());
451 assert!(app.pending_operation().is_none());
452 }
453
454 #[test]
455 fn stale_result_is_rejected() {
456 let mut app = App::new(AppOptions::default());
457 app.set_operation_mode(OperationMode::Deferred);
458 app.queue_operation(FileOperation::Delete { paths: Vec::new() });
459 let error = app
460 .apply_operation_result(OperationResult {
461 id: OperationId(999),
462 succeeded: Vec::new(),
463 failures: Vec::new(),
464 })
465 .unwrap_err();
466 assert_eq!(error.received, OperationId(999));
467 }
468}