Skip to content

Commit 5a1f78c

Browse files
committed
Volumes: resolve paths via statfs, not enumeration
- Add `resolve_path_volume` backend command: calls `statfs()` directly on the path to get its mount point and fs type. Local paths resolve in <1ms regardless of network mount health. - Handle APFS firmlinks (`/System/Volumes/Data` → `/`), parent-walk on `ENOENT`, `mtp://`/`smb://` protocol dispatch. - Linux: parse `/proc/mounts` for mount-point resolution, add timeout protection (was missing). - Migrate all 6 `findContainingVolume` callers to `resolvePathVolume`. - Add `requestVolumeRefresh()` to retry handler — fixes "retry fixes pane but not volume selector." - Remove redundant `listVolumes()` call in `NetworkMountView` (mount event already triggers broadcast). - Delete `find_containing_volume` command from all platforms. - Deduplicate `path_to_id` between `mod.rs` and `watcher.rs`.
1 parent 8f2296a commit 5a1f78c

26 files changed

Lines changed: 665 additions & 210 deletions

File tree

apps/desktop/src-tauri/src/commands/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ immediately to business-logic modules. No significant logic lives here.
1010
| `mod.rs` | Re-exports | `mtp`, `network` gated behind `#[cfg(any(target_os = "macos", target_os = "linux"))]`; `volumes` behind `#[cfg(target_os = "macos")]`; `volumes_linux` behind `#[cfg(target_os = "linux")]` |
1111
| `util.rs` | Shared helpers | `TimedOut<T>`, `IpcError`, `blocking_with_timeout`, `blocking_with_timeout_flag`, `blocking_result_with_timeout`. See "Timeout-aware return types" below. |
1212
| `file_system.rs` | File listing & writes | Largest file. Streaming + virtual-scroll listing API, write ops (copy, move, delete, trash), scan preview, conflict resolution, volume copy, native drag, self-drag overlay. Contains `expand_tilde()`. |
13-
| `volumes.rs` | Volume management (macOS) | `list_volumes`, `get_default_volume_id`, `find_containing_volume`, `get_volume_space` |
13+
| `volumes.rs` | Volume management (macOS) | `list_volumes`, `get_default_volume_id`, `get_volume_space`, `resolve_path_volume` (statfs-based, no volume enumeration) |
1414
| `volumes_linux.rs` | Volume management (Linux) | Same interface as `volumes.rs`, delegates to `volumes_linux` module |
1515
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |
1616
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting. |

apps/desktop/src-tauri/src/commands/volumes.rs

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
//! Tauri commands for volume operations.
22
3+
use serde::Serialize;
34
use tokio::time::Duration;
45

56
use super::util::{TimedOut, blocking_with_timeout_flag};
67
use crate::volumes::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, VolumeSpaceInfo};
78

9+
/// Result of resolving a path to its containing volume.
10+
/// Unlike `TimedOut<Option<VolumeInfo>>`, `timed_out: true` means "the filesystem
11+
/// didn't respond, we genuinely don't know" — not "here's a fallback."
12+
#[derive(Debug, Clone, Serialize)]
13+
#[serde(rename_all = "camelCase")]
14+
pub struct PathVolumeResolution {
15+
pub volume: Option<VolumeInfo>,
16+
pub timed_out: bool,
17+
}
18+
819
const VOLUME_TIMEOUT: Duration = Duration::from_secs(2);
920

1021
/// Lists all mounted volumes, including connected MTP devices.
@@ -21,38 +32,6 @@ pub fn get_default_volume_id() -> String {
2132
DEFAULT_VOLUME_ID.to_string()
2233
}
2334

24-
/// Finds the actual volume (not a favorite) that contains a given path.
25-
/// Returns the volume info for the best matching volume, excluding favorites.
26-
/// This is used to determine which volume to set as active when a favorite is chosen.
27-
#[tauri::command]
28-
pub async fn find_containing_volume(path: String) -> TimedOut<Option<VolumeInfo>> {
29-
let mut result = blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_locations).await;
30-
append_mtp_volumes(&mut result.data).await;
31-
32-
// Only consider actual volumes, not favorites
33-
let volumes: Vec<_> = result
34-
.data
35-
.into_iter()
36-
.filter(|loc| loc.category != LocationCategory::Favorite)
37-
.collect();
38-
39-
// Find the volume with the longest matching path prefix
40-
let mut best_match: Option<VolumeInfo> = None;
41-
let mut best_len = 0;
42-
43-
for vol in volumes {
44-
if path.starts_with(&vol.path) && vol.path.len() > best_len {
45-
best_len = vol.path.len();
46-
best_match = Some(vol);
47-
}
48-
}
49-
50-
TimedOut {
51-
data: best_match,
52-
timed_out: result.timed_out,
53-
}
54-
}
55-
5635
/// Gets space information for a volume at the given path.
5736
/// Returns total and available bytes for the volume.
5837
/// For MTP paths (`mtp://`), fetches from the MTP connection manager instead of macOS NSURL.
@@ -67,6 +46,64 @@ pub async fn get_volume_space(path: String) -> TimedOut<Option<VolumeSpaceInfo>>
6746
blocking_with_timeout_flag(VOLUME_TIMEOUT, None, move || volumes::get_volume_space(&path)).await
6847
}
6948

