Skip to content

Commit fd8dad6

Browse files
committed
MTP: Make mkdir and copy work on MTP drives
- Fix connection issues - Remove redundant MCP-specific copy dialog - Fix bug that listed devices by the wrong name - Make listing work flawlessly - Fix copying, now it works both FROM and TO MTP devices - Add file system watching - Add debug logging
1 parent 03eafdf commit fd8dad6

40 files changed

Lines changed: 5277 additions & 878 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,11 @@
2828
"licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" },
2929
"logger.ts": { "reason": "LogTape config, initialization code only" },
3030
"mtp/MtpBrowser.svelte": { "reason": "MTP file browser component, depends on Tauri APIs" },
31-
"mtp/MtpCopyDialog.svelte": { "reason": "UI modal for MTP copy progress, depends on Tauri APIs" },
3231
"mtp/MtpDeleteDialog.svelte": { "reason": "UI modal for MTP delete confirmation" },
3332
"mtp/MtpNewFolderDialog.svelte": { "reason": "UI modal for MTP new folder" },
3433
"mtp/MtpProgressDialog.svelte": { "reason": "UI modal for MTP progress tracking" },
3534
"mtp/MtpRenameDialog.svelte": { "reason": "UI modal for MTP rename" },
3635
"mtp/PtpcameradDialog.svelte": { "reason": "UI modal for macOS MTP workaround" },
37-
"mtp/mtp-path-utils.ts": { "reason": "MTP path utilities, tested implicitly via component usage" },
3836
"mtp/mtp-store.svelte.ts": { "reason": "Depends on Tauri APIs, tests planned in Phase 6" },
3937
"licensing/AboutWindow.svelte": { "reason": "UI component" },
4038
"licensing/CommercialReminderModal.svelte": { "reason": "UI component" },

apps/desktop/src-tauri/Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ tower-http = { version = "0.6", features = ["cors"] }
6464
tauri-plugin-updater = "2"
6565
tauri-plugin-process = "2"
6666
memchr = "2"
67+
walkdir = "2"
6768

6869
[target.'cfg(unix)'.dependencies]
6970
libc = "0.2"

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

Lines changed: 222 additions & 38 deletions
Large diffs are not rendered by default.

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Tauri commands for MTP (Android device) operations.
22
33
use log::debug;
4+
use serde::{Deserialize, Serialize};
45
use std::path::PathBuf;
56

67
use crate::file_system::FileEntry;
@@ -9,6 +10,18 @@ use crate::mtp::{
910
};
1011
use tauri::AppHandle;
1112

