folders后端多语言处理
This commit is contained in:
@@ -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(¤t_bytes, &before_meta);
|
let current_etag = compute_etag(¤t_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);
|
||||||
|
|||||||
Reference in New Issue
Block a user