Skip to content

Commit cd66031

Browse files
committed
Bugfix: MTP volumes in copy/move dialog
- `list_volumes()` and `find_containing_volume()` now include connected MTP devices as `MobileDevice` volumes (both macOS and Linux) - Transfer dialog shows volume-relative paths (`/DCIM` not `mtp://...`) and passes them end-to-end — no strip→reconstruct→strip cycle - Fixed `destVolumeId` bug: changing the volume in the dropdown now uses the selected volume, not the original - Fixed conflict check using wrong volume after dropdown change - `copy_between_volumes` local-to-local optimization now handles volume-relative `dest_path` correctly (aligns with `LocalPosixVolume::resolve`) - Removed `getMtpVolumes()` from all volume display/selection code — unified `VolumeInfo[]` from backend is the single source of truth - Added `MobileDevice` category and `is_read_only` field to `LocationInfo` on all platforms (+ fixed stub struct drift) - Re-fetch volumes on MTP connect/disconnect events
1 parent cf7c839 commit cd66031

File tree

18 files changed

+523
-154
lines changed

18 files changed

+523
-154
lines changed

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

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ use crate::volumes::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, Volu
77

88
const VOLUME_TIMEOUT: Duration = Duration::from_secs(2);
99

10-
/// Lists all mounted volumes.
10+
/// Lists all mounted volumes, including connected MTP devices.
1111
#[tauri::command]
1212
pub async fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
13-
blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_mounted_volumes).await
13+
let mut result = blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_mounted_volumes).await;
14+
append_mtp_volumes(&mut result.data).await;
15+
result
1416
}
1517

1618
/// Gets the default volume ID (root filesystem).
@@ -24,29 +26,31 @@ pub fn get_default_volume_id() -> String {
2426
/// This is used to determine which volume to set as active when a favorite is chosen.
2527
#[tauri::command]
2628
pub async fn find_containing_volume(path: String) -> TimedOut<Option<VolumeInfo>> {
27-
blocking_with_timeout_flag(VOLUME_TIMEOUT, None, move || {
28-
let locations = volumes::list_locations();
29+
let mut result = blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_locations).await;
30+
append_mtp_volumes(&mut result.data).await;
2931

30-
// Only consider actual volumes, not favorites
31-
let volumes: Vec<_> = locations
32-
.into_iter()
33-
.filter(|loc| loc.category != LocationCategory::Favorite)
34-
.collect();
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();
3538

36-
// Find the volume with the longest matching path prefix
37-
let mut best_match: Option<VolumeInfo> = None;
38-
let mut best_len = 0;
39+
// Find the volume with the longest matching path prefix
40+
let mut best_match: Option<VolumeInfo> = None;
41+
let mut best_len = 0;
3942

40-
for vol in volumes {
41-
if path.starts_with(&vol.path) && vol.path.len() > best_len {
42-
best_len = vol.path.len();
43-
best_match = Some(vol);
44-
}
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);
4547
}
48+
}
4649

47-
best_match
48-
})
49-
.await
50+
TimedOut {
51+
data: best_match,
52+
timed_out: result.timed_out,
53+
}
5054
}
5155

5256
/// Gets space information for a volume at the given path.
@@ -63,6 +67,39 @@ pub async fn get_volume_space(path: String) -> TimedOut<Option<VolumeSpaceInfo>>
6367
blocking_with_timeout_flag(VOLUME_TIMEOUT, None, move || volumes::get_volume_space(&path)).await
6468
}
6569