49+
/// Resolves a path to its containing volume without enumerating all volumes.
50+
/// Uses `statfs()` for filesystem paths (<1ms for local disks), protocol
51+
/// dispatch for MTP/SMB paths. Returns `timed_out: true` if the filesystem
52+
/// didn't respond within 2s.
53+
#[tauri::command]
54+
pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
55+
// MTP protocol dispatch
56+
if path.starts_with("mtp://") {
57+
let mtp_volume = find_mtp_volume_for_path(&path).await;
58+
return PathVolumeResolution {
59+
volume: mtp_volume,
60+
timed_out: false,
61+
};
62+
}
63+
64+
// SMB/network protocol paths → return the virtual network volume
65+
if path.starts_with("smb://") {
66+
return PathVolumeResolution {
67+
volume: Some(VolumeInfo {
68+
id: "network".to_string(),
69+
name: "Network".to_string(),
70+
path: "smb://".to_string(),
71+
category: LocationCategory::Network,
72+
icon: None,
73+
is_ejectable: false,
74+
fs_type: Some("smbfs".to_string()),
75+
supports_trash: false,
76+
is_read_only: false,
77+
}),
78+
timed_out: false,
79+
};
80+
}
81+
82+
// Filesystem paths: resolve via statfs with timeout
83+
let result =
84+
blocking_with_timeout_flag(VOLUME_TIMEOUT, None, move || volumes::resolve_path_volume_fast(&path)).await;
85+
86+
PathVolumeResolution {
87+
volume: result.data,
88+
timed_out: result.timed_out,
89+
}
90+
}
91+
92+
/// Finds the MTP volume matching a `mtp://device_id/storage_id/...` path.
93+
async fn find_mtp_volume_for_path(path: &str) -> Option<VolumeInfo> {
94+
let rest = path.strip_prefix("mtp://")?;
95+
let mut parts = rest.splitn(3, '/');
96+
let device_id = parts.next()?;
97+
let storage_id_str = parts.next()?;
98+
let _storage_id: u32 = storage_id_str.parse().ok()?;
99+
100+
let mut volumes = Vec::new();
101+
append_mtp_volumes(&mut volumes).await;
102+
// Match on the path prefix (mtp://device_id/storage_id)
103+
let prefix = format!("mtp://{}/{}", device_id, storage_id_str);
104+
volumes.into_iter().find(|v| v.path == prefix)
105+
}
106+
70107
/// Appends connected MTP device storages to the volume list.
71108
/// Each storage becomes a separate volume entry with category `MobileDevice`.
72109
async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {

apps/desktop/src-tauri/src/commands/volumes_linux.rs

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
//! Tauri commands for volume operations on Linux.
22
3-
use super::util::TimedOut;
3+
use serde::Serialize;
4+
use tokio::time::Duration;
5+
6+
use super::util::{TimedOut, blocking_with_timeout_flag};
47
use crate::volumes_linux::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, VolumeSpaceInfo};
58

9+
const VOLUME_TIMEOUT: Duration = Duration::from_secs(2);
10+
11+
/// Result of resolving a path to its containing volume.
12+
#[derive(Debug, Clone, Serialize)]
13+
#[serde(rename_all = "camelCase")]
14+
pub struct PathVolumeResolution {
15+
pub volume: Option<VolumeInfo>,
16+
pub timed_out: bool,
17+
}
18+
619
/// Lists all mounted volumes, including connected MTP devices.
720
#[tauri::command]
821
pub async fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
@@ -17,34 +30,6 @@ pub fn get_default_volume_id() -> String {
1730
DEFAULT_VOLUME_ID.to_string()
1831
}
1932

