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:
Alex Netsch
2025-12-04 15:36:14 +00:00
committed by GitHub
parent 9bfaa6dde5
commit ef1ba1b4bb
13 changed files with 380 additions and 241 deletions

13
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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, &copy_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)]
{ {

View 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);
}
}

View File

@@ -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 {

View File

@@ -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' && (

View File

@@ -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}
/> />
); );
} }

View File

@@ -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" />

View File

@@ -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) => (

View File

@@ -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)"
} }

View File

@@ -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)"
} }

View File

@@ -46,5 +46,6 @@
"unlinkFromOrganization": "リモートプロジェクトからリンク解除", "unlinkFromOrganization": "リモートプロジェクトからリンク解除",
"viewProject": "プロジェクトを表示", "viewProject": "プロジェクトを表示",
"openInIDE": "IDEで開く", "openInIDE": "IDEで開く",
"createdDate": "作成日 {{date}}" "createdDate": "作成日 {{date}}",
"copyFilesPlaceholderWithSearch": "パスまたはglobパターンを入力 (.env, config/*.json)"
} }

View File

@@ -46,5 +46,6 @@
"unlinkFromOrganization": "원격 프로젝트에서 연결 해제", "unlinkFromOrganization": "원격 프로젝트에서 연결 해제",
"viewProject": "프로젝트 보기", "viewProject": "프로젝트 보기",
"openInIDE": "IDE에서 열기", "openInIDE": "IDE에서 열기",
"createdDate": "생성일 {{date}}" "createdDate": "생성일 {{date}}",
"copyFilesPlaceholderWithSearch": "경로 또는 glob 패턴 입력 (.env, config/*.json)"
} }