70+
/// Appends connected MTP device storages to the volume list.
71+
/// Each storage becomes a separate volume entry with category `MobileDevice`.
72+
async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
73+
let devices = crate::mtp::connection_manager().get_all_connected_devices().await;
74+
for device in devices {
75+
let multi = device.storages.len() > 1;
76+
let device_name = device
77+
.device
78+
.product
79+
.as_deref()
80+
.or(device.device.manufacturer.as_deref())
81+
.unwrap_or("Mobile device");
82+
for storage in &device.storages {
83+
let name = if multi {
84+
format!("{} - {}", device_name, storage.name)
85+
} else {
86+
device_name.to_string()
87+
};
88+
volumes.push(VolumeInfo {
89+
id: format!("{}:{}", device.device.id, storage.id),
90+
name,
91+
path: format!("mtp://{}/{}", device.device.id, storage.id),
92+
category: LocationCategory::MobileDevice,
93+
icon: None,
94+
is_ejectable: true,
95+
is_read_only: storage.is_read_only,
96+
fs_type: Some("mtp".to_string()),
97+
supports_trash: false,
98+
});
99+
}
100+
}
101+
}
102+
66103
/// Queries live MTP space info from a `mtp://{device_id}/{storage_id}/...` path.
67104
async fn get_mtp_space_info(path: &str) -> Option<VolumeSpaceInfo> {
68105
let rest = path.strip_prefix("mtp://")?;

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

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
use super::util::TimedOut;
44
use crate::volumes_linux::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, VolumeSpaceInfo};
55

6-
/// Lists all mounted volumes.
6+
/// Lists all mounted volumes, including connected MTP devices.
77
#[tauri::command]
8-
pub fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
9-
TimedOut {
10-
data: volumes_linux::list_mounted_volumes(),
11-
timed_out: false,
12-
}
8+
pub async fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
9+
let mut data = volumes_linux::list_mounted_volumes();
10+
append_mtp_volumes(&mut data).await;
11+
TimedOut { data, timed_out: false }
1312
}
1413

1514
/// Gets the default volume ID (root filesystem).
@@ -20,10 +19,12 @@ pub fn get_default_volume_id() -> String {
2019

2120
/// Finds the actual volume (not a favorite) that contains a given path.
2221
#[tauri::command]
23-
pub fn find_containing_volume(path: String) -> TimedOut<Option<VolumeInfo>> {
24-
let locations = volumes_linux::list_locations();
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;
2525

26-
let volumes: Vec<_> = locations
26+
// Only consider actual volumes, not favorites
27+
let volumes: Vec<_> = all_locations
2728
.into_iter()
2829
.filter(|loc| loc.category != LocationCategory::Favorite)
2930
.collect();
@@ -60,6 +61,39 @@ pub async fn get_volume_space(path: String) -> TimedOut<Option<VolumeSpaceInfo>>
6061
}
6162
}
6263

64+
/// Appends connected MTP device storages to the volume list.
65+
/// Each storage becomes a separate volume entry with category `MobileDevice`.
66+
async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
67+
let devices = crate::mtp::connection_manager().get_all_connected_devices().await;
68+
for device in devices {
69+
let multi = device.storages.len() > 1;
70+
let device_name = device
71+
.device
72+
.product
73+
.as_deref()
74+
.or(device.device.manufacturer.as_deref())
75+
.unwrap_or("Mobile device");
76+
for storage in &device.storages {
77+
let name = if multi {
78+
format!("{} - {}", device_name, storage.name)
79+
} else {
80+
device_name.to_string()
81+
};
82+
volumes.push(VolumeInfo {
83+
id: format!("{}:{}", device.device.id, storage.id),
84+
name,
85+
path: format!("mtp://{}/{}", device.device.id, storage.id),
86+
category: LocationCategory::MobileDevice,
87+
icon: None,
88+
is_ejectable: true,
89+
is_read_only: storage.is_read_only,
90+
fs_type: Some("mtp".to_string()),
91+
supports_trash: false,
92+
});
93+
}
94+
}
95+
}
96+
6397
/// Queries live MTP space info from a `mtp://{device_id}/{storage_id}/...` path.
6498
async fn get_mtp_space_info(path: &str) -> Option<VolumeSpaceInfo> {
6599
let rest = path.strip_prefix("mtp://")?;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ pub async fn copy_between_volumes(
8989

9090
// Convert relative paths to absolute paths
9191
let absolute_sources: Vec<PathBuf> = source_paths.iter().map(|p| src_root.join(p)).collect();
92-
let absolute_dest = dest_root.join(&dest_path);
92+
let absolute_dest = dest_root.join(dest_path.strip_prefix("/").unwrap_or(&dest_path));
9393

9494
// Convert VolumeCopyConfig to WriteOperationConfig
9595
let write_config = WriteOperationConfig {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum LocationCategory {
1414
AttachedVolume,
1515
CloudDrive,
1616
Network,
17+
MobileDevice,
1718
}
1819

1920
/// Information about a location (volume, folder, or cloud drive).
@@ -27,6 +28,13 @@ pub struct VolumeInfo {
2728
#[serde(skip_serializing_if = "Option::is_none")]
2829
pub icon: Option<String>,
2930
pub is_ejectable: bool,
31+
/// Filesystem type (for example, "ext4", "apfs"). Stubs don't detect this.
32+
#[serde(skip_serializing_if = "Option::is_none")]
33+
pub fs_type: Option<String>,
34+
/// Whether this volume supports trash operations.
35+
pub supports_trash: bool,
36+
/// Whether this location is read-only.
37+
pub is_read_only: bool,
3038
}
3139

3240
/// Information about volume space.
@@ -62,6 +70,9 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
6270
category: LocationCategory::Favorite,
6371
icon: None,
6472
is_ejectable: false,
73+
fs_type: None,
74+
supports_trash: true,
75+
is_read_only: false,
6576
});
6677
}
6778
}
@@ -74,6 +85,9 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
7485
category: LocationCategory::MainVolume,
7586
icon: None,
7687
is_ejectable: false,
88+
fs_type: None,
89+
supports_trash: true,
90+
is_read_only: false,
7791
});
7892

