feat: copy-file autocomplete (#2004)

* add repo file search endpoint and use for copy-file autocomplete

* address feedback and fix i18n errors

* remove unused i18n
This commit is contained in:
Gabriel Gordon-Hall
2026-01-13 17:34:54 +00:00
committed by GitHub
parent 5502a4cad6
commit cdfb081cf8
32 changed files with 296 additions and 241 deletions

View File

@@ -22,7 +22,7 @@ use services::services::{
config::{Config, ConfigError},
container::{ContainerError, ContainerService},
events::{EventError, EventService},
file_search_cache::FileSearchCache,
file_search::FileSearchCache,
filesystem::{FilesystemError, FilesystemService},
filesystem_watcher::FilesystemWatcherError,
git::{GitService, GitServiceError},

View File

@@ -11,7 +11,7 @@ use services::services::{
config::{Config, load_config_from_file, save_config_to_file},
container::ContainerService,
events::EventService,
file_search_cache::FileSearchCache,
file_search::FileSearchCache,
filesystem::FilesystemService,
git::GitService,
image::ImageService,

View File

@@ -151,6 +151,7 @@ fn generate_types_content() -> String {
server::routes::task_attempts::workspace_summary::WorkspaceSummaryResponse::decl(),
services::services::filesystem::DirectoryEntry::decl(),
services::services::filesystem::DirectoryListResponse::decl(),
services::services::file_search::SearchMode::decl(),
services::services::config::Config::decl(),
services::services::config::NotificationConfig::decl(),
services::services::config::ThemeMode::decl(),

View File

@@ -21,7 +21,7 @@ use deployment::Deployment;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use serde::Deserialize;
use services::services::{
file_search_cache::SearchQuery, project::ProjectServiceError,
file_search::SearchQuery, project::ProjectServiceError,
remote_client::CreateRemoteProjectPayload,
};
use ts_rs::TS;

View File

@@ -1,13 +1,17 @@
use axum::{
Router,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::Json as ResponseJson,
routing::{get, post},
};
use db::models::repo::{Repo, UpdateRepo};
use db::models::{
project::SearchResult,
repo::{Repo, UpdateRepo},
};
use deployment::Deployment;
use serde::Deserialize;
use services::services::git::GitBranch;
use services::services::{file_search::SearchQuery, git::GitBranch};
use ts_rs::TS;
use utils::response::ApiResponse;
use uuid::Uuid;
@@ -166,6 +170,42 @@ pub async fn open_repo_in_editor(
}
}
pub async fn search_repo(
State(deployment): State<DeploymentImpl>,
Path(repo_id): Path<Uuid>,
Query(search_query): Query<SearchQuery>,
) -> Result<ResponseJson<ApiResponse<Vec<SearchResult>>>, StatusCode> {
if search_query.q.trim().is_empty() {
return Ok(ResponseJson(ApiResponse::error(
"Query parameter 'q' is required and cannot be empty",
)));
}
let repo = match deployment
.repo()
.get_by_id(&deployment.db().pool, repo_id)
.await
{
Ok(repo) => repo,
Err(e) => {
tracing::error!("Failed to get repo {}: {}", repo_id, e);
return Err(StatusCode::NOT_FOUND);
}
};
match deployment
.file_search_cache()
.search_repo(&repo.path, &search_query.q, search_query.mode)
.await
{
Ok(results) => Ok(ResponseJson(ApiResponse::success(results))),
Err(e) => {
tracing::error!("Failed to search files in repo {}: {}", repo_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub fn router() -> Router<DeploymentImpl> {
Router::new()
.route("/repos", get(get_repos).post(register_repo))
@@ -173,5 +213,6 @@ pub fn router() -> Router<DeploymentImpl> {
.route("/repos/batch", post(get_repos_batch))
.route("/repos/{repo_id}", get(get_repo).put(update_repo))
.route("/repos/{repo_id}/branches", get(get_repo_branches))
.route("/repos/{repo_id}/search", get(search_repo))
.route("/repos/{repo_id}/open-editor", post(open_repo_in_editor))
}

View File

@@ -276,6 +276,153 @@ impl FileSearchCache {
results
}
/// Search files in a single repository with cache + fallback
pub async fn search_repo(
&self,
repo_path: &Path,
query: &str,
mode: SearchMode,
) -> Result<Vec<SearchResult>, String> {
let query = query.trim();
if query.is_empty() {
return Ok(vec![]);
}
// Try cache first
match self.search(repo_path, query, mode.clone()).await {
Ok(results) => Ok(results),
Err(CacheError::Miss) | Err(CacheError::BuildError(_)) => {
// Fall back to filesystem search
self.search_files_no_cache(repo_path, query, mode).await
}
}
}
/// Fallback filesystem search when cache is not available
async fn search_files_no_cache(
&self,
repo_path: &Path,
query: &str,
mode: SearchMode,
) -> Result<Vec<SearchResult>, String> {
if !repo_path.exists() {
return Err(format!("Path not found: {:?}", repo_path));
}
let mut results = Vec::new();
let query_lower = query.to_lowercase();
let walker = match mode {
SearchMode::Settings => {
// Settings mode: Include ignored files but exclude performance killers
WalkBuilder::new(repo_path)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.hidden(false)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
name != ".git"
&& name != "node_modules"
&& name != "target"
&& name != "dist"
&& name != "build"
})
.build()
}
SearchMode::TaskForm => WalkBuilder::new(repo_path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(false)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
name != ".git"
})
.build(),
};
for result in walker {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
// Skip the root directory itself
if path == repo_path {
continue;
}
let relative_path = match path.strip_prefix(repo_path) {
Ok(p) => p,
Err(_) => continue,
};
let relative_path_str = relative_path.to_string_lossy().to_lowercase();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default();
if file_name.contains(&query_lower) {
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type: SearchMatchType::FileName,
score: 0,
});
} else if relative_path_str.contains(&query_lower) {
let match_type = if path
.parent()
.and_then(|p| p.file_name())
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default()
.contains(&query_lower)
{
SearchMatchType::DirectoryName
} else {
SearchMatchType::FullPath
};
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type,
score: 0,
});
}
}
// Apply git history-based ranking
match self.file_ranker.get_stats(repo_path).await {
Ok(stats) => {
self.file_ranker.rerank(&mut results, &stats);
// Populate scores for sorted results
for result in &mut results {
result.score = self.file_ranker.calculate_score(result, &stats);
}
}
Err(_) => {
// Fallback to basic priority sorting
results.sort_by(|a, b| {
let priority = |match_type: &SearchMatchType| match match_type {
SearchMatchType::FileName => 0,
SearchMatchType::DirectoryName => 1,
SearchMatchType::FullPath => 2,
};
priority(&a.match_type)
.cmp(&priority(&b.match_type))
.then_with(|| a.path.cmp(&b.path))
});
}
}
results.truncate(10);
Ok(results)
}
/// Build cache entry for a repository
async fn build_repo_cache(&self, repo_path: &Path) -> Result<CachedRepo, String> {
let repo_path_buf = repo_path.to_path_buf();

View File

@@ -6,7 +6,7 @@ pub mod container;
pub mod diff_stream;
pub mod events;
pub mod file_ranker;
pub mod file_search_cache;
pub mod file_search;
pub mod filesystem;
pub mod filesystem_watcher;
pub mod git;

View File

@@ -9,15 +9,13 @@ use db::models::{
repo::Repo,
task::Task,
};
use ignore::WalkBuilder;
use sqlx::SqlitePool;
use thiserror::Error;
use utils::api::projects::RemoteProject;
use uuid::Uuid;
use super::{
file_ranker::FileRanker,
file_search_cache::{CacheError, FileSearchCache, SearchMode, SearchQuery},
file_search::{FileSearchCache, SearchQuery},
repo::{RepoError, RepoService},
share::ShareError,
};
@@ -277,10 +275,11 @@ impl ProjectService {
.map(|repo| {
let repo_name = repo.name.clone();
let repo_path = repo.path.clone();
let query = query.clone();
let mode = query.mode.clone();
let query_str = query_str.to_string();
async move {
let results = self
.search_single_repo(cache, &repo_path, &query)
let results = cache
.search_repo(&repo_path, &query_str, mode)
.await
.unwrap_or_else(|e| {
tracing::warn!("Search failed for repo {}: {}", repo_name, e);
@@ -319,147 +318,4 @@ impl ProjectService {
all_results.truncate(10);
Ok(all_results)
}
async fn search_single_repo(
&self,
cache: &FileSearchCache,
repo_path: &Path,
query: &SearchQuery,
) -> Result<Vec<SearchResult>> {
let query_str = query.q.trim();
if query_str.is_empty() {
return Ok(vec![]);
}
// Try cache first
match cache.search(repo_path, query_str, query.mode.clone()).await {
Ok(results) => Ok(results),
Err(CacheError::Miss) | Err(CacheError::BuildError(_)) => {
// Fall back to filesystem search
self.search_files_in_repo(repo_path, query_str, query.mode.clone())
.await
}
}
}
async fn search_files_in_repo(
&self,
repo_path: &Path,
query: &str,
mode: SearchMode,
) -> Result<Vec<SearchResult>> {
if !repo_path.exists() {
return Err(ProjectServiceError::PathNotFound(repo_path.to_path_buf()));
}
let mut results = Vec::new();
let query_lower = query.to_lowercase();
let walker = match mode {
SearchMode::Settings => {
// Settings mode: Include ignored files but exclude performance killers
WalkBuilder::new(repo_path)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.hidden(false)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
name != ".git"
&& name != "node_modules"
&& name != "target"
&& name != "dist"
&& name != "build"
})
.build()
}
SearchMode::TaskForm => WalkBuilder::new(repo_path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(false)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
name != ".git"
})
.build(),
};
for result in walker {
let entry = result.map_err(std::io::Error::other)?;
let path = entry.path();
// Skip the root directory itself
if path == repo_path {
continue;
}
let relative_path = path
.strip_prefix(repo_path)
.map_err(std::io::Error::other)?;
let relative_path_str = relative_path.to_string_lossy().to_lowercase();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default();
if file_name.contains(&query_lower) {
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type: SearchMatchType::FileName,
score: 0,
});
} else if relative_path_str.contains(&query_lower) {
let match_type = if path
.parent()
.and_then(|p| p.file_name())
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default()
.contains(&query_lower)
{
SearchMatchType::DirectoryName
} else {
SearchMatchType::FullPath
};
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type,
score: 0,
});
}
}
// Apply git history-based ranking
let file_ranker = FileRanker::new();
match file_ranker.get_stats(repo_path).await {
Ok(stats) => {
file_ranker.rerank(&mut results, &stats);
// Populate scores for sorted results
for result in &mut results {
result.score = file_ranker.calculate_score(result, &stats);
}
}
Err(_) => {
// Fallback to basic priority sorting
results.sort_by(|a, b| {
let priority = |match_type: &SearchMatchType| match match_type {
SearchMatchType::FileName => 0,
SearchMatchType::DirectoryName => 1,
SearchMatchType::FullPath => 2,
};
priority(&a.match_type)
.cmp(&priority(&b.match_type))
.then_with(|| a.path.cmp(&b.path))
});
}
}
results.truncate(10);
Ok(results)
}
}

