From 2b68d77adb5902166e4c07969fe458fee3e09836 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 7 Mar 2026 16:11:31 +0800 Subject: [PATCH] =?UTF-8?q?folders=E5=90=8E=E7=AB=AF=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/folders.rs | 409 ++++++++++++++++++++---------- 1 file changed, 270 insertions(+), 139 deletions(-) diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 4158f9b..5bd760a 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -1221,7 +1221,10 @@ fn is_codeg_edit_temp_path(path: &Path) -> bool { .unwrap_or(false) } -fn git_check_ignored_paths(repo_path: &str, paths: &[String]) -> Result, String> { +fn git_check_ignored_paths( + repo_path: &str, + paths: &[String], +) -> Result, AppCommandError> { if paths.is_empty() { return Ok(HashSet::new()); } @@ -1233,27 +1236,22 @@ fn git_check_ignored_paths(repo_path: &str, paths: &[String]) -> Result Result { +fn resolve_tree_path(root: &Path, rel_path: &str) -> Result { let rel = Path::new(rel_path); if rel.is_absolute() { - return Err("Path must be relative".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path must be relative", + )); } for component in rel.components() { match component { Component::Normal(_) | Component::CurDir => {} Component::ParentDir => { - return Err("Path cannot contain '..'".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path cannot contain '..'", + )); } Component::RootDir | Component::Prefix(_) => { - return Err("Invalid path component".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Invalid path component", + )); } } } @@ -1552,16 +1559,25 @@ fn resolve_tree_path(root: &Path, rel_path: &str) -> Result { Ok(root.join(rel)) } -fn validate_new_name(new_name: &str) -> Result<&str, String> { +fn validate_new_name(new_name: &str) -> Result<&str, AppCommandError> { let trimmed = new_name.trim(); if trimmed.is_empty() { - return Err("New name cannot be empty".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "New name cannot be empty", + )); } if trimmed == "." || trimmed == ".." { - return Err("Invalid file name".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Invalid file name", + )); } if trimmed.contains('/') || trimmed.contains('\\') { - return Err("New name cannot contain path separators".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "New name cannot contain path separators", + )); } Ok(trimmed) } @@ -1781,28 +1797,32 @@ fn compute_etag(content: &[u8], metadata: &std::fs::Metadata) -> String { format!("{:016x}", hasher.finish()) } -fn ensure_path_in_workspace(root: &Path, target: &Path) -> Result<(), String> { - let canonical_root = std::fs::canonicalize(root) - .map_err(|e| format!("Unable to resolve workspace root: {e}"))?; - let canonical_target = - std::fs::canonicalize(target).map_err(|e| format!("Unable to resolve file path: {e}"))?; +fn ensure_path_in_workspace(root: &Path, target: &Path) -> Result<(), AppCommandError> { + let canonical_root = std::fs::canonicalize(root).map_err(AppCommandError::io)?; + let canonical_target = std::fs::canonicalize(target).map_err(AppCommandError::io)?; if !canonical_target.starts_with(&canonical_root) { - return Err("Path is outside workspace root".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path is outside workspace root", + )); } Ok(()) } -fn read_text_preview(target: &Path, limit: usize) -> Result<(String, bool), String> { - let metadata = std::fs::metadata(target).map_err(|e| e.to_string())?; - let mut file = File::open(target).map_err(|e| e.to_string())?; +fn read_text_preview(target: &Path, limit: usize) -> Result<(String, bool), AppCommandError> { + let metadata = std::fs::metadata(target).map_err(AppCommandError::io)?; + let mut file = File::open(target).map_err(AppCommandError::io)?; let mut bytes = Vec::new(); let mut limited_reader = (&mut file).take(limit as u64 + 1); limited_reader .read_to_end(&mut bytes) - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if bytes.iter().take(2_048).any(|b| *b == 0) { - return Err("Binary files are not supported in preview".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Binary files are not supported in preview", + )); } let truncated = bytes.len() > limit || metadata.len() > limit as u64; @@ -1812,15 +1832,20 @@ fn read_text_preview(target: &Path, limit: usize) -> Result<(String, bool), Stri Ok((String::from_utf8_lossy(&bytes).to_string(), truncated)) } -fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("Cannot determine parent directory for {}", path.display()))?; +fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), AppCommandError> { + let parent = path.parent().ok_or_else(|| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot determine parent directory for target file", + ) + .with_detail(path.display().to_string()) + })?; if !parent.exists() { - return Err(format!( - "Parent directory does not exist: {}", - parent.display() - )); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Parent directory does not exist", + ) + .with_detail(parent.display().to_string())); } let temp_path = parent.join(format!( @@ -1830,21 +1855,18 @@ fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), String> { )); let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions()); - let write_result = (|| -> Result<(), String> { + let write_result = (|| -> Result<(), AppCommandError> { let mut temp = OpenOptions::new() .create_new(true) .write(true) .open(&temp_path) - .map_err(|e| format!("Failed to create temporary file: {e}"))?; + .map_err(AppCommandError::io)?; - temp.write_all(bytes) - .map_err(|e| format!("Failed to write temporary file: {e}"))?; - temp.sync_all() - .map_err(|e| format!("Failed to flush temporary file: {e}"))?; + temp.write_all(bytes).map_err(AppCommandError::io)?; + temp.sync_all().map_err(AppCommandError::io)?; if let Some(permissions) = existing_permissions { - std::fs::set_permissions(&temp_path, permissions) - .map_err(|e| format!("Failed to set temporary file permissions: {e}"))?; + std::fs::set_permissions(&temp_path, permissions).map_err(AppCommandError::io)?; } replace_file(&temp_path, path)?; @@ -1860,12 +1882,12 @@ fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), String> { } #[cfg(unix)] -fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> { - std::fs::rename(temp_path, target_path).map_err(|e| format!("Failed to replace file: {e}")) +fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { + std::fs::rename(temp_path, target_path).map_err(AppCommandError::io) } #[cfg(target_os = "windows")] -fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> { +fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::{ @@ -1892,52 +1914,52 @@ fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> { }; if ok == 0 { - return Err(format!( - "Failed to atomically replace file: {}", - std::io::Error::last_os_error() - )); + return Err(AppCommandError::new( + AppErrorCode::IoError, + "Failed to atomically replace file", + ) + .with_detail(std::io::Error::last_os_error().to_string())); } Ok(()) } #[cfg(not(any(unix, target_os = "windows")))] -fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> { - std::fs::rename(temp_path, target_path).map_err(|e| format!("Failed to replace file: {e}")) +fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { + std::fs::rename(temp_path, target_path).map_err(AppCommandError::io) } #[cfg(unix)] -fn sync_directory(path: &Path) -> Result<(), String> { - let dir = File::open(path).map_err(|e| format!("Failed to sync directory: {e}"))?; - dir.sync_all() - .map_err(|e| format!("Failed to sync directory: {e}")) +fn sync_directory(path: &Path) -> Result<(), AppCommandError> { + let dir = File::open(path).map_err(AppCommandError::io)?; + dir.sync_all().map_err(AppCommandError::io) } #[cfg(not(unix))] -fn sync_directory(_path: &Path) -> Result<(), String> { +fn sync_directory(_path: &Path) -> Result<(), AppCommandError> { Ok(()) } -async fn run_file_io(f: F) -> Result +async fn run_file_io(f: F) -> Result where T: Send + 'static, - F: FnOnce() -> Result + Send + 'static, + F: FnOnce() -> Result + Send + 'static, { - let _permit = FILE_IO_SEMAPHORE - .acquire() - .await - .map_err(|_| "File I/O runtime is unavailable".to_string())?; + let _permit = FILE_IO_SEMAPHORE.acquire().await.map_err(|_| { + AppCommandError::new(AppErrorCode::Unknown, "File I/O runtime is unavailable") + })?; - tokio::task::spawn_blocking(f) - .await - .map_err(|e| format!("File I/O task failed: {e}"))? + tokio::task::spawn_blocking(f).await.map_err(|e| { + AppCommandError::new(AppErrorCode::Unknown, "File I/O task failed") + .with_detail(e.to_string()) + })? } #[tauri::command] pub async fn get_file_tree( path: String, max_depth: Option, -) -> Result, String> { +) -> Result, AppCommandError> { let root = PathBuf::from(&path); let depth = max_depth.unwrap_or(usize::MAX); @@ -1959,7 +1981,10 @@ pub async fn get_file_tree( } }) { - let entry = entry.map_err(|e| e.to_string())?; + let entry = entry.map_err(|e| { + AppCommandError::new(AppErrorCode::IoError, "Failed to walk file tree") + .with_detail(e.to_string()) + })?; let entry_path = entry.path().to_path_buf(); // Skip the root itself @@ -2072,18 +2097,27 @@ pub async fn read_file_preview( root_path: String, path: String, max_bytes: Option, -) -> Result { +) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { - return Err("File does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "File does not exist", + )); } if !target.is_file() { - return Err("Path is not a file".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path is not a file", + )); } let limit = max_bytes .unwrap_or(FILE_PREVIEW_DEFAULT_MAX_BYTES) @@ -2107,18 +2141,27 @@ pub async fn read_file_for_edit( root_path: String, path: String, max_bytes: Option, -) -> Result { +) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { - return Err("File does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "File does not exist", + )); } if !target.is_file() { - return Err("Path is not a file".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path is not a file", + )); } let limit = max_bytes @@ -2128,7 +2171,7 @@ pub async fn read_file_for_edit( run_file_io(move || { ensure_path_in_workspace(&root, &target)?; - let metadata = std::fs::metadata(&target).map_err(|e| e.to_string())?; + let metadata = std::fs::metadata(&target).map_err(AppCommandError::io)?; let (content, truncated) = read_text_preview(&target, limit)?; let readonly = metadata.permissions().readonly() || truncated; let mtime_ms = file_mtime_ms(&metadata); @@ -2159,54 +2202,76 @@ pub async fn save_file_content( path: String, content: String, expected_etag: Option, -) -> Result { +) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } if content.len() > FILE_EDIT_MAX_BYTES { - return Err(format!( - "File is too large to save in editor ({} bytes max)", - FILE_EDIT_MAX_BYTES - )); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "File is too large to save in editor", + ) + .with_detail(format!("max_bytes={FILE_EDIT_MAX_BYTES}"))); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { - return Err("File does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "File does not exist", + )); } if !target.is_file() { - return Err("Path is not a file".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path is not a file", + )); } let path_for_response = path.clone(); run_file_io(move || { ensure_path_in_workspace(&root, &target)?; - let link_meta = std::fs::symlink_metadata(&target).map_err(|e| e.to_string())?; + let link_meta = std::fs::symlink_metadata(&target).map_err(AppCommandError::io)?; if link_meta.file_type().is_symlink() { - return Err("Saving symlink targets is not supported".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Saving symlink targets is not supported", + )); } - let before_meta = std::fs::metadata(&target).map_err(|e| e.to_string())?; + let before_meta = std::fs::metadata(&target).map_err(AppCommandError::io)?; if before_meta.permissions().readonly() { - return Err("File is read-only".to_string()); + return Err(AppCommandError::new( + AppErrorCode::PermissionDenied, + "File is read-only", + )); } - let current_bytes = std::fs::read(&target).map_err(|e| e.to_string())?; + let current_bytes = std::fs::read(&target).map_err(AppCommandError::io)?; if current_bytes.iter().take(2_048).any(|b| *b == 0) { - return Err("Binary files are not supported in editor".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Binary files are not supported in editor", + )); } let current_etag = compute_etag(¤t_bytes, &before_meta); if let Some(expected) = expected_etag { if expected != current_etag { - return Err("File has changed on disk. Reload the file before saving.".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "File has changed on disk. Reload the file before saving.", + )); } } atomic_write_text(&target, content.as_bytes())?; - let after_meta = std::fs::metadata(&target).map_err(|e| e.to_string())?; + let after_meta = std::fs::metadata(&target).map_err(AppCommandError::io)?; let etag = compute_etag(content.as_bytes(), &after_meta); let mtime_ms = file_mtime_ms(&after_meta); let readonly = after_meta.permissions().readonly(); @@ -2252,44 +2317,67 @@ pub async fn save_file_copy( root_path: String, path: String, content: String, -) -> Result { +) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } if content.len() > FILE_EDIT_MAX_BYTES { - return Err(format!( - "File is too large to save in editor ({} bytes max)", - FILE_EDIT_MAX_BYTES - )); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "File is too large to save in editor", + ) + .with_detail(format!("max_bytes={FILE_EDIT_MAX_BYTES}"))); } let source = resolve_tree_path(&root, &path)?; if !source.exists() { - return Err("File does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "File does not exist", + )); } if !source.is_file() { - return Err("Path is not a file".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Path is not a file", + )); } run_file_io(move || { ensure_path_in_workspace(&root, &source)?; - let source_meta = std::fs::symlink_metadata(&source).map_err(|e| e.to_string())?; + let source_meta = std::fs::symlink_metadata(&source).map_err(AppCommandError::io)?; if source_meta.file_type().is_symlink() { - return Err("Saving symlink targets is not supported".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Saving symlink targets is not supported", + )); } let parent = source .parent() - .ok_or_else(|| "Cannot determine parent directory for source file".to_string())? + .ok_or_else(|| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot determine parent directory for source file", + ) + })? .to_path_buf(); ensure_path_in_workspace(&root, &parent)?; let source_name = source .file_name() .map(|value| value.to_string_lossy().to_string()) - .ok_or_else(|| "Cannot determine source file name".to_string())?; + .ok_or_else(|| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot determine source file name", + ) + })?; let mut created_path: Option = None; for attempt in 1..=9_999 { @@ -2303,18 +2391,27 @@ pub async fn save_file_copy( } let created_path = created_path.ok_or_else(|| { - "Unable to create copy file: too many existing local copies".to_string() + AppCommandError::new( + AppErrorCode::AlreadyExists, + "Unable to create copy file: too many existing local copies", + ) })?; atomic_write_text(&created_path, content.as_bytes())?; - let metadata = std::fs::metadata(&created_path).map_err(|e| e.to_string())?; + let metadata = std::fs::metadata(&created_path).map_err(AppCommandError::io)?; let etag = compute_etag(content.as_bytes(), &metadata); let mtime_ms = file_mtime_ms(&metadata); let readonly = metadata.permissions().readonly(); let line_ending = detect_line_ending(content.as_bytes()); let rel_path = created_path .strip_prefix(&root) - .map_err(|e| e.to_string())? + .map_err(|e| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Failed to compute relative path for copy", + ) + .with_detail(e.to_string()) + })? .to_string_lossy() .replace('\\', "/"); @@ -2334,23 +2431,35 @@ pub async fn rename_file_tree_entry( root_path: String, path: String, new_name: String, -) -> Result { +) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { - return Err("Target file does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Target file does not exist", + )); } if target == root { - return Err("Cannot rename workspace root".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot rename workspace root", + )); } - let parent = target - .parent() - .ok_or_else(|| "Cannot rename path without parent".to_string())?; + let parent = target.parent().ok_or_else(|| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot rename path without parent", + ) + })?; let validated_name = validate_new_name(&new_name)?; let next_path = parent.join(validated_name); @@ -2358,39 +2467,60 @@ pub async fn rename_file_tree_entry( return Ok(path); } if next_path.exists() { - return Err("A file with this name already exists".to_string()); + return Err(AppCommandError::new( + AppErrorCode::AlreadyExists, + "A file with this name already exists", + )); } - std::fs::rename(&target, &next_path).map_err(|e| e.to_string())?; + std::fs::rename(&target, &next_path).map_err(AppCommandError::io)?; let rel = next_path .strip_prefix(&root) - .map_err(|e| e.to_string())? + .map_err(|e| { + AppCommandError::new( + AppErrorCode::InvalidInput, + "Failed to compute relative path", + ) + .with_detail(e.to_string()) + })? .to_string_lossy() .to_string(); Ok(rel) } #[tauri::command] -pub async fn delete_file_tree_entry(root_path: String, path: String) -> Result<(), String> { +pub async fn delete_file_tree_entry( + root_path: String, + path: String, +) -> Result<(), AppCommandError> { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { - return Err("Folder does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Folder does not exist", + )); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { - return Err("Target file does not exist".to_string()); + return Err(AppCommandError::new( + AppErrorCode::NotFound, + "Target file does not exist", + )); } if target == root { - return Err("Cannot delete workspace root".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Cannot delete workspace root", + )); } - let meta = std::fs::symlink_metadata(&target).map_err(|e| e.to_string())?; + let meta = std::fs::symlink_metadata(&target).map_err(AppCommandError::io)?; if meta.is_dir() { - std::fs::remove_dir_all(&target).map_err(|e| e.to_string())?; + std::fs::remove_dir_all(&target).map_err(AppCommandError::io)?; } else { - std::fs::remove_file(&target).map_err(|e| e.to_string())?; + std::fs::remove_file(&target).map_err(AppCommandError::io)?; } Ok(()) @@ -2401,7 +2531,7 @@ pub async fn git_log( path: String, limit: Option, branch: Option, -) -> Result, String> { +) -> Result, AppCommandError> { let limit_str = format!("-{}", limit.unwrap_or(100)); let mut args = vec![ "log".to_string(), @@ -2419,11 +2549,10 @@ pub async fn git_log( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git log failed: {}", stderr.trim())); + return Err(git_command_error("log", &output.stderr)); } let mut entries = Vec::::new(); @@ -2480,7 +2609,10 @@ pub async fn git_log( } #[tauri::command] -pub async fn git_commit_branches(path: String, commit: String) -> Result, String> { +pub async fn git_commit_branches( + path: String, + commit: String, +) -> Result, AppCommandError> { let contains_arg = format!("--contains={commit}"); let output = crate::process::tokio_command("git") .args([ @@ -2493,11 +2625,10 @@ pub async fn git_commit_branches(path: String, commit: String) -> Result u32 { value.parse::().unwrap_or(0) } -async fn get_unpushed_hashes(path: &str) -> Result>, String> { +async fn get_unpushed_hashes(path: &str) -> Result>, AppCommandError> { let upstream_output = crate::process::tokio_command("git") .args([ "rev-parse", @@ -2612,7 +2743,7 @@ async fn get_unpushed_hashes(path: &str) -> Result>, Stri .current_dir(path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !upstream_output.status.success() { return Ok(None); @@ -2631,7 +2762,7 @@ async fn get_unpushed_hashes(path: &str) -> Result>, Stri .current_dir(path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !rev_list_output.status.success() { return Ok(None);