|
| 1 | +//! Batched per-path "is this a directory?" probe. |
| 2 | +//! |
| 3 | +//! Used by the drag-and-drop transfer path: dropped paths arrive from the OS |
| 4 | +//! pasteboard as bare absolute paths with no type info, so the confirmation |
| 5 | +//! dialog and completion toast can't split them into files vs. folders without |
| 6 | +//! a stat. This command resolves the top-level kind of each path in ONE batched |
| 7 | +//! IPC, never walking subtrees. |
| 8 | +//! |
| 9 | +//! Per-item failures (a path that doesn't resolve to the local filesystem — a |
| 10 | +//! virtual MTP/SMB path that landed on the pasteboard, a vanished entry, a |
| 11 | +//! permission error) map to `None` ("unknown"), never an error for the whole |
| 12 | +//! batch. The caller treats `None` as "fall back to today's approximate |
| 13 | +//! behavior" rather than blocking the drop. |
| 14 | +
|
| 15 | +use crate::commands::util::{TimedOut, blocking_with_timeout_flag}; |
| 16 | +use std::path::Path; |
| 17 | +use tokio::time::Duration; |
| 18 | + |
| 19 | +/// Reads stat for paths from the pasteboard, so use the read timeout. A batch |
| 20 | +/// of plain `symlink_metadata` calls on local paths is microseconds each; the |
| 21 | +/// timeout only bites if one of the paths sits on a hung mount. |
| 22 | +const STAT_PATHS_TIMEOUT: Duration = Duration::from_secs(2); |
| 23 | + |
| 24 | +/// For each input path, returns: |
| 25 | +/// - `Some(true)` — the path is a directory, |
| 26 | +/// - `Some(false)` — the path is a non-directory (file, symlink, …), |
| 27 | +/// - `None` — the kind is unknown (stat failed: the path doesn't resolve |
| 28 | +/// to the local filesystem, vanished, or we lack permission). |
| 29 | +/// |
| 30 | +/// The result vector is index-aligned with `paths`. This is a pure, |
| 31 | +/// Tauri-free helper so the kind logic stays unit-testable. We use |
| 32 | +/// `symlink_metadata` (not `metadata`) so a symlink reports as a non-directory |
| 33 | +/// rather than following into its (possibly slow / missing) target. |
| 34 | +pub fn stat_paths_kinds_blocking(paths: &[String]) -> Vec<Option<bool>> { |
| 35 | + paths |
| 36 | + .iter() |
| 37 | + .map(|p| match std::fs::symlink_metadata(Path::new(p)) { |
| 38 | + Ok(meta) => Some(meta.is_dir()), |
| 39 | + Err(_) => None, |
| 40 | + }) |
| 41 | + .collect() |
| 42 | +} |
| 43 | + |
| 44 | +/// Batched per-path directory probe for the drag-and-drop transfer path. |
| 45 | +/// |
| 46 | +/// Returns a `Vec<Option<bool>>` index-aligned with `paths` (see |
| 47 | +/// `stat_paths_kinds_blocking`). Runs in `spawn_blocking` under the read |
| 48 | +/// timeout; on a batch timeout the whole vector falls back to `None` |
| 49 | +/// (all-unknown) with `timed_out: true`, so the caller cleanly degrades to the |
| 50 | +/// approximate count shape rather than freezing the drop on slow volume I/O. |
| 51 | +#[tauri::command] |
| 52 | +#[specta::specta] |
| 53 | +pub async fn stat_paths_kinds(paths: Vec<String>) -> TimedOut<Vec<Option<bool>>> { |
| 54 | + let count = paths.len(); |
| 55 | + let fallback = vec![None; count]; |
| 56 | + blocking_with_timeout_flag(STAT_PATHS_TIMEOUT, fallback, move || stat_paths_kinds_blocking(&paths)).await |
| 57 | +} |
| 58 | + |
| 59 | +#[cfg(test)] |
| 60 | +mod tests { |
| 61 | + use super::*; |
| 62 | + use std::fs; |
| 63 | + |
| 64 | + fn test_dir(name: &str) -> std::path::PathBuf { |
| 65 | + let dir = std::env::temp_dir().join(format!("cmdr_stat_kinds_test_{name}")); |
| 66 | + let _ = fs::remove_dir_all(&dir); |
| 67 | + fs::create_dir_all(&dir).expect("create test dir"); |
| 68 | + dir |
| 69 | + } |
| 70 | + |
| 71 | + #[test] |
| 72 | + fn classifies_files_and_dirs() { |
| 73 | + let tmp = test_dir("mixed"); |
| 74 | + let file = tmp.join("a.txt"); |
| 75 | + fs::write(&file, b"hi").unwrap(); |
| 76 | + let subdir = tmp.join("sub"); |
| 77 | + fs::create_dir(&subdir).unwrap(); |
| 78 | + |
| 79 | + let paths = vec![ |
| 80 | + file.to_string_lossy().into_owned(), |
| 81 | + subdir.to_string_lossy().into_owned(), |
| 82 | + ]; |
| 83 | + let kinds = stat_paths_kinds_blocking(&paths); |
| 84 | + assert_eq!(kinds, vec![Some(false), Some(true)]); |
| 85 | + |
| 86 | + let _ = fs::remove_dir_all(&tmp); |
| 87 | + } |
| 88 | + |
| 89 | + #[test] |
| 90 | + fn unknown_for_nonexistent_and_virtual_paths() { |
| 91 | + // A vanished local path, an MTP-shaped virtual path, and an SMB virtual |
| 92 | + // path all fail `symlink_metadata` → None. They must NOT poison the |
| 93 | + // whole batch, and the result stays index-aligned. |
| 94 | + let tmp = test_dir("unknown"); |
| 95 | + let real = tmp.join("real"); |
| 96 | + fs::create_dir(&real).unwrap(); |
| 97 | + |
| 98 | + let paths = vec![ |
| 99 | + real.to_string_lossy().into_owned(), |
| 100 | + "/this/does/not/exist/12345".to_string(), |
| 101 | + "mtp-1234://Internal storage/DCIM".to_string(), |
| 102 | + "smb://server/share/file".to_string(), |
| 103 | + ]; |
| 104 | + let kinds = stat_paths_kinds_blocking(&paths); |
| 105 | + assert_eq!(kinds, vec![Some(true), None, None, None]); |
| 106 | + |
| 107 | + let _ = fs::remove_dir_all(&tmp); |
| 108 | + } |
| 109 | + |
| 110 | + #[test] |
| 111 | + fn empty_input_yields_empty() { |
| 112 | + assert_eq!(stat_paths_kinds_blocking(&[]), Vec::<Option<bool>>::new()); |
| 113 | + } |
| 114 | + |
| 115 | + #[tokio::test] |
| 116 | + async fn command_returns_aligned_kinds_without_timeout() { |
| 117 | + let tmp = test_dir("cmd"); |
| 118 | + let file = tmp.join("f"); |
| 119 | + fs::write(&file, b"x").unwrap(); |
| 120 | + let paths = vec![file.to_string_lossy().into_owned(), "/nope/nope".to_string()]; |
| 121 | + |
| 122 | + let result = stat_paths_kinds(paths).await; |
| 123 | + assert!(!result.timed_out); |
| 124 | + assert_eq!(result.data, vec![Some(false), None]); |
| 125 | + |
| 126 | + let _ = fs::remove_dir_all(&tmp); |
| 127 | + } |
| 128 | +} |
0 commit comments