13+
/// Result of scanning an MTP path for copy operation.
14+
#[derive(Debug, Clone, Serialize, Deserialize)]
15+
#[serde(rename_all = "camelCase")]
16+
pub struct MtpScanResult {
17+
/// Number of files found.
18+
pub file_count: usize,
19+
/// Number of directories found.
20+
pub dir_count: usize,
21+
/// Total bytes of all files.
22+
pub total_bytes: u64,
23+
}
24+
1225
/// Lists all connected MTP devices.
1326
///
1427
/// This returns devices detected via USB that support MTP protocol.
@@ -121,11 +134,7 @@ pub async fn list_mtp_directory(
121134
.list_directory(&device_id, storage_id, &path)
122135
.await;
123136
match &result {
124-
Ok(entries) => debug!(
125-
"list_mtp_directory: SUCCESS - {} entries for {}",
126-
entries.len(),
127-
path
128-
),
137+
Ok(entries) => debug!("list_mtp_directory: SUCCESS - {} entries for {}", entries.len(), path),
129138
Err(e) => debug!("list_mtp_directory: ERROR - {:?}", e),
130139
}
131140
result
@@ -276,3 +285,38 @@ pub async fn move_mtp_object(
276285
.move_object(&device_id, storage_id, &object_path, &new_parent_path)
277286
.await
278287
}
288+
289+
// ============================================================================
290+
// Phase 5: Copy/Export Operations
291+
// ============================================================================
292+
293+
/// Scans an MTP path for copy statistics.
294+
///
295+
/// Recursively scans the specified path to get file count, directory count,
296+
/// and total bytes. Useful for showing progress during copy operations.
297+
///
298+
/// # Arguments
299+
///
300+
/// * `device_id` - The connected device ID
301+
/// * `storage_id` - The storage ID within the device
302+
/// * `path` - Virtual path on the device to scan
303+
#[tauri::command]
304+
pub async fn scan_mtp_for_copy(
305+
device_id: String,
306+
storage_id: u32,
307+
path: String,
308+
) -> Result<MtpScanResult, MtpConnectionError> {
309+
debug!(
310+
"scan_mtp_for_copy: device={}, storage={}, path={}",
311+
device_id, storage_id, path
312+
);
313+
let result = mtp::connection_manager()
314+
.scan_for_copy(&device_id, storage_id, &path)
315+
.await?;
316+
317+
Ok(MtpScanResult {
318+
file_count: result.file_count,
319+
dir_count: result.dir_count,
320+
total_bytes: result.total_bytes,
321+
})
322+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ pub use operations::get_paths_at_indices;
3737
pub use provider::FileSystemProvider;
3838
// Re-export volume types (some not used externally yet)
3939
#[allow(unused_imports, reason = "Public API re-exports for future use")]
40-
pub use volume::{InMemoryVolume, LocalPosixVolume, MtpVolume, Volume, VolumeError};
40+
pub use volume::{
41+
ConflictInfo, CopyScanResult, InMemoryVolume, LocalPosixVolume, MtpVolume, SourceItemInfo, SpaceInfo, Volume,
42+
VolumeError,
43+
};
4144
#[allow(unused_imports, reason = "Public API re-exports for future use")]
4245
pub use volume_manager::VolumeManager;
4346
// Watcher management - init_watcher_manager must be called from lib.rs
@@ -48,6 +51,10 @@ pub use write_operations::{
4851
cancel_write_operation, copy_files_start, delete_files_start, get_operation_status, list_active_operations,
4952
move_files_start,
5053
};
54+
// Re-export volume copy types and functions
55+
// TODO: Remove this allow once volume_copy is integrated into Tauri commands (Phase 5)
56+
#[allow(unused_imports, reason = "Volume copy not yet integrated into Tauri commands")]
57+
pub use write_operations::{VolumeCopyConfig, VolumeCopyScanResult, copy_between_volumes, scan_for_volume_copy};
5158

5259
/// Global volume manager instance
5360
static VOLUME_MANAGER: LazyLock<VolumeManager> = LazyLock::new(VolumeManager::new);

apps/desktop/src-tauri/src/file_system/operations.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,14 +1342,20 @@ fn read_directory_with_progress(
13421342
listing_id: &str,
13431343
state: &Arc<StreamingListingState>,
13441344
volume_id: &str,
1345-
path: &PathBuf,
1345+
path: &Path,
13461346
include_hidden: bool,
13471347
sort_by: SortColumn,
13481348
sort_order: SortOrder,
13491349
) -> Result<(), std::io::Error> {
13501350
use tauri::Emitter;
13511351

13521352
benchmark::log_event("read_directory_with_progress START");
1353+
log::debug!(
1354+
"read_directory_with_progress: listing_id={}, volume_id={}, path={}",
1355+
listing_id,
1356+
volume_id,
1357+
path.display()
1358+
);
13531359

13541360
// Emit opening event - this is the slow part for network folders
13551361
// (SMB connection establishment, directory handle creation, MTP queries)
@@ -1373,18 +1379,15 @@ fn read_directory_with_progress(
13731379
}
13741380

13751381
// Get the volume from VolumeManager
1376-
let volume = super::get_volume_manager().get(volume_id).ok_or_else(|| {
1377-
std::io::Error::new(
1378-
std::io::ErrorKind::NotFound,
1379-
format!("Volume not found: {}", volume_id),
1380-
)
1381-
})?;
1382+
let volume = super::get_volume_manager()
1383+
.get(volume_id)
1384+
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("Volume not found: {}", volume_id)))?;
13821385

13831386
// Read directory entries via Volume abstraction
13841387
let read_start = std::time::Instant::now();
13851388
let mut entries = volume
13861389
.list_directory(path)
1387-
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
1390+
.map_err(|e| std::io::Error::other(e.to_string()))?;
13881391
let read_dir_time = read_start.elapsed();
13891392
benchmark::log_event_value("read_dir COMPLETE, entries", entries.len());
13901393

@@ -1434,7 +1437,7 @@ fn read_directory_with_progress(
14341437
listing_id.to_string(),
14351438
CachedListing {
14361439
volume_id: volume_id.to_string(),
1437-
path: path.clone(),
1440+
path: path.to_path_buf(),
14381441
entries,
14391442
sort_by,
14401443
sort_order,

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

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
//! Local POSIX file system volume implementation.
22
3-
use super::{Volume, VolumeError};
3+
use super::{ConflictInfo, CopyScanResult, SourceItemInfo, SpaceInfo, Volume, VolumeError};
44
use crate::file_system::FileEntry;
55
use crate::file_system::operations::{get_single_entry, list_directory_core};
66
use std::path::{Path, PathBuf};
7+
use walkdir::WalkDir;
78

89
/// A volume backed by the local POSIX file system.
910
///
@@ -116,4 +117,144 @@ impl Volume for LocalPosixVolume {
116117
std::fs::rename(&from_abs, &to_abs)?;
117118
Ok(())
118119
}
120+
121+
fn supports_export(&self) -> bool {
122+
true
123+
}
124+
125+
fn scan_for_copy(&self, path: &Path) -> Result<CopyScanResult, VolumeError> {
126+
let abs_path = self.resolve(path);
127+
let mut file_count = 0;
128+
let mut dir_count = 0;
129+
let mut total_bytes = 0u64;
130+
131+
for entry in WalkDir::new(&abs_path).min_depth(0) {
132+
let entry = entry.map_err(|e| VolumeError::IoError(e.to_string()))?;
133+
let ft = entry.file_type();
134+
if ft.is_file() {
135+
file_count += 1;
136+
if let Ok(meta) = entry.metadata() {
137+
total_bytes += meta.len();
138+
}
139+
} else if ft.is_dir() {
140+
// Don't count the root itself if it's the starting point
141+
if entry.depth() > 0 {
142+
dir_count += 1;
143+
}
144+
}
145+
}
146+
147+
// If the path is a single file, count it
148+
if let Ok(meta) = std::fs::metadata(&abs_path) {
149+
if meta.is_file() && file_count == 0 {
150+
file_count = 1;
151+
total_bytes = meta.len();
152+
} else if meta.is_dir() && dir_count == 0 && file_count == 0 {
153+
dir_count = 1;
154+
}
155+
}
156+
157+
Ok(CopyScanResult {
158+
file_count,
159+
dir_count,
160+
total_bytes,
161+
})
162+
}
163+
164+
fn export_to_local(&self, source: &Path, local_dest: &Path) -> Result<u64, VolumeError> {
165+
let src_abs = self.resolve(source);
166+
copy_recursive(&src_abs, local_dest)
167+
}
168+
169+
fn import_from_local(&self, local_source: &Path, dest: &Path) -> Result<u64, VolumeError> {
170+
let dest_abs = self.resolve(dest);
171+
copy_recursive(local_source, &dest_abs)
172+
}
173+
174+
fn scan_for_conflicts(
175+
&self,
176+
source_items: &[SourceItemInfo],
177+
dest_path: &Path,
178+
) -> Result<Vec<ConflictInfo>, VolumeError> {
179+
let dest_abs = self.resolve(dest_path);
180+
let mut conflicts = Vec::new();
181+
182+
for item in source_items {
183+
let dest_file_path = dest_abs.join(&item.name);
184+
if dest_file_path.exists()
185+
&& let Ok(meta) = std::fs::metadata(&dest_file_path)
186+
{
187+
let dest_modified = meta
188+
.modified()
189+
.ok()
190+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64));
191+
192+
conflicts.push(ConflictInfo {
193+
source_path: item.name.clone(),
194+
dest_path: dest_file_path.to_string_lossy().to_string(),
195+
source_size: item.size,
196+
dest_size: meta.len(),
197+
source_modified: item.modified,
198+
dest_modified,
199+
});
200+
}
201+
}
202+
203+
Ok(conflicts)
204+
}
205+
206+
fn get_space_info(&self) -> Result<SpaceInfo, VolumeError> {
207+
get_space_info_for_path(&self.root)
208+
}
209+
}
210+
211+
/// Recursively copies a file or directory from source to destination.
212+
/// Returns total bytes copied.
213+
fn copy_recursive(source: &Path, dest: &Path) -> Result<u64, VolumeError> {
214+
let meta = std::fs::metadata(source)?;
215+
let mut total_bytes = 0;
216+
217+
if meta.is_file() {
218+
// Copy single file
219+
std::fs::copy(source, dest)?;
220+
total_bytes = meta.len();
221+
} else if meta.is_dir() {
222+
// Create destination directory
223+
std::fs::create_dir_all(dest)?;
224+
225+
// Copy all contents
226+
for entry in std::fs::read_dir(source)? {
227+
let entry = entry?;
228+
let src_path = entry.path();
229+
let dest_path = dest.join(entry.file_name());
230+
total_bytes += copy_recursive(&src_path, &dest_path)?;
231+
}
232+
}
233+
234+
Ok(total_bytes)
235+
}
236+
237+
/// Gets space information for a path using statvfs.
238+
fn get_space_info_for_path(path: &Path) -> Result<SpaceInfo, VolumeError> {
239+
use std::ffi::CString;
240+
241+
let path_c = CString::new(path.to_string_lossy().as_bytes()).map_err(|e| VolumeError::IoError(e.to_string()))?;
242+
243+
unsafe {
244+
let mut stat: libc::statvfs = std::mem::zeroed();
245+
if libc::statvfs(path_c.as_ptr(), &mut stat) == 0 {
246+
let block_size = stat.f_frsize as u64;
247+
let total_bytes = (stat.f_blocks as u64) * block_size;
248+
let available_bytes = (stat.f_bavail as u64) * block_size;
249+
let used_bytes = total_bytes.saturating_sub((stat.f_bfree as u64) * block_size);
250+
251+
Ok(SpaceInfo {
252+
total_bytes,
253+
available_bytes,
254+
used_bytes,
255+
})
256+
} else {
257+
Err(VolumeError::IoError("Failed to get space info".into()))
258+
}
259+
}
119260
}

0 commit comments

Comments
 (0)