7993
// Add home directory
@@ -84,6 +98,9 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
8498
category: LocationCategory::Favorite,
8599
icon: None,
86100
is_ejectable: false,
101+
fs_type: None,
102+
supports_trash: true,
103+
is_read_only: false,
87104
});
88105

89106
locations

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub enum LocationCategory {
2424
AttachedVolume,
2525
CloudDrive,
2626
Network,
27+
MobileDevice,
2728
}
2829

2930
/// Information about a location (volume, folder, or cloud drive).
@@ -43,6 +44,8 @@ pub struct LocationInfo {
4344
pub fs_type: Option<String>,
4445
/// Whether this volume supports macOS trash. Derived from `fs_type`.
4546
pub supports_trash: bool,
47+
/// Whether this location is read-only (for example, MTP devices with locked storage).
48+
pub is_read_only: bool,
4649
}
4750

4851
/// Default volume ID for the root filesystem.
@@ -189,6 +192,7 @@ fn get_favorites() -> Vec<LocationInfo> {
189192
is_ejectable: false,
190193
fs_type,
191194
supports_trash,
195+
is_read_only: false,
192196
}
193197
})
194198
.collect()
@@ -228,6 +232,7 @@ fn get_main_volume() -> Option<LocationInfo> {
228232
is_ejectable: false,
229233
fs_type,
230234
supports_trash,
235+
is_read_only: false,
231236
});
232237
}
233238
}
@@ -293,6 +298,7 @@ pub fn get_attached_volumes() -> Vec<LocationInfo> {
293298
is_ejectable,
294299
fs_type,
295300
supports_trash,
301+
is_read_only: false,
296302
});
297303
}
298304

@@ -322,6 +328,7 @@ pub fn get_cloud_drives() -> Vec<LocationInfo> {
322328
is_ejectable: false,
323329
fs_type,
324330
supports_trash,
331+
is_read_only: false,
325332
});
326333
}
327334

@@ -348,6 +355,7 @@ pub fn get_cloud_drives() -> Vec<LocationInfo> {
348355
is_ejectable: false,
349356
fs_type,
350357
supports_trash,
358+
is_read_only: false,
351359
});
352360
}
353361
}
@@ -409,6 +417,7 @@ fn get_network_locations() -> Vec<LocationInfo> {
409417
is_ejectable: false,
410418
fs_type: None,
411419
supports_trash: false,
420+
is_read_only: false,
412421
});
413422

414423
locations

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub enum LocationCategory {
2424
AttachedVolume,
2525
CloudDrive,
2626
Network,
27+
MobileDevice,
2728
}
2829

2930
/// Information about a location (volume, folder, or cloud drive).
@@ -40,6 +41,8 @@ pub struct LocationInfo {
4041
#[serde(skip_serializing_if = "Option::is_none")]
4142
pub fs_type: Option<String>,
4243
pub supports_trash: bool,
44+
/// Whether this location is read-only (for example, MTP devices with locked storage).
45+
pub is_read_only: bool,
4346
}
4447

