folders后端多语言处理

This commit is contained in:
xintaofei
2026-03-07 16:11:31 +08:00
parent 349845c137
commit 2b68d77adb

View File

@@ -1221,7 +1221,10 @@ fn is_codeg_edit_temp_path(path: &Path) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn git_check_ignored_paths(repo_path: &str, paths: &[String]) -> Result<HashSet<String>, String> { fn git_check_ignored_paths(
repo_path: &str,
paths: &[String],
) -> Result<HashSet<String>, AppCommandError> {
if paths.is_empty() { if paths.is_empty() {
return Ok(HashSet::new()); return Ok(HashSet::new());
} }
@@ -1233,27 +1236,22 @@ fn git_check_ignored_paths(repo_path: &str, paths: &[String]) -> Result<HashSet<
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| format!("failed to start git check-ignore: {e}"))?; .map_err(AppCommandError::io)?;
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
for path in paths { for path in paths {
stdin stdin
.write_all(path.as_bytes()) .write_all(path.as_bytes())
.map_err(|e| format!("failed to write git check-ignore stdin: {e}"))?; .map_err(AppCommandError::io)?;
stdin stdin.write_all(&[0]).map_err(AppCommandError::io)?;
.write_all(&[0])
.map_err(|e| format!("failed to write git check-ignore stdin: {e}"))?;
} }
} }
let output = child let output = child.wait_with_output().map_err(AppCommandError::io)?;
.wait_with_output()
.map_err(|e| format!("failed to read git check-ignore output: {e}"))?;
// Exit code 1 means "no matches", which is expected. // Exit code 1 means "no matches", which is expected.
if !output.status.success() && output.status.code() != Some(1) { if !output.status.success() && output.status.code() != Some(1) {
let stderr = String::from_utf8_lossy(&output.stderr); return Err(git_command_error("check-ignore", &output.stderr));
return Err(format!("git check-ignore failed: {}", stderr.trim()));
} }
let mut ignored = HashSet::new(); let mut ignored = HashSet::new();
@@ -1531,20 +1529,29 @@ fn run_file_watch_event_loop(
} }
} }
fn resolve_tree_path(root: &Path, rel_path: &str) -> Result<PathBuf, String> { fn resolve_tree_path(root: &Path, rel_path: &str) -> Result<PathBuf, AppCommandError> {
let rel = Path::new(rel_path); let rel = Path::new(rel_path);
if rel.is_absolute() { 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() { for component in rel.components() {
match component { match component {
Component::Normal(_) | Component::CurDir => {} Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => { Component::ParentDir => {
return Err("Path cannot contain '..'".to_string()); return Err(AppCommandError::new(
AppErrorCode::InvalidInput,
"Path cannot contain '..'",
));
} }
Component::RootDir | Component::Prefix(_) => { 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<PathBuf, String> {
Ok(root.join(rel)) 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(); let trimmed = new_name.trim();
if trimmed.is_empty() { 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 == ".." { if trimmed == "." || trimmed == ".." {
return Err("Invalid file name".to_string()); return Err(AppCommandError::new(
AppErrorCode::InvalidInput,
"Invalid file name",
));
} }
if trimmed.contains('/') || trimmed.contains('\\') { 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) Ok(trimmed)
} }
@@ -1781,28 +1797,32 @@ fn compute_etag(content: &[u8], metadata: &std::fs::Metadata) -> String {
format!("{:016x}", hasher.finish()) format!("{:016x}", hasher.finish())
} }
fn ensure_path_in_workspace(root: &Path, target: &Path) -> Result<(), String> { fn ensure_path_in_workspace(root: &Path, target: &Path) -> Result<(), AppCommandError> {
let canonical_root = std::fs::canonicalize(root) let canonical_root = std::fs::canonicalize(root).map_err(AppCommandError::io)?;
.map_err(|e| format!("Unable to resolve workspace root: {e}"))?; let canonical_target = std::fs::canonicalize(target).map_err(AppCommandError::io)?;
let canonical_target =
std::fs::canonicalize(target).map_err(|e| format!("Unable to resolve file path: {e}"))?;
if !canonical_target.starts_with(&canonical_root) { 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(()) Ok(())
} }
fn read_text_preview(target: &Path, limit: usize) -> Result<(String, bool), String> { fn read_text_preview(target: &Path, limit: usize) -> Result<(String, bool), AppCommandError> {
let metadata = std::fs::metadata(target).map_err(|e| e.to_string())?; let metadata = std::fs::metadata(target).map_err(AppCommandError::io)?;
let mut file = File::open(target).map_err(|e| e.to_string())?; let mut file = File::open(target).map_err(AppCommandError::io)?;
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let mut limited_reader = (&mut file).take(limit as u64 + 1); let mut limited_reader = (&mut file).take(limit as u64 + 1);
limited_reader limited_reader
.read_to_end(&mut bytes) .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) { 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; 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)) Ok((String::from_utf8_lossy(&bytes).to_string(), truncated))
} }
fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), String> { fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), AppCommandError> {
let parent = path let parent = path.parent().ok_or_else(|| {
.parent() AppCommandError::new(
.ok_or_else(|| format!("Cannot determine parent directory for {}", path.display()))?; AppErrorCode::InvalidInput,
"Cannot determine parent directory for target file",
)
.with_detail(path.display().to_string())
})?;
if !parent.exists() { if !parent.exists() {
return Err(format!( return Err(AppCommandError::new(
"Parent directory does not exist: {}", AppErrorCode::NotFound,
parent.display() "Parent directory does not exist",
)); )
.with_detail(parent.display().to_string()));
} }
let temp_path = parent.join(format!( 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 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() let mut temp = OpenOptions::new()
.create_new(true) .create_new(true)
.write(true) .write(true)
.open(&temp_path) .open(&temp_path)
.map_err(|e| format!("Failed to create temporary file: {e}"))?; .map_err(AppCommandError::io)?;
temp.write_all(bytes) temp.write_all(bytes).map_err(AppCommandError::io)?;
.map_err(|e| format!("Failed to write temporary file: {e}"))?; temp.sync_all().map_err(AppCommandError::io)?;
temp.sync_all()
.map_err(|e| format!("Failed to flush temporary file: {e}"))?;
if let Some(permissions) = existing_permissions { if let Some(permissions) = existing_permissions {
std::fs::set_permissions(&temp_path, permissions) std::fs::set_permissions(&temp_path, permissions).map_err(AppCommandError::io)?;
.map_err(|e| format!("Failed to set temporary file permissions: {e}"))?;
} }
replace_file(&temp_path, path)?; replace_file(&temp_path, path)?;
@@ -1860,12 +1882,12 @@ fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), String> {
} }
#[cfg(unix)] #[cfg(unix)]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> { fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> {
std::fs::rename(temp_path, target_path).map_err(|e| format!("Failed to replace file: {e}")) std::fs::rename(temp_path, target_path).map_err(AppCommandError::io)
} }
#[cfg(target_os = "windows")] #[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 std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{ use windows_sys::Win32::Storage::FileSystem::{
@@ -1892,52 +1914,52 @@ fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), String> {
}; };
if ok == 0 { if ok == 0 {
return Err(format!( return Err(AppCommandError::new(
"Failed to atomically replace file: {}", AppErrorCode::IoError,
std::io::Error::last_os_error() "Failed to atomically replace file",
)); )
.with_detail(std::io::Error::last_os_error().to_string()));
} }
Ok(()) Ok(())
} }
#[cfg(not(any(unix, target_os = "windows")))] #[cfg(not(any(unix, 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> {
std::fs::rename(temp_path, target_path).map_err(|e| format!("Failed to replace file: {e}")) std::fs::rename(temp_path, target_path).map_err(AppCommandError::io)
} }
#[cfg(unix)] #[cfg(unix)]
fn sync_directory(path: &Path) -> Result<(), String> { fn sync_directory(path: &Path) -> Result<(), AppCommandError> {
let dir = File::open(path).map_err(|e| format!("Failed to sync directory: {e}"))?; let dir = File::open(path).map_err(AppCommandError::io)?;
dir.sync_all() dir.sync_all().map_err(AppCommandError::io)
.map_err(|e| format!("Failed to sync directory: {e}"))
} }
#[cfg(not(unix))] #[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<(), String> { fn sync_directory(_path: &Path) -> Result<(), AppCommandError> {
Ok(()) Ok(())
} }
async fn run_file_io<T, F>(f: F) -> Result<T, String> async fn run_file_io<T, F>(f: F) -> Result<T, AppCommandError>
where where
T: Send + 'static, T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static, F: FnOnce() -> Result<T, AppCommandError> + Send + 'static,
{ {
let _permit = FILE_IO_SEMAPHORE let _permit = FILE_IO_SEMAPHORE.acquire().await.map_err(|_| {
.acquire() AppCommandError::new(AppErrorCode::Unknown, "File I/O runtime is unavailable")
.await })?;
.map_err(|_| "File I/O runtime is unavailable".to_string())?;
tokio::task::spawn_blocking(f) tokio::task::spawn_blocking(f).await.map_err(|e| {
.await AppCommandError::new(AppErrorCode::Unknown, "File I/O task failed")
.map_err(|e| format!("File I/O task failed: {e}"))? .with_detail(e.to_string())
})?
} }
#[tauri::command] #[tauri::command]
pub async fn get_file_tree( pub async fn get_file_tree(
path: String, path: String,
max_depth: Option<usize>, max_depth: Option<usize>,
) -> Result<Vec<FileTreeNode>, String> { ) -> Result<Vec<FileTreeNode>, AppCommandError> {
let root = PathBuf::from(&path); let root = PathBuf::from(&path);
let depth = max_depth.unwrap_or(usize::MAX); 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(); let entry_path = entry.path().to_path_buf();
// Skip the root itself // Skip the root itself
@@ -2072,18 +2097,27 @@ pub async fn read_file_preview(
root_path: String, root_path: String,
path: String, path: String,
max_bytes: Option<usize>, max_bytes: Option<usize>,
) -> Result<FilePreviewContent, String> { ) -> Result<FilePreviewContent, AppCommandError> {
let root = PathBuf::from(&root_path); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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)?; let target = resolve_tree_path(&root, &path)?;
if !target.exists() { 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() { 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 let limit = max_bytes
.unwrap_or(FILE_PREVIEW_DEFAULT_MAX_BYTES) .unwrap_or(FILE_PREVIEW_DEFAULT_MAX_BYTES)
@@ -2107,18 +2141,27 @@ pub async fn read_file_for_edit(
root_path: String, root_path: String,
path: String, path: String,
max_bytes: Option<usize>, max_bytes: Option<usize>,
) -> Result<FileEditContent, String> { ) -> Result<FileEditContent, AppCommandError> {
let root = PathBuf::from(&root_path); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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)?; let target = resolve_tree_path(&root, &path)?;
if !target.exists() { 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() { 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 let limit = max_bytes
@@ -2128,7 +2171,7 @@ pub async fn read_file_for_edit(
run_file_io(move || { run_file_io(move || {
ensure_path_in_workspace(&root, &target)?; 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 (content, truncated) = read_text_preview(&target, limit)?;
let readonly = metadata.permissions().readonly() || truncated; let readonly = metadata.permissions().readonly() || truncated;
let mtime_ms = file_mtime_ms(&metadata); let mtime_ms = file_mtime_ms(&metadata);
@@ -2159,54 +2202,76 @@ pub async fn save_file_content(
path: String, path: String,
content: String, content: String,
expected_etag: Option<String>, expected_etag: Option<String>,
) -> Result<FileSaveResult, String> { ) -> Result<FileSaveResult, AppCommandError> {
let root = PathBuf::from(&root_path); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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 { if content.len() > FILE_EDIT_MAX_BYTES {
return Err(format!( return Err(AppCommandError::new(
"File is too large to save in editor ({} bytes max)", AppErrorCode::InvalidInput,
FILE_EDIT_MAX_BYTES "File is too large to save in editor",
)); )
.with_detail(format!("max_bytes={FILE_EDIT_MAX_BYTES}")));
} }
let target = resolve_tree_path(&root, &path)?; let target = resolve_tree_path(&root, &path)?;
if !target.exists() { 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() { 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(); let path_for_response = path.clone();
run_file_io(move || { run_file_io(move || {
ensure_path_in_workspace(&root, &target)?; 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() { 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() { 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) { 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(&current_bytes, &before_meta); let current_etag = compute_etag(&current_bytes, &before_meta);
if let Some(expected) = expected_etag { if let Some(expected) = expected_etag {
if expected != current_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())?; 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 etag = compute_etag(content.as_bytes(), &after_meta);
let mtime_ms = file_mtime_ms(&after_meta); let mtime_ms = file_mtime_ms(&after_meta);
let readonly = after_meta.permissions().readonly(); let readonly = after_meta.permissions().readonly();
@@ -2252,44 +2317,67 @@ pub async fn save_file_copy(
root_path: String, root_path: String,
path: String, path: String,
content: String, content: String,
) -> Result<FileSaveResult, String> { ) -> Result<FileSaveResult, AppCommandError> {
let root = PathBuf::from(&root_path); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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 { if content.len() > FILE_EDIT_MAX_BYTES {
return Err(format!( return Err(AppCommandError::new(
"File is too large to save in editor ({} bytes max)", AppErrorCode::InvalidInput,
FILE_EDIT_MAX_BYTES "File is too large to save in editor",
)); )
.with_detail(format!("max_bytes={FILE_EDIT_MAX_BYTES}")));
} }
let source = resolve_tree_path(&root, &path)?; let source = resolve_tree_path(&root, &path)?;
if !source.exists() { 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() { 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 || { run_file_io(move || {
ensure_path_in_workspace(&root, &source)?; 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() { 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 let parent = source
.parent() .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(); .to_path_buf();
ensure_path_in_workspace(&root, &parent)?; ensure_path_in_workspace(&root, &parent)?;
let source_name = source let source_name = source
.file_name() .file_name()
.map(|value| value.to_string_lossy().to_string()) .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<PathBuf> = None; let mut created_path: Option<PathBuf> = None;
for attempt in 1..=9_999 { for attempt in 1..=9_999 {
@@ -2303,18 +2391,27 @@ pub async fn save_file_copy(
} }
let created_path = created_path.ok_or_else(|| { 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())?; 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 etag = compute_etag(content.as_bytes(), &metadata);
let mtime_ms = file_mtime_ms(&metadata); let mtime_ms = file_mtime_ms(&metadata);
let readonly = metadata.permissions().readonly(); let readonly = metadata.permissions().readonly();
let line_ending = detect_line_ending(content.as_bytes()); let line_ending = detect_line_ending(content.as_bytes());
let rel_path = created_path let rel_path = created_path
.strip_prefix(&root) .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() .to_string_lossy()
.replace('\\', "/"); .replace('\\', "/");
@@ -2334,23 +2431,35 @@ pub async fn rename_file_tree_entry(
root_path: String, root_path: String,
path: String, path: String,
new_name: String, new_name: String,
) -> Result<String, String> { ) -> Result<String, AppCommandError> {
let root = PathBuf::from(&root_path); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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)?; let target = resolve_tree_path(&root, &path)?;
if !target.exists() { 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 { if target == root {
return Err("Cannot rename workspace root".to_string()); return Err(AppCommandError::new(
AppErrorCode::InvalidInput,
"Cannot rename workspace root",
));
} }
let parent = target let parent = target.parent().ok_or_else(|| {
.parent() AppCommandError::new(
.ok_or_else(|| "Cannot rename path without parent".to_string())?; AppErrorCode::InvalidInput,
"Cannot rename path without parent",
)
})?;
let validated_name = validate_new_name(&new_name)?; let validated_name = validate_new_name(&new_name)?;
let next_path = parent.join(validated_name); let next_path = parent.join(validated_name);
@@ -2358,39 +2467,60 @@ pub async fn rename_file_tree_entry(
return Ok(path); return Ok(path);
} }
if next_path.exists() { 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 let rel = next_path
.strip_prefix(&root) .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_lossy()
.to_string(); .to_string();
Ok(rel) Ok(rel)
} }
#[tauri::command] #[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); let root = PathBuf::from(&root_path);
if !root.exists() || !root.is_dir() { 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)?; let target = resolve_tree_path(&root, &path)?;
if !target.exists() { 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 { 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() { 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 { } else {
std::fs::remove_file(&target).map_err(|e| e.to_string())?; std::fs::remove_file(&target).map_err(AppCommandError::io)?;
} }
Ok(()) Ok(())
@@ -2401,7 +2531,7 @@ pub async fn git_log(
path: String, path: String,
limit: Option<u32>, limit: Option<u32>,
branch: Option<String>, branch: Option<String>,
) -> Result<Vec<GitLogEntry>, String> { ) -> Result<Vec<GitLogEntry>, AppCommandError> {
let limit_str = format!("-{}", limit.unwrap_or(100)); let limit_str = format!("-{}", limit.unwrap_or(100));
let mut args = vec![ let mut args = vec![
"log".to_string(), "log".to_string(),
@@ -2419,11 +2549,10 @@ pub async fn git_log(
.current_dir(&path) .current_dir(&path)
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(AppCommandError::io)?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); return Err(git_command_error("log", &output.stderr));
return Err(format!("git log failed: {}", stderr.trim()));
} }
let mut entries = Vec::<GitLogEntry>::new(); let mut entries = Vec::<GitLogEntry>::new();
@@ -2480,7 +2609,10 @@ pub async fn git_log(
} }
#[tauri::command] #[tauri::command]
pub async fn git_commit_branches(path: String, commit: String) -> Result<Vec<String>, String> { pub async fn git_commit_branches(
path: String,
commit: String,
) -> Result<Vec<String>, AppCommandError> {
let contains_arg = format!("--contains={commit}"); let contains_arg = format!("--contains={commit}");
let output = crate::process::tokio_command("git") let output = crate::process::tokio_command("git")
.args([ .args([
@@ -2493,11 +2625,10 @@ pub async fn git_commit_branches(path: String, commit: String) -> Result<Vec<Str
.current_dir(&path) .current_dir(&path)
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(AppCommandError::io)?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); return Err(git_command_error("for-each-ref", &output.stderr));
return Err(format!("git for-each-ref failed: {}", stderr.trim()));
} }
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -2601,7 +2732,7 @@ fn parse_numstat_count(value: &str) -> u32 {
value.parse::<u32>().unwrap_or(0) value.parse::<u32>().unwrap_or(0)
} }
async fn get_unpushed_hashes(path: &str) -> Result<Option<HashSet<String>>, String> { async fn get_unpushed_hashes(path: &str) -> Result<Option<HashSet<String>>, AppCommandError> {
let upstream_output = crate::process::tokio_command("git") let upstream_output = crate::process::tokio_command("git")
.args([ .args([
"rev-parse", "rev-parse",
@@ -2612,7 +2743,7 @@ async fn get_unpushed_hashes(path: &str) -> Result<Option<HashSet<String>>, Stri
.current_dir(path) .current_dir(path)
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(AppCommandError::io)?;
if !upstream_output.status.success() { if !upstream_output.status.success() {
return Ok(None); return Ok(None);
@@ -2631,7 +2762,7 @@ async fn get_unpushed_hashes(path: &str) -> Result<Option<HashSet<String>>, Stri
.current_dir(path) .current_dir(path)
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(AppCommandError::io)?;
if !rev_list_output.status.success() { if !rev_list_output.status.success() {
return Ok(None); return Ok(None);