From 5cc208ed745759c3470040ba9aeb0fad11c38301 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 8 Apr 2026 21:01:01 +0800 Subject: [PATCH] refactor(core): consolidate filesystem services and simplify tool context - move file watching into `service::file_watch` and split path management into `infrastructure::app_paths` - extract directory listing utilities into `service::filesystem` and update prompt builder and `ls` tool to use them - simplify `ToolUseContext` and `ToolPipeline` by replacing nested options with `custom_data` - centralize desktop log plugin setup and remove obsolete workspace context generator --- src/apps/cli/src/agent/agentic_system.rs | 1 - src/apps/desktop/src/api/commands.rs | 11 +- src/apps/desktop/src/api/tool_api.rs | 11 +- src/apps/desktop/src/lib.rs | 27 +- src/apps/desktop/src/logging.rs | 25 +- src/apps/server/src/bootstrap.rs | 1 - .../prompt_builder/prompt_builder_impl.rs | 15 +- .../src/agentic/execution/execution_engine.rs | 23 +- .../core/src/agentic/tools/framework.rs | 52 +- .../implementations/computer_use_tool.rs | 6 +- .../agentic/tools/implementations/ls_tool.rs | 20 +- .../tools/implementations/web_tools.rs | 10 +- .../agentic/tools/pipeline/tool_pipeline.rs | 108 +-- src/crates/core/src/agentic/util/mod.rs | 2 - .../core/src/infrastructure/app_paths/mod.rs | 9 + .../{filesystem => app_paths}/path_manager.rs | 0 .../core/src/infrastructure/filesystem/mod.rs | 10 +- src/crates/core/src/infrastructure/mod.rs | 7 +- src/crates/core/src/service/file_watch/mod.rs | 12 + .../file_watch/service.rs} | 74 +- .../core/src/service/file_watch/types.rs | 37 + .../filesystem/listing.rs} | 208 ++---- src/crates/core/src/service/filesystem/mod.rs | 5 + src/crates/core/src/service/mcp/auth.rs | 2 +- src/crates/core/src/service/mod.rs | 6 + .../core/src/service/snapshot/manager.rs | 6 +- .../service/workspace/context_generator.rs | 646 ------------------ src/crates/core/src/service/workspace/mod.rs | 6 - 28 files changed, 226 insertions(+), 1114 deletions(-) create mode 100644 src/crates/core/src/infrastructure/app_paths/mod.rs rename src/crates/core/src/infrastructure/{filesystem => app_paths}/path_manager.rs (100%) create mode 100644 src/crates/core/src/service/file_watch/mod.rs rename src/crates/core/src/{infrastructure/filesystem/file_watcher.rs => service/file_watch/service.rs} (83%) create mode 100644 src/crates/core/src/service/file_watch/types.rs rename src/crates/core/src/{agentic/util/list_files.rs => service/filesystem/listing.rs} (56%) delete mode 100644 src/crates/core/src/service/workspace/context_generator.rs diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index 64b9d7b6..d7184af4 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -48,7 +48,6 @@ pub async fn init_agentic_system() -> Result { tool_registry, tool_state_manager, None, - None, )); let stream_processor = Arc::new(execution::StreamProcessor::new(event_queue.clone())); diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index a230bc60..78b0de11 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -3,9 +3,10 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; use bitfun_core::infrastructure::{ - file_watcher, BatchedFileSearchProgressSink, FileOperationOptions, FileSearchResult, - FileSearchResultGroup, FileTreeNode, SearchMatchType, + BatchedFileSearchProgressSink, FileOperationOptions, FileSearchResult, FileSearchResultGroup, + FileTreeNode, SearchMatchType, }; +use bitfun_core::service::file_watch; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::{get_remote_workspace_manager, RemoteWorkspaceEntry}; use bitfun_core::service::workspace::{ @@ -2925,15 +2926,15 @@ pub async fn report_ide_control_result(request: IdeControlResultRequest) -> Resu #[tauri::command] pub async fn start_file_watch(path: String, recursive: Option) -> Result<(), String> { - file_watcher::start_file_watch(path, recursive).await + file_watch::start_file_watch(path, recursive).await } #[tauri::command] pub async fn stop_file_watch(path: String) -> Result<(), String> { - file_watcher::stop_file_watch(path).await + file_watch::stop_file_watch(path).await } #[tauri::command] pub async fn get_watched_paths() -> Result, String> { - file_watcher::get_watched_paths().await + file_watch::get_watched_paths().await } diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index a8a26d71..6f006caf 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -4,9 +4,7 @@ use log::error; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; -use crate::api::context_upload_api::create_image_context_provider; use bitfun_core::agentic::{ tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, @@ -90,20 +88,13 @@ fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { ToolUseContext { tool_call_id: None, - message_id: None, agent_type: None, session_id: None, dialog_turn_id: None, workspace: normalized_workspace_path .map(|path| WorkspaceBinding::new(None, PathBuf::from(path))), - safe_mode: Some(false), - abort_controller: None, - read_file_timestamps: HashMap::new(), - options: None, - response_state: None, - image_context_provider: Some(Arc::new(create_image_context_provider())), + custom_data: HashMap::new(), computer_use_host: None, - subagent_parent_info: None, cancellation_token: None, workspace_services: None, } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 77661670..4617005a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -21,7 +21,6 @@ use std::sync::{ #[cfg(target_os = "macos")] use tauri::Emitter; use tauri::Manager; -use tauri_plugin_log::{RotationStrategy, TimezoneStrategy}; // Re-export API pub use api::*; @@ -148,27 +147,7 @@ pub async fn run() { setup_panic_hook(); let run_result = tauri::Builder::default() - .plugin( - tauri_plugin_log::Builder::new() - .level(log::LevelFilter::Trace) - .level_for("ignore", log::LevelFilter::Off) - .level_for("ignore::walk", log::LevelFilter::Off) - .level_for("globset", log::LevelFilter::Off) - .level_for("tracing", log::LevelFilter::Off) - .level_for("opentelemetry_sdk", log::LevelFilter::Off) - .level_for("opentelemetry-otlp", log::LevelFilter::Off) - .level_for("notify", log::LevelFilter::Off) - .level_for("hyper_util", log::LevelFilter::Info) - .level_for("h2", log::LevelFilter::Info) - .level_for("portable_pty", log::LevelFilter::Info) - .level_for("russh", log::LevelFilter::Info) - .targets(log_targets) - .rotation_strategy(RotationStrategy::KeepSome(2)) // 1 active + 2 backups - .max_file_size(10 * 1024 * 1024) - .timezone_strategy(TimezoneStrategy::UseLocal) - .clear_format() - .build(), - ) + .plugin(logging::build_log_plugin(log_targets)) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -771,7 +750,6 @@ async fn init_agentic_system() -> anyhow::Result<( let tool_registry = tools::registry::get_global_tool_registry(); let tool_state_manager = Arc::new(tools::pipeline::ToolStateManager::new(event_queue.clone())); - let image_context_provider = Arc::new(api::context_upload_api::create_image_context_provider()); let computer_use_host: ComputerUseHostRef = Arc::new(computer_use::DesktopComputerUseHost::new()); @@ -780,7 +758,6 @@ async fn init_agentic_system() -> anyhow::Result<( let tool_pipeline = Arc::new(tools::pipeline::ToolPipeline::new( tool_registry, tool_state_manager, - Some(image_context_provider), Some(computer_use_host), )); @@ -958,7 +935,7 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt service::snapshot::initialize_snapshot_event_emitter(emitter.clone()); - infrastructure::initialize_file_watcher(emitter.clone()); + bitfun_core::service::initialize_file_watch_service(emitter.clone()); if let Err(e) = workspace_identity_watch_service .set_event_emitter(emitter.clone()) diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index a9ee2136..dafe27c0 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -9,7 +9,8 @@ use std::sync::{ OnceLock, }; use std::thread; -use tauri_plugin_log::{fern, Target, TargetKind}; +use tauri::{plugin::TauriPlugin, Runtime}; +use tauri_plugin_log::{fern, RotationStrategy, Target, TargetKind, TimezoneStrategy}; const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$"; const MAX_LOG_SESSIONS: usize = 10; @@ -274,6 +275,28 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { targets } +pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin { + tauri_plugin_log::Builder::new() + .level(log::LevelFilter::Trace) + .level_for("ignore", log::LevelFilter::Off) + .level_for("ignore::walk", log::LevelFilter::Off) + .level_for("globset", log::LevelFilter::Off) + .level_for("tracing", log::LevelFilter::Off) + .level_for("opentelemetry_sdk", log::LevelFilter::Off) + .level_for("opentelemetry-otlp", log::LevelFilter::Off) + .level_for("notify", log::LevelFilter::Off) + .level_for("hyper_util", log::LevelFilter::Info) + .level_for("h2", log::LevelFilter::Info) + .level_for("portable_pty", log::LevelFilter::Info) + .level_for("russh", log::LevelFilter::Info) + .targets(log_targets) + .rotation_strategy(RotationStrategy::KeepSome(2)) // 1 active + 2 backups + .max_file_size(10 * 1024 * 1024) + .timezone_strategy(TimezoneStrategy::UseLocal) + .clear_format() + .build() +} + fn format_log_plain( out: fern::FormatCallback, message: &std::fmt::Arguments, diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 861ac6e9..10d62946 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -65,7 +65,6 @@ pub async fn initialize(workspace: Option) -> anyhow::Result\n\n"); project_layout } @@ -202,7 +204,6 @@ impl PromptBuilder { let result = format!( r#"# Project Context The following are project documentation that describe the project's architecture, conventions, and guidelines, etc. -These files are maintained by the user and should NOT be modified unless explicitly requested. {} diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index c38ef11d..dc2b1402 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -12,7 +12,6 @@ use crate::agentic::image_analysis::{ ImageLimits, }; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; -use crate::agentic::tools::framework::ToolOptions; use crate::agentic::tools::{get_all_registered_tools, SubagentParentInfo}; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; @@ -1480,32 +1479,12 @@ impl ExecutionEngine { ); let description_context = crate::agentic::tools::framework::ToolUseContext { tool_call_id: None, - message_id: None, agent_type: Some(agent_type.to_string()), session_id: None, dialog_turn_id: None, workspace: workspace.cloned(), - safe_mode: None, - abort_controller: None, - read_file_timestamps: Default::default(), - options: Some(ToolOptions { - commands: vec![], - tools: vec![], - verbose: None, - slow_and_capable_model: None, - safe_mode: None, - fork_number: None, - message_log_name: None, - max_thinking_tokens: None, - is_koding_request: None, - koding_context: None, - is_custom_command: None, - custom_data: Some(tool_opts_custom), - }), - response_state: None, - image_context_provider: None, + custom_data: tool_opts_custom, computer_use_host: None, - subagent_parent_info: None, cancellation_token: None, workspace_services: None, }; diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 87fc915a..3e436d46 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,6 +1,4 @@ //! Tool framework - Tool interface definition and execution context -use super::image_context::ImageContextProviderRef; -use super::pipeline::SubagentParentInfo; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use crate::util::errors::BitFunResult; @@ -16,21 +14,14 @@ use tokio_util::sync::CancellationToken; #[derive(Debug, Clone)] pub struct ToolUseContext { pub tool_call_id: Option, - pub message_id: Option, pub agent_type: Option, pub session_id: Option, pub dialog_turn_id: Option, pub workspace: Option, - pub safe_mode: Option, - pub abort_controller: Option, - pub read_file_timestamps: HashMap, - pub options: Option, - pub response_state: Option, - /// Image context provider (dependency injection) - pub image_context_provider: Option, + /// Extended context data passed from execution layer to tools. + pub custom_data: HashMap, /// Desktop automation (Computer use); only set in BitFun desktop. pub computer_use_host: Option, - pub subagent_parent_info: Option, // Cancel tool execution more timely, especially for tools like TaskTool that need to run for a long time pub cancellation_token: Option, /// Workspace I/O services (filesystem + shell) — use these instead of @@ -61,10 +52,8 @@ impl ToolUseContext { /// Whether the session primary model accepts image inputs (from tool-definition / pipeline context). /// Defaults to **true** when unset (e.g. API listings without model metadata). pub fn primary_model_supports_image_understanding(&self) -> bool { - self.options - .as_ref() - .and_then(|o| o.custom_data.as_ref()) - .and_then(|m| m.get("primary_model_supports_image_understanding")) + self.custom_data + .get("primary_model_supports_image_understanding") .and_then(|v| v.as_bool()) .unwrap_or(true) } @@ -90,31 +79,6 @@ impl ToolUseContext { } } -/// Tool options -#[derive(Debug, Clone)] -pub struct ToolOptions { - pub commands: Vec, - pub tools: Vec, - pub verbose: Option, - pub slow_and_capable_model: Option, - pub safe_mode: Option, - pub fork_number: Option, - pub message_log_name: Option, - pub max_thinking_tokens: Option, - pub is_koding_request: Option, - pub koding_context: Option, - pub is_custom_command: Option, - /// Extended data fields, for passing extra context information - pub custom_data: Option>, -} - -/// Response state - for model state management like GPT-5 -#[derive(Debug, Clone)] -pub struct ResponseState { - pub previous_response_id: Option, - pub conversation_id: Option, -} - /// Validation result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationResult { @@ -302,13 +266,19 @@ pub trait Tool: Send + Sync { format!("{} completed", self.name()) } - /// Call tool - return async generator + /// Execute the tool's concrete business logic. + /// Implementors should put the actual tool behavior here and assume + /// [`call`] will wrap it with cross-cutting concerns such as cancellation. async fn call_impl( &self, input: &Value, context: &ToolUseContext, ) -> BitFunResult>; + /// Unified tool entry point. + /// This method owns shared framework behavior and delegates the actual + /// execution to [`call_impl`], so most tools should override `call_impl` + /// instead of overriding this method directly. async fn call(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { if let Some(cancellation_token) = context.cancellation_token.as_ref() { tokio::select! { diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs index b00ddfd1..2265007e 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -316,10 +316,8 @@ The **primary model cannot consume images** in tool results — **do not** use * } fn primary_api_format(ctx: &ToolUseContext) -> String { - ctx.options - .as_ref() - .and_then(|o| o.custom_data.as_ref()) - .and_then(|m| m.get("primary_model_provider")) + ctx.custom_data + .get("primary_model_provider") .and_then(|v| v.as_str()) .unwrap_or("") .to_lowercase() diff --git a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs index daf0f9a2..664f7238 100644 --- a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs @@ -5,7 +5,7 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::util::list_files::{format_files_list, list_files}; +use crate::service::filesystem::{format_directory_listing, list_directory_entries}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use chrono::{DateTime, Local}; @@ -52,7 +52,6 @@ impl Tool for LSTool { Usage: - The path parameter must be an absolute path, not a relative path -- You can optionally provide an array of glob patterns to ignore with the ignore parameter - Hidden files (files starting with '.') are automatically excluded - Results are sorted by modification time (newest first)"# .to_string()) @@ -66,13 +65,6 @@ Usage: "type": "string", "description": "The absolute path to the directory to list (must be absolute, not relative)" }, - "ignore": { - "type": "array", - "items": { - "type": "string", - }, - "description": "List of glob patterns (relative to `path`) to ignore. Examples: \"*.js\" ignores all .js files." - }, "limit": { "type": "number", "description": "The maximum number of entries to return. Defaults to 100." @@ -266,13 +258,7 @@ Usage: } // Local: original implementation - let ignore_patterns = input.get("ignore").and_then(|v| v.as_array()).map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>() - }); - - let entries = list_files(path, limit, ignore_patterns).map_err(BitFunError::tool)?; + let entries = list_directory_entries(path, limit).map_err(BitFunError::tool)?; let entries_json = entries .iter() @@ -288,7 +274,7 @@ Usage: .collect::>(); let total_entries = entries.len(); - let mut result_text = format_files_list(entries, path); + let mut result_text = format_directory_listing(&entries, path); if total_entries == 0 { result_text.push_str("\n(no entries found)"); } else if total_entries >= limit { diff --git a/src/crates/core/src/agentic/tools/implementations/web_tools.rs b/src/crates/core/src/agentic/tools/implementations/web_tools.rs index fe8b6c64..7aab2851 100644 --- a/src/crates/core/src/agentic/tools/implementations/web_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/web_tools.rs @@ -555,7 +555,6 @@ mod tests { use super::{WebFetchTool, WebSearchTool}; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use serde_json::json; - use std::collections::HashMap; use std::io::ErrorKind; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -563,19 +562,12 @@ mod tests { fn empty_context() -> ToolUseContext { ToolUseContext { tool_call_id: None, - message_id: None, agent_type: None, session_id: None, dialog_turn_id: None, workspace: None, - safe_mode: None, - abort_controller: None, - read_file_timestamps: HashMap::new(), - options: None, - response_state: None, - image_context_provider: None, + custom_data: std::collections::HashMap::new(), computer_use_host: None, - subagent_parent_info: None, cancellation_token: None, workspace_services: None, } diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index b833e668..2076358d 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -8,10 +8,7 @@ use super::types::*; use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; use crate::agentic::events::types::ToolEventData; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; -use crate::agentic::tools::framework::{ - ToolOptions, ToolResult as FrameworkToolResult, ToolUseContext, -}; -use crate::agentic::tools::image_context::ImageContextProviderRef; +use crate::agentic::tools::framework::{ToolResult as FrameworkToolResult, ToolUseContext}; use crate::agentic::tools::registry::ToolRegistry; use crate::util::errors::{BitFunError, BitFunResult}; use dashmap::DashMap; @@ -208,8 +205,6 @@ pub struct ToolPipeline { confirmation_channels: Arc>>, /// Cancellation token management (tool_id -> CancellationToken) cancellation_tokens: Arc>, - /// Image context provider (dependency injection) - image_context_provider: Option, computer_use_host: Option, } @@ -217,7 +212,6 @@ impl ToolPipeline { pub fn new( tool_registry: Arc>, state_manager: Arc, - image_context_provider: Option, computer_use_host: Option, ) -> Self { Self { @@ -225,7 +219,6 @@ impl ToolPipeline { state_manager, confirmation_channels: Arc::new(DashMap::new()), cancellation_tokens: Arc::new(DashMap::new()), - image_context_provider, computer_use_host, } } @@ -719,88 +712,43 @@ impl ToolPipeline { // Build tool context (pass all resource IDs) let tool_context = ToolUseContext { tool_call_id: Some(task.tool_call.tool_id.clone()), - message_id: None, agent_type: Some(task.context.agent_type.clone()), session_id: Some(task.context.session_id.clone()), dialog_turn_id: Some(task.context.dialog_turn_id.clone()), workspace: task.context.workspace.clone(), - safe_mode: None, - abort_controller: None, - read_file_timestamps: Default::default(), - options: Some(ToolOptions { - commands: vec![], - tools: vec![], - verbose: None, - slow_and_capable_model: None, - safe_mode: None, - fork_number: None, - message_log_name: None, - max_thinking_tokens: None, - is_koding_request: None, - koding_context: None, - is_custom_command: None, - custom_data: Some({ - let mut map = HashMap::new(); - - if let Some(snapshot_id) = task - .context - .context_vars - .get("snapshot_session_id") - .or_else(|| task.context.context_vars.get("sandbox_session_id")) - { - map.insert( - "snapshot_session_id".to_string(), - serde_json::json!(snapshot_id), - ); - } - if let Some(turn_index) = task.context.context_vars.get("turn_index") { - if let Ok(n) = turn_index.parse::() { - map.insert("turn_index".to_string(), serde_json::json!(n)); - } - } + custom_data: { + let mut map = HashMap::new(); - if let Some(provider) = task.context.context_vars.get("primary_model_provider") - { - if !provider.is_empty() { - map.insert( - "primary_model_provider".to_string(), - serde_json::json!(provider), - ); - } + if let Some(turn_index) = task.context.context_vars.get("turn_index") { + if let Ok(n) = turn_index.parse::() { + map.insert("turn_index".to_string(), serde_json::json!(n)); } - if let Some(model_id) = task.context.context_vars.get("primary_model_id") { - if !model_id.is_empty() { - map.insert("primary_model_id".to_string(), serde_json::json!(model_id)); - } - } - if let Some(model_name) = task.context.context_vars.get("primary_model_name") { - if !model_name.is_empty() { - map.insert( - "primary_model_name".to_string(), - serde_json::json!(model_name), - ); - } + } + + if let Some(provider) = task.context.context_vars.get("primary_model_provider") { + if !provider.is_empty() { + map.insert( + "primary_model_provider".to_string(), + serde_json::json!(provider), + ); } - if let Some(supports_images) = task - .context - .context_vars - .get("primary_model_supports_image_understanding") - { - if let Ok(flag) = supports_images.parse::() { - map.insert( - "primary_model_supports_image_understanding".to_string(), - serde_json::json!(flag), - ); - } + } + if let Some(supports_images) = task + .context + .context_vars + .get("primary_model_supports_image_understanding") + { + if let Ok(flag) = supports_images.parse::() { + map.insert( + "primary_model_supports_image_understanding".to_string(), + serde_json::json!(flag), + ); } + } - map - }), - }), - response_state: None, - image_context_provider: self.image_context_provider.clone(), + map + }, computer_use_host: self.computer_use_host.clone(), - subagent_parent_info: task.context.subagent_parent_info.clone(), cancellation_token: Some(cancellation_token), workspace_services: task.context.workspace_services.clone(), }; diff --git a/src/crates/core/src/agentic/util/mod.rs b/src/crates/core/src/agentic/util/mod.rs index 05b3267e..50ec63cd 100644 --- a/src/crates/core/src/agentic/util/mod.rs +++ b/src/crates/core/src/agentic/util/mod.rs @@ -1,5 +1,3 @@ -pub mod list_files; pub mod remote_workspace_layout; -pub use list_files::get_formatted_files_list; pub use remote_workspace_layout::build_remote_workspace_layout_preview; diff --git a/src/crates/core/src/infrastructure/app_paths/mod.rs b/src/crates/core/src/infrastructure/app_paths/mod.rs new file mode 100644 index 00000000..093dcdbe --- /dev/null +++ b/src/crates/core/src/infrastructure/app_paths/mod.rs @@ -0,0 +1,9 @@ +//! Application path infrastructure. +//! +//! Centralizes path policy for user data, caches, sessions, and workspace-adjacent storage. + +pub mod path_manager; + +pub use path_manager::{ + get_path_manager_arc, try_get_path_manager_arc, CacheType, PathManager, StorageLevel, +}; diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/app_paths/path_manager.rs similarity index 100% rename from src/crates/core/src/infrastructure/filesystem/path_manager.rs rename to src/crates/core/src/infrastructure/app_paths/path_manager.rs diff --git a/src/crates/core/src/infrastructure/filesystem/mod.rs b/src/crates/core/src/infrastructure/filesystem/mod.rs index 9630a942..e1b0f234 100644 --- a/src/crates/core/src/infrastructure/filesystem/mod.rs +++ b/src/crates/core/src/infrastructure/filesystem/mod.rs @@ -1,11 +1,9 @@ //! Filesystem infrastructure //! -//! File operations, file tree building, file watching, and path management. +//! File operations and file tree building. pub mod file_operations; pub mod file_tree; -pub mod file_watcher; -pub mod path_manager; pub use file_operations::{ normalize_text_for_editor_disk_sync, FileInfo, FileOperationOptions, FileOperationService, @@ -16,9 +14,3 @@ pub use file_tree::{ FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, SearchMatchType, }; -pub use file_watcher::initialize_file_watcher; -#[cfg(feature = "tauri-support")] -pub use file_watcher::{get_watched_paths, start_file_watch, stop_file_watch}; -pub use path_manager::{ - get_path_manager_arc, try_get_path_manager_arc, CacheType, PathManager, StorageLevel, -}; diff --git a/src/crates/core/src/infrastructure/mod.rs b/src/crates/core/src/infrastructure/mod.rs index b3405815..b947b73a 100644 --- a/src/crates/core/src/infrastructure/mod.rs +++ b/src/crates/core/src/infrastructure/mod.rs @@ -3,18 +3,21 @@ //! Provides low-level services: AI clients, storage, event system pub mod ai; +pub mod app_paths; pub mod debug_log; pub mod events; pub mod filesystem; pub mod storage; pub use ai::AIClient; +pub use app_paths::{ + get_path_manager_arc, try_get_path_manager_arc, CacheType, PathManager, StorageLevel, +}; pub use events::BackendEventManager; pub use filesystem::{ - file_watcher, get_path_manager_arc, initialize_file_watcher, try_get_path_manager_arc, BatchedFileSearchProgressSink, FileContentSearchOptions, FileInfo, FileNameSearchOptions, FileOperationOptions, FileOperationService, FileReadResult, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, FileTreeOptions, - FileTreeService, FileTreeStatistics, FileWriteResult, PathManager, SearchMatchType, + FileTreeService, FileTreeStatistics, FileWriteResult, SearchMatchType, }; // pub use storage::{}; diff --git a/src/crates/core/src/service/file_watch/mod.rs b/src/crates/core/src/service/file_watch/mod.rs new file mode 100644 index 00000000..bb37c8d0 --- /dev/null +++ b/src/crates/core/src/service/file_watch/mod.rs @@ -0,0 +1,12 @@ +//! File watch service. +//! +//! Exposes filesystem watching as a product-facing service instead of a raw infrastructure detail. + +pub mod service; +pub mod types; + +pub use service::{ + get_global_file_watch_service, get_watched_paths, initialize_file_watch_service, + start_file_watch, stop_file_watch, FileWatchService, +}; +pub use types::{FileWatchEvent, FileWatchEventKind, FileWatcherConfig}; diff --git a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs b/src/crates/core/src/service/file_watch/service.rs similarity index 83% rename from src/crates/core/src/infrastructure/filesystem/file_watcher.rs rename to src/crates/core/src/service/file_watch/service.rs index 2e174c62..3d702ab3 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs +++ b/src/crates/core/src/service/file_watch/service.rs @@ -1,32 +1,12 @@ -//! File watcher service -//! -//! Uses the notify crate to watch filesystem changes and send them to the frontend via Tauri events - use crate::infrastructure::events::EventEmitter; use log::{debug, error}; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::{Mutex, RwLock}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileWatchEvent { - pub path: String, - pub kind: FileWatchEventKind, - pub timestamp: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FileWatchEventKind { - Create, - Modify, - Remove, - Rename { from: String, to: String }, - Other, -} +use super::types::{FileWatchEvent, FileWatchEventKind, FileWatcherConfig}; impl From<&EventKind> for FileWatchEventKind { fn from(kind: &EventKind) -> Self { @@ -40,26 +20,7 @@ impl From<&EventKind> for FileWatchEventKind { } } -#[derive(Debug, Clone)] -pub struct FileWatcherConfig { - pub watch_recursively: bool, - pub ignore_hidden_files: bool, - pub debounce_interval_ms: u64, - pub max_events_per_interval: usize, -} - -impl Default for FileWatcherConfig { - fn default() -> Self { - Self { - watch_recursively: true, - ignore_hidden_files: true, - debounce_interval_ms: 500, - max_events_per_interval: 100, - } - } -} - -pub struct FileWatcher { +pub struct FileWatchService { emitter: Arc>>>, watcher: Arc>>, watched_paths: Arc>>, @@ -79,7 +40,7 @@ fn lock_event_buffer( } } -impl FileWatcher { +impl FileWatchService { pub fn new(config: FileWatcherConfig) -> Self { Self { emitter: Arc::new(Mutex::new(None)), @@ -167,10 +128,6 @@ impl FileWatcher { let config = self.config.clone(); let watched_paths = self.watched_paths.clone(); - // Run on a dedicated blocking thread to avoid starving the async runtime. - // True debounce: accumulate events, then flush once the stream goes quiet for - // `debounce_interval_ms`. A 50 ms poll interval keeps latency low even for - // single-event bursts (e.g. one `fs::write` from an agentic tool). tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Handle::current(); let debounce = std::time::Duration::from_millis(config.debounce_interval_ms); @@ -193,7 +150,6 @@ impl FileWatcher { Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } - // Flush only after events have been quiet for the debounce window. if let Some(t) = last_event_time { if t.elapsed() >= debounce { rt.block_on(Self::flush_events_static(&event_buffer, &emitter_arc)); @@ -306,7 +262,7 @@ impl FileWatcher { || name_str.ends_with(".temp") || name_str.ends_with(".bak") || name_str.ends_with(".old") - || name_str.starts_with("#") && name_str.ends_with("#") + || name_str.starts_with('#') && name_str.ends_with('#') || name_str == ".DS_Store" || name_str == "Thumbs.db" || name_str == "desktop.ini" @@ -401,17 +357,17 @@ impl FileWatcher { } } -static GLOBAL_FILE_WATCHER: std::sync::OnceLock> = std::sync::OnceLock::new(); +static GLOBAL_FILE_WATCH_SERVICE: std::sync::OnceLock> = + std::sync::OnceLock::new(); -pub fn get_global_file_watcher() -> Arc { - GLOBAL_FILE_WATCHER - .get_or_init(|| Arc::new(FileWatcher::new(FileWatcherConfig::default()))) +pub fn get_global_file_watch_service() -> Arc { + GLOBAL_FILE_WATCH_SERVICE + .get_or_init(|| Arc::new(FileWatchService::new(FileWatcherConfig::default()))) .clone() } -// Note: This function is called by the Tauri API layer; tauri::command is declared in the API layer. pub async fn start_file_watch(path: String, recursive: Option) -> Result<(), String> { - let watcher = get_global_file_watcher(); + let watcher = get_global_file_watch_service(); let mut config = FileWatcherConfig::default(); if let Some(rec) = recursive { config.watch_recursively = rec; @@ -420,20 +376,18 @@ pub async fn start_file_watch(path: String, recursive: Option) -> Result<( watcher.watch_path(&path, Some(config)).await } -// Note: This function is called by the Tauri API layer, but is not directly marked #[tauri::command]. pub async fn stop_file_watch(path: String) -> Result<(), String> { - let watcher = get_global_file_watcher(); + let watcher = get_global_file_watch_service(); watcher.unwatch_path(&path).await } -// Note: This function is called by the Tauri API layer, but is not directly marked #[tauri::command]. pub async fn get_watched_paths() -> Result, String> { - let watcher = get_global_file_watcher(); + let watcher = get_global_file_watch_service(); Ok(watcher.get_watched_paths().await) } -pub fn initialize_file_watcher(emitter: Arc) { - let watcher = get_global_file_watcher(); +pub fn initialize_file_watch_service(emitter: Arc) { + let watcher = get_global_file_watch_service(); tokio::spawn(async move { watcher.set_emitter(emitter).await; diff --git a/src/crates/core/src/service/file_watch/types.rs b/src/crates/core/src/service/file_watch/types.rs new file mode 100644 index 00000000..d91a79f3 --- /dev/null +++ b/src/crates/core/src/service/file_watch/types.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileWatchEvent { + pub path: String, + pub kind: FileWatchEventKind, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileWatchEventKind { + Create, + Modify, + Remove, + Rename { from: String, to: String }, + Other, +} + +#[derive(Debug, Clone)] +pub struct FileWatcherConfig { + pub watch_recursively: bool, + pub ignore_hidden_files: bool, + pub debounce_interval_ms: u64, + pub max_events_per_interval: usize, +} + +impl Default for FileWatcherConfig { + fn default() -> Self { + Self { + watch_recursively: true, + ignore_hidden_files: true, + debounce_interval_ms: 500, + max_events_per_interval: 100, + } + } +} diff --git a/src/crates/core/src/agentic/util/list_files.rs b/src/crates/core/src/service/filesystem/listing.rs similarity index 56% rename from src/crates/core/src/agentic/util/list_files.rs rename to src/crates/core/src/service/filesystem/listing.rs index e6da5a4d..b2921604 100644 --- a/src/crates/core/src/agentic/util/list_files.rs +++ b/src/crates/core/src/service/filesystem/listing.rs @@ -1,94 +1,53 @@ -use globset::{GlobBuilder, GlobMatcher}; +use crate::util::errors::*; use ignore::gitignore::Gitignore; -use std::collections::HashMap; -use std::collections::VecDeque; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; #[derive(Debug, Clone)] -pub struct FileEntry { +pub struct DirectoryListingEntry { pub path: PathBuf, pub is_dir: bool, pub depth: usize, pub modified_time: SystemTime, } -// Compiled glob matcher with its dir_only flag -struct CompiledGlob { - matcher: GlobMatcher, - dir_only: bool, +#[derive(Debug, Clone)] +pub struct FormattedDirectoryListing { + pub reached_limit: bool, + pub text: String, } -impl CompiledGlob { - /// Check if the given path matches this glob pattern - /// - `rel_path_str`: relative path with forward slashes - /// - `is_dir`: whether the path is a directory - fn is_match(&self, rel_path_str: &str, is_dir: bool) -> bool { - // If pattern ends with '/', only match directories - if self.dir_only && !is_dir { - return false; - } - - if is_dir { - // For directories, try matching with and without trailing slash - self.matcher.is_match(rel_path_str) - || self.matcher.is_match(format!("{}/", rel_path_str)) - } else { - self.matcher.is_match(rel_path_str) - } - } +#[derive(Debug, Clone)] +struct TreeEntry { + path: String, + is_dir: bool, + modified_time: SystemTime, } -pub fn list_files( - dir_path: &str, - limit: usize, - glob_patterns: Option>, -) -> Result, String> { - // Validate directory path +pub fn list_directory_entries(dir_path: &str, limit: usize) -> BitFunResult> { let path = Path::new(dir_path); if !path.exists() { - return Err(format!("Directory does not exist: {}", dir_path)); + return Err(BitFunError::service(format!( + "Directory does not exist: {}", + dir_path + ))); } let mut result = Vec::new(); let mut queue = VecDeque::new(); - // Compile glob patterns if provided - let compiled_globs: Vec = glob_patterns - .map(|patterns| { - patterns - .into_iter() - .filter_map(|pattern| { - let dir_only = pattern.ends_with('/'); - GlobBuilder::new(&pattern) - .literal_separator(true) // '*' and '?' won't match path separators, only '**' will - .build() - .ok() - .map(|g| CompiledGlob { - matcher: g.compile_matcher(), - dir_only, - }) - }) - .collect() - }) - .unwrap_or_default(); - - // Initialize with the root directory if let Ok(metadata) = fs::symlink_metadata(path) { - // Don't start if the root directory itself is a symbolic link if !metadata.file_type().is_symlink() && metadata.is_dir() { - // Add root directory contents to queue but don't add root itself to result if let Ok(entries) = fs::read_dir(path) { for dir_entry in entries.flatten() { let entry_path = dir_entry.path(); if let Ok(entry_metadata) = fs::symlink_metadata(&entry_path) { - // Skip symbolic links completely if !entry_metadata.file_type().is_symlink() { - let is_dir = entry_metadata.is_dir(); - queue.push_back(FileEntry { + queue.push_back(DirectoryListingEntry { path: entry_path, - is_dir, + is_dir: entry_metadata.is_dir(), depth: 1, modified_time: entry_metadata .modified() @@ -101,10 +60,8 @@ pub fn list_files( } } - // Load .gitignore if it exists let gitignore = load_gitignore(path); - // Special folders that should not be expanded let special_folders = [ Path::new("/"), Path::new("/home"), @@ -115,8 +72,7 @@ pub fn list_files( Path::new("/Program Files (x86)"), ]; - // Folders to exclude - let excluded_folders = vec![ + let excluded_folders = [ "node_modules", "__pycache__", "env", @@ -142,7 +98,6 @@ pub fn list_files( let current_level_size = queue.len(); let mut level_complete = true; - // Process the current level for _ in 0..current_level_size { if result.len() >= limit { level_complete = false; @@ -154,72 +109,47 @@ pub fn list_files( }; let entry_path = &entry.path; - // Check if this is a special folder that should not be expanded let is_special = special_folders .iter() .any(|special| entry_path == *special || entry_path.starts_with(special)); - // Check if this folder should be excluded let folder_name = entry_path .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); let is_excluded = if entry.depth == 0 { - // Never exclude the root directory false } else { excluded_folders.contains(&folder_name) || (folder_name.starts_with('.') && folder_name != "." && folder_name != "..") }; - // Check .gitignore let is_gitignored = if let Some(ref gitignore) = gitignore { gitignore.matched(entry_path, entry.is_dir).is_ignore() } else { false }; - // Check if the entry is a symbolic link let is_symlink = if let Ok(metadata) = fs::symlink_metadata(entry_path) { metadata.file_type().is_symlink() } else { false }; - // Add to result if not excluded, not gitignored, and not a symbolic link if !is_excluded && !is_gitignored && !is_symlink { - // Check glob pattern match (relative to dir_path) - let matches_glob = if compiled_globs.is_empty() { - true // No glob patterns means match everything - } else if let Ok(rel_path) = entry.path.strip_prefix(path) { - // Convert to forward slashes for consistent matching - let rel_path_str = rel_path.to_string_lossy().replace('\\', "/"); - // Match if any pattern matches (OR logic) - compiled_globs - .iter() - .any(|glob| glob.is_match(&rel_path_str, entry.is_dir)) - } else { - false - }; - - if matches_glob { - result.push(entry.clone()); - } + result.push(entry.clone()); } - // Expand directories if they should be expanded (but not symbolic links) if entry.is_dir && !is_special && !is_excluded && !is_gitignored && !is_symlink { if let Ok(entries) = fs::read_dir(entry_path) { for dir_entry in entries.flatten() { let path = dir_entry.path(); if let Ok(metadata) = fs::symlink_metadata(&path) { - // Skip symbolic links completely if !metadata.file_type().is_symlink() { - let is_dir = metadata.is_dir(); - queue.push_back(FileEntry { + queue.push_back(DirectoryListingEntry { path, - is_dir, + is_dir: metadata.is_dir(), depth: entry.depth + 1, modified_time: metadata .modified() @@ -232,10 +162,8 @@ pub fn list_files( } } - // If we hit the limit and the current level is not complete, - // remove only the entries that exceeded the limit from this level if !level_complete { - let excess = result.len() - limit; + let excess = result.len().saturating_sub(limit); if excess > 0 { result.truncate(result.len() - excess); } @@ -246,31 +174,18 @@ pub fn list_files( Ok(result) } -// Tree node entry with path and modified time for sorting -#[derive(Debug, Clone)] -struct TreeEntry { - path: String, // relative path (with trailing slash for directories) - is_dir: bool, - modified_time: SystemTime, -} - -pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { +pub fn format_directory_listing(entries: &[DirectoryListingEntry], dir_path: &str) -> String { let base_path = Path::new(dir_path); let mut result = String::new(); - - // Add the root path as the first line result.push_str(&format!( "{}\n", base_path.display().to_string().replace('\\', "/") )); - // Parse paths into a tree structure let mut tree: HashMap> = HashMap::new(); + let mut added_dirs: HashSet = HashSet::new(); - // Track which directory entries have been added to avoid duplicates - let mut added_dirs: std::collections::HashSet = std::collections::HashSet::new(); - - for entry in files_list { + for entry in entries { if let Ok(rel_path) = entry.path.strip_prefix(base_path) { if let Some(rel_str) = rel_path.to_str() { let normalized = rel_str.replace('\\', "/"); @@ -279,19 +194,16 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { continue; } - // Add trailing slash for directories let final_path = if entry.is_dir && !normalized.ends_with('/') { format!("{}/", normalized) } else { normalized.clone() }; - // First, ensure all ancestor directories are added to the tree let parts: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); for i in 0..parts.len() { let is_final_entry = i == parts.len() - 1 && !entry.is_dir; if is_final_entry { - // This is the actual file, not a directory ancestor break; } @@ -302,27 +214,22 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { format!("{}/", parts[..i].join("/")) }; - // Only add if not already added if !added_dirs.contains(&ancestor_path) { added_dirs.insert(ancestor_path.clone()); tree.entry(ancestor_parent).or_default().push(TreeEntry { path: ancestor_path, is_dir: true, - modified_time: entry.modified_time, // Use the file's time for the directory + modified_time: entry.modified_time, }); } } - // Now add the actual entry (file or directory) - // For directories, skip if already added as ancestor if entry.is_dir && added_dirs.contains(&final_path) { continue; } - // Determine parent directory let parts_for_parent: Vec<&str> = final_path.split('/').collect(); let parent = if entry.is_dir { - // For directories, parts_for_parent ends with empty string if parts_for_parent.len() > 2 { format!( "{}/", @@ -331,16 +238,10 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { } else { "/".to_string() } + } else if parts_for_parent.len() > 1 { + format!("{}/", parts_for_parent[..parts_for_parent.len() - 1].join("/")) } else { - // For files - if parts_for_parent.len() > 1 { - format!( - "{}/", - parts_for_parent[..parts_for_parent.len() - 1].join("/") - ) - } else { - "/".to_string() - } + "/".to_string() }; if entry.is_dir { @@ -356,21 +257,13 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { } } - // Sort all entries in the tree: first by modified time (newest first), then by name for children in tree.values_mut() { - children.sort_by(|a, b| { - // First compare by modified time (descending - newest first) - match b.modified_time.cmp(&a.modified_time) { - std::cmp::Ordering::Equal => { - // If modified times are equal, sort by name (ascending) - a.path.cmp(&b.path) - } - other => other, - } + children.sort_by(|a, b| match b.modified_time.cmp(&a.modified_time) { + std::cmp::Ordering::Equal => a.path.cmp(&b.path), + other => other, }); } - // Build the formatted output recursively with tree-style prefixes fn format_tree( tree: &HashMap>, parent: &str, @@ -381,8 +274,6 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { let count = children.len(); for (i, child) in children.iter().enumerate() { let is_last = i == count - 1; - - // Extract the file/directory name (last component) let name = if child.is_dir { let dir_name = child.path[..child.path.len() - 1] .rsplit('/') @@ -393,15 +284,10 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { child.path.rsplit('/').next().unwrap_or("").to_string() }; - // Choose the appropriate connector let connector = if is_last { "└── " } else { "├── " }; - - // Add the line with prefix and connector result.push_str(&format!("{}{}{}\n", prefix, connector, name)); - // If it's a directory, recurse with updated prefix if child.is_dir { - // For children, add either "│ " or " " depending on whether current is last let child_prefix = if is_last { format!("{} ", prefix) } else { @@ -413,10 +299,8 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { } } - // Start with root level (empty parent string) format_tree(&tree, "/", "", &mut result); - // Remove trailing newline if result.ends_with('\n') { result.pop(); } @@ -424,6 +308,19 @@ pub fn format_files_list(files_list: Vec, dir_path: &str) -> String { result } +pub fn get_formatted_directory_listing( + dir_path: &str, + limit: usize, +) -> BitFunResult { + let entries = list_directory_entries(dir_path, limit)?; + let reached_limit = entries.len() >= limit; + let text = format_directory_listing(&entries, dir_path); + Ok(FormattedDirectoryListing { + reached_limit, + text, + }) +} + fn load_gitignore(dir_path: &Path) -> Option { let gitignore_path = dir_path.join(".gitignore"); @@ -436,14 +333,3 @@ fn load_gitignore(dir_path: &Path) -> Option { None } } - -pub fn get_formatted_files_list( - dir_path: &str, - limit: usize, - glob_patterns: Option>, -) -> Result<(bool, String), String> { - let files_list = list_files(dir_path, limit, glob_patterns)?; - let files_count = files_list.len(); - let formatted_files_list = format_files_list(files_list, dir_path); - Ok((files_count >= limit, formatted_files_list)) -} diff --git a/src/crates/core/src/service/filesystem/mod.rs b/src/crates/core/src/service/filesystem/mod.rs index 43147c7c..b139d2f7 100644 --- a/src/crates/core/src/service/filesystem/mod.rs +++ b/src/crates/core/src/service/filesystem/mod.rs @@ -3,9 +3,14 @@ //! Integrates file operations, file tree building, search, and related functionality. pub mod factory; +pub mod listing; pub mod service; pub mod types; pub use factory::FileSystemServiceFactory; +pub use listing::{ + format_directory_listing, get_formatted_directory_listing, list_directory_entries, + DirectoryListingEntry, FormattedDirectoryListing, +}; pub use service::FileSystemService; pub use types::{DirectoryScanResult, DirectoryStats, FileSearchOptions, FileSystemConfig}; diff --git a/src/crates/core/src/service/mcp/auth.rs b/src/crates/core/src/service/mcp/auth.rs index 718cef6b..5b92f57c 100644 --- a/src/crates/core/src/service/mcp/auth.rs +++ b/src/crates/core/src/service/mcp/auth.rs @@ -13,7 +13,7 @@ use std::path::PathBuf; use tokio::net::TcpListener; use tokio::sync::Mutex; -use crate::infrastructure::filesystem::path_manager::try_get_path_manager_arc; +use crate::infrastructure::try_get_path_manager_arc; use crate::service::mcp::server::{MCPServerConfig, MCPServerOAuthConfig}; use crate::util::errors::{BitFunError, BitFunResult}; diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 7430e32a..42496c43 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management pub mod cron; // Scheduled jobs pub mod diff; +pub mod file_watch; pub mod filesystem; // FileSystem management pub mod git; // Git service pub mod i18n; // I18n service @@ -38,6 +39,11 @@ pub use cron::{ pub use diff::{ DiffConfig, DiffHunk, DiffLine, DiffLineType, DiffOptions, DiffResult, DiffService, }; +pub use file_watch::{ + get_global_file_watch_service, get_watched_paths, initialize_file_watch_service, + start_file_watch, stop_file_watch, FileWatchEvent, FileWatchEventKind, FileWatchService, + FileWatcherConfig, +}; pub use filesystem::{DirectoryStats, FileSystemService, FileSystemServiceFactory}; pub use git::GitService; pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, LocaleMetadata}; diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index f92b88a4..1222b20c 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -567,10 +567,8 @@ impl WrappedTool { /// Extracts the turn index. fn extract_turn_index(&self, context: &ToolUseContext) -> usize { context - .options - .as_ref() - .and_then(|opts| opts.custom_data.as_ref()) - .and_then(|data| data.get("turn_index")) + .custom_data + .get("turn_index") .and_then(|v| v.as_u64()) .map(|v| v as usize) .unwrap_or(0) diff --git a/src/crates/core/src/service/workspace/context_generator.rs b/src/crates/core/src/service/workspace/context_generator.rs deleted file mode 100644 index c67b495a..00000000 --- a/src/crates/core/src/service/workspace/context_generator.rs +++ /dev/null @@ -1,646 +0,0 @@ -//! Workspace context generator - -use crate::infrastructure::FileTreeService; -use crate::service::workspace::WorkspaceManager; -use crate::util::errors::*; - -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// Workspace context generator. -pub struct WorkspaceContextGenerator { - workspace_manager: Option>>, - file_tree_service: Arc, -} - -/// Generated workspace context information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneratedWorkspaceContext { - pub app_name: String, - pub current_date: String, - pub operating_system: String, - pub working_directory: String, - pub directory_structure: String, - pub project_summary: Option, - pub git_info: Option, - pub statistics: Option, -} - -impl Default for GeneratedWorkspaceContext { - fn default() -> Self { - Self { - app_name: "BitFun".to_string(), - current_date: chrono::Utc::now() - .format("%Y-%m-%d %H:%M:%S UTC") - .to_string(), - operating_system: std::env::consts::OS.to_string(), - working_directory: std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "Unknown".to_string()), - directory_structure: "Unable to analyze directory structure".to_string(), - project_summary: None, - git_info: None, - statistics: None, - } - } -} - -/// Git information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GitInfo { - pub branch: Option, - pub commit_hash: Option, - pub status: Option, - pub remote_url: Option, -} - -/// Workspace statistics. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceStatistics { - pub total_files: usize, - pub total_directories: usize, - pub total_size_mb: u64, - pub code_files_count: usize, - pub most_common_extensions: Vec<(String, usize)>, -} - -/// Options for generating context. -#[derive(Debug, Clone)] -pub struct ContextGenerationOptions { - pub include_git_info: bool, - pub include_statistics: bool, - pub include_project_summary: bool, - pub max_directory_depth: Option, - pub max_files_shown: usize, - pub language: ContextLanguage, -} - -/// Context language. -#[derive(Debug, Clone)] -pub enum ContextLanguage { - Chinese, - English, -} - -impl Default for ContextGenerationOptions { - fn default() -> Self { - Self { - include_git_info: false, - include_statistics: true, - include_project_summary: true, - max_directory_depth: Some(3), - max_files_shown: 20, - language: ContextLanguage::Chinese, - } - } -} - -impl WorkspaceContextGenerator { - /// Creates a new workspace context generator. - pub fn new( - workspace_manager: Option>>, - file_tree_service: Arc, - ) -> Self { - Self { - workspace_manager, - file_tree_service, - } - } - - /// Creates a default generator. - #[allow(clippy::should_implement_trait)] - pub fn default() -> Self { - Self::new(None, Arc::new(FileTreeService::default())) - } - - /// Generates the full workspace context. - pub async fn generate_context( - &self, - workspace_path: Option<&str>, - options: ContextGenerationOptions, - ) -> BitFunResult { - let app_name = "BitFun".to_string(); - let current_date = self.get_current_date(&options.language); - let operating_system = self.get_operating_system(); - - let (working_directory, directory_structure, project_summary, git_info, statistics) = - if let Some(ws_path) = workspace_path { - self.generate_context_for_path(ws_path, &options).await? - } else if let Some(ref workspace_manager) = self.workspace_manager { - let manager = workspace_manager.read().await; - if let Some(current_workspace) = manager.get_current_workspace() { - let working_dir = current_workspace.root_path.to_string_lossy(); - self.generate_context_for_path(&working_dir, &options) - .await? - } else { - let current_dir = std::env::current_dir() - .map_err(|e| { - BitFunError::service(format!("Failed to get current directory: {}", e)) - })? - .to_string_lossy() - .to_string(); - self.generate_context_for_path(¤t_dir, &options) - .await? - } - } else { - let current_dir = std::env::current_dir() - .map_err(|e| { - BitFunError::service(format!("Failed to get current directory: {}", e)) - })? - .to_string_lossy() - .to_string(); - self.generate_context_for_path(¤t_dir, &options) - .await? - }; - - Ok(GeneratedWorkspaceContext { - app_name, - current_date, - operating_system, - working_directory, - directory_structure, - project_summary, - git_info, - statistics, - }) - } - - /// Generates context for the given path. - async fn generate_context_for_path( - &self, - path: &str, - options: &ContextGenerationOptions, - ) -> BitFunResult<( - String, - String, - Option, - Option, - Option, - )> { - let working_dir = path.to_string(); - - let dir_structure = self.generate_directory_structure(path, options).await?; - - let proj_summary = if options.include_project_summary { - self.get_project_summary(path).await - } else { - None - }; - - let git_info = if options.include_git_info { - self.get_git_info(path).await - } else { - None - }; - - let statistics = if options.include_statistics { - self.get_workspace_statistics(path).await.ok() - } else { - None - }; - - Ok(( - working_dir, - dir_structure, - proj_summary, - git_info, - statistics, - )) - } - - /// Generates the workspace context prompt. - pub async fn generate_context_prompt( - &self, - workspace_path: Option<&str>, - options: ContextGenerationOptions, - ) -> BitFunResult { - let context = self - .generate_context(workspace_path, options.clone()) - .await?; - - let mut prompt = match options.language { - ContextLanguage::Chinese => { - format!( - "这是{}。我们正在设置聊天上下文。\n\ - 今天的日期是:{}\n\ - 我的操作系统是:{}\n\ - 我当前正在工作的目录:{}\n\ - 以下是当前工作目录的文件夹结构:\n\n\ - 显示最多{}个项目(文件+文件夹)。用...表示的文件夹或文件包含更多未显示的项目,或者已达到显示限制。\n\n\ - {}\n", - context.app_name, - context.current_date, - context.operating_system, - context.working_directory, - options.max_files_shown, - context.directory_structure - ) - } - ContextLanguage::English => { - format!( - "This is the {}. We are setting up the context for our chat.\n\ - Today's date is {}.\n\ - My operating system is: {}\n\ - I'm currently working in the directory: {}\n\ - Here is the folder structure of the current working directories:\n\n\ - Showing up to {} items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit was reached.\n\n\ - {}\n", - context.app_name, - context.current_date, - context.operating_system, - context.working_directory, - options.max_files_shown, - context.directory_structure - ) - } - }; - - if let Some(summary) = context.project_summary { - match options.language { - ContextLanguage::Chinese => { - prompt.push_str(&format!("\n\n项目总结:\n{}\n", summary)); - } - ContextLanguage::English => { - prompt.push_str(&format!("\n\nProject Summary:\n{}\n", summary)); - } - } - } - - if let Some(git_info) = context.git_info { - match options.language { - ContextLanguage::Chinese => { - prompt.push_str("\n\nGit信息:\n"); - if let Some(branch) = git_info.branch { - prompt.push_str(&format!("- 当前分支:{}\n", branch)); - } - if let Some(commit) = git_info.commit_hash { - prompt.push_str(&format!("- 最新提交:{}\n", commit)); - } - if let Some(status) = git_info.status { - prompt.push_str(&format!("- 工作区状态:{}\n", status)); - } - } - ContextLanguage::English => { - prompt.push_str("\n\nGit Information:\n"); - if let Some(branch) = git_info.branch { - prompt.push_str(&format!("- Current branch: {}\n", branch)); - } - if let Some(commit) = git_info.commit_hash { - prompt.push_str(&format!("- Latest commit: {}\n", commit)); - } - if let Some(status) = git_info.status { - prompt.push_str(&format!("- Working tree status: {}\n", status)); - } - } - } - } - - if let Some(stats) = context.statistics { - match options.language { - ContextLanguage::Chinese => { - prompt.push_str(&format!( - "\n\n工作区统计:\n\ - - 总文件数:{}\n\ - - 总目录数:{}\n\ - - 总大小:{}MB\n\ - - 代码文件数:{}\n", - stats.total_files, - stats.total_directories, - stats.total_size_mb, - stats.code_files_count - )); - - if !stats.most_common_extensions.is_empty() { - prompt.push_str("- 常见文件类型:"); - for (ext, count) in stats.most_common_extensions.iter().take(5) { - prompt.push_str(&format!(" .{}({})", ext, count)); - } - prompt.push('\n'); - } - } - ContextLanguage::English => { - prompt.push_str(&format!( - "\n\nWorkspace Statistics:\n\ - - Total files: {}\n\ - - Total directories: {}\n\ - - Total size: {}MB\n\ - - Code files: {}\n", - stats.total_files, - stats.total_directories, - stats.total_size_mb, - stats.code_files_count - )); - - if !stats.most_common_extensions.is_empty() { - prompt.push_str("- Common file types:"); - for (ext, count) in stats.most_common_extensions.iter().take(5) { - prompt.push_str(&format!(" .{}({})", ext, count)); - } - prompt.push('\n'); - } - } - } - } - - Ok(prompt) - } - - /// Legacy-compatible API: generate context. - pub async fn generate_context_legacy( - &self, - workspace_path: Option<&str>, - ) -> BitFunResult { - let options = ContextGenerationOptions::default(); - self.generate_context(workspace_path, options).await - } - - /// Legacy-compatible API: generate context prompt. - pub async fn generate_context_prompt_legacy( - &self, - workspace_path: Option<&str>, - ) -> BitFunResult { - let options = ContextGenerationOptions::default(); - self.generate_context_prompt(workspace_path, options).await - } - - /// Returns the current date. - fn get_current_date(&self, language: &ContextLanguage) -> String { - let now = Utc::now(); - match language { - ContextLanguage::Chinese => now - .format("%Y年%m月%d日星期%u") - .to_string() - .replace("星期1", "星期一") - .replace("星期2", "星期二") - .replace("星期3", "星期三") - .replace("星期4", "星期四") - .replace("星期5", "星期五") - .replace("星期6", "星期六") - .replace("星期7", "星期日"), - ContextLanguage::English => now.format("%A, %B %d, %Y").to_string(), - } - } - - /// Returns operating system information. - fn get_operating_system(&self) -> String { - match std::env::consts::OS { - "windows" => { - let arch = if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "x86") { - "x86" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - "unknown" - }; - format!("Windows ({})", arch) - } - "macos" => { - let arch = if cfg!(target_arch = "aarch64") { - "Apple Silicon" - } else { - "Intel" - }; - format!("macOS ({})", arch) - } - "linux" => { - let arch = std::env::consts::ARCH; - format!("Linux ({})", arch) - } - other => other.to_string(), - } - } - - /// Generates the directory structure (using the new file tree service). - async fn generate_directory_structure( - &self, - path: &str, - options: &ContextGenerationOptions, - ) -> BitFunResult { - let path_buf = PathBuf::from(path); - if !path_buf.exists() { - return Err(BitFunError::service(format!( - "Directory does not exist: {}", - path - ))); - } - - let contents = self - .file_tree_service - .get_directory_contents(path) - .await - .map_err(BitFunError::service)?; - - let mut structure = String::new(); - let dir_name = path_buf - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown"); - - structure.push_str(&format!("{}/\n", dir_name)); - - let mut directories = Vec::new(); - let mut files = Vec::new(); - - for item in contents { - let display_name = if item.is_directory { - format!("{}/", item.name) - } else { - item.name.clone() - }; - - if item.is_directory { - directories.push(display_name); - } else { - files.push(display_name); - } - } - - directories.sort(); - files.sort(); - - let mut all_entries = Vec::new(); - all_entries.extend(directories); - all_entries.extend(files); - - if all_entries.len() > options.max_files_shown { - all_entries.truncate(options.max_files_shown); - all_entries.push("... (more items not shown)".to_string()); - } - - for (i, entry) in all_entries.iter().enumerate() { - let prefix = if i == all_entries.len() - 1 { - "└── " - } else { - "├── " - }; - structure.push_str(&format!("{}{}\n", prefix, entry)); - } - - Ok(structure) - } - - /// Retrieves a project summary. - async fn get_project_summary(&self, workspace_path: &str) -> Option { - let readme_files = ["README.md", "README.txt", "README.rst", "readme.md"]; - let package_files = ["package.json", "Cargo.toml", "pyproject.toml", "pom.xml"]; - - for readme_file in &readme_files { - let readme_path = PathBuf::from(workspace_path).join(readme_file); - if let Ok(content) = tokio::fs::read_to_string(&readme_path).await { - let lines: Vec<&str> = content.lines().take(10).collect(); - let summary = lines.join("\n"); - if !summary.trim().is_empty() { - return Some(format!("From {}:\n{}", readme_file, summary)); - } - } - } - - for package_file in &package_files { - let package_path = PathBuf::from(workspace_path).join(package_file); - if package_path.exists() { - if let Some(summary) = self.extract_project_info_from_config(&package_path).await { - return Some(summary); - } - } - } - - None - } - - /// Extracts project information from a config file. - async fn extract_project_info_from_config(&self, config_path: &PathBuf) -> Option { - let file_name = config_path.file_name()?.to_str()?; - - match file_name { - "package.json" => { - if let Ok(content) = tokio::fs::read_to_string(config_path).await { - if let Ok(json) = serde_json::from_str::(&content) { - let mut info = String::new(); - if let Some(name) = json.get("name").and_then(|v| v.as_str()) { - info.push_str(&format!("Package: {}\n", name)); - } - if let Some(desc) = json.get("description").and_then(|v| v.as_str()) { - info.push_str(&format!("Description: {}\n", desc)); - } - if let Some(version) = json.get("version").and_then(|v| v.as_str()) { - info.push_str(&format!("Version: {}\n", version)); - } - if !info.is_empty() { - return Some(format!("From package.json:\n{}", info)); - } - } - } - } - "Cargo.toml" => { - if let Ok(content) = tokio::fs::read_to_string(config_path).await { - let mut info = String::new(); - let lines: Vec<&str> = content.lines().collect(); - let mut in_package = false; - - for line in lines { - let line = line.trim(); - if line == "[package]" { - in_package = true; - continue; - } - if line.starts_with('[') && line != "[package]" { - in_package = false; - } - - if in_package - && line.contains('=') - && (line.starts_with("name") - || line.starts_with("description") - || line.starts_with("version")) - { - info.push_str(&format!("{}\n", line)); - } - } - - if !info.is_empty() { - return Some(format!("From Cargo.toml:\n{}", info)); - } - } - } - _ => {} - } - - None - } - - /// Retrieves Git information. - async fn get_git_info(&self, workspace_path: &str) -> Option { - let git_dir = PathBuf::from(workspace_path).join(".git"); - if !git_dir.exists() { - return None; - } - - let mut git_info = GitInfo { - branch: None, - commit_hash: None, - status: None, - remote_url: None, - }; - - if let Ok(head_content) = tokio::fs::read_to_string(git_dir.join("HEAD")).await { - if let Some(branch) = head_content.strip_prefix("ref: refs/heads/") { - git_info.branch = Some(branch.trim().to_string()); - } - } - - if let Some(branch) = &git_info.branch { - let commit_file = git_dir.join("refs").join("heads").join(branch); - if let Ok(commit_content) = tokio::fs::read_to_string(commit_file).await { - git_info.commit_hash = Some(commit_content.trim().chars().take(8).collect()); - } - } - - git_info.status = Some("Clean".to_string()); - - Some(git_info) - } - - /// Retrieves workspace statistics. - async fn get_workspace_statistics( - &self, - workspace_path: &str, - ) -> BitFunResult { - if let Ok((_, file_stats)) = self - .file_tree_service - .build_tree_with_stats(workspace_path) - .await - { - let code_extensions = [ - "rs", "js", "ts", "py", "java", "cpp", "c", "h", "hpp", "go", "php", "rb", "swift", - "kt", "scala", "sh", "bash", "html", "css", "scss", "less", "vue", "jsx", "tsx", - ]; - - let code_files_count = file_stats - .file_type_counts - .iter() - .filter(|(ext, _)| code_extensions.contains(&ext.as_str())) - .map(|(_, count)| count) - .sum(); - - let mut most_common: Vec<_> = file_stats.file_type_counts.into_iter().collect(); - most_common.sort_by(|a, b| b.1.cmp(&a.1)); - - Ok(WorkspaceStatistics { - total_files: file_stats.total_files, - total_directories: file_stats.total_directories, - total_size_mb: file_stats.total_size_bytes / (1024 * 1024), - code_files_count, - most_common_extensions: most_common.into_iter().take(10).collect(), - }) - } else { - Err(BitFunError::service( - "Failed to get workspace statistics".to_string(), - )) - } - } -} diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index 4cbfcc87..aabedea5 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -2,7 +2,6 @@ //! //! Full workspace management system: open, manage, scan, statistics, etc. -pub mod context_generator; pub mod factory; pub mod identity_watch; pub mod manager; @@ -10,11 +9,6 @@ pub mod provider; pub mod service; // Re-export main components -pub use context_generator::{ - ContextGenerationOptions, ContextLanguage, GeneratedWorkspaceContext, - GitInfo as ContextGitInfo, WorkspaceContextGenerator, - WorkspaceStatistics as ContextWorkspaceStatistics, -}; pub use factory::WorkspaceFactory; pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{