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
This commit is contained in:
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -2041,6 +2041,17 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"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]]
|
[[package]]
|
||||||
name = "gloo-timers"
|
name = "gloo-timers"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -2910,6 +2921,7 @@ dependencies = [
|
|||||||
"deployment",
|
"deployment",
|
||||||
"executors",
|
"executors",
|
||||||
"futures",
|
"futures",
|
||||||
|
"globwalk",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -2920,6 +2932,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"services",
|
"services",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debu
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
json-patch = "2.0"
|
json-patch = "2.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
globwalk = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.8"
|
||||||
@@ -64,7 +64,7 @@ use utils::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::command;
|
use crate::{command, copy};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalContainerService {
|
pub struct LocalContainerService {
|
||||||
@@ -1196,40 +1196,19 @@ impl ContainerService for LocalContainerService {
|
|||||||
target_dir: &Path,
|
target_dir: &Path,
|
||||||
copy_files: &str,
|
copy_files: &str,
|
||||||
) -> Result<(), ContainerError> {
|
) -> Result<(), ContainerError> {
|
||||||
let files: Vec<&str> = copy_files
|
let source_dir = source_dir.to_path_buf();
|
||||||
.split(',')
|
let target_dir = target_dir.to_path_buf();
|
||||||
.map(|s| s.trim())
|
let copy_files = copy_files.to_string();
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for file_path in files {
|
tokio::time::timeout(
|
||||||
let source_file = source_dir.join(file_path);
|
std::time::Duration::from_secs(30),
|
||||||
let target_file = target_dir.join(file_path);
|
tokio::task::spawn_blocking(move || {
|
||||||
|
copy::copy_project_files_impl(&source_dir, &target_dir, ©_files)
|
||||||
// Create parent directories if needed
|
}),
|
||||||
if let Some(parent) = target_file.parent()
|
)
|
||||||
&& !parent.exists()
|
.await
|
||||||
{
|
.map_err(|_| ContainerError::Other(anyhow!("Copy project files timed out after 30s")))?
|
||||||
std::fs::create_dir_all(parent).map_err(|e| {
|
.map_err(|e| ContainerError::Other(anyhow!("Copy files task failed: {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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn kill_all_running_processes(&self) -> Result<(), ContainerError> {
|
async fn kill_all_running_processes(&self) -> Result<(), ContainerError> {
|
||||||
@@ -1252,7 +1231,6 @@ impl ContainerService for LocalContainerService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn success_exit_status() -> std::process::ExitStatus {
|
fn success_exit_status() -> std::process::ExitStatus {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
|||||||
326
crates/local-deployment/src/copy.rs
Normal file
326
crates/local-deployment/src/copy.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
) -> Result<bool, ContainerError> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ use uuid::Uuid;
|
|||||||
use crate::container::LocalContainerService;
|
use crate::container::LocalContainerService;
|
||||||
mod command;
|
mod command;
|
||||||
pub mod container;
|
pub mod container;
|
||||||
|
mod copy;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalDeployment {
|
pub struct LocalDeployment {
|
||||||
|
|||||||
@@ -39,16 +39,6 @@ const ProjectFormDialogImpl = NiceModal.create<ProjectFormDialogProps>(() => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Handle direct project creation from repo selection
|
||||||
const handleDirectCreate = async (path: string, suggestedName: string) => {
|
const handleDirectCreate = async (path: string, suggestedName: string) => {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -125,27 +115,15 @@ const ProjectFormDialogImpl = NiceModal.create<ProjectFormDialogProps>(() => {
|
|||||||
<div className="mx-auto w-full max-w-2xl overflow-x-hidden px-1">
|
<div className="mx-auto w-full max-w-2xl overflow-x-hidden px-1">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<ProjectFormFields
|
<ProjectFormFields
|
||||||
isEditing={false}
|
|
||||||
repoMode={repoMode}
|
repoMode={repoMode}
|
||||||
setRepoMode={setRepoMode}
|
setRepoMode={setRepoMode}
|
||||||
gitRepoPath={gitRepoPath}
|
|
||||||
handleGitRepoPathChange={handleGitRepoPathChange}
|
|
||||||
parentPath={parentPath}
|
parentPath={parentPath}
|
||||||
setParentPath={setParentPath}
|
setParentPath={setParentPath}
|
||||||
setFolderName={setFolderName}
|
setFolderName={setFolderName}
|
||||||
setName={setName}
|
setName={setName}
|
||||||
name={name}
|
name={name}
|
||||||
setupScript=""
|
|
||||||
setSetupScript={() => {}}
|
|
||||||
devScript=""
|
|
||||||
setDevScript={() => {}}
|
|
||||||
cleanupScript=""
|
|
||||||
setCleanupScript={() => {}}
|
|
||||||
copyFiles=""
|
|
||||||
setCopyFiles={() => {}}
|
|
||||||
error={error}
|
error={error}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
projectId={undefined}
|
|
||||||
onCreateProject={handleDirectCreate}
|
onCreateProject={handleDirectCreate}
|
||||||
/>
|
/>
|
||||||
{repoMode === 'new' && (
|
{repoMode === 'new' && (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MultiFileSearchTextarea } from '@/components/ui/multi-file-search-textarea';
|
import { MultiFileSearchTextarea } from '@/components/ui/multi-file-search-textarea';
|
||||||
|
|
||||||
interface CopyFilesFieldProps {
|
interface CopyFilesFieldProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
projectId?: string;
|
projectId: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,31 +14,18 @@ export function CopyFilesField({
|
|||||||
projectId,
|
projectId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CopyFilesFieldProps) {
|
}: CopyFilesFieldProps) {
|
||||||
if (projectId) {
|
const { t } = useTranslation('projects');
|
||||||
// Editing existing project - use file search
|
|
||||||
return (
|
|
||||||
<MultiFileSearchTextarea
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder="Start typing a file path... (.env, config.local.json, .local/settings.yml)"
|
|
||||||
rows={3}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground disabled:opacity-50 rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
projectId={projectId}
|
|
||||||
maxRows={6}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creating new project - fall back to plain textarea
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<MultiFileSearchTextarea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={onChange}
|
||||||
placeholder=".env,config.local.json,.local/settings.yml"
|
placeholder={t('copyFilesPlaceholderWithSearch')}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground disabled:opacity-50 rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
projectId={projectId}
|
||||||
|
maxRows={6}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders';
|
|
||||||
import { CopyFilesField } from './CopyFilesField';
|
|
||||||
// Removed collapsible sections for simplicity; show fields always in edit mode
|
// Removed collapsible sections for simplicity; show fields always in edit mode
|
||||||
import { fileSystemApi } from '@/lib/api';
|
import { fileSystemApi } from '@/lib/api';
|
||||||
import { FolderPickerDialog } from '@/components/dialogs/shared/FolderPickerDialog';
|
import { FolderPickerDialog } from '@/components/dialogs/shared/FolderPickerDialog';
|
||||||
@@ -20,56 +18,30 @@ import { DirectoryEntry } from 'shared/types';
|
|||||||
import { generateProjectNameFromPath } from '@/utils/string';
|
import { generateProjectNameFromPath } from '@/utils/string';
|
||||||
|
|
||||||
interface ProjectFormFieldsProps {
|
interface ProjectFormFieldsProps {
|
||||||
isEditing: boolean;
|
|
||||||
repoMode: 'existing' | 'new';
|
repoMode: 'existing' | 'new';
|
||||||
setRepoMode: (mode: 'existing' | 'new') => void;
|
setRepoMode: (mode: 'existing' | 'new') => void;
|
||||||
gitRepoPath: string;
|
|
||||||
handleGitRepoPathChange: (path: string) => void;
|
|
||||||
parentPath: string;
|
parentPath: string;
|
||||||
setParentPath: (path: string) => void;
|
setParentPath: (path: string) => void;
|
||||||
setFolderName: (name: string) => void;
|
setFolderName: (name: string) => void;
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
name: string;
|
name: string;
|
||||||
setupScript: string;
|
|
||||||
setSetupScript: (script: string) => void;
|
|
||||||
devScript: string;
|
|
||||||
setDevScript: (script: string) => void;
|
|
||||||
cleanupScript: string;
|
|
||||||
setCleanupScript: (script: string) => void;
|
|
||||||
copyFiles: string;
|
|
||||||
setCopyFiles: (files: string) => void;
|
|
||||||
error: string;
|
error: string;
|
||||||
setError: (error: string) => void;
|
setError: (error: string) => void;
|
||||||
projectId?: string;
|
|
||||||
onCreateProject?: (path: string, name: string) => void;
|
onCreateProject?: (path: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectFormFields({
|
export function ProjectFormFields({
|
||||||
isEditing,
|
|
||||||
repoMode,
|
repoMode,
|
||||||
setRepoMode,
|
setRepoMode,
|
||||||
gitRepoPath,
|
|
||||||
handleGitRepoPathChange,
|
|
||||||
parentPath,
|
parentPath,
|
||||||
setParentPath,
|
setParentPath,
|
||||||
setFolderName,
|
setFolderName,
|
||||||
setName,
|
setName,
|
||||||
name,
|
name,
|
||||||
setupScript,
|
|
||||||
setSetupScript,
|
|
||||||
devScript,
|
|
||||||
setDevScript,
|
|
||||||
cleanupScript,
|
|
||||||
setCleanupScript,
|
|
||||||
copyFiles,
|
|
||||||
setCopyFiles,
|
|
||||||
error,
|
error,
|
||||||
setError,
|
setError,
|
||||||
projectId,
|
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
}: ProjectFormFieldsProps) {
|
}: ProjectFormFieldsProps) {
|
||||||
const placeholders = useScriptPlaceholders();
|
|
||||||
|
|
||||||
// Repository loading state
|
// Repository loading state
|
||||||
const [allRepos, setAllRepos] = useState<DirectoryEntry[]>([]);
|
const [allRepos, setAllRepos] = useState<DirectoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -94,14 +66,14 @@ export function ProjectFormFields({
|
|||||||
|
|
||||||
// Lazy-load repositories when the user navigates to the repo list
|
// Lazy-load repositories when the user navigates to the repo list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing && showRecentRepos && !loading && allRepos.length === 0) {
|
if (showRecentRepos && !loading && allRepos.length === 0) {
|
||||||
loadRecentRepos();
|
loadRecentRepos();
|
||||||
}
|
}
|
||||||
}, [isEditing, showRecentRepos, loading, allRepos.length, loadRecentRepos]);
|
}, [showRecentRepos, loading, allRepos.length, loadRecentRepos]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isEditing && repoMode === 'existing' && (
|
{repoMode === 'existing' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Show selection interface only when no repo is selected */}
|
{/* Show selection interface only when no repo is selected */}
|
||||||
<>
|
<>
|
||||||
@@ -276,7 +248,7 @@ export function ProjectFormFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blank Project Form */}
|
{/* Blank Project Form */}
|
||||||
{!isEditing && repoMode === 'new' && (
|
{repoMode === 'new' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -362,127 +334,6 @@ export function ProjectFormFields({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="git-repo-path">Git Repository Path</Label>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Input
|
|
||||||
id="git-repo-path"
|
|
||||||
type="text"
|
|
||||||
value={gitRepoPath}
|
|
||||||
onChange={(e) => handleGitRepoPathChange(e.target.value)}
|
|
||||||
placeholder="/path/to/your/existing/repo"
|
|
||||||
required
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
|
||||||
const selectedPath = await FolderPickerDialog.show({
|
|
||||||
title: 'Select Git Repository',
|
|
||||||
description: 'Choose an existing git repository',
|
|
||||||
value: gitRepoPath,
|
|
||||||
});
|
|
||||||
if (selectedPath) {
|
|
||||||
handleGitRepoPathChange(selectedPath);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Folder className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Project Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Enter project name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditing && (
|
|
||||||
<div className="space-y-4 pt-4 border-t border-border">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="setup-script">Setup Script</Label>
|
|
||||||
<textarea
|
|
||||||
id="setup-script"
|
|
||||||
value={setupScript}
|
|
||||||
onChange={(e) => setSetupScript(e.target.value)}
|
|
||||||
placeholder={placeholders.setup}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This script will run after creating the worktree and before the
|
|
||||||
coding agent starts. Use it for setup tasks like installing
|
|
||||||
dependencies or preparing the environment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="dev-script">Dev Server Script</Label>
|
|
||||||
<textarea
|
|
||||||
id="dev-script"
|
|
||||||
value={devScript}
|
|
||||||
onChange={(e) => setDevScript(e.target.value)}
|
|
||||||
placeholder={placeholders.dev}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This script can be run from task attempts to start a development
|
|
||||||
server. Use it to quickly start your project's dev server for
|
|
||||||
testing changes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cleanup-script">Cleanup Script</Label>
|
|
||||||
<textarea
|
|
||||||
id="cleanup-script"
|
|
||||||
value={cleanupScript}
|
|
||||||
onChange={(e) => setCleanupScript(e.target.value)}
|
|
||||||
placeholder={placeholders.cleanup}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This script runs after coding agent execution{' '}
|
|
||||||
<strong>only if changes were made</strong>. Use it for quality
|
|
||||||
assurance tasks like running linters, formatters, tests, or other
|
|
||||||
validation steps. If no changes are made, this script is skipped.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Copy Files</Label>
|
|
||||||
<CopyFilesField
|
|
||||||
value={copyFiles}
|
|
||||||
onChange={setCopyFiles}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Comma-separated list of files to copy from the original project
|
|
||||||
directory to the worktree. These files will be copied after the
|
|
||||||
worktree is created but before the setup script runs. Useful for
|
|
||||||
environment-specific files like .env, configuration files, and
|
|
||||||
local settings. Make sure these are gitignored or they could get
|
|
||||||
committed!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function MultiFileSearchTextarea({
|
|||||||
const cached = searchCacheRef.current.get(searchQuery);
|
const cached = searchCacheRef.current.get(searchQuery);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setSearchResults(cached);
|
setSearchResults(cached);
|
||||||
setShowDropdown(true);
|
setShowDropdown(cached.length > 0);
|
||||||
setSelectedIndex(-1);
|
setSelectedIndex(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export function MultiFileSearchTextarea({
|
|||||||
searchCacheRef.current.set(searchQuery, fileResults);
|
searchCacheRef.current.set(searchQuery, fileResults);
|
||||||
|
|
||||||
setSearchResults(fileResults);
|
setSearchResults(fileResults);
|
||||||
setShowDropdown(true);
|
setShowDropdown(fileResults.length > 0);
|
||||||
setSelectedIndex(-1);
|
setSelectedIndex(-1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -354,10 +354,6 @@ export function MultiFileSearchTextarea({
|
|||||||
<div className="p-2 text-sm text-muted-foreground">
|
<div className="p-2 text-sm text-muted-foreground">
|
||||||
Searching...
|
Searching...
|
||||||
</div>
|
</div>
|
||||||
) : searchResults.length === 0 ? (
|
|
||||||
<div className="p-2 text-sm text-muted-foreground">
|
|
||||||
No files found
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{searchResults.map((file, index) => (
|
{searchResults.map((file, index) => (
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"unlinkFromOrganization": "Unlink from Remote Project",
|
"unlinkFromOrganization": "Unlink from Remote Project",
|
||||||
"viewProject": "View Project",
|
"viewProject": "View Project",
|
||||||
"openInIDE": "Open in IDE",
|
"openInIDE": "Open in IDE",
|
||||||
"createdDate": "Created {{date}}"
|
"createdDate": "Created {{date}}",
|
||||||
|
"copyFilesPlaceholderWithSearch": "File paths or glob patterns (e.g., .env, config/*.json)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"unlinkFromOrganization": "Desvincular de Proyecto Remoto",
|
"unlinkFromOrganization": "Desvincular de Proyecto Remoto",
|
||||||
"viewProject": "Ver Proyecto",
|
"viewProject": "Ver Proyecto",
|
||||||
"openInIDE": "Abrir en IDE",
|
"openInIDE": "Abrir en IDE",
|
||||||
"createdDate": "Creado {{date}}"
|
"createdDate": "Creado {{date}}",
|
||||||
|
"copyFilesPlaceholderWithSearch": "Escribe una ruta o patrón glob (.env, config/*.json)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"unlinkFromOrganization": "リモートプロジェクトからリンク解除",
|
"unlinkFromOrganization": "リモートプロジェクトからリンク解除",
|
||||||
"viewProject": "プロジェクトを表示",
|
"viewProject": "プロジェクトを表示",
|
||||||
"openInIDE": "IDEで開く",
|
"openInIDE": "IDEで開く",
|
||||||
"createdDate": "作成日 {{date}}"
|
"createdDate": "作成日 {{date}}",
|
||||||
|
"copyFilesPlaceholderWithSearch": "パスまたはglobパターンを入力 (.env, config/*.json)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"unlinkFromOrganization": "원격 프로젝트에서 연결 해제",
|
"unlinkFromOrganization": "원격 프로젝트에서 연결 해제",
|
||||||
"viewProject": "프로젝트 보기",
|
"viewProject": "프로젝트 보기",
|
||||||
"openInIDE": "IDE에서 열기",
|
"openInIDE": "IDE에서 열기",
|
||||||
"createdDate": "생성일 {{date}}"
|
"createdDate": "생성일 {{date}}",
|
||||||
|
"copyFilesPlaceholderWithSearch": "경로 또는 glob 패턴 입력 (.env, config/*.json)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user