20-
/// Finds the actual volume (not a favorite) that contains a given path.
21-
#[tauri::command]
22-
pub async fn find_containing_volume(path: String) -> TimedOut<Option<VolumeInfo>> {
23-
let mut all_locations = volumes_linux::list_locations();
24-
append_mtp_volumes(&mut all_locations).await;
25-
26-
// Only consider actual volumes, not favorites
27-
let volumes: Vec<_> = all_locations
28-
.into_iter()
29-
.filter(|loc| loc.category != LocationCategory::Favorite)
30-
.collect();
31-
32-
let mut best_match: Option<VolumeInfo> = None;
33-
let mut best_len = 0;
34-
35-
for vol in volumes {
36-
if path.starts_with(&vol.path) && vol.path.len() > best_len {
37-
best_len = vol.path.len();
38-
best_match = Some(vol);
39-
}
40-
}
41-
42-
TimedOut {
43-
data: best_match,
44-
timed_out: false,
45-
}
46-
}
47-
4833
/// Gets space information for a volume at the given path.
4934
/// For MTP paths (`mtp://`), fetches from the MTP connection manager instead of statvfs.
5035
#[tauri::command]
@@ -61,6 +46,64 @@ pub async fn get_volume_space(path: String) -> TimedOut<Option<VolumeSpaceInfo>>
6146
}
6247
}
6348

49+
/// Resolves a path to its containing volume without enumerating all volumes.
50+
/// Parses `/proc/self/mountinfo` for filesystem paths, dispatches on protocol
51+
/// for MTP/SMB. Uses `spawn_blocking` + timeout (2s).
52+
#[tauri::command]
53+
pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
54+
// MTP protocol dispatch
55+
if path.starts_with("mtp://") {
56+
let mtp_volume = find_mtp_volume_for_path(&path).await;
57+
return PathVolumeResolution {
58+
volume: mtp_volume,
59+
timed_out: false,
60+
};
61+
}
62+
63+
// SMB/network protocol paths
64+
if path.starts_with("smb://") {
65+
return PathVolumeResolution {
66+
volume: Some(VolumeInfo {
67+
id: "network".to_string(),
68+
name: "Network".to_string(),
69+
path: "smb://".to_string(),
70+
category: LocationCategory::Network,
71+
icon: None,
72+
is_ejectable: false,
73+
fs_type: Some("cifs".to_string()),
74+
supports_trash: false,
75+
is_read_only: false,
76+
}),
77+
timed_out: false,
78+
};
79+
}
80+
81+
// Filesystem paths: resolve via /proc/self/mountinfo with timeout
82+
let result = blocking_with_timeout_flag(VOLUME_TIMEOUT, None, move || {
83+
volumes_linux::resolve_path_volume_fast(&path)
84+
})
85+
.await;
86+
87+
PathVolumeResolution {
88+
volume: result.data,
89+
timed_out: result.timed_out,
90+
}
91+
}
92+
93+
/// Finds the MTP volume matching a `mtp://device_id/storage_id/...` path.
94+
async fn find_mtp_volume_for_path(path: &str) -> Option<VolumeInfo> {
95+
let rest = path.strip_prefix("mtp://")?;
96+
let mut parts = rest.splitn(3, '/');
97+
let device_id = parts.next()?;
98+
let storage_id_str = parts.next()?;
99+
let _storage_id: u32 = storage_id_str.parse().ok()?;
100+
101+
let mut volumes = Vec::new();
102+
append_mtp_volumes(&mut volumes).await;
103+
let prefix = format!("mtp://{}/{}", device_id, storage_id_str);
104+
volumes.into_iter().find(|v| v.path == prefix)
105+
}
106+
64107
/// Appends connected MTP device storages to the volume list.
65108
/// Each storage becomes a separate volume entry with category `MobileDevice`.
66109
async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ pub enum VolumeError {
7777
/// Device or volume is read-only.
7878
ReadOnly(String),
7979
/// Device storage is full.
80-
StorageFull { message: String },
80+
StorageFull {
81+
message: String,
82+
},
8183
/// Connection timed out.
8284
ConnectionTimeout(String),
8385
IoError(String),

apps/desktop/src-tauri/src/file_system/write_operations/delete.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,17 @@ fn scan_volume_recursive(
203203
});
204204
}
205205

206-
let is_dir = volume.is_directory(path).map_err(|e| map_volume_error(&path.display().to_string(), e))?;
206+
let is_dir = volume
207+
.is_directory(path)
208+
.map_err(|e| map_volume_error(&path.display().to_string(), e))?;
207209

208210
if is_dir {
209211
// Recurse into children first — list_directory returns FileEntry with size,
210212
// so we use child.size directly instead of calling get_metadata (which returns
211213
// NotSupported on MTP).
212-
let children = volume.list_directory(path).map_err(|e| map_volume_error(&path.display().to_string(), e))?;
214+
let children = volume
215+
.list_directory(path)
216+
.map_err(|e| map_volume_error(&path.display().to_string(), e))?;
213217

214218
for child in &children {
215219
let child_path = PathBuf::from(&child.path);
@@ -391,7 +395,9 @@ pub(super) fn delete_volume_files_with_progress(
391395
});
392396
}
393397