View File

@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { MultiFileSearchTextarea } from '@/components/ui/multi-file-search-textarea';
interface CopyFilesFieldProps {
value: string;
onChange: (value: string) => void;
projectId: string;
disabled?: boolean;
}
export function CopyFilesField({
value,
onChange,
projectId,
disabled = false,
}: CopyFilesFieldProps) {
const { t } = useTranslation('projects');
return (
<MultiFileSearchTextarea
value={value}
onChange={onChange}
placeholder={t('copyFilesPlaceholderWithSearch')}
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}
/>
);
}

View File

@@ -75,7 +75,8 @@ export function PreviewControls({
)}
onClick={() => onTabChange(process.id)}
>
{getDevServerWorkingDir(process) ?? 'Dev Server'}
{getDevServerWorkingDir(process) ??
t('preview.browser.devServerFallback')}
</button>
))}
</div>

View File

@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
import { usePortalContainer } from '@/contexts/PortalContainerContext';
import { projectsApi } from '@/lib/api';
import { projectsApi, repoApi } from '@/lib/api';
import type { SearchResult } from 'shared/types';
@@ -17,7 +17,10 @@ interface MultiFileSearchTextareaProps {
rows?: number;
disabled?: boolean;
className?: string;
projectId: string;
/** Project ID for project-level file search (searches across all repos in project) */
projectId?: string;
/** Repo ID for repo-level file search (searches within a single repo) */
repoId?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
maxRows?: number;
}
@@ -30,9 +33,13 @@ export function MultiFileSearchTextarea({
disabled = false,
className,
projectId,
repoId,
onKeyDown,
maxRows = 10,
}: MultiFileSearchTextareaProps) {
// Require at least one of projectId or repoId
const searchId = projectId || repoId;
const searchType = projectId ? 'project' : 'repo';
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
@@ -48,9 +55,16 @@ export function MultiFileSearchTextarea({
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const portalContainer = usePortalContainer();
useEffect(() => {
searchCacheRef.current.clear();
setSearchQuery('');
setSearchResults([]);
setShowDropdown(false);
}, [searchId]);
// Search for files when query changes
useEffect(() => {
if (!searchQuery || !projectId || searchQuery.length < 2) {
if (!searchQuery || !searchId || searchQuery.length < 2) {
setSearchResults([]);
setShowDropdown(false);
return;
@@ -77,14 +91,14 @@ export function MultiFileSearchTextarea({
abortControllerRef.current = abortController;
try {
const result = await projectsApi.searchFiles(
projectId,
searchQuery,
'settings',
{
signal: abortController.signal,
}
);
const result =
searchType === 'project'
? await projectsApi.searchFiles(searchId, searchQuery, 'settings', {
signal: abortController.signal,
})
: await repoApi.searchFiles(searchId, searchQuery, 'settings', {
signal: abortController.signal,
});
// Only process if this request wasn't aborted
if (!abortController.signal.aborted) {
@@ -118,7 +132,7 @@ export function MultiFileSearchTextarea({
abortControllerRef.current.abort();
}
};
}, [searchQuery, projectId]);
}, [searchQuery, searchId, searchType]);
// Find current token boundaries based on cursor position
const findCurrentToken = (text: string, cursorPosition: number) => {

View File

@@ -55,6 +55,5 @@
"projectNotFound": "The project you're looking for doesn't exist or has been deleted.",
"viewProject": "View Project",
"openInIDE": "Open in IDE",
"createdDate": "Created {{date}}",
"copyFilesPlaceholderWithSearch": "File paths or glob patterns (e.g., .env, config/*.json)"
"createdDate": "Created {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "Copy Files",
"helper": "Comma-separated list of files to copy from the original repository directory to the worktree. Useful for environment files like .env. Make sure these are gitignored!"
"helper": "Comma-separated list of files to copy from the original repository directory to the worktree. Useful for environment files like .env. Make sure these are gitignored!",
"placeholder": "File paths or glob patterns (e.g., .env, config/*.json)"
},
"devServer": {
"label": "Dev Server Script",

View File

@@ -137,9 +137,10 @@
},
"browser": {
"title": "Dev Server Preview",
"startButton": "Start Dev Server",
"stopButton": "Stop",
"startingButton": "Start"
"devServerFallback": "Dev Server"
},
"urlInput": {
"placeholder": "Enter URL..."
}
},
"diff": {

View File

@@ -55,6 +55,5 @@
"projectNotFound": "El proyecto que buscas no existe o ha sido eliminado.",
"viewProject": "Ver Proyecto",
"openInIDE": "Abrir en IDE",
"createdDate": "Creado {{date}}",
"copyFilesPlaceholderWithSearch": "Escribe una ruta o patrón glob (.env, config/*.json)"
"createdDate": "Creado {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "Copiar Archivos",
"helper": "Lista separada por comas de archivos para copiar del directorio del repositorio original al worktree. Útil para archivos de entorno como .env. ¡Asegúrate de que estén en gitignore!"
"helper": "Lista separada por comas de archivos para copiar del directorio del repositorio original al worktree. Útil para archivos de entorno como .env. ¡Asegúrate de que estén en gitignore!",
"placeholder": "Rutas de archivos o patrones glob (ej., .env, config/*.json)"
},
"devServer": {
"label": "Script del Servidor de Desarrollo",

View File

@@ -373,9 +373,10 @@
"noDevScriptHint": "Agrega un script de desarrollo en la configuración del proyecto para habilitar la vista previa.",
"browser": {
"title": "Vista previa del servidor de desarrollo",
"startButton": "Iniciar servidor de desarrollo",
"stopButton": "Detener",
"startingButton": "Iniciar"
"devServerFallback": "Servidor de desarrollo"
},
"urlInput": {
"placeholder": "Ingresar URL..."
},
"noServer": {
"companionLink": "Ver guía de instalación",

View File

@@ -55,6 +55,5 @@
"projectNotFound": "お探しのプロジェクトは存在しないか、削除されました。",
"viewProject": "プロジェクトを表示",
"openInIDE": "IDEで開く",
"createdDate": "作成日 {{date}}",
"copyFilesPlaceholderWithSearch": "パスまたはglobパターンを入力 (.env, config/*.json)"
"createdDate": "作成日 {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "ファイルをコピー",
"helper": "元のリポジトリディレクトリからワークツリーにコピーするファイルのカンマ区切りリスト。.envなどの環境ファイルに役立ちます。gitignoreされていることを確認してください"
"helper": "元のリポジトリディレクトリからワークツリーにコピーするファイルのカンマ区切りリスト。.envなどの環境ファイルに役立ちます。gitignoreされていることを確認してください",
"placeholder": "ファイルパスまたはglobパターン.env、config/*.json"
},
"devServer": {
"label": "開発サーバースクリプト",

View File

@@ -371,9 +371,10 @@
},
"browser": {
"title": "開発サーバープレビュー",
"startButton": "開発サーバーを開始",
"stopButton": "停止",
"startingButton": "開始"
"devServerFallback": "開発サーバー"
},
"urlInput": {
"placeholder": "URLを入力..."
},
"noDevScript": "開発スクリプトが設定されていません",
"noDevScriptHint": "プレビューを有効にするには、プロジェクト設定で開発スクリプトを追加してください。",

View File

@@ -55,6 +55,5 @@
"projectNotFound": "찾으시는 프로젝트가 존재하지 않거나 삭제되었습니다.",
"viewProject": "프로젝트 보기",
"openInIDE": "IDE에서 열기",
"createdDate": "생성일 {{date}}",
"copyFilesPlaceholderWithSearch": "경로 또는 glob 패턴 입력 (.env, config/*.json)"
"createdDate": "생성일 {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "파일 복사",
"helper": "원래 저장소 디렉토리에서 워크트리로 복사할 파일의 쉼표로 구분된 목록입니다. .env와 같은 환경 파일에 유용합니다. gitignore되었는지 확인하세요!"
"helper": "원래 저장소 디렉토리에서 워크트리로 복사할 파일의 쉼표로 구분된 목록입니다. .env와 같은 환경 파일에 유용합니다. gitignore되었는지 확인하세요!",
"placeholder": "파일 경로 또는 glob 패턴 (예: .env, config/*.json)"
},
"devServer": {
"label": "개발 서버 스크립트",

View File

@@ -413,9 +413,10 @@
},
"browser": {
"title": "개발 서버 미리보기",
"startButton": "개발 서버 시작",
"stopButton": "중지",
"startingButton": "시작"
"devServerFallback": "개발 서버"
},
"urlInput": {
"placeholder": "URL 입력..."
},
"noDevScript": "개발 스크립트가 설정되지 않았습니다",
"noDevScriptHint": "미리보기를 활성화하려면 프로젝트 설정에서 개발 스크립트를 추가하세요."

View File

@@ -55,6 +55,5 @@
"projectNotFound": "您查找的项目不存在或已被删除。",
"viewProject": "查看项目",
"openInIDE": "在 IDE 中打开",
"createdDate": "创建于 {{date}}",
"copyFilesPlaceholderWithSearch": "文件路径或 glob 模式(例如:.env、config/*.json"
"createdDate": "创建于 {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "复制文件",
"helper": "要从原始仓库目录复制到工作树的文件的逗号分隔列表。对 .env 等环境文件很有用。确保这些文件被 gitignore"
"helper": "要从原始仓库目录复制到工作树的文件的逗号分隔列表。对 .env 等环境文件很有用。确保这些文件被 gitignore",
"placeholder": "文件路径或 glob 模式(例如:.env、config/*.json"
},
"devServer": {
"label": "开发服务器脚本",

View File

@@ -130,9 +130,10 @@
"noDevScriptHint": "在项目设置中添加开发脚本以启用预览。",
"browser": {
"title": "开发服务器预览",
"startButton": "启动开发服务器",
"stopButton": "停止",
"startingButton": "启动"
"devServerFallback": "开发服务器"
},
"urlInput": {
"placeholder": "输入 URL..."
},
"iframe": {
"title": "开发服务器预览"

View File

@@ -55,6 +55,5 @@
"projectNotFound": "您要查找的專案不存在或已被刪除。",
"viewProject": "查看專案",
"openInIDE": "在 IDE 中開啟",
"createdDate": "建立於 {{date}}",
"copyFilesPlaceholderWithSearch": "檔案路徑或 glob 模式(例如:.env、config/*.json"
"createdDate": "建立於 {{date}}"
}

View File

@@ -380,7 +380,8 @@
},
"copyFiles": {
"label": "複製檔案",
"helper": "要從原始儲存庫目錄複製到工作樹的檔案清單(以逗號分隔)。適合用於 .env 等環境檔案。請確保這些檔案已加入 gitignore"
"helper": "要從原始儲存庫目錄複製到工作樹的檔案清單(以逗號分隔)。適合用於 .env 等環境檔案。請確保這些檔案已加入 gitignore",
"placeholder": "檔案路徑或 glob 模式(例如:.env、config/*.json"
},
"devServer": {
"label": "開發伺服器腳本",

View File

@@ -130,9 +130,10 @@
"noDevScriptHint": "在專案設定中新增開發指令碼以啟用預覽。",
"browser": {
"title": "開發伺服器預覽",
"startButton": "啟動開發伺服器",
"stopButton": "停止",
"startingButton": "啟動"
"devServerFallback": "開發伺服器"
},
"urlInput": {
"placeholder": "輸入 URL..."
},
"iframe": {
"title": "開發伺服器預覽"

View File

@@ -22,6 +22,7 @@ import {
CreateProject,
CreateProjectRepo,
UpdateRepo,
SearchMode,
SearchResult,
ShareTaskResponse,
Task,
@@ -283,7 +284,7 @@ export const projectsApi = {
searchFiles: async (
id: string,
query: string,
mode?: string,
mode?: SearchMode,
options?: RequestInit
): Promise<SearchResult[]> => {
const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : '';
@@ -895,6 +896,20 @@ export const repoApi = {
});
return handleApiResponse<OpenEditorResponse>(response);
},
searchFiles: async (
repoId: string,
query: string,
mode?: SearchMode,
options?: RequestInit
): Promise<SearchResult[]> => {
const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : '';
const response = await makeRequest(
`/api/repos/${repoId}/search?q=${encodeURIComponent(query)}${modeParam}`,
options
);
return handleApiResponse<SearchResult[]>(response);
},
};
// Config APIs (backwards compatible)

View File

@@ -24,6 +24,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2 } from 'lucide-react';
import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders';
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
import { MultiFileSearchTextarea } from '@/components/ui/multi-file-search-textarea';
import { repoApi } from '@/lib/api';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Repo, UpdateRepo } from 'shared/types';
@@ -422,12 +423,14 @@ export function ReposSettings() {
<Label htmlFor="copy-files">
{t('settings.repos.scripts.copyFiles.label')}
</Label>
<AutoExpandingTextarea
id="copy-files"
<MultiFileSearchTextarea
value={draft.copy_files}
onChange={(e) => updateDraft({ copy_files: e.target.value })}
placeholder=".env, .env.local"
onChange={(value) => updateDraft({ copy_files: value })}
placeholder={t(
'settings.repos.scripts.copyFiles.placeholder'
)}
maxRows={6}
repoId={selectedRepo.id}
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<p className="text-sm text-muted-foreground">

View File

@@ -370,6 +370,8 @@ export type DirectoryEntry = { name: string, path: string, is_directory: boolean
export type DirectoryListResponse = { entries: Array<DirectoryEntry>, current_path: string, };
export type SearchMode = "taskform" | "settings";
export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, pr_auto_description_enabled: boolean, pr_auto_description_prompt: string | null, };
export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, };