Skip to main content

tui_file_explorer/app/
operation.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4};
5
6use super::App;
7use crate::FileSystem;
8
9/// Identifies one file operation, so late progress and results can be matched to it.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct OperationId(pub u64);
12
13/// Who runs file operations.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum OperationMode {
16    /// The app runs them itself, on the UI thread.
17    #[default]
18    Immediate,
19    /// The app hands them to the host as [`OperationRequest`]s.
20    Deferred,
21}
22
23/// A copy, move or delete over one or more paths.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum FileOperation {
26    /// Copy `sources` into the `destination` directory.
27    Copy {
28        /// Files and directories to copy.
29        sources: Vec<PathBuf>,
30        /// Directory to copy into.
31        destination: PathBuf,
32        /// Replace existing files of the same name.
33        overwrite: bool,
34    },
35    /// Move `sources` into the `destination` directory.
36    Move {
37        /// Files and directories to move.
38        sources: Vec<PathBuf>,
39        /// Directory to move into.
40        destination: PathBuf,
41        /// Replace existing files of the same name.
42        overwrite: bool,
43    },
44    /// Delete `paths`, directories included.
45    Delete {
46        /// Files and directories to delete.
47        paths: Vec<PathBuf>,
48    },
49}
50
51/// A file operation the host should run (see [`OperationMode::Deferred`]).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct OperationRequest {
54    /// Pass this back with progress and the result.
55    pub id: OperationId,
56    /// What to do.
57    pub operation: FileOperation,
58}
59
60/// Progress of a running operation.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct OperationProgress {
63    /// The operation this update belongs to.
64    pub id: OperationId,
65    /// Items processed so far.
66    pub completed: usize,
67    /// Total number of items.
68    pub total: usize,
69    /// Item being processed now; `None` once all are done.
70    pub current_path: Option<PathBuf>,
71}
72
73/// One item that an operation could not process.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct OperationFailure {
76    /// The item that failed.
77    pub path: PathBuf,
78    /// Why it failed.
79    pub error: String,
80}
81
82/// Outcome of a finished operation.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct OperationResult {
85    /// The operation that finished.
86    pub id: OperationId,
87    /// Paths that were processed: new paths for copy and move, removed paths for delete.
88    pub succeeded: Vec<PathBuf>,
89    /// Items that failed.
90    pub failures: Vec<OperationFailure>,
91}
92
93/// Progress or a result arrived for an operation that is not the pending one.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct OperationApplyError {
96    /// The pending operation, if any.
97    pub expected: Option<OperationId>,
98    /// The operation the update was for.
99    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
138/// Execute an operation synchronously, reporting item-level progress.
139///
140/// Embedders can run this helper on a worker thread, send progress back to the
141/// UI thread, then call [`App::apply_operation_result`].
142pub 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
149/// Execute an operation against a custom filesystem backend.
150pub 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/// Execute an operation synchronously without progress callbacks.
224#[must_use]
225pub fn execute_operation(request: &OperationRequest) -> OperationResult {
226    execute_operation_with_progress(request, |_| {})
227}
228
229impl App {
230    /// Who runs file operations.
231    #[must_use]
232    pub fn operation_mode(&self) -> OperationMode {
233        self.operation_mode
234    }
235
236    /// Filesystem backend associated with the active operation destination.
237    #[must_use]
238    pub fn operation_filesystem(&self) -> crate::SharedFileSystem {
239        self.active_pane().filesystem().clone()
240    }
241
242    /// Set who runs file operations.
243    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    /// Take a deferred request when using the legacy `handle_key` API.
264    pub fn take_operation_request(&mut self) -> Option<OperationRequest> {
265        self.queued_operation.take()
266    }
267
268    /// The operation that is waiting for its result, if any.
269    #[must_use]
270    pub fn pending_operation(&self) -> Option<&OperationRequest> {
271        self.pending_operation.as_ref()
272    }
273
274    /// Cancel a queued/pending operation. Worker cancellation itself remains
275    /// host-owned; late results are rejected by operation ID.
276    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    /// Show progress reported by the host for the pending operation.
294    ///
295    /// # Errors
296    ///
297    /// [`OperationApplyError`] when `progress` is not for the pending operation.
298    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    /// Finish the pending operation: reload the panes and report the outcome.
331    /// Returns the paths that succeeded.
332    ///
333    /// # Errors
334    ///
335    /// [`OperationApplyError`] when `result` is not for the pending operation.
336    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}