4548
/// Information about volume space.
@@ -193,6 +196,7 @@ fn get_favorites(mounts: &[MountEntry]) -> Vec<LocationInfo> {
193196
is_ejectable: false,
194197
fs_type,
195198
supports_trash,
199+
is_read_only: false,
196200
}
197201
})
198202
.collect()
@@ -211,6 +215,7 @@ fn get_main_volume(mounts: &[MountEntry]) -> Option<LocationInfo> {
211215
is_ejectable: false,
212216
fs_type,
213217
supports_trash,
218+
is_read_only: false,
214219
})
215220
}
216221

@@ -256,6 +261,7 @@ pub fn get_mounted_volumes(mounts: &[MountEntry]) -> Vec<LocationInfo> {
256261
is_ejectable: is_removable,
257262
fs_type,
258263
supports_trash,
264+
is_read_only: false,
259265
});
260266
}
261267

@@ -289,6 +295,7 @@ fn get_cloud_drives(mounts: &[MountEntry]) -> Vec<LocationInfo> {
289295
is_ejectable: false,
290296
fs_type,
291297
supports_trash,
298+
is_read_only: false,
292299
});
293300
}
294301
}
@@ -354,6 +361,7 @@ fn get_network_mounts() -> Vec<LocationInfo> {
354361
is_ejectable: true,
355362
fs_type: None,
356363
supports_trash: false,
364+
is_read_only: false,
357365
});
358366
}
359367
}

apps/desktop/src/lib/file-explorer/navigation/CLAUDE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,22 @@ real containing volume, not the `volumeId` prop (which may be a favorite's virtu
105105
Keyboard/mouse mode: entering keyboard nav sets `isKeyboardMode = true`, suppressing CSS `:hover` highlights. Mouse
106106
movement > 5px threshold exits keyboard mode.
107107

108-
MTP volumes are `$derived` from `getMtpVolumes()` — reactively updated when `mtp-store`'s `state.devices` changes (on
109-
connect/disconnect/hotplug). No separate event listeners needed. MTP space info (`totalBytes`/`availableBytes`) is
110-
rendered directly from the `MtpVolume` data, not via `volumeSpaceMap` (which is for local volumes only).
108+
MTP volumes come from the unified `volumes` list (returned by `listVolumes()`). The parent component
109+
(`DualPaneExplorer`) re-fetches volumes on `mtp-device-connected`/`mtp-device-disconnected` events, so the breadcrumb
110+
stays current. MTP volume space is fetched via `getVolumeSpace()` like any other volume.
111111

112112
Exported methods for parent components: `toggle()`, `open()`, `close()`, `getIsOpen()`, `handleKeyDown(e)`.
113113

114114
## `volume-grouping.ts`
115115

116116
Pure logic for organizing volumes into display groups. No reactive state.
117117

118-
`groupByCategory(vols, mtpVols)` — groups volumes by category in display order:
118+
`groupByCategory(vols)` — groups volumes by category in display order:
119119

120120
1. Favorites — no checkmark shown even if current path is a favorite
121121
2. main_volume + attached_volume — merged into one group
122122
3. Cloud drives
123-
4. Mobile (MTP) devices — mapped from `MtpVolume[]`
123+
4. Mobile (MTP) devices — filtered from unified volume list (`category === 'mobile_device'`)
124124
5. Network — always includes a synthetic `'network'` entry (`smb://`) plus any mounted SMB shares
125125

126126
`getIconForVolume(volume)` — returns the appropriate icon path for a volume based on its category.
@@ -152,5 +152,5 @@ reactive sets for the component to render inline indicators (no toasts):
152152
- `$lib/tauri-commands``listVolumes`, `findContainingVolume`, `listen`, `pathExists`
153153
- `$lib/utils/timing``withTimeout` (defense-in-depth IPC timeout wrapper)
154154
- `$lib/app-status-store``getLastUsedPathForVolume`
155-
- `$lib/mtp``getMtpVolumes`, `initialize`, `scanDevices`
155+
- `$lib/mtp``initialize`, `scanDevices`
156156
- `../types``VolumeInfo`, `LocationCategory`, `NetworkHost`

0 commit comments

Comments
 (0)