diff --git a/.gitignore b/.gitignore index ebd5a591..9b7bb541 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,4 @@ tests/e2e/reports/ ASSETS_LICENSES.md -external/ \ No newline at end of file +external/ diff --git a/resources/codgrep/README.md b/resources/codgrep/README.md new file mode 100644 index 00000000..18c739a0 --- /dev/null +++ b/resources/codgrep/README.md @@ -0,0 +1,8 @@ +Place the prebuilt `cg` daemon binary in this directory. + +Expected filenames: + +- macOS/Linux: `cg` +- Windows: `cg.exe` + +BitFun dev/build scripts load the daemon from this repository-relative path. diff --git a/resources/codgrep/cg b/resources/codgrep/cg new file mode 100755 index 00000000..65f213d9 Binary files /dev/null and b/resources/codgrep/cg differ diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 0ef8efaf..b4438ea9 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { readdirSync } from 'fs'; import { ensureOpenSslWindows } from './ensure-openssl-windows.mjs'; +import { ensureCodgrepBinary } from './prepare-codgrep-resource.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -26,6 +27,7 @@ async function main() { const forward = tauriBuildArgsFromArgv(); await ensureOpenSslWindows(); + process.env.CODGREP_DAEMON_BIN = ensureCodgrepBinary(); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). diff --git a/scripts/dev.cjs b/scripts/dev.cjs index cb7b3a80..f3d6d49c 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -6,6 +6,7 @@ */ const { execSync, spawn } = require('child_process'); +const { existsSync } = require('fs'); const path = require('path'); const { pathToFileURL } = require('url'); const { @@ -110,12 +111,16 @@ function runCommand(command, cwd = ROOT_DIR) { /** * Spawn a command with explicit args array (no shell interpolation, safe for paths with spaces) */ -function spawnCommand(cmd, args, cwd = ROOT_DIR) { +function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: true, + env: { + ...process.env, + ...envOverrides, + }, }); child.on('close', (code) => { @@ -130,6 +135,34 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR) { }); } +function codgrepBinaryName() { + return process.platform === 'win32' ? 'cg.exe' : 'cg'; +} + +function codgrepBinaryPath() { + return path.join(ROOT_DIR, 'resources', 'codgrep', codgrepBinaryName()); +} + +function ensureCodgrepBinary() { + const binaryPath = codgrepBinaryPath(); + if (!existsSync(binaryPath)) { + return { + ok: false, + error: new Error( + `codgrep binary not found: ${binaryPath}. Put the prebuilt daemon binary at resources/codgrep/${codgrepBinaryName()}` + ), + }; + } + + return { ok: true, binaryPath }; +} + +async function ensureCodgrepBundleResource() { + const helperUrl = pathToFileURL(path.join(__dirname, 'prepare-codgrep-resource.mjs')).href; + const helper = await import(helperUrl); + return helper.ensureCodgrepBinary(); +} + /** * Main entry */ @@ -141,7 +174,7 @@ async function main() { printHeader(`BitFun ${modeLabel} Development`); printBlank(); - const totalSteps = mode === 'desktop' ? 4 : 3; + const totalSteps = mode === 'desktop' ? 5 : 3; // Step 1: Copy resources printStep(1, totalSteps, 'Copy resources'); @@ -181,7 +214,7 @@ async function main() { // Step 3: Build mobile-web (desktop only) if (mode === 'desktop') { - printStep(3, 4, 'Build mobile-web'); + printStep(3, totalSteps, 'Build mobile-web'); const mobileWebResult = buildMobileWeb({ install: true, logInfo: printInfo, @@ -191,6 +224,27 @@ async function main() { if (!mobileWebResult.ok) { process.exit(1); } + + printStep(4, totalSteps, 'Build workspace search daemon'); + const codgrepResult = ensureCodgrepBinary(); + if (!codgrepResult.ok) { + printError('Workspace search daemon is missing'); + if (codgrepResult.error && codgrepResult.error.message) { + printError(codgrepResult.error.message); + } + if (codgrepResult.error && codgrepResult.error.status !== undefined) { + printError(`Exit code: ${codgrepResult.error.status}`); + } + process.exit(1); + } + + try { + await ensureCodgrepBundleResource(); + } catch (error) { + printError('Validate workspace search daemon failed'); + printError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } // Final step: Start dev server @@ -217,7 +271,12 @@ async function main() { const desktopDir = path.join(ROOT_DIR, 'src/apps/desktop'); const tauriConfig = path.join(desktopDir, 'tauri.conf.json'); const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); - await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir); + await spawnCommand( + tauriBin, + ['dev', '--config', tauriConfig], + desktopDir, + { CODGREP_DAEMON_BIN: codgrepBinaryPath() } + ); } else { await runCommand('pnpm exec vite', path.join(ROOT_DIR, 'src/web-ui')); } diff --git a/scripts/prepare-codgrep-resource.mjs b/scripts/prepare-codgrep-resource.mjs new file mode 100644 index 00000000..336f1d2d --- /dev/null +++ b/scripts/prepare-codgrep-resource.mjs @@ -0,0 +1,29 @@ +import { chmodSync, existsSync, statSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const RESOURCE_DIR = join(ROOT, 'resources', 'codgrep'); + +export function codgrepBinaryName() { + return process.platform === 'win32' ? 'cg.exe' : 'cg'; +} + +export function codgrepBinaryPath() { + return join(RESOURCE_DIR, codgrepBinaryName()); +} + +export function ensureCodgrepBinary() { + const binaryPath = codgrepBinaryPath(); + if (!existsSync(binaryPath)) { + throw new Error( + `codgrep binary not found: ${binaryPath}. Put the prebuilt daemon binary at resources/codgrep/${codgrepBinaryName()}` + ); + } + + if (process.platform !== 'win32') { + chmodSync(binaryPath, statSync(binaryPath).mode | 0o111); + } + return binaryPath; +} diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 4cc7199b..b5bea6e9 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -8,7 +8,7 @@ use bitfun_core::service::remote_ssh::{ init_remote_workspace_manager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager, }; use bitfun_core::service::{ - ai_rules, announcement, config, filesystem, mcp, token_usage, workspace, + ai_rules, announcement, config, filesystem, mcp, search, token_usage, workspace, }; use bitfun_core::util::errors::*; @@ -67,6 +67,7 @@ pub struct AppState { pub workspace_path: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, + pub workspace_search_service: Arc, pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, @@ -113,6 +114,8 @@ impl AppState { ); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); + let workspace_search_service = Arc::new(search::WorkspaceSearchService::new()); + search::set_global_workspace_search_service(workspace_search_service.clone()); ai_rules::initialize_global_ai_rules_service() .await @@ -170,10 +173,10 @@ impl AppState { uptime_seconds: 0, })); - let initial_workspace_path = workspace_service - .get_current_workspace() - .await - .map(|workspace| workspace.root_path); + let initial_workspace = workspace_service.get_current_workspace().await; + let initial_workspace_path = initial_workspace + .as_ref() + .map(|workspace| workspace.root_path.clone()); if let Some(workspace_path) = initial_workspace_path.clone() { if let Err(e) = @@ -194,6 +197,21 @@ impl AppState { } } + if let Some(workspace_info) = initial_workspace { + if workspace_info.workspace_kind != workspace::WorkspaceKind::Remote { + if let Err(e) = workspace_search_service + .open_repo(&workspace_info.root_path) + .await + { + log::warn!( + "Failed to restore workspace search repository session on startup: path={}, error={}", + workspace_info.root_path.display(), + e + ); + } + } + } + // Initialize SSH Remote services synchronously so they're ready before app starts let ssh_data_dir = dirs::data_local_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) @@ -273,6 +291,7 @@ impl AppState { workspace_path: Arc::new(RwLock::new(initial_workspace_path)), config_service, filesystem_service, + workspace_search_service, ai_rules_service, agent_registry, mcp_service, @@ -304,6 +323,7 @@ impl AppState { services.insert("workspace_service".to_string(), true); services.insert("config_service".to_string(), true); services.insert("filesystem_service".to_string(), true); + services.insert("workspace_search_service".to_string(), true); let all_healthy = services.values().all(|&status| status); diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 029e4dc1..f2960818 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2,6 +2,10 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; +use crate::api::search_api::{ + group_search_results, search_file_contents_via_workspace_search, + search_metadata_from_content_result, should_use_workspace_search, SearchMetadataResponse, +}; use bitfun_core::infrastructure::{ BatchedFileSearchProgressSink, FileOperationOptions, FileSearchResult, FileSearchResultGroup, FileTreeNode, SearchMatchType, @@ -214,6 +218,8 @@ struct SearchCompleteEvent { limit: usize, truncated: bool, total_results: usize, + #[serde(skip_serializing_if = "Option::is_none")] + search_metadata: Option, } #[derive(Debug, Clone, Serialize)] @@ -272,6 +278,7 @@ fn emit_search_complete( limit: usize, truncated: bool, total_results: usize, + search_metadata: Option, ) { if let Err(error) = app_handle.emit( FILE_SEARCH_COMPLETE_EVENT, @@ -281,6 +288,7 @@ fn emit_search_complete( limit, truncated, total_results, + search_metadata, }, ) { warn!( @@ -321,18 +329,24 @@ struct SearchCommandResponse { results: Vec, limit: usize, truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + search_metadata: Option, } fn serialize_search_response( outcome: bitfun_core::infrastructure::FileSearchOutcome, limit: usize, + search_metadata: Option, ) -> serde_json::Value { serde_json::to_value(SearchCommandResponse { results: serialize_search_results(outcome.results), limit, truncated: outcome.truncated, + search_metadata, + }) + .unwrap_or_else(|_| { + serde_json::json!({ "results": [], "limit": limit, "truncated": false, "searchMetadata": null }) }) - .unwrap_or_else(|_| serde_json::json!({ "results": [], "limit": limit, "truncated": false })) } #[derive(Debug, Deserialize)] @@ -685,6 +699,20 @@ async fn apply_active_workspace_context( ); } + if workspace_info.workspace_kind != WorkspaceKind::Remote { + if let Err(e) = state + .workspace_search_service + .open_repo(&workspace_info.root_path) + .await + { + warn!( + "Failed to open workspace search repository session: path={}, error={}", + workspace_info.root_path.display(), + e + ); + } + } + #[cfg(target_os = "macos")] { let language = state @@ -2475,6 +2503,8 @@ pub async fn search_files( include_directories: request.include_directories, }; + let use_workspace_search = + request.search_content && should_use_workspace_search(&request.root_path).await; let result = if request.search_content { let filename_outcome = state .filesystem_service @@ -2494,20 +2524,35 @@ pub async fn search_files( if filename_results.len() >= max_results { Ok(filename_results) } else { - let mut content_outcome = state - .filesystem_service - .search_file_contents( + let remaining = max_results - filename_results.len(); + let mut content_outcome = if use_workspace_search { + search_file_contents_via_workspace_search( + &state, &request.root_path, &request.pattern, - FileSearchOptions { - include_content: true, - include_directories: false, - max_results: Some(max_results - filename_results.len()), - ..options - }, - cancel_flag, + request.case_sensitive, + request.use_regex, + request.whole_word, + remaining, ) - .await?; + .await + .map(|result| result.outcome)? + } else { + state + .filesystem_service + .search_file_contents( + &request.root_path, + &request.pattern, + FileSearchOptions { + include_content: true, + include_directories: false, + max_results: Some(remaining), + ..options + }, + cancel_flag, + ) + .await? + }; if filename_outcome.truncated || content_outcome.truncated { debug!( "Legacy search truncated: root_path={}, pattern={}, search_content={}, limit={}", @@ -2586,7 +2631,7 @@ pub async fn search_filenames( limit, outcome.truncated ); - Ok(serialize_search_response(outcome, limit)) + Ok(serialize_search_response(outcome, limit, None)) } Err(error) => { error!( @@ -2618,14 +2663,33 @@ pub async fn search_file_contents( include_directories: false, }; - let result = state - .filesystem_service - .search_file_contents(&request.root_path, &request.pattern, options, cancel_flag) - .await; + let result = if should_use_workspace_search(&request.root_path).await { + search_file_contents_via_workspace_search( + &state, + &request.root_path, + &request.pattern, + request.case_sensitive, + request.use_regex, + request.whole_word, + limit, + ) + .await + .map(|result| { + let search_metadata = search_metadata_from_content_result(&result); + (result.outcome, Some(search_metadata)) + }) + } else { + state + .filesystem_service + .search_file_contents(&request.root_path, &request.pattern, options, cancel_flag) + .await + .map(|outcome| (outcome, None)) + .map_err(|error| format!("Failed to search file contents: {}", error)) + }; unregister_search(&state, search_id.as_deref()); match result { - Ok(outcome) => { + Ok((outcome, search_metadata)) => { info!( "Content search completed: root_path={}, pattern={}, results_count={}, limit={}, truncated={}", request.root_path, @@ -2634,7 +2698,7 @@ pub async fn search_file_contents( limit, outcome.truncated ); - Ok(serialize_search_response(outcome, limit)) + Ok(serialize_search_response(outcome, limit, search_metadata)) } Err(error) => { error!( @@ -2717,6 +2781,7 @@ pub async fn start_search_filenames_stream( limit, outcome.truncated, count_search_result_groups(&outcome.results), + None, ); } Err(error) => { @@ -2764,9 +2829,14 @@ pub async fn start_search_file_contents_stream( }; let filesystem_service = state.filesystem_service.clone(); + let workspace_search_service = state.workspace_search_service.clone(); let active_searches = state.active_searches.clone(); let root_path = request.root_path.clone(); let pattern = request.pattern.clone(); + let case_sensitive = request.case_sensitive; + let use_regex = request.use_regex; + let whole_word = request.whole_word; + let use_workspace_search = should_use_workspace_search(&root_path).await; let response_search_id = search_id.clone(); let progress_search_id = search_id.clone(); let progress_app_handle = app_handle.clone(); @@ -2784,20 +2854,77 @@ pub async fn start_search_file_contents_stream( )); tokio::spawn(async move { - let result = filesystem_service - .search_file_contents_with_progress( - &root_path, - &pattern, - options, - cancel_flag, - Some(progress_sink), - ) - .await; + let result = if use_workspace_search { + let result = workspace_search_service + .search_content(bitfun_core::service::search::ContentSearchRequest { + repo_root: root_path.clone().into(), + search_path: None, + pattern: pattern.clone(), + output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + case_sensitive, + use_regex, + whole_word, + multiline: false, + before_context: 0, + after_context: 0, + max_results: Some(limit), + globs: Vec::new(), + file_types: Vec::new(), + exclude_file_types: Vec::new(), + }) + .await + .map(|result| { + let search_metadata = search_metadata_from_content_result(&result); + (result.outcome, Some(search_metadata)) + }); + + if let Ok((outcome, _)) = &result { + if !cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + for group in group_search_results(outcome.results.clone()) { + bitfun_core::infrastructure::FileSearchProgressSink::report( + progress_sink.as_ref(), + group, + ); + } + bitfun_core::infrastructure::FileSearchProgressSink::flush( + progress_sink.as_ref(), + ); + } + } + + result.map_err(|error| { + bitfun_core::util::errors::BitFunError::service(format!( + "Failed to search file contents via workspace search: {}", + error + )) + }) + } else { + filesystem_service + .search_file_contents_with_progress( + &root_path, + &pattern, + options, + cancel_flag.clone(), + Some(progress_sink), + ) + .await + .map(|outcome| (outcome, None)) + }; unregister_search_registry(&active_searches, Some(&search_id)); + if cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + return; + } + match result { - Ok(outcome) => { + Ok((outcome, search_metadata)) => { info!( "Content search stream completed: root_path={}, pattern={}, results_count={}, limit={}, truncated={}", root_path, @@ -2813,6 +2940,7 @@ pub async fn start_search_file_contents_stream( limit, outcome.truncated, count_search_result_groups(&outcome.results), + search_metadata, ); } Err(error) => { diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 26ac8c1f..1e193bc4 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod project_context_api; pub mod remote_connect_api; pub mod runtime_api; pub mod self_control_api; +pub mod search_api; pub mod session_api; pub mod session_storage_path; pub mod skill_api; diff --git a/src/apps/desktop/src/api/search_api.rs b/src/apps/desktop/src/api/search_api.rs new file mode 100644 index 00000000..da6428a9 --- /dev/null +++ b/src/apps/desktop/src/api/search_api.rs @@ -0,0 +1,162 @@ +use crate::api::app_state::AppState; +use bitfun_core::infrastructure::{FileSearchResult, FileSearchResultGroup, SearchMatchType}; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::search::{ + ContentSearchResult, WorkspaceSearchBackend, WorkspaceSearchRepoPhase, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchRepoIndexRequest { + pub root_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchMetadataResponse { + pub backend: WorkspaceSearchBackend, + pub repo_phase: WorkspaceSearchRepoPhase, + pub rebuild_recommended: bool, + pub candidate_docs: usize, + pub matched_lines: usize, + pub matched_occurrences: usize, +} + +pub(crate) async fn should_use_workspace_search(root_path: &str) -> bool { + !is_remote_path(root_path.trim()).await +} + +pub(crate) async fn search_file_contents_via_workspace_search( + state: &State<'_, AppState>, + root_path: &str, + pattern: &str, + case_sensitive: bool, + use_regex: bool, + whole_word: bool, + max_results: usize, +) -> Result { + state + .workspace_search_service + .search_content(bitfun_core::service::search::ContentSearchRequest { + repo_root: root_path.into(), + search_path: None, + pattern: pattern.to_string(), + output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + case_sensitive, + use_regex, + whole_word, + multiline: false, + before_context: 0, + after_context: 0, + max_results: Some(max_results), + globs: Vec::new(), + file_types: Vec::new(), + exclude_file_types: Vec::new(), + }) + .await + .map_err(|error| format!("Failed to search file contents via workspace search: {}", error)) +} + +pub(crate) fn group_search_results(results: Vec) -> Vec { + let mut grouped = Vec::::new(); + let mut positions = std::collections::HashMap::::new(); + + for result in results { + let path = result.path.clone(); + let position = if let Some(position) = positions.get(&path).copied() { + position + } else { + let position = grouped.len(); + positions.insert(path.clone(), position); + grouped.push(FileSearchResultGroup { + path, + name: result.name.clone(), + is_directory: result.is_directory, + file_name_match: None, + content_matches: Vec::new(), + }); + position + }; + let group = &mut grouped[position]; + + match result.match_type { + SearchMatchType::FileName => group.file_name_match = Some(result), + SearchMatchType::Content => group.content_matches.push(result), + } + } + + grouped +} + +pub(crate) fn search_metadata_from_content_result( + result: &ContentSearchResult, +) -> SearchMetadataResponse { + SearchMetadataResponse { + backend: result.backend, + repo_phase: result.repo_status.phase, + rebuild_recommended: result.repo_status.rebuild_recommended, + candidate_docs: result.candidate_docs, + matched_lines: result.matched_lines, + matched_occurrences: result.matched_occurrences, + } +} + +#[tauri::command] +pub async fn search_get_repo_status( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if !should_use_workspace_search(&request.root_path).await { + return Err("Remote workspace search status is not managed by BitFun workspace search" + .to_string()); + } + + state + .workspace_search_service + .get_index_status(&request.root_path) + .await + .map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to get search repository status: {}", error)) +} + +#[tauri::command] +pub async fn search_build_index( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if !should_use_workspace_search(&request.root_path).await { + return Err( + "Remote workspace search indexing is not managed by BitFun workspace search" + .to_string(), + ); + } + + state + .workspace_search_service + .build_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to build workspace index: {}", error)) +} + +#[tauri::command] +pub async fn search_rebuild_index( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if !should_use_workspace_search(&request.root_path).await { + return Err( + "Remote workspace search indexing is not managed by BitFun workspace search" + .to_string(), + ); + } + + state + .workspace_search_service + .rebuild_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to rebuild workspace index: {}", error)) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 1a0badce..5ca585e6 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -11,6 +11,7 @@ use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_deskt use bitfun_core::agentic::tools::computer_use_host::ComputerUseHostRef; use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::infrastructure::{get_path_manager_arc, try_get_path_manager_arc}; +use bitfun_core::service::search::get_global_workspace_search_service; use bitfun_core::service::workspace::get_global_workspace_service; use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; @@ -39,6 +40,7 @@ use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; use api::runtime_api::*; +use api::search_api::*; use api::session_api::*; use api::skill_api::*; use api::snapshot_service::*; @@ -146,7 +148,7 @@ pub async fn run() { setup_panic_hook(); - let run_result = tauri::Builder::default() + let app = tauri::Builder::default() .plugin(logging::build_log_plugin(log_targets)) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -287,22 +289,12 @@ pub async fn run() { Ok(()) }) .on_window_event({ - static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); - move |window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if let tauri::WindowEvent::CloseRequested { .. } = event { if window.label() == "main" { - if CLEANUP_DONE - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { + if perform_process_exit_cleanup() { log::info!("Main window close requested, cleaning up"); - bitfun_core::util::process_manager::cleanup_all_processes(); - api::remote_connect_api::cleanup_on_exit(); - window.app_handle().exit(0); - } else { - api.prevent_close(); } } } @@ -373,6 +365,9 @@ pub async fn run() { search_files, search_filenames, search_file_contents, + search_get_repo_status, + search_build_index, + search_rebuild_index, start_search_filenames_stream, start_search_file_contents_stream, cancel_search, @@ -728,9 +723,22 @@ pub async fn run() { api::announcement_api::trigger_announcement, api::announcement_api::get_announcement_tips, ]) - .run(tauri::generate_context!()); - if let Err(e) = run_result { - log::error!("Error while running tauri application: {}", e); + .build(tauri::generate_context!()); + + match app { + Ok(app) => { + app.run(|_app_handle, event| { + if matches!( + event, + tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit + ) { + perform_process_exit_cleanup(); + } + }); + } + Err(e) => { + log::error!("Error while running tauri application: {}", e); + } } } @@ -898,10 +906,62 @@ fn setup_panic_hook() { log::error!(" 3) Run as administrator"); } + perform_process_exit_cleanup(); std::process::exit(1); })); } +fn perform_process_exit_cleanup() -> bool { + static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); + + if CLEANUP_DONE + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return false; + } + + if let Some(search_service) = get_global_workspace_search_service() { + let shutdown_thread = std::thread::Builder::new() + .name("workspace-search-shutdown".to_string()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => { + runtime.block_on(async move { + search_service.shutdown_all_daemons().await; + }); + } + Err(error) => { + log::warn!( + "Failed to create runtime for workspace search shutdown: {}", + error + ); + } + } + }); + + match shutdown_thread { + Ok(handle) => { + if handle.join().is_err() { + log::warn!("Workspace search shutdown thread panicked"); + } + } + Err(error) => { + log::warn!( + "Failed to spawn workspace search shutdown thread: {}", + error + ); + } + } + } + bitfun_core::util::process_manager::cleanup_all_processes(); + api::remote_connect_api::cleanup_on_exit(); + true +} + fn start_event_loop_with_transport( event_queue: Arc, event_router: Arc, diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 9894cf5c..86fe33c0 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -17,7 +17,8 @@ "icons/icon.png" ], "resources": { - "../../mobile-web/dist": "mobile-web/dist" + "../../mobile-web/dist": "mobile-web/dist", + "../../../resources/codgrep": "codgrep" }, "linux": { "deb": { diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 00de893f..da84cf9a 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -1,4 +1,5 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::search::{get_global_workspace_search_service, GlobSearchRequest}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use globset::{GlobBuilder, GlobMatcher}; @@ -543,6 +544,46 @@ impl Tool for GlobTool { }]); } + if let Some(search_service) = get_global_workspace_search_service() { + let workspace_root = context + .workspace + .as_ref() + .map(|workspace| PathBuf::from(workspace.root_path_string())) + .ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when Glob path is omitted".to_string(), + ) + })?; + let resolved_path = PathBuf::from(&resolved_str); + let glob_result = search_service + .glob(GlobSearchRequest { + repo_root: workspace_root.clone(), + search_path: (resolved_path != workspace_root).then_some(resolved_path), + pattern: pattern.to_string(), + limit, + }) + .await?; + + let result_text = if glob_result.paths.is_empty() { + format!("No files found matching pattern '{}'", pattern) + } else { + glob_result.paths.join("\n") + }; + + return Ok(vec![ToolResult::Result { + data: json!({ + "pattern": pattern, + "path": resolved_str, + "matches": glob_result.paths, + "match_count": glob_result.paths.len(), + "repo_phase": glob_result.repo_status.phase, + "rebuild_recommended": glob_result.repo_status.rebuild_recommended + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]); + } + let resolved_str_for_rg = resolved_str.clone(); let pattern_for_rg = pattern.to_string(); let matches = tokio::task::spawn_blocking(move || { diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index deac3689..b5bf7a22 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -1,9 +1,16 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::search::{ + get_global_workspace_search_service, ContentSearchOutputMode, ContentSearchRequest, + WorkspaceSearchHit, WorkspaceSearchLine, +}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; +use std::collections::HashSet; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; +use std::time::Instant; use tool_runtime::search::grep_search::{ grep_search, GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, }; @@ -23,12 +30,31 @@ impl GrepTool { Self } + fn explicit_head_limit(input: &Value) -> Option> { + input + .get("head_limit") + .and_then(|v| v.as_u64()) + .map(|value| { + if value == 0 { + None + } else { + Some(value as usize) + } + }) + } + fn resolve_head_limit(input: &Value) -> Option { - match input.get("head_limit").and_then(|v| v.as_u64()) { - Some(0) => None, - Some(value) => Some(value as usize), - None => Some(DEFAULT_HEAD_LIMIT), - } + Self::explicit_head_limit(input).unwrap_or(Some(DEFAULT_HEAD_LIMIT)) + } + + fn backend_max_results( + input: &Value, + offset: usize, + _display_head_limit: Option, + ) -> Option { + Self::explicit_head_limit(input) + .flatten() + .map(|limit| limit.saturating_add(offset)) } fn shell_escape(value: &str) -> String { @@ -299,11 +325,195 @@ impl GrepTool { Ok(options) } + + fn build_workspace_search_request( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult<(ContentSearchRequest, String, bool, usize, Option)> { + let workspace_root = context + .workspace + .as_ref() + .map(|workspace| PathBuf::from(workspace.root_path_string())) + .ok_or_else(|| BitFunError::tool("Workspace is required for Grep".to_string()))?; + + let pattern = input + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; + let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); + let resolved_path = context.resolve_workspace_tool_path(search_path)?; + let resolved_path_buf = PathBuf::from(&resolved_path); + let output_mode = input + .get("output_mode") + .and_then(|v| v.as_str()) + .unwrap_or("files_with_matches") + .to_string(); + let show_line_numbers = input + .get("-n") + .and_then(|v| v.as_bool()) + .unwrap_or(output_mode == "content"); + let offset = Self::resolve_offset(input); + let head_limit = Self::resolve_head_limit(input); + let max_results = Self::backend_max_results(input, offset, head_limit); + let before_context = input.get("-B").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let after_context = input.get("-A").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let shared_context = input + .get("context") + .or_else(|| input.get("-C")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + let globs = Self::parse_glob_patterns(input.get("glob").and_then(|v| v.as_str())); + let file_types = input + .get("type") + .and_then(|v| v.as_str()) + .map(|value| vec![value.to_string()]) + .unwrap_or_default(); + let output_mode_enum = match output_mode.as_str() { + "content" => ContentSearchOutputMode::Content, + "count" => ContentSearchOutputMode::Count, + _ => ContentSearchOutputMode::FilesWithMatches, + }; + let request = ContentSearchRequest { + repo_root: workspace_root.clone(), + search_path: (resolved_path_buf != workspace_root).then_some(resolved_path_buf), + pattern: pattern.to_string(), + output_mode: output_mode_enum, + case_sensitive: !input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false), + use_regex: true, + whole_word: false, + multiline: input + .get("multiline") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + before_context: if shared_context > 0 { + shared_context + } else { + before_context + }, + after_context: if shared_context > 0 { + shared_context + } else { + after_context + }, + max_results, + globs, + file_types, + exclude_file_types: Vec::new(), + }; + + Ok((request, output_mode, show_line_numbers, offset, head_limit)) + } + + fn format_workspace_search_output( + &self, + output_mode: &str, + show_line_numbers: bool, + offset: usize, + head_limit: Option, + result: &crate::service::search::ContentSearchResult, + display_base: Option<&str>, + ) -> (String, usize, usize) { + match output_mode { + "content" => { + let mut lines = + render_workspace_search_content_lines(&result.hits, show_line_numbers); + apply_offset_and_limit(&mut lines, offset, head_limit); + let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + let file_count = result + .hits + .iter() + .map(|hit| hit.path.as_str()) + .collect::>() + .len(); + (rendered, file_count, result.matched_occurrences) + } + "count" => { + let mut lines = result + .file_counts + .iter() + .map(|count| format!("{}:{}", count.path, count.matched_lines)) + .collect::>(); + lines.sort(); + let mut lines = lines.into_iter().collect::>(); + apply_offset_and_limit(&mut lines, offset, head_limit); + let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + (rendered, result.file_counts.len(), result.matched_lines) + } + _ => { + let mut files = result + .outcome + .results + .iter() + .map(|item| item.path.clone()) + .collect::>(); + files.sort(); + files.dedup(); + apply_offset_and_limit(&mut files, offset, head_limit); + let rendered = Self::relativize_result_text(&files.join("\n"), display_base); + let total_matches = files.len(); + (rendered, total_matches, total_matches) + } + } + } +} + +fn render_workspace_search_content_lines( + hits: &[WorkspaceSearchHit], + show_line_numbers: bool, +) -> Vec { + let mut lines = Vec::new(); + for hit in hits { + for line in &hit.lines { + match line { + WorkspaceSearchLine::Match { value } => { + let snippet = value.snippet.trim_end(); + if show_line_numbers { + lines.push(format!("{}:{}:{}", hit.path, value.location.line, snippet)); + } else { + lines.push(format!("{}:{}", hit.path, snippet)); + } + } + WorkspaceSearchLine::Context { value } => { + let snippet = value.snippet.trim_end(); + if show_line_numbers { + lines.push(format!("{}-{}:{}", hit.path, value.line_number, snippet)); + } else { + lines.push(format!("{}-{}", hit.path, snippet)); + } + } + WorkspaceSearchLine::ContextBreak => lines.push("--".to_string()), + } + } + } + lines +} + +fn apply_offset_and_limit(items: &mut Vec, offset: usize, head_limit: Option) { + if offset > 0 { + if offset >= items.len() { + items.clear(); + } else { + *items = items[offset..].to_vec(); + } + } + + if let Some(limit) = head_limit { + if items.len() > limit { + items.truncate(limit); + } + } } #[cfg(test)] mod tests { - use super::{GrepTool, DEFAULT_HEAD_LIMIT}; + use super::{render_workspace_search_content_lines, GrepTool, DEFAULT_HEAD_LIMIT}; + use crate::infrastructure::FileSearchOutcome; + use crate::service::search::{ + ContentSearchResult, WorkspaceSearchBackend, WorkspaceSearchHit, WorkspaceSearchLine, + WorkspaceSearchMatch, WorkspaceSearchMatchLocation, WorkspaceSearchRepoPhase, + WorkspaceSearchRepoStatus, + }; use serde_json::json; #[test] @@ -322,6 +532,22 @@ mod tests { ); } + #[test] + fn backend_max_results_only_uses_explicit_limit() { + assert_eq!( + GrepTool::backend_max_results(&json!({}), 0, Some(DEFAULT_HEAD_LIMIT)), + None + ); + assert_eq!( + GrepTool::backend_max_results(&json!({ "head_limit": 25 }), 3, Some(25)), + Some(28) + ); + assert_eq!( + GrepTool::backend_max_results(&json!({ "head_limit": 0 }), 7, None), + None + ); + } + #[test] fn relativizes_prefixed_result_lines() { let text = "/repo/src/main.rs:12:fn main()\n/repo/src/lib.rs:3:pub fn lib()"; @@ -332,6 +558,146 @@ mod tests { "src/main.rs:12:fn main()\nsrc/lib.rs:3:pub fn lib()" ); } + + #[test] + fn renders_workspace_search_context_lines_in_rg_style() { + let lines = render_workspace_search_content_lines( + &[WorkspaceSearchHit { + path: "/repo/src/main.rs".to_string(), + matches: vec![WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }], + lines: vec![ + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 10, + snippet: "let a = 1".to_string(), + }, + }, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 11, + snippet: "let b = 2".to_string(), + }, + }, + WorkspaceSearchLine::Match { + value: WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }, + }, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 13, + snippet: "cleanup()".to_string(), + }, + }, + WorkspaceSearchLine::ContextBreak, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 20, + snippet: "return".to_string(), + }, + }, + ], + }], + true, + ); + + assert_eq!( + lines, + vec![ + "/repo/src/main.rs-10:let a = 1", + "/repo/src/main.rs-11:let b = 2", + "/repo/src/main.rs:12:panic!(\"x\")", + "/repo/src/main.rs-13:cleanup()", + "--", + "/repo/src/main.rs-20:return", + ] + ); + } + + #[test] + fn content_workspace_output_uses_hits_for_context_lines() { + let tool = GrepTool::new(); + let result = ContentSearchResult { + outcome: FileSearchOutcome { + results: Vec::new(), + truncated: false, + }, + file_counts: Vec::new(), + hits: vec![WorkspaceSearchHit { + path: "/repo/src/main.rs".to_string(), + matches: vec![WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }], + lines: vec![ + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 11, + snippet: "let b = 2".to_string(), + }, + }, + WorkspaceSearchLine::Match { + value: WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }, + }, + ], + }], + backend: WorkspaceSearchBackend::Indexed, + repo_status: WorkspaceSearchRepoStatus { + repo_id: "repo".to_string(), + repo_path: "/repo".to_string(), + index_path: "/repo/.bitfun/search/codgrep-index".to_string(), + phase: WorkspaceSearchRepoPhase::Ready, + snapshot_key: None, + last_probe_unix_secs: None, + last_rebuild_unix_secs: None, + dirty_files: crate::service::search::WorkspaceSearchDirtyFiles { + modified: 0, + deleted: 0, + new: 0, + }, + rebuild_recommended: false, + active_task_id: None, + watcher_healthy: true, + last_error: None, + }, + candidate_docs: 1, + matched_lines: 1, + matched_occurrences: 1, + }; + + let (rendered, file_count, total_matches) = + tool.format_workspace_search_output("content", true, 0, None, &result, Some("/repo")); + + assert_eq!( + rendered, + "src/main.rs-11:let b = 2\nsrc/main.rs:12:panic!(\"x\")" + ); + assert_eq!(file_count, 1); + assert_eq!(total_matches, 1); + } } #[async_trait] @@ -395,7 +761,7 @@ Usage: } fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true + false } fn needs_permissions(&self, _input: Option<&Value>) -> bool { @@ -450,6 +816,68 @@ Usage: return self.call_remote(input, context).await; } + if let Some(search_service) = get_global_workspace_search_service() { + let (request, output_mode, show_line_numbers, offset, head_limit) = + self.build_workspace_search_request(input, context)?; + let pattern = request.pattern.clone(); + let search_mode = request.output_mode.search_mode(); + let path = request + .search_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| request.repo_root.to_string_lossy().to_string()); + let search_started_at = Instant::now(); + let search_result = search_service.search_content(request).await?; + let display_base = Self::display_base(context); + let (result_text, file_count, total_matches) = self.format_workspace_search_output( + &output_mode, + show_line_numbers, + offset, + head_limit, + &search_result, + display_base.as_deref(), + ); + let workspace_search_elapsed_ms = search_started_at.elapsed().as_millis(); + + log::info!( + "Grep tool workspace-search result: pattern={}, path={}, output_mode={}, search_mode={:?}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", + pattern, + path, + output_mode, + search_mode, + file_count, + total_matches, + search_result.backend, + search_result.repo_status.phase, + search_result.repo_status.rebuild_recommended, + search_result.repo_status.dirty_files.modified, + search_result.repo_status.dirty_files.deleted, + search_result.repo_status.dirty_files.new, + search_result.candidate_docs, + search_result.matched_lines, + search_result.matched_occurrences, + workspace_search_elapsed_ms, + ); + + return Ok(vec![ToolResult::Result { + data: json!({ + "pattern": pattern, + "path": path, + "output_mode": output_mode, + "file_count": file_count, + "total_matches": total_matches, + "backend": search_result.backend, + "repo_phase": search_result.repo_status.phase, + "rebuild_recommended": search_result.repo_status.rebuild_recommended, + "applied_limit": head_limit, + "applied_offset": if offset > 0 { Some(offset) } else { None:: }, + "result": result_text, + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]); + } + let grep_options = self.build_grep_options(input, context)?; let pattern = grep_options.pattern.clone(); let path = grep_options.path.clone(); diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 63c7c05e..23f899d2 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -20,6 +20,7 @@ pub mod project_context; // Project context management pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) pub mod runtime; // Managed runtime and capability management +pub mod search; // Workspace search via managed codgrep daemon pub mod session; // Session persistence pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution @@ -53,6 +54,15 @@ pub use lsp::LspManager; pub use mcp::MCPService; pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; +pub use search::{ + get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, + ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, + WorkspaceIndexStatus, WorkspaceSearchBackend, WorkspaceSearchContextLine, + WorkspaceSearchDirtyFiles, WorkspaceSearchFileCount, WorkspaceSearchHit, WorkspaceSearchLine, + WorkspaceSearchMatch, WorkspaceSearchMatchLocation, WorkspaceSearchRepoPhase, + WorkspaceSearchRepoStatus, WorkspaceSearchService, WorkspaceSearchTaskKind, + WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, WorkspaceSearchTaskStatus, +}; pub use snapshot::SnapshotService; pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, diff --git a/src/crates/core/src/service/search/codgrep/daemon/managed.rs b/src/crates/core/src/service/search/codgrep/daemon/managed.rs new file mode 100644 index 00000000..d0d0ba91 --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/daemon/managed.rs @@ -0,0 +1,271 @@ +use std::{ + ffi::OsString, + fs::{self, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Write}, + net::TcpStream, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +use serde::Deserialize; + +use crate::service::search::codgrep::error::{AppError, Result}; + +use super::protocol::{OpenRepoParams, Request, RequestEnvelope, Response, ResponseEnvelope}; + +const DEFAULT_DAEMON_STATE_FILE: &str = "daemon-state.json"; +const DEFAULT_DAEMON_START_LOCK_FILE: &str = "daemon-state.lock"; +const MIN_STALE_STARTUP_LOCK_AGE: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone)] +pub struct ManagedDaemonClient { + daemon_program: Option, + start_timeout: Duration, + retry_interval: Duration, +} + +#[derive(Debug, Clone)] +pub struct OpenedRepo { + pub addr: String, + pub repo_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct DaemonStateFile { + addr: String, +} + +impl Default for ManagedDaemonClient { + fn default() -> Self { + Self { + daemon_program: None, + start_timeout: Duration::from_secs(10), + retry_interval: Duration::from_millis(100), + } + } +} + +impl ManagedDaemonClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_daemon_program(mut self, program: impl Into) -> Self { + self.daemon_program = Some(program.into()); + self + } + + pub fn with_start_timeout(mut self, timeout: Duration) -> Self { + self.start_timeout = timeout; + self + } + + pub fn with_retry_interval(mut self, interval: Duration) -> Self { + self.retry_interval = interval; + self + } + + pub fn open_repo(&self, params: OpenRepoParams) -> Result { + let state_file = daemon_state_file_path_from_open(¶ms); + let lock_file = daemon_start_lock_file_path(&state_file); + if let Ok(repo) = self.try_open_repo(&state_file, ¶ms) { + return Ok(repo); + } + + let started = Instant::now(); + loop { + if let Ok(repo) = self.try_open_repo(&state_file, ¶ms) { + return Ok(repo); + } + + if let Some(_guard) = + self.try_acquire_startup_lock(&lock_file, MIN_STALE_STARTUP_LOCK_AGE)? + { + if let Ok(repo) = self.try_open_repo(&state_file, ¶ms) { + return Ok(repo); + } + self.spawn_daemon(&state_file)?; + loop { + match self.try_open_repo(&state_file, ¶ms) { + Ok(repo) => return Ok(repo), + Err(error) if started.elapsed() < self.start_timeout => { + let _ = error; + std::thread::sleep(self.retry_interval); + } + Err(error) => return Err(error), + } + } + } + + match self.try_open_repo(&state_file, ¶ms) { + Ok(repo) => return Ok(repo), + Err(error) if started.elapsed() < self.start_timeout => { + let _ = error; + std::thread::sleep(self.retry_interval); + } + Err(error) => return Err(error), + } + } + } + + fn try_open_repo(&self, state_file: &Path, params: &OpenRepoParams) -> Result { + let state = read_state_file(state_file)?; + match send_request(&state.addr, Request::OpenRepo { params: params.clone() })? { + Response::RepoOpened { repo_id, status: _ } => Ok(OpenedRepo { + addr: state.addr, + repo_id, + }), + other => Err(AppError::Protocol(format!( + "unexpected open_repo response: {other:?}" + ))), + } + } + + fn try_acquire_startup_lock( + &self, + lock_file: &Path, + stale_after: Duration, + ) -> Result> { + if let Some(parent) = lock_file.parent() { + fs::create_dir_all(parent)?; + } + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(lock_file) + { + Ok(mut file) => { + let _ = writeln!(file, "pid={}", std::process::id()); + Ok(Some(StartupLockGuard { + path: lock_file.to_path_buf(), + })) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if startup_lock_is_stale(lock_file, stale_after) { + match fs::remove_file(lock_file) { + Ok(()) => self.try_acquire_startup_lock(lock_file, stale_after), + Err(remove_error) + if remove_error.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(remove_error) => Err(remove_error.into()), + } + } else { + Ok(None) + } + } + Err(error) => Err(error.into()), + } + } + + fn spawn_daemon(&self, state_file: &Path) -> Result<()> { + if state_file.exists() { + fs::remove_file(state_file)?; + } + + let program = self + .daemon_program + .clone() + .or_else(|| std::env::var_os("CODGREP_DAEMON_BIN")) + .unwrap_or_else(|| OsString::from("cg")); + + let mut command = Command::new(program); + command + .arg("serve") + .arg("--bind") + .arg("127.0.0.1:0") + .arg("--state-file") + .arg(state_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.spawn()?; + Ok(()) + } +} + +struct StartupLockGuard { + path: PathBuf, +} + +impl Drop for StartupLockGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn read_state_file(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents) + .map_err(|error| AppError::Protocol(format!("invalid daemon state file: {error}"))) +} + +fn send_request(addr: &str, request: Request) -> Result { + let envelope = RequestEnvelope { + jsonrpc: "2.0".into(), + id: Some(1), + request, + }; + + let stream = TcpStream::connect(addr)?; + let reader_stream = stream.try_clone()?; + let mut reader = BufReader::new(reader_stream); + let mut writer = BufWriter::new(stream); + + serde_json::to_writer(&mut writer, &envelope) + .map_err(|error| AppError::Protocol(format!("failed to encode request: {error}")))?; + writer.write_all(b"\n")?; + writer.flush()?; + + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Err(AppError::Protocol( + "daemon closed connection without a response".into(), + )); + } + + let response: ResponseEnvelope = serde_json::from_str(&line) + .map_err(|error| AppError::Protocol(format!("failed to decode response: {error}")))?; + + if response.jsonrpc != "2.0" { + return Err(AppError::Protocol(format!( + "unsupported daemon jsonrpc version: {}", + response.jsonrpc + ))); + } + + if let Some(error) = response.error { + return Err(AppError::Protocol(error.message)); + } + + response + .result + .ok_or_else(|| AppError::Protocol("daemon response missing result".into())) +} + +fn daemon_state_file_path_from_open(params: &OpenRepoParams) -> PathBuf { + let index_path = params + .index_path + .clone() + .unwrap_or_else(|| params.repo_path.join(".codgrep-index")); + index_path.join(DEFAULT_DAEMON_STATE_FILE) +} + +fn daemon_start_lock_file_path(state_file: &Path) -> PathBuf { + state_file + .parent() + .map(|parent| parent.join(DEFAULT_DAEMON_START_LOCK_FILE)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_DAEMON_START_LOCK_FILE)) +} + +fn startup_lock_is_stale(path: &Path, stale_after: Duration) -> bool { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= stale_after) +} diff --git a/src/crates/core/src/service/search/codgrep/daemon/mod.rs b/src/crates/core/src/service/search/codgrep/daemon/mod.rs new file mode 100644 index 00000000..a97f467b --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/daemon/mod.rs @@ -0,0 +1,4 @@ +mod managed; +pub mod protocol; + +pub use managed::{ManagedDaemonClient, OpenedRepo}; diff --git a/src/crates/core/src/service/search/codgrep/daemon/protocol.rs b/src/crates/core/src/service/search/codgrep/daemon/protocol.rs new file mode 100644 index 00000000..b1244621 --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/daemon/protocol.rs @@ -0,0 +1,416 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +fn default_jsonrpc_version() -> String { + "2.0".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestEnvelope { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(flatten)] + pub request: Request, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", rename_all = "snake_case")] +pub enum Request { + #[serde(rename = "index/build")] + IndexBuild { params: RepoRef }, + #[serde(rename = "index/rebuild")] + IndexRebuild { params: RepoRef }, + #[serde(rename = "task/status")] + TaskStatus { params: TaskRef }, + OpenRepo { params: OpenRepoParams }, + GetRepoStatus { params: RepoRef }, + Search { params: SearchParams }, + Glob { params: GlobParams }, + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoRef { + pub repo_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskRef { + pub task_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenRepoParams { + pub repo_path: PathBuf, + pub index_path: Option, + #[serde(default)] + pub config: RepoConfig, + #[serde(default)] + pub refresh: RefreshPolicyConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchParams { + pub repo_id: String, + pub query: QuerySpec, + #[serde(default)] + pub scope: PathScope, + #[serde(default)] + pub consistency: ConsistencyMode, + #[serde(default)] + pub allow_scan_fallback: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobParams { + pub repo_id: String, + #[serde(default)] + pub scope: PathScope, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuerySpec { + pub pattern: String, + #[serde(default)] + pub patterns: Vec, + #[serde(default)] + pub case_insensitive: bool, + #[serde(default)] + pub multiline: bool, + #[serde(default)] + pub dot_matches_new_line: bool, + #[serde(default)] + pub fixed_strings: bool, + #[serde(default)] + pub word_regexp: bool, + #[serde(default)] + pub line_regexp: bool, + #[serde(default)] + pub before_context: usize, + #[serde(default)] + pub after_context: usize, + #[serde(default = "default_top_k_tokens")] + pub top_k_tokens: usize, + #[serde(default)] + pub max_count: Option, + #[serde(default)] + pub global_max_results: Option, + #[serde(default)] + pub search_mode: SearchModeConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PathScope { + #[serde(default)] + pub roots: Vec, + #[serde(default)] + pub globs: Vec, + #[serde(default)] + pub iglobs: Vec, + #[serde(default)] + pub type_add: Vec, + #[serde(default)] + pub type_clear: Vec, + #[serde(default)] + pub types: Vec, + #[serde(default)] + pub type_not: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoConfig { + #[serde(default)] + pub tokenizer: TokenizerModeConfig, + #[serde(default)] + pub corpus_mode: CorpusModeConfig, + #[serde(default)] + pub include_hidden: bool, + #[serde(default = "default_max_file_size")] + pub max_file_size: u64, + #[serde(default = "default_min_sparse_len")] + pub min_sparse_len: usize, + #[serde(default = "default_max_sparse_len")] + pub max_sparse_len: usize, +} + +impl Default for RepoConfig { + fn default() -> Self { + Self { + tokenizer: TokenizerModeConfig::default(), + corpus_mode: CorpusModeConfig::default(), + include_hidden: false, + max_file_size: default_max_file_size(), + min_sparse_len: default_min_sparse_len(), + max_sparse_len: default_max_sparse_len(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshPolicyConfig { + #[serde(default = "default_rebuild_dirty_threshold")] + pub rebuild_dirty_threshold: usize, +} + +impl Default for RefreshPolicyConfig { + fn default() -> Self { + Self { + rebuild_dirty_threshold: default_rebuild_dirty_threshold(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TokenizerModeConfig { + Trigram, + #[default] + SparseNgram, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CorpusModeConfig { + #[default] + RespectIgnore, + NoIgnore, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SearchModeConfig { + CountOnly, + CountMatches, + FirstHitOnly, + #[default] + MaterializeMatches, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ConsistencyMode { + SnapshotOnly, + #[default] + WorkspaceEventual, + WorkspaceStrict, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResponseEnvelope { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Response { + RepoOpened { + repo_id: String, + status: RepoStatus, + }, + RepoStatus { + status: RepoStatus, + }, + TaskStarted { + task: TaskStatus, + }, + TaskStatus { + task: TaskStatus, + }, + SearchCompleted { + repo_id: String, + backend: SearchBackend, + consistency_applied: ConsistencyMode, + status: RepoStatus, + results: SearchResults, + }, + GlobCompleted { + repo_id: String, + status: RepoStatus, + paths: Vec, + }, + ShutdownAck, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoStatus { + pub repo_id: String, + pub repo_path: String, + pub index_path: String, + pub phase: RepoPhase, + pub snapshot_key: Option, + pub last_probe_unix_secs: Option, + pub last_rebuild_unix_secs: Option, + pub dirty_files: DirtyFileStats, + pub rebuild_recommended: bool, + pub active_task_id: Option, + pub watcher_healthy: bool, + pub last_error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepoPhase { + Opening, + MissingIndex, + Indexing, + ReadyClean, + ReadyDirty, + Rebuilding, + Degraded, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirtyFileStats { + pub modified: usize, + pub deleted: usize, + pub new: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskStatus { + pub task_id: String, + pub workspace_id: String, + pub kind: TaskKind, + pub state: TaskState, + pub phase: Option, + pub message: String, + pub processed: usize, + pub total: Option, + pub started_unix_secs: u64, + pub updated_unix_secs: u64, + pub finished_unix_secs: Option, + pub cancellable: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskKind { + BuildIndex, + RebuildIndex, + RefreshWorkspace, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskState { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskPhase { + Scanning, + Tokenizing, + Writing, + Finalizing, + RefreshingOverlay, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchBackend { + IndexedSnapshot, + IndexedClean, + IndexedWorkspaceRepair, + RgFallback, + ScanFallback, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResults { + pub candidate_docs: usize, + pub searches_with_match: usize, + pub bytes_searched: u64, + pub matched_lines: usize, + pub matched_occurrences: usize, + #[serde(default)] + pub file_counts: Vec, + #[serde(default)] + pub file_match_counts: Vec, + pub hits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileCount { + pub path: String, + pub matched_lines: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileMatchCount { + pub path: String, + pub matched_occurrences: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchHit { + pub path: String, + pub matches: Vec, + pub lines: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileMatch { + pub location: MatchLocation, + pub snippet: String, + pub matched_text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchLocation { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SearchLine { + Match { value: FileMatch }, + Context { line_number: usize, snippet: String }, + ContextBreak, +} + +fn default_top_k_tokens() -> usize { + 6 +} + +fn default_max_file_size() -> u64 { + 2 * 1024 * 1024 +} + +fn default_min_sparse_len() -> usize { + 3 +} + +fn default_max_sparse_len() -> usize { + 8 +} + +fn default_rebuild_dirty_threshold() -> usize { + 256 +} diff --git a/src/crates/core/src/service/search/codgrep/error.rs b/src/crates/core/src/service/search/codgrep/error.rs new file mode 100644 index 00000000..edd5c0f2 --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/error.rs @@ -0,0 +1,13 @@ +use std::io; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("protocol error: {0}")] + Protocol(String), +} + +pub type Result = std::result::Result; diff --git a/src/crates/core/src/service/search/codgrep/mod.rs b/src/crates/core/src/service/search/codgrep/mod.rs new file mode 100644 index 00000000..69272d09 --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/mod.rs @@ -0,0 +1,3 @@ +pub mod daemon; +pub mod error; +pub mod sdk; diff --git a/src/crates/core/src/service/search/codgrep/sdk/mod.rs b/src/crates/core/src/service/search/codgrep/sdk/mod.rs new file mode 100644 index 00000000..b0fb8c9a --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/sdk/mod.rs @@ -0,0 +1,70 @@ +pub mod tokio; + +pub use crate::service::search::codgrep::daemon::protocol::{ + ConsistencyMode, DirtyFileStats, FileCount, OpenRepoParams, PathScope, QuerySpec, + RefreshPolicyConfig, RepoConfig, RepoPhase, RepoStatus, SearchBackend, SearchModeConfig, + SearchResults, TaskKind, TaskPhase, TaskState, TaskStatus, +}; + +#[derive(Debug, Clone)] +pub struct SearchRequest { + pub query: QuerySpec, + pub scope: PathScope, + pub consistency: ConsistencyMode, + pub allow_scan_fallback: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct GlobRequest { + pub scope: PathScope, +} + +#[derive(Debug, Clone)] +pub struct SearchOutcome { + pub backend: SearchBackend, + pub status: RepoStatus, + pub results: SearchResults, +} + +#[derive(Debug, Clone)] +pub struct GlobOutcome { + pub status: RepoStatus, + pub paths: Vec, +} + +impl SearchRequest { + pub fn new(query: QuerySpec) -> Self { + Self { + query, + scope: PathScope::default(), + consistency: ConsistencyMode::WorkspaceEventual, + allow_scan_fallback: false, + } + } + + pub fn with_scope(mut self, scope: PathScope) -> Self { + self.scope = scope; + self + } + + pub fn with_consistency(mut self, consistency: ConsistencyMode) -> Self { + self.consistency = consistency; + self + } + + pub fn with_scan_fallback(mut self, allow_scan_fallback: bool) -> Self { + self.allow_scan_fallback = allow_scan_fallback; + self + } +} + +impl GlobRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn with_scope(mut self, scope: PathScope) -> Self { + self.scope = scope; + self + } +} diff --git a/src/crates/core/src/service/search/codgrep/sdk/tokio.rs b/src/crates/core/src/service/search/codgrep/sdk/tokio.rs new file mode 100644 index 00000000..8f431d7a --- /dev/null +++ b/src/crates/core/src/service/search/codgrep/sdk/tokio.rs @@ -0,0 +1,332 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}, + net::{ + tcp::{OwnedReadHalf, OwnedWriteHalf}, + TcpStream, + }, + sync::Mutex, + task, +}; + +use crate::service::search::codgrep::{ + daemon::{ + protocol::{ + GlobParams, RepoRef, Request, RequestEnvelope, Response, ResponseEnvelope, SearchParams, + TaskRef, + }, + ManagedDaemonClient, OpenedRepo, + }, + error::{AppError, Result}, + sdk::{GlobOutcome, GlobRequest, OpenRepoParams, RepoStatus, SearchOutcome, SearchRequest, TaskStatus}, +}; + +#[derive(Debug, Clone)] +pub struct ManagedClient { + inner: ManagedDaemonClient, +} + +#[derive(Debug)] +pub struct RepoSession { + repo_id: String, + client: AsyncDaemonClient, +} + +#[derive(Debug)] +struct AsyncDaemonClient { + addr: String, + next_id: AtomicU64, + connection: Mutex>, +} + +#[derive(Debug)] +struct AsyncDaemonConnection { + reader: BufReader, + writer: BufWriter, +} + +impl Default for ManagedClient { + fn default() -> Self { + Self { + inner: ManagedDaemonClient::new(), + } + } +} + +impl ManagedClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_daemon_program(mut self, program: impl Into) -> Self { + self.inner = self.inner.with_daemon_program(program); + self + } + + pub fn with_start_timeout(mut self, timeout: std::time::Duration) -> Self { + self.inner = self.inner.with_start_timeout(timeout); + self + } + + pub fn with_retry_interval(mut self, interval: std::time::Duration) -> Self { + self.inner = self.inner.with_retry_interval(interval); + self + } + + pub async fn open_repo(&self, params: OpenRepoParams) -> Result { + let inner = self.inner.clone(); + let opened = task::spawn_blocking(move || inner.open_repo(params)) + .await + .map_err(|error| AppError::Protocol(format!("async open_repo task failed: {error}")))??; + Ok(RepoSession::from_opened(opened)) + } +} + +impl RepoSession { + fn from_opened(opened: OpenedRepo) -> Self { + Self { + client: AsyncDaemonClient::new(opened.addr), + repo_id: opened.repo_id, + } + } + + pub async fn status(&self) -> Result { + match self + .client + .get_repo_status_isolated(self.repo_id.clone()) + .await? + { + Response::RepoStatus { status } => Ok(status), + other => unexpected_response("get_repo_status", other), + } + } + + pub async fn search(&self, request: SearchRequest) -> Result { + match self + .client + .search(SearchParams { + repo_id: self.repo_id.clone(), + query: request.query, + scope: request.scope, + consistency: request.consistency, + allow_scan_fallback: request.allow_scan_fallback, + }) + .await? + { + Response::SearchCompleted { + repo_id: _, + backend, + consistency_applied: _, + status, + results, + } => Ok(SearchOutcome { + backend, + status, + results, + }), + other => unexpected_response("search", other), + } + } + + pub async fn glob(&self, request: GlobRequest) -> Result { + match self + .client + .glob(GlobParams { + repo_id: self.repo_id.clone(), + scope: request.scope, + }) + .await? + { + Response::GlobCompleted { + repo_id: _, + status, + paths, + } => Ok(GlobOutcome { + status, + paths, + }), + other => unexpected_response("glob", other), + } + } + + pub async fn index_build(&self) -> Result { + match self.client.index_build(self.repo_id.clone()).await? { + Response::TaskStarted { task } => Ok(task), + other => unexpected_response("index/build", other), + } + } + + pub async fn index_rebuild(&self) -> Result { + match self.client.index_rebuild(self.repo_id.clone()).await? { + Response::TaskStarted { task } => Ok(task), + other => unexpected_response("index/rebuild", other), + } + } + + pub async fn task_status(&self, task_id: impl Into) -> Result { + match self.client.task_status(task_id).await? { + Response::TaskStatus { task } => Ok(task), + other => unexpected_response("task/status", other), + } + } +} + +impl AsyncDaemonClient { + fn new(addr: impl Into) -> Self { + Self { + addr: addr.into(), + next_id: AtomicU64::new(1), + connection: Mutex::new(None), + } + } + + async fn search(&self, params: SearchParams) -> Result { + self.send_isolated(Request::Search { params }).await + } + + async fn glob(&self, params: GlobParams) -> Result { + self.send(Request::Glob { params }).await + } + + async fn get_repo_status_isolated(&self, repo_id: impl Into) -> Result { + self.send_isolated(Request::GetRepoStatus { + params: RepoRef { + repo_id: repo_id.into(), + }, + }) + .await + } + + async fn index_build(&self, repo_id: impl Into) -> Result { + self.send(Request::IndexBuild { + params: RepoRef { + repo_id: repo_id.into(), + }, + }) + .await + } + + async fn index_rebuild(&self, repo_id: impl Into) -> Result { + self.send(Request::IndexRebuild { + params: RepoRef { + repo_id: repo_id.into(), + }, + }) + .await + } + + async fn task_status(&self, task_id: impl Into) -> Result { + self.send(Request::TaskStatus { + params: TaskRef { + task_id: task_id.into(), + }, + }) + .await + } + + async fn send(&self, request: Request) -> Result { + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let envelope = RequestEnvelope { + jsonrpc: "2.0".into(), + id: Some(request_id), + request, + }; + + let mut connection = self.connection.lock().await; + let response = match self.send_with_connection(&mut connection, &envelope).await { + Ok(response) => response, + Err(_) => { + *connection = None; + self.send_with_connection(&mut connection, &envelope).await? + } + }; + + decode_response(request_id, response) + } + + async fn send_isolated(&self, request: Request) -> Result { + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let envelope = RequestEnvelope { + jsonrpc: "2.0".into(), + id: Some(request_id), + request, + }; + + let mut connection = Some(self.connect().await?); + let response = self.send_with_connection(&mut connection, &envelope).await?; + decode_response(request_id, response) + } + + async fn send_with_connection( + &self, + connection: &mut Option, + envelope: &RequestEnvelope, + ) -> Result { + let connection = match connection { + Some(connection) => connection, + None => { + *connection = Some(self.connect().await?); + connection + .as_mut() + .expect("connection must exist after successful connect") + } + }; + + let payload = serde_json::to_vec(envelope) + .map_err(|error| AppError::Protocol(format!("failed to encode request: {error}")))?; + connection.writer.write_all(&payload).await?; + connection.writer.write_all(b"\n").await?; + connection.writer.flush().await?; + + let mut line = String::new(); + let read = connection.reader.read_line(&mut line).await?; + if read == 0 { + return Err(AppError::Protocol( + "daemon closed connection without a response".into(), + )); + } + + serde_json::from_str(&line) + .map_err(|error| AppError::Protocol(format!("failed to decode response: {error}"))) + } + + async fn connect(&self) -> Result { + let stream = TcpStream::connect(&self.addr).await?; + let (reader, writer) = stream.into_split(); + Ok(AsyncDaemonConnection { + reader: BufReader::new(reader), + writer: BufWriter::new(writer), + }) + } +} + +fn decode_response(request_id: u64, response: ResponseEnvelope) -> Result { + if response.id != Some(request_id) { + return Err(AppError::Protocol(format!( + "daemon response id mismatch: expected {request_id:?}, got {:?}", + response.id + ))); + } + + if response.jsonrpc != "2.0" { + return Err(AppError::Protocol(format!( + "unsupported daemon jsonrpc version: {}", + response.jsonrpc + ))); + } + + if let Some(error) = response.error { + return Err(AppError::Protocol(error.message)); + } + + response + .result + .ok_or_else(|| AppError::Protocol("daemon response missing result".into())) +} + +fn unexpected_response(method: &str, response: Response) -> Result { + Err(AppError::Protocol(format!( + "unexpected {method} response: {response:?}" + ))) +} diff --git a/src/crates/core/src/service/search/mod.rs b/src/crates/core/src/service/search/mod.rs new file mode 100644 index 00000000..db7014f3 --- /dev/null +++ b/src/crates/core/src/service/search/mod.rs @@ -0,0 +1,16 @@ +pub(crate) mod codgrep; +pub mod service; +pub mod types; + +pub use service::{ + get_global_workspace_search_service, set_global_workspace_search_service, + WorkspaceSearchService, +}; +pub use types::{ + ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, + GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchBackend, + WorkspaceSearchContextLine, WorkspaceSearchDirtyFiles, WorkspaceSearchFileCount, + WorkspaceSearchHit, WorkspaceSearchLine, WorkspaceSearchMatch, WorkspaceSearchMatchLocation, + WorkspaceSearchRepoPhase, WorkspaceSearchRepoStatus, WorkspaceSearchTaskKind, + WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, WorkspaceSearchTaskStatus, +}; diff --git a/src/crates/core/src/service/search/service.rs b/src/crates/core/src/service/search/service.rs new file mode 100644 index 00000000..71279371 --- /dev/null +++ b/src/crates/core/src/service/search/service.rs @@ -0,0 +1,608 @@ +use crate::infrastructure::{FileSearchOutcome, FileSearchResult, SearchMatchType}; +use crate::service::search::codgrep::sdk::tokio::{ManagedClient, RepoSession}; +use crate::service::search::codgrep::sdk::{ + ConsistencyMode, GlobRequest, OpenRepoParams, PathScope, QuerySpec, RefreshPolicyConfig, + RepoConfig, SearchRequest, SearchResults, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use super::types::{ + ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, + GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchFileCount, + WorkspaceSearchHit, +}; + +static GLOBAL_WORKSPACE_SEARCH_SERVICE: OnceLock> = OnceLock::new(); + +const DEFAULT_TOP_K_TOKENS: usize = 6; + +pub struct WorkspaceSearchService { + client: ManagedClient, + sessions: RwLock>>, +} + +impl WorkspaceSearchService { + pub fn new() -> Self { + let mut client = ManagedClient::new() + .with_start_timeout(Duration::from_secs(10)) + .with_retry_interval(Duration::from_millis(100)); + let program = resolve_daemon_program(); + if let Some(program) = program { + log::info!( + "WorkspaceSearchService daemon configured: program={}", + PathBuf::from(&program).display() + ); + client = client.with_daemon_program(program); + } else { + log::info!("WorkspaceSearchService daemon configured: program=cg"); + } + + Self { + client, + sessions: RwLock::new(HashMap::new()), + } + } + + pub async fn open_repo( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + self.index_status_for_session(session).await + } + + pub async fn get_index_status( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + self.index_status_for_session(session).await + } + + pub async fn build_index(&self, repo_root: impl AsRef) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + let task = session + .index_build() + .await + .map_err(map_codgrep_error("Failed to start index build"))?; + let repo_status = session + .status() + .await + .map_err(map_codgrep_error("Failed to fetch repository status"))?; + Ok(IndexTaskHandle { + task: task.into(), + repo_status: repo_status.into(), + }) + } + + pub async fn rebuild_index( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + let task = session + .index_rebuild() + .await + .map_err(map_codgrep_error("Failed to start index rebuild"))?; + let repo_status = session + .status() + .await + .map_err(map_codgrep_error("Failed to fetch repository status"))?; + Ok(IndexTaskHandle { + task: task.into(), + repo_status: repo_status.into(), + }) + } + + pub async fn search_content( + &self, + request: ContentSearchRequest, + ) -> BitFunResult { + let started_at = Instant::now(); + let pattern_for_log = abbreviate_pattern_for_log(&request.pattern); + let repo_root = normalize_repo_root(&request.repo_root)?; + let normalized_at = Instant::now(); + let scope = build_scope( + &repo_root, + request.search_path.as_deref(), + request.globs, + request.file_types, + request.exclude_file_types, + )?; + let scope_built_at = Instant::now(); + let scope_roots_count = scope.roots.len(); + let scope_globs_count = scope.globs.len(); + let scope_types_count = scope.types.len(); + let max_results = request.max_results.filter(|limit| *limit > 0); + let query = QuerySpec { + pattern: request.pattern, + patterns: Vec::new(), + case_insensitive: !request.case_sensitive, + multiline: request.multiline, + dot_matches_new_line: request.multiline, + fixed_strings: !request.use_regex, + word_regexp: request.whole_word, + line_regexp: false, + before_context: request.before_context, + after_context: request.after_context, + top_k_tokens: DEFAULT_TOP_K_TOKENS, + max_count: None, + global_max_results: max_results, + search_mode: request.output_mode.search_mode(), + }; + + let session = self.get_or_open_session(&repo_root).await?; + let session_ready_at = Instant::now(); + let search = session + .search( + SearchRequest::new(query) + .with_scope(scope) + .with_consistency(ConsistencyMode::WorkspaceEventual) + .with_scan_fallback(true), + ) + .await + .map_err(map_codgrep_error("Content search failed"))?; + let search_completed_at = Instant::now(); + + let mut results = convert_search_results(&search.results, request.output_mode); + let converted_at = Instant::now(); + let truncated = max_results + .map(|limit| results.len() >= limit) + .unwrap_or(false); + if let Some(limit) = max_results { + results.truncate(limit); + } + + let result = ContentSearchResult { + outcome: FileSearchOutcome { results, truncated }, + file_counts: search + .results + .file_counts + .clone() + .into_iter() + .map(WorkspaceSearchFileCount::from) + .collect(), + hits: search + .results + .hits + .clone() + .into_iter() + .map(WorkspaceSearchHit::from) + .collect(), + backend: search.backend.into(), + repo_status: search.status.into(), + candidate_docs: search.results.candidate_docs, + matched_lines: search.results.matched_lines, + matched_occurrences: search.results.matched_occurrences, + }; + + log::info!( + "Workspace content search completed: repo_root={}, pattern={}, output_mode={:?}, search_mode={:?}, scope_roots={}, globs={}, file_types={}, max_results={:?}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, returned_results={}, truncated={}, normalize_ms={}, build_scope_ms={}, session_ms={}, search_ms={}, convert_ms={}, total_ms={}", + repo_root.display(), + pattern_for_log, + request.output_mode, + request.output_mode.search_mode(), + scope_roots_count, + scope_globs_count, + scope_types_count, + max_results, + result.backend, + result.repo_status.phase, + result.repo_status.rebuild_recommended, + result.repo_status.dirty_files.modified, + result.repo_status.dirty_files.deleted, + result.repo_status.dirty_files.new, + result.candidate_docs, + result.matched_lines, + result.matched_occurrences, + result.outcome.results.len(), + result.outcome.truncated, + normalized_at.duration_since(started_at).as_millis(), + scope_built_at.duration_since(normalized_at).as_millis(), + session_ready_at.duration_since(scope_built_at).as_millis(), + search_completed_at.duration_since(session_ready_at).as_millis(), + converted_at.duration_since(search_completed_at).as_millis(), + converted_at.duration_since(started_at).as_millis(), + ); + + Ok(result) + } + + pub async fn glob(&self, request: GlobSearchRequest) -> BitFunResult { + let repo_root = normalize_repo_root(&request.repo_root)?; + let scope = build_scope( + &repo_root, + request.search_path.as_deref(), + vec![request.pattern], + vec![], + vec![], + )?; + let session = self.get_or_open_session(&repo_root).await?; + let mut outcome = session + .glob(GlobRequest::new().with_scope(scope)) + .await + .map_err(map_codgrep_error("Glob search failed"))?; + outcome.paths.sort(); + if request.limit > 0 { + outcome.paths.truncate(request.limit); + } else { + outcome.paths.clear(); + } + + Ok(GlobSearchResult { + paths: outcome.paths, + repo_status: outcome.status.into(), + }) + } + + pub async fn shutdown_all_daemons(&self) { + self.sessions.write().await.clear(); + } + + async fn get_or_open_session(&self, repo_root: &Path) -> BitFunResult> { + let repo_root = normalize_repo_root(repo_root)?; + if let Some(existing) = self.sessions.read().await.get(&repo_root).cloned() { + if existing.status().await.is_ok() { + return Ok(existing); + } + log::warn!( + "Workspace search session became unhealthy, reopening repository session: path={}", + repo_root.display() + ); + self.sessions.write().await.remove(&repo_root); + } + + let params = OpenRepoParams { + repo_path: repo_root.clone(), + index_path: Some(default_index_path(&repo_root)), + config: RepoConfig::default(), + refresh: RefreshPolicyConfig::default(), + }; + + let session = Arc::new( + self.client + .open_repo(params) + .await + .map_err(map_codgrep_error( + "Failed to open codgrep repository session", + ))?, + ); + + let mut sessions = self.sessions.write().await; + Ok(sessions + .entry(repo_root) + .or_insert_with(|| session.clone()) + .clone()) + } + + async fn index_status_for_session( + &self, + session: Arc, + ) -> BitFunResult { + let repo_status = session + .status() + .await + .map_err(map_codgrep_error("Failed to fetch repository status"))?; + let active_task = match repo_status.active_task_id.clone() { + Some(task_id) => match session.task_status(task_id).await { + Ok(task) => Some(task), + Err(error) => { + log::warn!("Failed to fetch active codgrep task status: {}", error); + None + } + }, + None => None, + }; + + Ok(WorkspaceIndexStatus { + repo_status: repo_status.into(), + active_task: active_task.map(Into::into), + }) + } +} + +impl Default for WorkspaceSearchService { + fn default() -> Self { + Self::new() + } +} + +pub fn set_global_workspace_search_service(service: Arc) { + let _ = GLOBAL_WORKSPACE_SEARCH_SERVICE.set(service); +} + +pub fn get_global_workspace_search_service() -> Option> { + GLOBAL_WORKSPACE_SEARCH_SERVICE.get().cloned() +} + +fn resolve_daemon_program() -> Option { + if let Some(program) = std::env::var_os("CODGREP_DAEMON_BIN") { + return Some(program); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.join("../../.."); + let binary_name = if cfg!(windows) { "cg.exe" } else { "cg" }; + let profile = std::env::var("PROFILE").ok(); + + for candidate in daemon_binary_candidates(&workspace_root, binary_name, profile.as_deref()) { + if candidate.exists() { + return Some(candidate.into_os_string()); + } + } + + which::which("cg").ok().map(|path| path.into_os_string()) +} + +fn daemon_binary_candidates( + workspace_root: &Path, + binary_name: &str, + current_profile: Option<&str>, +) -> Vec { + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_candidate = |path: PathBuf| { + if seen.insert(path.clone()) { + candidates.push(path); + } + }; + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + push_candidate(parent.join(binary_name)); + push_exe_relative_bundle_candidates(&mut push_candidate, parent, binary_name); + } + } + + for profile in current_profile + .into_iter() + .chain(["debug", "release", "release-fast"]) + { + push_candidate( + workspace_root + .join("target") + .join(profile) + .join(binary_name), + ); + } + + candidates +} + +fn push_exe_relative_bundle_candidates( + push_candidate: &mut impl FnMut(PathBuf), + exe_dir: &Path, + binary_name: &str, +) { + if cfg!(target_os = "macos") { + push_candidate(exe_dir.join("../Resources/codgrep").join(binary_name)); + } + + push_candidate(exe_dir.join("codgrep").join(binary_name)); + push_candidate(exe_dir.join("resources/codgrep").join(binary_name)); + + if cfg!(target_os = "linux") { + push_candidate(exe_dir.join("../lib/bitfun/codgrep").join(binary_name)); + push_candidate(exe_dir.join("../share/bitfun/codgrep").join(binary_name)); + push_candidate( + exe_dir + .join("../share/com.bitfun.desktop/codgrep") + .join(binary_name), + ); + } +} + +fn default_index_path(repo_root: &Path) -> PathBuf { + repo_root + .join(".bitfun") + .join("search") + .join("codgrep-index") +} + +fn abbreviate_pattern_for_log(pattern: &str) -> String { + const MAX_CHARS: usize = 120; + let mut chars = pattern.chars(); + let abbreviated: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { + format!("{}...", abbreviated) + } else { + abbreviated + } +} + +fn normalize_repo_root(repo_root: &Path) -> BitFunResult { + if !repo_root.exists() { + return Err(BitFunError::service(format!( + "Search root does not exist: {}", + repo_root.display() + ))); + } + if !repo_root.is_dir() { + return Err(BitFunError::service(format!( + "Search root is not a directory: {}", + repo_root.display() + ))); + } + + dunce::canonicalize(repo_root).map_err(|error| { + BitFunError::service(format!( + "Failed to normalize search root {}: {}", + repo_root.display(), + error + )) + }) +} + +fn build_scope( + repo_root: &Path, + search_path: Option<&Path>, + globs: Vec, + file_types: Vec, + exclude_file_types: Vec, +) -> BitFunResult { + let roots = match search_path { + Some(path) => { + let normalized = normalize_scope_path(repo_root, path)?; + if normalized == repo_root { + Vec::new() + } else { + vec![normalized] + } + } + None => Vec::new(), + }; + + Ok(PathScope { + roots, + globs, + iglobs: Vec::new(), + type_add: Vec::new(), + type_clear: Vec::new(), + types: file_types, + type_not: exclude_file_types, + }) +} + +fn normalize_scope_path(repo_root: &Path, search_path: &Path) -> BitFunResult { + let normalized = dunce::canonicalize(search_path).map_err(|error| { + BitFunError::service(format!( + "Failed to normalize search path {}: {}", + search_path.display(), + error + )) + })?; + if !normalized.starts_with(repo_root) { + return Err(BitFunError::service(format!( + "Search path is outside workspace root: {}", + normalized.display() + ))); + } + Ok(normalized) +} + +fn convert_search_results( + search_results: &SearchResults, + output_mode: ContentSearchOutputMode, +) -> Vec { + match output_mode { + ContentSearchOutputMode::Content => convert_hits_to_file_search_results(search_results), + ContentSearchOutputMode::Count => convert_file_counts_to_search_results(search_results), + ContentSearchOutputMode::FilesWithMatches => { + convert_hits_to_file_only_results(search_results) + } + } +} + +fn convert_file_counts_to_search_results(search_results: &SearchResults) -> Vec { + search_results + .file_counts + .iter() + .map(|count| FileSearchResult { + path: count.path.clone(), + name: Path::new(&count.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&count.path) + .to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: None, + matched_content: Some(count.matched_lines.to_string()), + preview_before: None, + preview_inside: None, + preview_after: None, + }) + .collect() +} + +fn convert_hits_to_file_search_results(search_results: &SearchResults) -> Vec { + let mut file_results = Vec::new(); + for hit in &search_results.hits { + let name = Path::new(&hit.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&hit.path) + .to_string(); + + let mut lines = BTreeMap::new(); + for file_match in &hit.matches { + lines + .entry(file_match.location.line) + .or_insert_with(|| file_match.clone()); + } + + for (_, file_match) in lines { + let (preview_before, preview_inside, preview_after) = + split_preview(&file_match.snippet, &file_match.matched_text); + file_results.push(FileSearchResult { + path: hit.path.clone(), + name: name.clone(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: Some(file_match.location.line), + matched_content: Some(file_match.snippet), + preview_before, + preview_inside, + preview_after, + }); + } + } + file_results +} + +fn convert_hits_to_file_only_results(search_results: &SearchResults) -> Vec { + search_results + .hits + .iter() + .map(|hit| FileSearchResult { + path: hit.path.clone(), + name: Path::new(&hit.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&hit.path) + .to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: None, + matched_content: None, + preview_before: None, + preview_inside: None, + preview_after: None, + }) + .collect() +} + +fn split_preview( + snippet: &str, + matched_text: &str, +) -> (Option, Option, Option) { + if matched_text.is_empty() { + return (None, Some(snippet.to_string()), None); + } + + if let Some(offset) = snippet.find(matched_text) { + let before = snippet[..offset].to_string(); + let inside = matched_text.to_string(); + let after = snippet[offset + matched_text.len()..].to_string(); + return ( + (!before.is_empty()).then_some(before), + Some(inside), + (!after.is_empty()).then_some(after), + ); + } + + (None, Some(snippet.to_string()), None) +} + +fn map_codgrep_error( + prefix: &'static str, +) -> impl Fn(crate::service::search::codgrep::error::AppError) -> BitFunError { + move |error| BitFunError::service(format!("{prefix}: {error}")) +} diff --git a/src/crates/core/src/service/search/types.rs b/src/crates/core/src/service/search/types.rs new file mode 100644 index 00000000..ca3643da --- /dev/null +++ b/src/crates/core/src/service/search/types.rs @@ -0,0 +1,395 @@ +use crate::infrastructure::FileSearchOutcome; +use crate::service::search::codgrep::daemon::protocol::{ + FileMatch as CodgrepFileMatch, MatchLocation as CodgrepMatchLocation, + SearchHit as CodgrepSearchHit, SearchLine as CodgrepSearchLine, +}; +use crate::service::search::codgrep::sdk::{ + DirtyFileStats as CodgrepDirtyFileStats, FileCount as CodgrepFileCount, + RepoPhase as CodgrepRepoPhase, RepoStatus as CodgrepRepoStatus, + SearchBackend as CodgrepSearchBackend, SearchModeConfig, TaskKind as CodgrepTaskKind, + TaskPhase as CodgrepTaskPhase, TaskState as CodgrepTaskState, TaskStatus as CodgrepTaskStatus, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentSearchOutputMode { + Content, + FilesWithMatches, + Count, +} + +impl ContentSearchOutputMode { + pub fn search_mode(self) -> SearchModeConfig { + match self { + Self::Content => SearchModeConfig::MaterializeMatches, + Self::Count => SearchModeConfig::CountOnly, + Self::FilesWithMatches => SearchModeConfig::FirstHitOnly, + } + } +} + +#[derive(Debug, Clone)] +pub struct ContentSearchRequest { + pub repo_root: PathBuf, + pub search_path: Option, + pub pattern: String, + pub output_mode: ContentSearchOutputMode, + pub case_sensitive: bool, + pub use_regex: bool, + pub whole_word: bool, + pub multiline: bool, + pub before_context: usize, + pub after_context: usize, + pub max_results: Option, + pub globs: Vec, + pub file_types: Vec, + pub exclude_file_types: Vec, +} + +#[derive(Debug, Clone)] +pub struct GlobSearchRequest { + pub repo_root: PathBuf, + pub search_path: Option, + pub pattern: String, + pub limit: usize, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchBackend { + Indexed, + IndexedRepair, + TextFallback, + ScanFallback, +} + +impl From for WorkspaceSearchBackend { + fn from(value: CodgrepSearchBackend) -> Self { + match value { + CodgrepSearchBackend::IndexedSnapshot | CodgrepSearchBackend::IndexedClean => { + Self::Indexed + } + CodgrepSearchBackend::IndexedWorkspaceRepair => Self::IndexedRepair, + CodgrepSearchBackend::RgFallback => Self::TextFallback, + CodgrepSearchBackend::ScanFallback => Self::ScanFallback, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchRepoPhase { + Preparing, + NeedsIndex, + Building, + Ready, + Stale, + Refreshing, + Limited, +} + +impl From for WorkspaceSearchRepoPhase { + fn from(value: CodgrepRepoPhase) -> Self { + match value { + CodgrepRepoPhase::Opening => Self::Preparing, + CodgrepRepoPhase::MissingIndex => Self::NeedsIndex, + CodgrepRepoPhase::Indexing => Self::Building, + CodgrepRepoPhase::ReadyClean => Self::Ready, + CodgrepRepoPhase::ReadyDirty => Self::Stale, + CodgrepRepoPhase::Rebuilding => Self::Refreshing, + CodgrepRepoPhase::Degraded => Self::Limited, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskKind { + Build, + Rebuild, + Refresh, +} + +impl From for WorkspaceSearchTaskKind { + fn from(value: CodgrepTaskKind) -> Self { + match value { + CodgrepTaskKind::BuildIndex => Self::Build, + CodgrepTaskKind::RebuildIndex => Self::Rebuild, + CodgrepTaskKind::RefreshWorkspace => Self::Refresh, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskState { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +impl From for WorkspaceSearchTaskState { + fn from(value: CodgrepTaskState) -> Self { + match value { + CodgrepTaskState::Queued => Self::Queued, + CodgrepTaskState::Running => Self::Running, + CodgrepTaskState::Completed => Self::Completed, + CodgrepTaskState::Failed => Self::Failed, + CodgrepTaskState::Cancelled => Self::Cancelled, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskPhase { + Discovering, + Processing, + Persisting, + Finalizing, + Refreshing, +} + +impl From for WorkspaceSearchTaskPhase { + fn from(value: CodgrepTaskPhase) -> Self { + match value { + CodgrepTaskPhase::Scanning => Self::Discovering, + CodgrepTaskPhase::Tokenizing => Self::Processing, + CodgrepTaskPhase::Writing => Self::Persisting, + CodgrepTaskPhase::Finalizing => Self::Finalizing, + CodgrepTaskPhase::RefreshingOverlay => Self::Refreshing, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchDirtyFiles { + pub modified: usize, + pub deleted: usize, + pub new: usize, +} + +impl From for WorkspaceSearchDirtyFiles { + fn from(value: CodgrepDirtyFileStats) -> Self { + Self { + modified: value.modified, + deleted: value.deleted, + new: value.new, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchRepoStatus { + pub repo_id: String, + pub repo_path: String, + pub index_path: String, + pub phase: WorkspaceSearchRepoPhase, + pub snapshot_key: Option, + pub last_probe_unix_secs: Option, + pub last_rebuild_unix_secs: Option, + pub dirty_files: WorkspaceSearchDirtyFiles, + pub rebuild_recommended: bool, + pub active_task_id: Option, + pub watcher_healthy: bool, + pub last_error: Option, +} + +impl From for WorkspaceSearchRepoStatus { + fn from(value: CodgrepRepoStatus) -> Self { + Self { + repo_id: value.repo_id, + repo_path: value.repo_path, + index_path: value.index_path, + phase: value.phase.into(), + snapshot_key: value.snapshot_key, + last_probe_unix_secs: value.last_probe_unix_secs, + last_rebuild_unix_secs: value.last_rebuild_unix_secs, + dirty_files: value.dirty_files.into(), + rebuild_recommended: value.rebuild_recommended, + active_task_id: value.active_task_id, + watcher_healthy: value.watcher_healthy, + last_error: value.last_error, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchTaskStatus { + pub task_id: String, + pub workspace_id: String, + pub kind: WorkspaceSearchTaskKind, + pub state: WorkspaceSearchTaskState, + pub phase: Option, + pub message: String, + pub processed: usize, + pub total: Option, + pub started_unix_secs: u64, + pub updated_unix_secs: u64, + pub finished_unix_secs: Option, + pub cancellable: bool, + pub error: Option, +} + +impl From for WorkspaceSearchTaskStatus { + fn from(value: CodgrepTaskStatus) -> Self { + Self { + task_id: value.task_id, + workspace_id: value.workspace_id, + kind: value.kind.into(), + state: value.state.into(), + phase: value.phase.map(Into::into), + message: value.message, + processed: value.processed, + total: value.total, + started_unix_secs: value.started_unix_secs, + updated_unix_secs: value.updated_unix_secs, + finished_unix_secs: value.finished_unix_secs, + cancellable: value.cancellable, + error: value.error, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchFileCount { + pub path: String, + pub matched_lines: usize, +} + +impl From for WorkspaceSearchFileCount { + fn from(value: CodgrepFileCount) -> Self { + Self { + path: value.path, + matched_lines: value.matched_lines, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchMatchLocation { + pub line: usize, + pub column: usize, +} + +impl From for WorkspaceSearchMatchLocation { + fn from(value: CodgrepMatchLocation) -> Self { + Self { + line: value.line, + column: value.column, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchMatch { + pub location: WorkspaceSearchMatchLocation, + pub snippet: String, + pub matched_text: String, +} + +impl From for WorkspaceSearchMatch { + fn from(value: CodgrepFileMatch) -> Self { + Self { + location: value.location.into(), + snippet: value.snippet, + matched_text: value.matched_text, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchContextLine { + pub line_number: usize, + pub snippet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkspaceSearchLine { + Match { value: WorkspaceSearchMatch }, + Context { value: WorkspaceSearchContextLine }, + ContextBreak, +} + +impl From for WorkspaceSearchLine { + fn from(value: CodgrepSearchLine) -> Self { + match value { + CodgrepSearchLine::Match { value } => Self::Match { + value: value.into(), + }, + CodgrepSearchLine::Context { + line_number, + snippet, + } => Self::Context { + value: WorkspaceSearchContextLine { + line_number, + snippet, + }, + }, + CodgrepSearchLine::ContextBreak => Self::ContextBreak, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchHit { + pub path: String, + pub matches: Vec, + pub lines: Vec, +} + +impl From for WorkspaceSearchHit { + fn from(value: CodgrepSearchHit) -> Self { + Self { + path: value.path, + matches: value.matches.into_iter().map(Into::into).collect(), + lines: value.lines.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIndexStatus { + pub repo_status: WorkspaceSearchRepoStatus, + pub active_task: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentSearchResult { + pub outcome: FileSearchOutcome, + pub file_counts: Vec, + pub hits: Vec, + pub backend: WorkspaceSearchBackend, + pub repo_status: WorkspaceSearchRepoStatus, + pub candidate_docs: usize, + pub matched_lines: usize, + pub matched_occurrences: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlobSearchResult { + pub paths: Vec, + pub repo_status: WorkspaceSearchRepoStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexTaskHandle { + pub task: WorkspaceSearchTaskStatus, + pub repo_status: WorkspaceSearchRepoStatus, +} diff --git a/src/crates/core/tests/common/stream_test_harness.rs b/src/crates/core/tests/common/stream_test_harness.rs index 615c6cb3..487e7bca 100644 --- a/src/crates/core/tests/common/stream_test_harness.rs +++ b/src/crates/core/tests/common/stream_test_harness.rs @@ -1,11 +1,11 @@ use super::fixture_loader::load_fixture_bytes; use super::sse_fixture_server::{FixtureSseServer, FixtureSseServerOptions}; -use bitfun_core::agentic::events::{AgenticEvent, EventQueue, EventQueueConfig}; -use bitfun_core::agentic::execution::{StreamProcessError, StreamResult}; use bitfun_ai_adapters::stream::{ handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, UnifiedResponse, }; +use bitfun_core::agentic::events::{AgenticEvent, EventQueue, EventQueueConfig}; +use bitfun_core::agentic::execution::{StreamProcessError, StreamResult}; use bitfun_core::StreamProcessor; use futures::StreamExt; use std::sync::Arc; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index e2d59304..4063f91d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; -import { ConfirmDialog, Tooltip } from '@/component-library'; +import { Button, ConfirmDialog, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { i18nService } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -27,6 +28,7 @@ import { type WorkspaceInfo, } from '@/shared/types'; import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; +import { useWorkspaceSearchIndex } from '@/tools/file-explorer'; interface WorkspaceItemProps { workspace: WorkspaceInfo; @@ -38,6 +40,13 @@ interface WorkspaceItemProps { onDragEnd?: React.DragEventHandler; } +function getIndexActionKind(phase?: string | null): 'build' | 'rebuild' { + if (!phase || phase === 'needs_index' || phase === 'preparing') { + return 'build'; + } + return 'rebuild'; +} + const WorkspaceItem: React.FC = ({ workspace, isActive, @@ -48,6 +57,7 @@ const WorkspaceItem: React.FC = ({ onDragEnd, }) => { const { t } = useI18n('common'); + const { t: tFiles } = useTranslation('panels/files'); const { openWorkspace, setActiveWorkspace, @@ -82,6 +92,14 @@ const WorkspaceItem: React.FC = ({ ? workspace.identity?.name?.trim() || workspace.name : workspace.name; const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); + const canShowSearchIndex = + isActive + && workspace.workspaceKind === WorkspaceKind.Normal + && !isRemoteWorkspace(workspace); + const workspaceSearchIndex = useWorkspaceSearchIndex({ + workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, + enabled: canShowSearchIndex, + }); // Remote connection status — optional: safe if not inside SSHRemoteProvider const sshContext = useContext(SSHContext); @@ -89,6 +107,138 @@ const WorkspaceItem: React.FC = ({ ? (sshContext.workspaceStatuses[workspace.connectionId] ?? 'connecting') : undefined; + const searchIndexIndicator = useMemo(() => { + if (!canShowSearchIndex) { + return null; + } + + const repoStatus = workspaceSearchIndex.indexStatus?.repoStatus ?? null; + const activeTask = workspaceSearchIndex.indexStatus?.activeTask ?? null; + const phase = repoStatus?.phase; + const isTaskActive = activeTask?.state === 'queued' || activeTask?.state === 'running'; + const hasError = Boolean( + workspaceSearchIndex.error + || repoStatus?.lastError + || activeTask?.error + || activeTask?.state === 'failed' + ); + const dirtyFiles = repoStatus + ? repoStatus.dirtyFiles.modified + repoStatus.dirtyFiles.deleted + repoStatus.dirtyFiles.new + : 0; + + let tone: 'green' | 'yellow' | 'gray' | 'red' = 'gray'; + if (hasError || phase === 'limited') { + tone = 'red'; + } else if (!phase || phase === 'needs_index') { + tone = 'gray'; + } else if ( + isTaskActive + || phase === 'preparing' + || phase === 'building' + || phase === 'refreshing' + || Boolean(repoStatus?.rebuildRecommended) + ) { + tone = 'yellow'; + } else if (phase === 'ready' || phase === 'stale') { + tone = 'green'; + } + + const phaseLabel = tFiles(`search.index.phase.${phase ?? 'unknown'}`, { + defaultValue: phase ?? tFiles('search.index.phase.unknown'), + }); + const title = tFiles(`search.index.indicator.tones.${tone}`); + const summary = repoStatus + ? tFiles(`search.index.summary.${phase ?? 'unavailable'}`, { + defaultValue: tFiles('search.index.summary.unavailable'), + }) + : workspaceSearchIndex.loading + ? tFiles('search.index.indicator.checking') + : tFiles('search.index.summary.unavailable'); + const activeTaskLabel = activeTask + ? tFiles(`search.index.taskState.${activeTask.state}`, { + defaultValue: activeTask.state, + }) + : null; + const progressLabel = activeTask + ? typeof activeTask.total === 'number' && activeTask.total > 0 + ? tFiles('search.index.indicator.progressKnown', { + processed: activeTask.processed, + total: activeTask.total, + }) + : tFiles('search.index.indicator.progressUnknown', { + processed: activeTask.processed, + }) + : null; + const progressPercent = + activeTask && typeof activeTask.total === 'number' && activeTask.total > 0 + ? Math.max(0, Math.min(100, (activeTask.processed / activeTask.total) * 100)) + : null; + const progressPercentLabel = + typeof progressPercent === 'number' + ? `${Math.round(progressPercent)}%` + : null; + const dirtyFilesLabel = + repoStatus && dirtyFiles > 0 + ? tFiles('search.index.indicator.dirtyFiles', { + modified: repoStatus.dirtyFiles.modified, + deleted: repoStatus.dirtyFiles.deleted, + new: repoStatus.dirtyFiles.new, + }) + : null; + const errorText = workspaceSearchIndex.error ?? activeTask?.error ?? repoStatus?.lastError ?? null; + + return { + tone, + title, + phaseLabel, + summary, + activeTaskLabel, + activeTaskMessage: activeTask?.message ?? null, + progressLabel, + progressPercent, + progressPercentLabel, + dirtyFilesLabel, + rebuildRecommended: Boolean(repoStatus?.rebuildRecommended), + watcherHealthy: repoStatus?.watcherHealthy ?? true, + errorText, + ariaLabel: `${tFiles('search.index.indicator.label')}: ${title} · ${phaseLabel}`, + }; + }, [ + canShowSearchIndex, + tFiles, + workspaceSearchIndex.error, + workspaceSearchIndex.indexStatus, + workspaceSearchIndex.loading, + ]); + const searchIndexActionKind = getIndexActionKind( + workspaceSearchIndex.indexStatus?.repoStatus.phase ?? null + ); + const searchIndexActionLabel = tFiles( + searchIndexActionKind === 'build' + ? 'search.index.actions.build' + : 'search.index.actions.rebuild' + ); + + const handleSearchIndexAction = useCallback(async () => { + const result = + searchIndexActionKind === 'build' + ? await workspaceSearchIndex.buildIndex() + : await workspaceSearchIndex.rebuildIndex(); + + if (!result) { + return; + } + + notificationService.success( + tFiles( + searchIndexActionKind === 'build' + ? 'notifications.searchIndexBuildStarted' + : 'notifications.searchIndexRebuildStarted' + ), + { duration: 2200 } + ); + }, [searchIndexActionKind, tFiles, workspaceSearchIndex]); + const updateMenuPosition = useCallback(() => { const anchor = menuAnchorRef.current; if (!anchor) return; @@ -625,96 +775,192 @@ const WorkspaceItem: React.FC = ({ -
- - - -
- +
+
+ )} > - - - + + + )} - {menuOpen && menuPosition && createPortal( -
- - - -
- {isLinkedWorktree ? ( +
+ + {menuOpen && menuPosition && createPortal( +
+ + + +
+ {isLinkedWorktree ? ( + + ) : ( + + )} - ) : ( - )} - - -
- -
, - document.body - )} +
+ +
, + document.body + )} +
diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 2b10bcbb..fa20da17 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -237,7 +237,7 @@ align-items: center; gap: 6px; min-height: 30px; - padding: 0 58px 0 4px; + padding: 0 76px 0 4px; border: none; border-radius: 0 6px 6px 0; background: transparent; @@ -426,6 +426,326 @@ } } + &__workspace-index-indicator { + --bitfun-index-tone: var(--color-text-muted); + + display: inline-flex; + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 50%; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 18%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 34%, transparent); + transition: transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + opacity $motion-fast $easing-standard, + filter $motion-fast $easing-standard; + cursor: default; + + &:hover { + transform: scale(1.08); + filter: saturate(1.08); + } + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + background: + radial-gradient(circle at 30% 30%, color-mix(in srgb, #fff 40%, transparent), transparent 45%), + var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 30%, transparent), + 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 30%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 34%, transparent); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + background: + radial-gradient(circle at 30% 30%, color-mix(in srgb, #fff 40%, transparent), transparent 45%), + var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 30%, transparent), + 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 28%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 34%, transparent); + animation: bitfun-status-dot-pulse 1.4s ease-in-out infinite; + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + background: + radial-gradient(circle at 30% 30%, color-mix(in srgb, #fff 28%, transparent), transparent 45%), + var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 22%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent); + opacity: 0.9; + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + background: + radial-gradient(circle at 30% 30%, color-mix(in srgb, #fff 38%, transparent), transparent 45%), + var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 30%, transparent), + 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 30%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 34%, transparent); + animation: bitfun-status-dot-pulse 1s ease-in-out infinite; + } + } + + &__workspace-index-tooltip { + --bitfun-index-tone: var(--color-text-secondary); + + display: flex; + flex-direction: column; + gap: 10px; + min-width: 236px; + max-width: 296px; + padding: 2px; + color: var(--color-text-primary); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + } + + &__workspace-index-tooltip-heading { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1; + } + + &__workspace-index-tooltip-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + margin-top: 3px; + border-radius: 50%; + background: var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 18%, transparent), + 0 0 10px color-mix(in srgb, var(--bitfun-index-tone) 26%, transparent); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-title-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + + &__workspace-index-tooltip-title { + font-size: var(--font-size-xs); + font-weight: 700; + line-height: 1.2; + color: var(--color-text-primary); + } + + &__workspace-index-tooltip-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + font-size: var(--font-size-xxs); + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + border: 1px solid color-mix(in srgb, var(--bitfun-index-tone) 24%, transparent); + background: color-mix(in srgb, var(--bitfun-index-tone) 10%, transparent); + color: color-mix(in srgb, var(--bitfun-index-tone) 78%, var(--color-text-primary)); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-phase { + font-size: var(--font-size-xxs); + color: var(--color-text-muted); + line-height: 1.35; + } + + &__workspace-index-tooltip-summary { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--bitfun-index-tone) 12%, var(--border-subtle)); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--bitfun-index-tone) 7%, transparent), + color-mix(in srgb, var(--element-bg-subtle) 92%, transparent) + ); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.45; + } + + &__workspace-index-tooltip-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--element-bg-medium) 56%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 76%, transparent); + font-size: var(--font-size-xxs); + color: var(--color-text-secondary); + } + + &__workspace-index-tooltip-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + &__workspace-index-tooltip-progress-value { + font-variant-numeric: tabular-nums; + color: color-mix(in srgb, var(--bitfun-index-tone) 76%, var(--color-text-primary)); + font-weight: 600; + } + + &__workspace-index-tooltip-progress-bar { + width: 100%; + height: 6px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--element-bg-strong) 68%, transparent); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.22); + } + + &__workspace-index-tooltip-progress-fill { + display: block; + height: 100%; + border-radius: inherit; + box-shadow: 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 28%, transparent); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 82%, #fff 18%), + var(--bitfun-index-tone) + ); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + var(--bitfun-index-tone) + ); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 88%, #fff 12%), + var(--bitfun-index-tone) + ); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + var(--bitfun-index-tone) + ); + } + } + + &__workspace-index-tooltip-meta, + &__workspace-index-tooltip-error { + padding: 8px 10px; + border-radius: 9px; + font-size: var(--font-size-xxs); + line-height: 1.45; + border: 1px solid color-mix(in srgb, var(--border-subtle) 78%, transparent); + background: color-mix(in srgb, var(--element-bg-subtle) 92%, transparent); + } + + &__workspace-index-tooltip-meta { + color: var(--color-text-secondary); + + &.is-warning { + color: color-mix(in srgb, var(--color-warning, #e8b54b) 84%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-warning, #e8b54b) 18%, transparent); + background: color-mix(in srgb, var(--color-warning, #e8b54b) 10%, transparent); + } + } + + &__workspace-index-tooltip-error { + color: color-mix(in srgb, var(--color-error, #e05d5d) 88%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-error, #e05d5d) 22%, transparent); + background: color-mix(in srgb, var(--color-error, #e05d5d) 11%, transparent); + } + + &__workspace-index-tooltip-actions { + display: flex; + align-items: center; + justify-content: stretch; + padding-top: 2px; + + .btn { + width: 100%; + min-height: 28px; + border-radius: 9px; + font-weight: 600; + box-shadow: none; + } + } + &__workspace-item-badge { display: inline-flex; align-items: center; @@ -465,11 +785,17 @@ } } - &__workspace-item-menu { + &__workspace-item-actions { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); + display: inline-flex; + align-items: center; + gap: 6px; + } + + &__workspace-item-menu { display: inline-flex; align-items: center; gap: 4px; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.scss b/src/web-ui/src/app/components/panels/FilesPanel.scss index eebc6b01..755bbb78 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.scss +++ b/src/web-ui/src/app/components/panels/FilesPanel.scss @@ -91,6 +91,62 @@ min-width: 0; } + &__search-index { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border: 1px solid $border-base; + border-radius: $size-radius-base; + background: + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-subtle) 84%, transparent), transparent), + var(--color-bg-secondary); + } + + &__search-index-main { + display: flex; + flex-direction: column; + gap: $size-gap-1; + min-width: 0; + flex: 1 1 auto; + } + + &__search-index-badges { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + min-width: 0; + } + + &__search-index-summary { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: 1.4; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__search-index-error-text { + color: var(--color-error-600); + } + + &__search-index-actions { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-shrink: 0; + } + &__search-content { display: flex; flex-direction: column; @@ -107,6 +163,29 @@ overflow: hidden; } + &__search-backend { + display: flex; + flex-direction: column; + gap: $size-gap-1; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid $border-base; + background: color-mix(in srgb, var(--element-bg-subtle) 82%, var(--color-bg-secondary) 18%); + flex-shrink: 0; + } + + &__search-backend-badges { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + } + + &__search-backend-summary { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: 1.4; + } + &__search-status { display: flex; align-items: center; @@ -129,6 +208,11 @@ flex-shrink: 0; } + &__search-limit-notice--warning { + background: color-mix(in srgb, var(--element-bg-subtle) 64%, var(--color-warning-100) 36%); + color: var(--color-warning-700); + } + &__search-spinner { animation: bitfun-files-panel-spin 0.8s linear infinite; flex-shrink: 0; @@ -207,6 +291,22 @@ } } + @container files-panel (max-width: 340px) { + .bitfun-files-panel__search-index { + flex-direction: column; + align-items: stretch; + } + + .bitfun-files-panel__search-index-actions { + justify-content: space-between; + } + + .bitfun-files-panel__search-toolbar { + align-items: flex-start; + flex-direction: column; + } + } + // ==================== Panel Content ==================== &__content { flex: 1; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index 2b8b64ac..5554795f 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -13,7 +13,7 @@ import { type FileExplorerToolbarHandlers, } from '@/tools/file-system'; import { useExplorerSearch } from '@/tools/file-explorer'; -import { Search, IconButton, Tooltip } from '@/component-library'; +import { Search, IconButton, Tooltip, Badge } from '@/component-library'; import { FileSearchResults } from '@/tools/file-system/components/FileSearchResults'; import { workspaceAPI } from '@/infrastructure/api'; import type { FileSystemNode } from '@/tools/file-system/types'; @@ -33,6 +33,10 @@ import { import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { isRemoteWorkspace } from '@/shared/types'; +import type { + SearchMetadata, + WorkspaceSearchRepoPhase, +} from '@/infrastructure/api/service-api/tauri-commands'; import { downloadWorkspaceFileToDisk, isDragPositionOverElement, @@ -47,6 +51,39 @@ const log = createLogger('FilesPanel'); const FOCUS_REFRESH_THROTTLE_MS = 1000; const REMOTE_REFRESH_POLL_MS = 15000; +function getIndexPhaseBadgeVariant(phase?: WorkspaceSearchRepoPhase): 'neutral' | 'warning' | 'success' | 'error' | 'info' { + switch (phase) { + case 'ready': + return 'success'; + case 'stale': + case 'needs_index': + return 'warning'; + case 'building': + case 'refreshing': + case 'preparing': + return 'info'; + case 'limited': + return 'error'; + default: + return 'neutral'; + } +} + +function getSearchBackendBadgeVariant( + metadata: SearchMetadata | null +): 'neutral' | 'success' | 'warning' | 'info' { + switch (metadata?.backend) { + case 'indexed': + case 'indexed_repair': + return 'success'; + case 'text_fallback': + case 'scan_fallback': + return 'warning'; + default: + return 'neutral'; + } +} + interface FilesPanelProps { workspacePath?: string; onFileSelect?: (filePath: string, fileName: string) => void; @@ -82,7 +119,6 @@ const FilesPanel: React.FC = ({ && pathsEquivalentFs(currentWorkspace.rootPath, workspacePath) && isRemoteWorkspace(currentWorkspace) ); - const { query: searchQuery, setQuery: setSearchQuery, @@ -95,6 +131,7 @@ const FilesPanel: React.FC = ({ contentLimit, filenameTruncated, contentTruncated, + contentSearchMetadata, searchOptions, setSearchOptions, clearSearch, @@ -131,6 +168,13 @@ const FilesPanel: React.FC = ({ : filenameTruncated ? t('search.limitReachedFiles', { count: filenameLimit }) : null; + const contentSearchBackendLabel = contentSearchMetadata + ? t(`search.backend.${contentSearchMetadata.backend}`, { + defaultValue: contentSearchMetadata.backend, + }) + : null; + const showContentSearchMetadata = + searchMode === 'content' && Boolean(searchQuery.trim()) && Boolean(contentSearchMetadata); const { fileTree, @@ -869,7 +913,34 @@ const FilesPanel: React.FC = ({ {searchLimitNotice} )} - + + {showContentSearchMetadata && contentSearchMetadata && ( +
+
+ + {contentSearchBackendLabel} + + + {t(`search.index.phase.${contentSearchMetadata.repoPhase}`, { + defaultValue: contentSearchMetadata.repoPhase, + })} + + {contentSearchMetadata.rebuildRecommended ? ( + + {t('search.index.badges.rebuildRecommended')} + + ) : null} +
+
+ {t('search.backendSummary', { + candidateDocs: contentSearchMetadata.candidateDocs, + matchedLines: contentSearchMetadata.matchedLines, + matchedOccurrences: contentSearchMetadata.matchedOccurrences, + })} +
+
+ )} + {searchError && (

❌ {searchError}

diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index d1d7b297..a27a0756 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -14,6 +14,9 @@ import type { FileSearchResultGroup, FileSearchStreamKind, FileSearchStreamStartResponse, + SearchRepoIndexRequest, + WorkspaceSearchIndexStatus, + WorkspaceSearchIndexTaskHandle, } from './tauri-commands'; import { createLogger } from '@/shared/utils/logger'; @@ -27,6 +30,51 @@ interface FileSearchStreamCallbacks { onProgress?: (event: FileSearchProgressEvent) => void; } +interface WorkspaceSearchRepoStatusRaw { + repoId: string; + repoPath: string; + indexPath: string; + phase: WorkspaceSearchIndexStatus['repoStatus']['phase']; + snapshotKey?: string | null; + lastProbeUnixSecs?: number | null; + lastRebuildUnixSecs?: number | null; + dirtyFiles: { + modified: number; + deleted: number; + new: number; + }; + rebuildRecommended: boolean; + activeTaskId?: string | null; + watcherHealthy: boolean; + lastError?: string | null; +} + +interface WorkspaceSearchTaskStatusRaw { + taskId: string; + workspaceId: string; + kind: NonNullable['kind']; + state: NonNullable['state']; + phase?: NonNullable['phase'] | null; + message: string; + processed: number; + total?: number | null; + startedUnixSecs: number; + updatedUnixSecs: number; + finishedUnixSecs?: number | null; + cancellable: boolean; + error?: string | null; +} + +interface WorkspaceSearchIndexStatusRaw { + repoStatus: WorkspaceSearchRepoStatusRaw; + activeTask?: WorkspaceSearchTaskStatusRaw | null; +} + +interface WorkspaceSearchIndexTaskHandleRaw { + task: WorkspaceSearchTaskStatusRaw; + repoStatus: WorkspaceSearchRepoStatusRaw; +} + function groupSearchResultsByFile(results: FileSearchResult[]): FileSearchResultGroup[] { const groups = new Map(); @@ -53,6 +101,59 @@ function groupSearchResultsByFile(results: FileSearchResult[]): FileSearchResult return Array.from(groups.values()); } +function mapWorkspaceSearchRepoStatus(raw: WorkspaceSearchRepoStatusRaw): WorkspaceSearchIndexStatus['repoStatus'] { + return { + repoId: raw.repoId, + repoPath: raw.repoPath, + indexPath: raw.indexPath, + phase: raw.phase, + snapshotKey: raw.snapshotKey ?? null, + lastProbeUnixSecs: raw.lastProbeUnixSecs ?? null, + lastRebuildUnixSecs: raw.lastRebuildUnixSecs ?? null, + dirtyFiles: raw.dirtyFiles, + rebuildRecommended: raw.rebuildRecommended, + activeTaskId: raw.activeTaskId ?? null, + watcherHealthy: raw.watcherHealthy, + lastError: raw.lastError ?? null, + }; +} + +function mapWorkspaceSearchTaskStatus( + raw: WorkspaceSearchTaskStatusRaw +): NonNullable { + return { + taskId: raw.taskId, + workspaceId: raw.workspaceId, + kind: raw.kind, + state: raw.state, + phase: raw.phase ?? null, + message: raw.message, + processed: raw.processed, + total: raw.total ?? null, + startedUnixSecs: raw.startedUnixSecs, + updatedUnixSecs: raw.updatedUnixSecs, + finishedUnixSecs: raw.finishedUnixSecs ?? null, + cancellable: raw.cancellable, + error: raw.error ?? null, + }; +} + +function mapWorkspaceSearchIndexStatus(raw: WorkspaceSearchIndexStatusRaw): WorkspaceSearchIndexStatus { + return { + repoStatus: mapWorkspaceSearchRepoStatus(raw.repoStatus), + activeTask: raw.activeTask ? mapWorkspaceSearchTaskStatus(raw.activeTask) : null, + }; +} + +function mapWorkspaceSearchIndexTaskHandle( + raw: WorkspaceSearchIndexTaskHandleRaw +): WorkspaceSearchIndexTaskHandle { + return { + task: mapWorkspaceSearchTaskStatus(raw.task), + repoStatus: mapWorkspaceSearchRepoStatus(raw.repoStatus), + }; +} + export class WorkspaceAPI { async openWorkspace(path: string): Promise { @@ -699,6 +800,7 @@ export class WorkspaceAPI { limit: response.limit, truncated: response.truncated, totalResults: groupedResults.length, + searchMetadata: response.searchMetadata, }; if (groupedResults.length > 0) { callbacks.onProgress?.({ @@ -727,6 +829,36 @@ export class WorkspaceAPI { ); } + async getSearchRepoStatus(rootPath: string): Promise { + const request: SearchRepoIndexRequest = { rootPath }; + try { + const raw = await api.invoke('search_get_repo_status', { request }); + return mapWorkspaceSearchIndexStatus(raw); + } catch (error) { + throw createTauriCommandError('search_get_repo_status', error, { rootPath }); + } + } + + async buildSearchIndex(rootPath: string): Promise { + const request: SearchRepoIndexRequest = { rootPath }; + try { + const raw = await api.invoke('search_build_index', { request }); + return mapWorkspaceSearchIndexTaskHandle(raw); + } catch (error) { + throw createTauriCommandError('search_build_index', error, { rootPath }); + } + } + + async rebuildSearchIndex(rootPath: string): Promise { + const request: SearchRepoIndexRequest = { rootPath }; + try { + const raw = await api.invoke('search_rebuild_index', { request }); + return mapWorkspaceSearchIndexTaskHandle(raw); + } catch (error) { + throw createTauriCommandError('search_rebuild_index', error, { rootPath }); + } + } + async renameFile(oldPath: string, newPath: string): Promise { try { diff --git a/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts b/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts index 2daca9e8..3dc56a9d 100644 --- a/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts +++ b/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts @@ -180,6 +180,10 @@ export interface CancelSearchRequest { searchId: string; } +export interface SearchRepoIndexRequest { + rootPath: string; +} + export type SearchMatchType = 'fileName' | 'content'; export interface FileSearchResult { @@ -198,6 +202,7 @@ export interface FileSearchResponse { results: FileSearchResult[]; limit: number; truncated: boolean; + searchMetadata?: SearchMetadata; } export interface FileSearchResultGroup { @@ -227,6 +232,7 @@ export interface FileSearchCompleteEvent { limit: number; truncated: boolean; totalResults: number; + searchMetadata?: SearchMetadata; } export interface FileSearchErrorEvent { @@ -235,6 +241,96 @@ export interface FileSearchErrorEvent { error: string; } +export type SearchBackendKind = + | 'indexed' + | 'indexed_repair' + | 'text_fallback' + | 'scan_fallback'; + +export interface SearchMetadata { + backend: SearchBackendKind | string; + repoPhase: WorkspaceSearchRepoPhase | string; + rebuildRecommended: boolean; + candidateDocs: number; + matchedLines: number; + matchedOccurrences: number; +} + +export type WorkspaceSearchRepoPhase = + | 'preparing' + | 'needs_index' + | 'building' + | 'ready' + | 'stale' + | 'refreshing' + | 'limited'; + +export type WorkspaceSearchTaskKind = + | 'build' + | 'rebuild' + | 'refresh'; + +export type WorkspaceSearchTaskState = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export type WorkspaceSearchTaskPhase = + | 'discovering' + | 'processing' + | 'persisting' + | 'finalizing' + | 'refreshing'; + +export interface WorkspaceSearchDirtyFiles { + modified: number; + deleted: number; + new: number; +} + +export interface WorkspaceSearchRepoStatus { + repoId: string; + repoPath: string; + indexPath: string; + phase: WorkspaceSearchRepoPhase; + snapshotKey?: string | null; + lastProbeUnixSecs?: number | null; + lastRebuildUnixSecs?: number | null; + dirtyFiles: WorkspaceSearchDirtyFiles; + rebuildRecommended: boolean; + activeTaskId?: string | null; + watcherHealthy: boolean; + lastError?: string | null; +} + +export interface WorkspaceSearchTaskStatus { + taskId: string; + workspaceId: string; + kind: WorkspaceSearchTaskKind; + state: WorkspaceSearchTaskState; + phase?: WorkspaceSearchTaskPhase | null; + message: string; + processed: number; + total?: number | null; + startedUnixSecs: number; + updatedUnixSecs: number; + finishedUnixSecs?: number | null; + cancellable: boolean; + error?: string | null; +} + +export interface WorkspaceSearchIndexStatus { + repoStatus: WorkspaceSearchRepoStatus; + activeTask?: WorkspaceSearchTaskStatus | null; +} + +export interface WorkspaceSearchIndexTaskHandle { + task: WorkspaceSearchTaskStatus; + repoStatus: WorkspaceSearchRepoStatus; +} + export interface ExplorerNodeDto { path: string; name: string; diff --git a/src/web-ui/src/locales/en-US/panels/files.json b/src/web-ui/src/locales/en-US/panels/files.json index 1cb4a96f..9664d0ac 100644 --- a/src/web-ui/src/locales/en-US/panels/files.json +++ b/src/web-ui/src/locales/en-US/panels/files.json @@ -7,7 +7,67 @@ "modeFiles": "Files", "modeContent": "Text", "limitReachedFiles": "Showing the first {{count}} file matches. Refine the query to narrow the result set.", - "limitReachedContent": "Showing the first {{count}} content matches. Refine the query to narrow the result set." + "limitReachedContent": "Showing the first {{count}} content matches. Refine the query to narrow the result set.", + "backendSummary": "{{candidateDocs}} candidate docs, {{matchedLines}} matched lines, {{matchedOccurrences}} matched occurrences.", + "backend": { + "indexed": "Indexed", + "indexed_repair": "Indexed Repair", + "text_fallback": "Text Fallback", + "scan_fallback": "Scan Fallback" + }, + "index": { + "actions": { + "build": "Build Index", + "rebuild": "Rebuild Index", + "refresh": "Refresh index status", + "running": "Indexing..." + }, + "badges": { + "rebuildRecommended": "Rebuild Recommended" + }, + "phase": { + "preparing": "Preparing", + "needs_index": "Needs Index", + "building": "Building", + "ready": "Ready", + "stale": "Stale", + "refreshing": "Refreshing", + "limited": "Limited", + "unknown": "Unknown" + }, + "summary": { + "preparing": "Preparing the managed search workspace.", + "needs_index": "Search works with fallback now. Build the index for faster content search.", + "building": "Building the workspace index.", + "ready": "Managed index is ready. Content search can use the indexed backend.", + "stale": "Managed index is usable, but workspace changes suggest a rebuild soon.", + "refreshing": "Refreshing the workspace index with the latest file changes.", + "limited": "Managed index is limited. Search can still fall back, but rebuild is recommended.", + "unavailable": "Search index status is unavailable right now." + }, + "indicator": { + "label": "Workspace index status", + "checking": "Checking index status...", + "tones": { + "green": "Index ready", + "yellow": "Index needs attention", + "gray": "Index not built", + "red": "Index rebuild required" + }, + "progressKnown": "{{processed}} / {{total}}", + "progressUnknown": "{{processed}} items processed", + "dirtyFiles": "Pending changes: {{modified}} modified, {{deleted}} deleted, {{new}} new", + "rebuildRecommended": "A rebuild is recommended.", + "watcherDegraded": "Workspace watcher is degraded and the index may stop updating." + }, + "taskState": { + "queued": "Queued", + "running": "Running", + "completed": "Completed", + "failed": "Failed", + "cancelled": "Cancelled" + } + } }, "options": { "caseSensitive": "Case Sensitive (Aa)", @@ -65,6 +125,8 @@ "pasteNoFiles": "No files in clipboard", "pastingFiles": "Pasting {{count}} files to {{target}}...", "openExplorerFailed": "Failed to open file explorer: {{error}}", - "selectWorkspaceFirst": "Please open a workspace first" + "selectWorkspaceFirst": "Please open a workspace first", + "searchIndexBuildStarted": "Workspace index build started.", + "searchIndexRebuildStarted": "Workspace index rebuild started." } } diff --git a/src/web-ui/src/locales/zh-CN/panels/files.json b/src/web-ui/src/locales/zh-CN/panels/files.json index c2626c8d..9c2748e6 100644 --- a/src/web-ui/src/locales/zh-CN/panels/files.json +++ b/src/web-ui/src/locales/zh-CN/panels/files.json @@ -7,7 +7,67 @@ "modeFiles": "文件", "modeContent": "内容", "limitReachedFiles": "当前仅显示前 {{count}} 个文件匹配结果,请继续缩小查询范围。", - "limitReachedContent": "当前仅显示前 {{count}} 个内容匹配结果,请继续缩小查询范围。" + "limitReachedContent": "当前仅显示前 {{count}} 个内容匹配结果,请继续缩小查询范围。", + "backendSummary": "候选文档 {{candidateDocs}} 个,命中行 {{matchedLines}} 行,命中片段 {{matchedOccurrences}} 个。", + "backend": { + "indexed": "索引命中", + "indexed_repair": "索引修复", + "text_fallback": "文本回退", + "scan_fallback": "扫描回退" + }, + "index": { + "actions": { + "build": "建立索引", + "rebuild": "重建索引", + "refresh": "刷新索引状态", + "running": "索引中..." + }, + "badges": { + "rebuildRecommended": "建议重建" + }, + "phase": { + "preparing": "准备中", + "needs_index": "未建索引", + "building": "建立中", + "ready": "索引可用", + "stale": "索引已旧", + "refreshing": "刷新中", + "limited": "受限", + "unknown": "未知" + }, + "summary": { + "preparing": "正在准备托管搜索工作区。", + "needs_index": "当前搜索仍可通过 fallback 使用,建立索引后内容搜索会更快。", + "building": "正在为当前工作区建立索引。", + "ready": "索引已就绪,内容搜索可优先走索引后端。", + "stale": "索引当前可用,但工作区变更已提示应尽快重建。", + "refreshing": "正在根据最新文件变更刷新索引。", + "limited": "索引能力受限。搜索仍可 fallback,但建议重建。", + "unavailable": "当前无法获取索引状态。" + }, + "indicator": { + "label": "工作区索引状态", + "checking": "正在检查索引状态...", + "tones": { + "green": "索引正常", + "yellow": "索引需关注", + "gray": "尚未建索引", + "red": "需要重建索引" + }, + "progressKnown": "{{processed}} / {{total}}", + "progressUnknown": "已处理 {{processed}} 项", + "dirtyFiles": "待同步变更:修改 {{modified}},删除 {{deleted}},新增 {{new}}", + "rebuildRecommended": "当前建议执行重建。", + "watcherDegraded": "工作区监听状态异常,索引可能不会自动跟进。" + }, + "taskState": { + "queued": "排队中", + "running": "执行中", + "completed": "已完成", + "failed": "失败", + "cancelled": "已取消" + } + } }, "options": { "caseSensitive": "区分大小写 (Aa)", @@ -65,6 +125,8 @@ "pasteNoFiles": "剪贴板中没有文件", "pastingFiles": "正在粘贴 {{count}} 个文件到 {{target}}...", "openExplorerFailed": "打开文件管理器失败: {{error}}", - "selectWorkspaceFirst": "请先打开工作区" + "selectWorkspaceFirst": "请先打开工作区", + "searchIndexBuildStarted": "已开始建立工作区索引。", + "searchIndexRebuildStarted": "已开始重建工作区索引。" } } diff --git a/src/web-ui/src/tools/file-explorer/index.ts b/src/web-ui/src/tools/file-explorer/index.ts index 7a3de074..100e62b2 100644 --- a/src/web-ui/src/tools/file-explorer/index.ts +++ b/src/web-ui/src/tools/file-explorer/index.ts @@ -9,6 +9,11 @@ export { type UseExplorerSearchOptions, type UseExplorerSearchResult, } from './search/useExplorerSearch'; +export { + useWorkspaceSearchIndex, + type UseWorkspaceSearchIndexOptions, + type UseWorkspaceSearchIndexResult, +} from './search/useWorkspaceSearchIndex'; export { filterTreeByPredicate, filterTreeBySearch } from './search/treeFilter'; export type { ExplorerChildrenRequest, diff --git a/src/web-ui/src/tools/file-explorer/search/useExplorerSearch.ts b/src/web-ui/src/tools/file-explorer/search/useExplorerSearch.ts index 035136d7..0a521154 100644 --- a/src/web-ui/src/tools/file-explorer/search/useExplorerSearch.ts +++ b/src/web-ui/src/tools/file-explorer/search/useExplorerSearch.ts @@ -11,6 +11,7 @@ import { workspaceAPI } from '@/infrastructure/api'; import type { FileSearchResult, FileSearchResultGroup, + SearchMetadata, } from '@/infrastructure/api/service-api/tauri-commands'; import { createLogger } from '@/shared/utils/logger'; @@ -54,6 +55,7 @@ export interface UseExplorerSearchResult { filenameResults: FileSearchResult[]; contentResults: FileSearchResult[]; allResults: FileSearchResult[]; + contentSearchMetadata: SearchMetadata | null; searchPhase: ExplorerSearchPhase; filenameLimit: number; contentLimit: number; @@ -175,6 +177,7 @@ export function useExplorerSearch( const [searchMode, setSearchMode] = useState(initialMode); const [filenameGroups, setFilenameGroups] = useState([]); const [contentGroups, setContentGroups] = useState([]); + const [contentSearchMetadata, setContentSearchMetadata] = useState(null); const [searchPhase, setSearchPhase] = useState(() => buildIdlePhase(initialMode)); const [filenameLimit, setFilenameLimit] = useState(filenameMaxResults); const [contentLimit, setContentLimit] = useState(contentMaxResults); @@ -232,6 +235,7 @@ export function useExplorerSearch( setContentLimit(contentMaxResults); setFilenameTruncated(false); setContentTruncated(false); + setContentSearchMetadata(null); setSearchPhase(buildIdlePhase(searchMode)); setError(null); }, [cancelAllSearches, contentMaxResults, filenameMaxResults, searchMode]); @@ -340,6 +344,7 @@ export function useExplorerSearch( return; } + setContentSearchMetadata(response.searchMetadata ?? null); setContentLimit(response.limit); setContentTruncated(response.truncated); setSearchPhase((prev) => { @@ -397,6 +402,7 @@ export function useExplorerSearch( setContentLimit(contentMaxResults); setFilenameTruncated(false); setContentTruncated(false); + setContentSearchMetadata(null); setSearchPhase(buildIdlePhase(searchMode)); setError(null); return; @@ -410,6 +416,7 @@ export function useExplorerSearch( setContentLimit(contentMaxResults); setFilenameTruncated(false); setContentTruncated(false); + setContentSearchMetadata(null); setSearchPhase(buildSearchingPhase(searchMode, shouldRunFilename, shouldRunContent)); if (shouldRunFilename) { @@ -495,6 +502,7 @@ export function useExplorerSearch( filenameResults, contentResults, allResults, + contentSearchMetadata, searchPhase, filenameLimit, contentLimit, diff --git a/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts b/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts new file mode 100644 index 00000000..cbcc26d4 --- /dev/null +++ b/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { workspaceAPI } from '@/infrastructure/api'; +import type { + WorkspaceSearchIndexStatus, + WorkspaceSearchIndexTaskHandle, +} from '@/infrastructure/api/service-api/tauri-commands'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useWorkspaceSearchIndex'); +const ACTIVE_TASK_POLL_MS = 1000; +const IDLE_STATUS_POLL_MS = 5000; + +export interface UseWorkspaceSearchIndexOptions { + workspacePath?: string; + enabled?: boolean; +} + +export interface UseWorkspaceSearchIndexResult { + indexStatus: WorkspaceSearchIndexStatus | null; + loading: boolean; + refreshing: boolean; + actionRunning: boolean; + error: string | null; + supported: boolean; + hasActiveTask: boolean; + refreshStatus: (silent?: boolean) => Promise; + buildIndex: () => Promise; + rebuildIndex: () => Promise; +} + +function isTaskActive(status: WorkspaceSearchIndexStatus | null): boolean { + const state = status?.activeTask?.state; + return state === 'queued' || state === 'running'; +} + +export function useWorkspaceSearchIndex( + options: UseWorkspaceSearchIndexOptions = {} +): UseWorkspaceSearchIndexResult { + const { workspacePath, enabled = true } = options; + const supported = Boolean(workspacePath && enabled); + + const [indexStatus, setIndexStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [actionRunning, setActionRunning] = useState(false); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + const pollTimerRef = useRef | null>(null); + + const clearPollTimer = useCallback(() => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const refreshStatus = useCallback( + async (silent: boolean = false): Promise => { + if (!workspacePath || !enabled) { + if (mountedRef.current) { + setIndexStatus(null); + setError(null); + } + return null; + } + + if (mountedRef.current) { + if (silent) { + setRefreshing(true); + } else { + setLoading(true); + } + } + + try { + const status = await workspaceAPI.getSearchRepoStatus(workspacePath); + if (!mountedRef.current) { + return status; + } + setIndexStatus(status); + setError(null); + return status; + } catch (err) { + if (!mountedRef.current) { + return null; + } + const message = err instanceof Error ? err.message : 'Failed to load search index status'; + log.warn('Failed to refresh workspace search index status', { + workspacePath, + error: err, + }); + setError(message); + return null; + } finally { + if (mountedRef.current) { + setLoading(false); + setRefreshing(false); + } + } + }, + [enabled, workspacePath] + ); + + const runIndexAction = useCallback( + async ( + action: 'build' | 'rebuild' + ): Promise => { + if (!workspacePath || !enabled) { + return null; + } + + setActionRunning(true); + try { + const result = + action === 'build' + ? await workspaceAPI.buildSearchIndex(workspacePath) + : await workspaceAPI.rebuildSearchIndex(workspacePath); + if (mountedRef.current) { + setIndexStatus({ + repoStatus: result.repoStatus, + activeTask: result.task, + }); + setError(null); + } + return result; + } catch (err) { + if (mountedRef.current) { + const message = err instanceof Error ? err.message : `Failed to ${action} search index`; + setError(message); + } + return null; + } finally { + if (mountedRef.current) { + setActionRunning(false); + } + } + }, + [enabled, workspacePath] + ); + + const buildIndex = useCallback(async () => runIndexAction('build'), [runIndexAction]); + const rebuildIndex = useCallback(async () => runIndexAction('rebuild'), [runIndexAction]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearPollTimer(); + }; + }, [clearPollTimer]); + + useEffect(() => { + clearPollTimer(); + + if (!supported) { + setIndexStatus(null); + setLoading(false); + setRefreshing(false); + setActionRunning(false); + setError(null); + return; + } + + let cancelled = false; + + const scheduleNext = (status: WorkspaceSearchIndexStatus | null) => { + if (cancelled || !mountedRef.current) { + return; + } + const delay = isTaskActive(status) ? ACTIVE_TASK_POLL_MS : IDLE_STATUS_POLL_MS; + pollTimerRef.current = setTimeout(() => { + void refreshStatus(true).then((nextStatus) => { + scheduleNext(nextStatus); + }); + }, delay); + }; + + void refreshStatus(false).then((status) => { + if (!cancelled) { + scheduleNext(status); + } + }); + + return () => { + cancelled = true; + clearPollTimer(); + }; + }, [clearPollTimer, refreshStatus, supported, workspacePath]); + + return useMemo( + () => ({ + indexStatus, + loading, + refreshing, + actionRunning, + error, + supported, + hasActiveTask: isTaskActive(indexStatus), + refreshStatus, + buildIndex, + rebuildIndex, + }), + [ + actionRunning, + buildIndex, + error, + indexStatus, + loading, + rebuildIndex, + refreshStatus, + refreshing, + supported, + ] + ); +} + +export default useWorkspaceSearchIndex;