From ef1ba1b4bb9555a641def52b77552cf42e2c259f Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Thu, 4 Dec 2025 15:36:14 +0000 Subject: [PATCH] Glob copy files (#1420) * Simplify glob file search Switch to globwalk * Cleanup * Fix single file continue, add timeout to copy files * Remove error in copy file dropdown when no match found * Remove error message for file search text are, remove dead code * Move copy logic to copy.rs --- Cargo.lock | 13 + crates/local-deployment/Cargo.toml | 4 + crates/local-deployment/src/container.rs | 48 +-- crates/local-deployment/src/copy.rs | 326 ++++++++++++++++++ crates/local-deployment/src/lib.rs | 1 + .../dialogs/projects/ProjectFormDialog.tsx | 22 -- .../components/projects/CopyFilesField.tsx | 30 +- .../components/projects/ProjectFormFields.tsx | 157 +-------- .../ui/multi-file-search-textarea.tsx | 8 +- frontend/src/i18n/locales/en/projects.json | 3 +- frontend/src/i18n/locales/es/projects.json | 3 +- frontend/src/i18n/locales/ja/projects.json | 3 +- frontend/src/i18n/locales/ko/projects.json | 3 +- 13 files changed, 380 insertions(+), 241 deletions(-) create mode 100644 crates/local-deployment/src/copy.rs diff --git a/Cargo.lock b/Cargo.lock index 9ee73947..ee98ffcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2041,6 +2041,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.10.0", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2910,6 +2921,7 @@ dependencies = [ "deployment", "executors", "futures", + "globwalk", "json-patch", "nix 0.29.0", "notify", @@ -2920,6 +2932,7 @@ dependencies = [ "serde_json", "services", "sqlx", + "tempfile", "thiserror 2.0.17", "tokio", "tokio-util", diff --git a/crates/local-deployment/Cargo.toml b/crates/local-deployment/Cargo.toml index b4c8c5b4..e1d25173 100644 --- a/crates/local-deployment/Cargo.toml +++ b/crates/local-deployment/Cargo.toml @@ -28,3 +28,7 @@ sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debu futures = "0.3" json-patch = "2.0" tokio = { workspace = true } +globwalk = "0.9" + +[dev-dependencies] +tempfile = "3.8" \ No newline at end of file diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 8f3f9884..18a60ad0 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -64,7 +64,7 @@ use utils::{ }; use uuid::Uuid; -use crate::command; +use crate::{command, copy}; #[derive(Clone)] pub struct LocalContainerService { @@ -1196,40 +1196,19 @@ impl ContainerService for LocalContainerService { target_dir: &Path, copy_files: &str, ) -> Result<(), ContainerError> { - let files: Vec<&str> = copy_files - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .collect(); + let source_dir = source_dir.to_path_buf(); + let target_dir = target_dir.to_path_buf(); + let copy_files = copy_files.to_string(); - for file_path in files { - let source_file = source_dir.join(file_path); - let target_file = target_dir.join(file_path); - - // Create parent directories if needed - if let Some(parent) = target_file.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent).map_err(|e| { - ContainerError::Other(anyhow!("Failed to create directory {parent:?}: {e}")) - })?; - } - - // Copy the file - if source_file.exists() { - std::fs::copy(&source_file, &target_file).map_err(|e| { - ContainerError::Other(anyhow!( - "Failed to copy file {source_file:?} to {target_file:?}: {e}" - )) - })?; - tracing::info!("Copied file {:?} to worktree", file_path); - } else { - return Err(ContainerError::Other(anyhow!( - "File {source_file:?} does not exist in the project directory" - ))); - } - } - Ok(()) + tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::task::spawn_blocking(move || { + copy::copy_project_files_impl(&source_dir, &target_dir, ©_files) + }), + ) + .await + .map_err(|_| ContainerError::Other(anyhow!("Copy project files timed out after 30s")))? + .map_err(|e| ContainerError::Other(anyhow!("Copy files task failed: {e}")))? } async fn kill_all_running_processes(&self) -> Result<(), ContainerError> { @@ -1252,7 +1231,6 @@ impl ContainerService for LocalContainerService { Ok(()) } } - fn success_exit_status() -> std::process::ExitStatus { #[cfg(unix)] { diff --git a/crates/local-deployment/src/copy.rs b/crates/local-deployment/src/copy.rs new file mode 100644 index 00000000..74d9f9b6 --- /dev/null +++ b/crates/local-deployment/src/copy.rs @@ -0,0 +1,326 @@ +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; + +use anyhow::anyhow; +use globwalk::GlobWalkerBuilder; +use services::services::container::ContainerError; + +/// Normalize pattern for cross-platform glob matching (convert backslashes to forward slashes) +fn normalize_pattern(pattern: &str) -> String { + pattern.replace('\\', "/") +} + +pub(crate) fn copy_project_files_impl( + source_dir: &Path, + target_dir: &Path, + copy_files: &str, +) -> Result<(), ContainerError> { + let patterns: Vec<&str> = copy_files + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + // Track files to avoid duplicates + let mut seen = HashSet::new(); + + for pattern in patterns { + let pattern = normalize_pattern(pattern); + let pattern_path = source_dir.join(&pattern); + + if pattern_path.is_file() { + if let Err(e) = copy_single_file(&pattern_path, source_dir, target_dir, &mut seen) { + tracing::warn!( + "Failed to copy file {} (from {}): {}", + pattern, + pattern_path.display(), + e + ); + } + continue; + } + + let glob_pattern = if pattern_path.is_dir() { + // For directories, append /** to match all contents recursively + format!("{pattern}/**") + } else { + pattern.clone() + }; + + let walker = match GlobWalkerBuilder::from_patterns(source_dir, &[&glob_pattern]) + .file_type(globwalk::FileType::FILE) + .build() + { + Ok(w) => w, + Err(e) => { + tracing::warn!("Invalid glob pattern '{glob_pattern}': {e}"); + continue; + } + }; + + let mut had_matches = false; + for entry in walker.flatten() { + had_matches = true; + if let Err(e) = copy_single_file(entry.path(), source_dir, target_dir, &mut seen) { + tracing::warn!("Failed to copy file {:?}: {e}", entry.path()); + } + } + if !had_matches { + tracing::info!("No files matched pattern: {pattern}"); + } + } + + Ok(()) +} + +fn copy_single_file( + source_file: &Path, + source_root: &Path, + target_root: &Path, + seen: &mut HashSet, +) -> Result { + let canonical_source = source_root.canonicalize()?; + let canonical_file = source_file.canonicalize()?; + // Validate path is within source_dir + if !canonical_file.starts_with(canonical_source) { + return Err(ContainerError::Other(anyhow!( + "File {source_file:?} is outside project directory" + ))); + } + + if !seen.insert(canonical_file.clone()) { + return Ok(false); + } + + let relative_path = source_file.strip_prefix(source_root).map_err(|e| { + ContainerError::Other(anyhow!( + "Failed to get relative path for {source_file:?}: {e}" + )) + })?; + + let target_file = target_root.join(relative_path); + + if let Some(parent) = target_file.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + fs::copy(source_file, &target_file)?; + + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + #[test] + fn test_copy_project_files_mixed_patterns() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + fs::write(source_dir.path().join(".env"), "secret").unwrap(); + fs::write(source_dir.path().join("config.json"), "{}").unwrap(); + + let src_dir = source_dir.path().join("src"); + fs::create_dir(&src_dir).unwrap(); + fs::write(src_dir.join("main.rs"), "code").unwrap(); + fs::write(src_dir.join("lib.rs"), "lib").unwrap(); + + let config_dir = source_dir.path().join("config"); + fs::create_dir(&config_dir).unwrap(); + fs::write(config_dir.join("app.toml"), "config").unwrap(); + + copy_project_files_impl( + source_dir.path(), + target_dir.path(), + ".env, *.json, src, config", + ) + .unwrap(); + + assert!(target_dir.path().join(".env").exists()); + assert!(target_dir.path().join("config.json").exists()); + assert!(target_dir.path().join("src/main.rs").exists()); + assert!(target_dir.path().join("src/lib.rs").exists()); + assert!(target_dir.path().join("config/app.toml").exists()); + } + + #[test] + fn test_copy_project_files_nonexistent_pattern_ok() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + let result = + copy_project_files_impl(source_dir.path(), target_dir.path(), "nonexistent.txt"); + + assert!(result.is_ok()); + assert!(!target_dir.path().join("nonexistent.txt").exists()); + } + + #[test] + fn test_copy_project_files_empty_pattern_ok() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + let result = copy_project_files_impl(source_dir.path(), target_dir.path(), ""); + + assert!(result.is_ok()); + assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0); + } + + #[test] + fn test_copy_project_files_whitespace_handling() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + fs::write(source_dir.path().join("test.txt"), "content").unwrap(); + + copy_project_files_impl(source_dir.path(), target_dir.path(), " test.txt , ").unwrap(); + + assert!(target_dir.path().join("test.txt").exists()); + } + + #[test] + fn test_copy_project_files_nested_directory() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + let config_dir = source_dir.path().join("config"); + fs::create_dir(&config_dir).unwrap(); + fs::write(config_dir.join("app.json"), "{}").unwrap(); + + let nested_dir = config_dir.join("nested"); + fs::create_dir(&nested_dir).unwrap(); + fs::write(nested_dir.join("deep.txt"), "deep").unwrap(); + + copy_project_files_impl(source_dir.path(), target_dir.path(), "config").unwrap(); + + assert!(target_dir.path().join("config/app.json").exists()); + assert!(target_dir.path().join("config/nested/deep.txt").exists()); + } + + #[test] + fn test_copy_project_files_outside_source_skips_without_copying() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create file outside of source directory (one level up) + let parent_dir = source_dir.path().parent().unwrap().to_path_buf(); + let outside_file = parent_dir.join("secret.txt"); + fs::write(&outside_file, "secret").unwrap(); + + // Pattern referencing parent directory should resolve to outside_file and be rejected + let result = copy_project_files_impl(source_dir.path(), target_dir.path(), "../secret.txt"); + + assert!(result.is_ok()); + assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0); + } + + #[test] + fn test_copy_project_files_recursive_glob_extension_filter() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create nested directory structure with YAML files + let config_dir = source_dir.path().join("config"); + fs::create_dir(&config_dir).unwrap(); + fs::write(config_dir.join("app.yml"), "app: config").unwrap(); + fs::write(config_dir.join("db.json"), "{}").unwrap(); + + let nested_dir = config_dir.join("nested"); + fs::create_dir(&nested_dir).unwrap(); + fs::write(nested_dir.join("settings.yml"), "settings: value").unwrap(); + fs::write(nested_dir.join("other.txt"), "text").unwrap(); + + let deep_dir = nested_dir.join("deep"); + fs::create_dir(&deep_dir).unwrap(); + fs::write(deep_dir.join("deep.yml"), "deep: config").unwrap(); + + // Copy all YAML files recursively + copy_project_files_impl(source_dir.path(), target_dir.path(), "config/**/*.yml").unwrap(); + + // Verify only YAML files are copied + assert!(target_dir.path().join("config/app.yml").exists()); + assert!( + target_dir + .path() + .join("config/nested/settings.yml") + .exists() + ); + assert!( + target_dir + .path() + .join("config/nested/deep/deep.yml") + .exists() + ); + + // Verify non-YAML files are not copied + assert!(!target_dir.path().join("config/db.json").exists()); + assert!(!target_dir.path().join("config/nested/other.txt").exists()); + } + + #[test] + fn test_copy_project_files_duplicate_patterns_ok() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create source files + let src_dir = source_dir.path().join("src"); + fs::create_dir(&src_dir).unwrap(); + fs::write(src_dir.join("lib.rs"), "lib code").unwrap(); + fs::write(src_dir.join("main.rs"), "main code").unwrap(); + + // Copy with overlapping patterns: glob and specific file + copy_project_files_impl(source_dir.path(), target_dir.path(), "src/*.rs, src/lib.rs") + .unwrap(); + + // Verify file exists once (deduplication works) + let target_file = target_dir.path().join("src/lib.rs"); + assert!(target_file.exists()); + assert_eq!(fs::read_to_string(target_file).unwrap(), "lib code"); + + // Verify other file from glob is also copied + assert!(target_dir.path().join("src/main.rs").exists()); + } + + #[test] + fn test_copy_project_files_single_file_path() { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create source file + let src_dir = source_dir.path().join("src"); + fs::create_dir(&src_dir).unwrap(); + fs::write(src_dir.join("lib.rs"), "library code").unwrap(); + + // Copy single file by exact path (exercises fast path) + copy_project_files_impl(source_dir.path(), target_dir.path(), "src/lib.rs").unwrap(); + + // Verify file is copied + let target_file = target_dir.path().join("src/lib.rs"); + assert!(target_file.exists()); + assert_eq!(fs::read_to_string(target_file).unwrap(), "library code"); + } + + #[cfg(unix)] + #[test] + fn test_symlink_loop_is_skipped() { + use std::os::unix::fs::symlink; + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + + let loop_dir = src.path().join("loop"); + std::fs::create_dir(&loop_dir).unwrap(); + symlink(".", loop_dir.join("self")).unwrap(); // loop/self -> loop + + copy_project_files_impl(src.path(), dst.path(), "loop").unwrap(); + + assert_eq!(std::fs::read_dir(dst.path()).unwrap().count(), 0); + } +} diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index d4b7af93..8cbb8243 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -31,6 +31,7 @@ use uuid::Uuid; use crate::container::LocalContainerService; mod command; pub mod container; +mod copy; #[derive(Clone)] pub struct LocalDeployment { diff --git a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx index 2c7c62f7..25a44bc1 100644 --- a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx +++ b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx @@ -39,16 +39,6 @@ const ProjectFormDialogImpl = NiceModal.create(() => { }, }); - // Auto-populate project name from directory name - const handleGitRepoPathChange = (path: string) => { - setGitRepoPath(path); - - if (path) { - const cleanName = generateProjectNameFromPath(path); - if (cleanName) setName(cleanName); - } - }; - // Handle direct project creation from repo selection const handleDirectCreate = async (path: string, suggestedName: string) => { setError(''); @@ -125,27 +115,15 @@ const ProjectFormDialogImpl = NiceModal.create(() => {
{}} - devScript="" - setDevScript={() => {}} - cleanupScript="" - setCleanupScript={() => {}} - copyFiles="" - setCopyFiles={() => {}} error={error} setError={setError} - projectId={undefined} onCreateProject={handleDirectCreate} /> {repoMode === 'new' && ( diff --git a/frontend/src/components/projects/CopyFilesField.tsx b/frontend/src/components/projects/CopyFilesField.tsx index 7b266a20..9aa313b1 100644 --- a/frontend/src/components/projects/CopyFilesField.tsx +++ b/frontend/src/components/projects/CopyFilesField.tsx @@ -1,9 +1,10 @@ +import { useTranslation } from 'react-i18next'; import { MultiFileSearchTextarea } from '@/components/ui/multi-file-search-textarea'; interface CopyFilesFieldProps { value: string; onChange: (value: string) => void; - projectId?: string; + projectId: string; disabled?: boolean; } @@ -13,31 +14,18 @@ export function CopyFilesField({ projectId, disabled = false, }: CopyFilesFieldProps) { - if (projectId) { - // Editing existing project - use file search - return ( - - ); - } + const { t } = useTranslation('projects'); - // Creating new project - fall back to plain textarea return ( -