394-
volume.delete(&entry.path).map_err(|e| map_volume_error(&entry.path.display().to_string(), e))?;
398+
volume
399+
.delete(&entry.path)
400+
.map_err(|e| map_volume_error(&entry.path.display().to_string(), e))?;
395401

396402
files_done += 1;
397403
bytes_done += entry.size;

apps/desktop/src-tauri/src/file_system/write_operations/linux_copy.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,7 @@ fn map_io_error(err: std::io::Error, source: &Path, destination: &Path) -> Write
164164
device_name: None,
165165
};
166166
}
167-
libc::ENOTCONN | libc::ENETDOWN | libc::ENETUNREACH
168-
| libc::EHOSTUNREACH | libc::ETIMEDOUT => {
167+
libc::ENOTCONN | libc::ENETDOWN | libc::ENETUNREACH | libc::EHOSTUNREACH | libc::ETIMEDOUT => {
169168
return WriteOperationError::ConnectionInterrupted {
170169
path: source.display().to_string(),
171170
};

apps/desktop/src-tauri/src/file_system/write_operations/types.rs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,12 @@ fn classify_io_error(e: &std::io::Error, path: String) -> WriteOperationError {
320320
return WriteOperationError::ReadOnlyDevice {
321321
path,
322322
device_name: None,
323-
}
323+
};
324324
}
325325
libc::ENAMETOOLONG => return WriteOperationError::NameTooLong { path },
326-
libc::ENOTCONN | libc::ENETDOWN | libc::ENETUNREACH | libc::EHOSTUNREACH
327-
| libc::ETIMEDOUT => return WriteOperationError::ConnectionInterrupted { path },
326+
libc::ENOTCONN | libc::ENETDOWN | libc::ENETUNREACH | libc::EHOSTUNREACH | libc::ETIMEDOUT => {
327+
return WriteOperationError::ConnectionInterrupted { path };
328+
}
328329
libc::ENODEV => return WriteOperationError::DeviceDisconnected { path },
329330
_ => {} // Fall through to ErrorKind/message-based classification
330331
}
@@ -351,10 +352,7 @@ fn classify_io_error(e: &std::io::Error, path: String) -> WriteOperationError {
351352
return WriteOperationError::NameTooLong { path };
352353
}
353354
if lower.contains("invalid") && lower.contains("name") {
354-
return WriteOperationError::InvalidName {
355-
path,
356-
message: msg,
357-
};
355+
return WriteOperationError::InvalidName { path, message: msg };
358356
}
359357

360358
// ErrorKind-based fallback, with one kind-specific heuristic
@@ -365,16 +363,10 @@ fn classify_io_error(e: &std::io::Error, path: String) -> WriteOperationError {
365363
if lower.contains("immutable") || lower.contains("operation not permitted") {
366364
return WriteOperationError::FileLocked { path };
367365
}
368-
WriteOperationError::PermissionDenied {
369-
path,
370-
message: msg,
371-
}
366+
WriteOperationError::PermissionDenied { path, message: msg }
372367
}
373368
std::io::ErrorKind::AlreadyExists => WriteOperationError::DestinationExists { path },
374-
_ => WriteOperationError::IoError {
375-
path,
376-
message: msg,
377-
},
369+
_ => WriteOperationError::IoError { path, message: msg },
378370
}
379371
}
380372

apps/desktop/src-tauri/src/lib.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -732,25 +732,25 @@ pub fn run() {
732732
#[cfg(target_os = "macos")]
733733
commands::volumes::get_default_volume_id,
734734
#[cfg(target_os = "macos")]
735-
commands::volumes::find_containing_volume,
736-
#[cfg(target_os = "macos")]
737735
commands::volumes::get_volume_space,
736+
#[cfg(target_os = "macos")]
737+
commands::volumes::resolve_path_volume,
738738
#[cfg(target_os = "linux")]
739739
commands::volumes_linux::list_volumes,
740740
#[cfg(target_os = "linux")]
741741
commands::volumes_linux::get_default_volume_id,
742742
#[cfg(target_os = "linux")]
743-
commands::volumes_linux::find_containing_volume,
744-
#[cfg(target_os = "linux")]
745743
commands::volumes_linux::get_volume_space,
744+
#[cfg(target_os = "linux")]
745+
commands::volumes_linux::resolve_path_volume,
746746
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
747747
stubs::volumes::list_volumes,
748748
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
749749
stubs::volumes::get_default_volume_id,
750750
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
751-
stubs::volumes::find_containing_volume,
752-
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
753751
stubs::volumes::get_volume_space,
752+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
753+
stubs::volumes::resolve_path_volume,
754754
// Network commands (macOS + Linux, stubs for other platforms)
755755
#[cfg(any(target_os = "macos", target_os = "linux"))]
756756
commands::network::list_network_hosts,

0 commit comments

Comments
 (0)