From 15b14c9c94a2d4259701ee5d96a48491c4ed84c7 Mon Sep 17 00:00:00 2001 From: 1-6 Date: Sat, 4 Jul 2026 23:24:18 +0800 Subject: [PATCH 1/4] fix(conversation): search workspace files recursively --- crates/aionui-conversation/src/service_ops.rs | 118 ++++++++++++++++-- .../aionui-conversation/src/service_test.rs | 114 ++++++++++++++++- 2 files changed, 222 insertions(+), 10 deletions(-) diff --git a/crates/aionui-conversation/src/service_ops.rs b/crates/aionui-conversation/src/service_ops.rs index a67797d3..25996fd9 100644 --- a/crates/aionui-conversation/src/service_ops.rs +++ b/crates/aionui-conversation/src/service_ops.rs @@ -7,7 +7,7 @@ //! Kept in a separate file from service.rs to avoid pushing that file //! over 2000 lines. -use std::path::Component; +use std::path::{Component, Path, PathBuf}; use aionui_ai_agent::{AcpError, AgentError}; use aionui_api_types::{ @@ -21,6 +21,97 @@ use crate::ConversationError; use crate::service::{AssistantRuntimePreferenceUpdate, ConversationService}; const MAX_DIR_DEPTH: usize = 10; +const MAX_WORKSPACE_SEARCH_RESULTS: usize = 200; +const MAX_WORKSPACE_SEARCH_SCANNED: usize = 5000; +const MAX_WORKSPACE_SEARCH_FILE_BYTES: u64 = 512 * 1024; + +fn workspace_search_matches_content(path: &Path, needle: &str) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() || metadata.len() > MAX_WORKSPACE_SEARCH_FILE_BYTES { + return false; + } + + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + content.to_lowercase().contains(needle) +} + +fn workspace_search_entry(base: &Path, path: &Path, is_dir: bool) -> Option { + let relative = path.strip_prefix(base).ok()?; + let name = relative.to_string_lossy().replace('\\', "/"); + if name.is_empty() { + return None; + } + + Some(WorkspaceEntry { + name, + entry_type: if is_dir { "directory" } else { "file" }.into(), + }) +} + +fn search_workspace_entries_sync(base: PathBuf, search_root: PathBuf, search: String) -> Vec { + let needle = search.to_lowercase(); + let mut entries = Vec::new(); + let mut scanned = 0usize; + let mut stack = vec![search_root]; + + while let Some(dir) = stack.pop() { + if entries.len() >= MAX_WORKSPACE_SEARCH_RESULTS || scanned >= MAX_WORKSPACE_SEARCH_SCANNED { + break; + } + + let Ok(reader) = std::fs::read_dir(&dir) else { + continue; + }; + + for entry in reader.flatten() { + if entries.len() >= MAX_WORKSPACE_SEARCH_RESULTS || scanned >= MAX_WORKSPACE_SEARCH_SCANNED { + break; + } + + scanned += 1; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().into_owned(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + + let is_dir = metadata.is_dir(); + let is_file = metadata.is_file(); + let name_matches = file_name.to_lowercase().contains(&needle) + || path + .strip_prefix(&base) + .ok() + .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) + .unwrap_or(false); + let content_matches = is_file && workspace_search_matches_content(&path, &needle); + + if (name_matches || content_matches) + && let Some(search_entry) = workspace_search_entry(&base, &path, is_dir) + { + entries.push(search_entry); + } + + if is_dir { + stack.push(path); + } + } + } + + entries.sort_by(|a, b| { + let type_cmp = a.entry_type.cmp(&b.entry_type); + if type_cmp == std::cmp::Ordering::Equal { + a.name.to_lowercase().cmp(&b.name.to_lowercase()) + } else { + type_cmp + } + }); + + entries +} impl ConversationService { // ── Config Options ────────────────────────────────────────────── @@ -241,6 +332,23 @@ impl ConversationService { }); } + if let Some(search) = query + .search + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + let entries = tokio::task::spawn_blocking({ + let canonical_base = canonical_base.clone(); + let canonical_browse = canonical_browse.clone(); + let search = search.to_owned(); + move || search_workspace_entries_sync(canonical_base, canonical_browse, search) + }) + .await + .map_err(|e| ConversationError::internal(format!("Workspace search task failed: {e}")))?; + return Ok(entries); + } + let mut entries = Vec::new(); let mut dir_reader = tokio::fs::read_dir(&canonical_browse) .await @@ -249,14 +357,6 @@ impl ConversationService { while let Ok(Some(entry)) = dir_reader.next_entry().await { let name = entry.file_name().to_string_lossy().into_owned(); - // Apply search filter if provided - if let Some(ref search) = query.search - && !search.is_empty() - && !name.to_lowercase().contains(&search.to_lowercase()) - { - continue; - } - let entry_path = entry.path(); let metadata = tokio::fs::metadata(&entry_path) .await diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 630617a7..b06e0993 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -19,7 +19,7 @@ use aionui_ai_agent::{ use aionui_api_types::{ AcpConfigOptionDto, AgentErrorCode, AgentModeResponse, ConfigOptionConfirmation, ConversationArtifactKind, ConversationResponse, GetConfigOptionsResponse, GetModelInfoResponse, ModelInfoEntry, ModelInfoPayload, - SetConfigOptionRequest, SetConfigOptionResponse, + SetConfigOptionRequest, SetConfigOptionResponse, WorkspaceBrowseQuery, }; use aionui_api_types::{ CloneConversationRequest, CreateConversationRequest, ListConversationsQuery, SearchMessagesQuery, @@ -2227,6 +2227,118 @@ async fn delete_preserves_user_supplied_workspace_directory() { assert!(user_workspace.is_dir()); } +#[tokio::test] +async fn browse_workspace_search_matches_nested_file_names() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("src/components")).unwrap(); + std::fs::write( + user_workspace.join("src/components/SearchPanel.tsx"), + "export const panel = true;", + ) + .unwrap(); + std::fs::write(user_workspace.join("README.md"), "overview").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("searchpanel".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "src/components/SearchPanel.tsx"); + assert_eq!(entries[0].entry_type, "file"); +} + +#[tokio::test] +async fn browse_workspace_search_matches_nested_file_contents() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("docs")).unwrap(); + std::fs::write( + user_workspace.join("docs/notes.md"), + "release checklist mentions ProjectDelta", + ) + .unwrap(); + std::fs::write(user_workspace.join("unrelated.md"), "nothing here").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("projectdelta".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "docs/notes.md"); + assert_eq!(entries[0].entry_type, "file"); +} + +#[tokio::test] +async fn browse_workspace_without_search_keeps_shallow_directory_listing() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("src")).unwrap(); + std::fs::write(user_workspace.join("src/nested.txt"), "nested").unwrap(); + std::fs::write(user_workspace.join("README.md"), "overview").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: None, + }, + ) + .await + .unwrap(); + let names = entries.iter().map(|entry| entry.name.as_str()).collect::>(); + + assert_eq!(names, vec!["src", "README.md"]); +} + // ── Broadcast payload tests ──────────────────────────────────────── #[tokio::test] From 40e3b8694e59a05e4e280f59481b7e0e32185034 Mon Sep 17 00:00:00 2001 From: 1-6 Date: Sun, 5 Jul 2026 08:54:17 +0800 Subject: [PATCH 2/4] fix(conversation): respect workspace search excludes --- Cargo.lock | 1 + crates/aionui-conversation/Cargo.toml | 1 + crates/aionui-conversation/src/service_ops.rs | 99 ++++++++---- .../aionui-conversation/src/service_test.rs | 142 ++++++++++++++++++ 4 files changed, 210 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a6687d3..484d55e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,6 +571,7 @@ dependencies = [ "axum", "base64", "chrono", + "ignore", "regex", "serde", "serde_json", diff --git a/crates/aionui-conversation/Cargo.toml b/crates/aionui-conversation/Cargo.toml index 2a116084..11b2da5f 100644 --- a/crates/aionui-conversation/Cargo.toml +++ b/crates/aionui-conversation/Cargo.toml @@ -16,6 +16,7 @@ aionui-runtime.workspace = true axum.workspace = true base64.workspace = true chrono.workspace = true +ignore.workspace = true regex.workspace = true tokio.workspace = true serde.workspace = true diff --git a/crates/aionui-conversation/src/service_ops.rs b/crates/aionui-conversation/src/service_ops.rs index 25996fd9..74bece2e 100644 --- a/crates/aionui-conversation/src/service_ops.rs +++ b/crates/aionui-conversation/src/service_ops.rs @@ -15,6 +15,7 @@ use aionui_api_types::{ SideQuestionRequest, SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry, }; use aionui_common::{AgentKillReason, ErrorChain}; +use ignore::{DirEntry, WalkBuilder}; use tracing::warn; use crate::ConversationError; @@ -24,6 +25,20 @@ const MAX_DIR_DEPTH: usize = 10; const MAX_WORKSPACE_SEARCH_RESULTS: usize = 200; const MAX_WORKSPACE_SEARCH_SCANNED: usize = 5000; const MAX_WORKSPACE_SEARCH_FILE_BYTES: u64 = 512 * 1024; +const WORKSPACE_SEARCH_PRUNED_DIRS: &[&str] = &[ + ".git", + ".hg", + ".svn", + ".cache", + ".next", + ".turbo", + "build", + "coverage", + "dist", + "node_modules", + "out", + "target", +]; fn workspace_search_matches_content(path: &Path, needle: &str) -> bool { let Ok(metadata) = std::fs::metadata(path) else { @@ -52,52 +67,70 @@ fn workspace_search_entry(base: &Path, path: &Path, is_dir: bool) -> Option bool { + if entry.path() == search_root { + return false; + } + + if !entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false) { + return false; + } + + let Some(name) = entry.file_name().to_str() else { + return false; + }; + + WORKSPACE_SEARCH_PRUNED_DIRS.contains(&name) +} + fn search_workspace_entries_sync(base: PathBuf, search_root: PathBuf, search: String) -> Vec { let needle = search.to_lowercase(); let mut entries = Vec::new(); let mut scanned = 0usize; - let mut stack = vec![search_root]; - while let Some(dir) = stack.pop() { + let mut walker_builder = WalkBuilder::new(&search_root); + let filter_search_root = search_root.clone(); + walker_builder + .hidden(false) + .git_ignore(true) + .git_global(false) + .git_exclude(true) + .require_git(false) + .filter_entry(move |entry| !should_prune_workspace_search_entry(entry, &filter_search_root)); + let walker = walker_builder.build(); + + for entry in walker { if entries.len() >= MAX_WORKSPACE_SEARCH_RESULTS || scanned >= MAX_WORKSPACE_SEARCH_SCANNED { break; } - let Ok(reader) = std::fs::read_dir(&dir) else { + let Ok(entry) = entry else { continue; }; + let path = entry.path(); + if path == search_root { + continue; + } - for entry in reader.flatten() { - if entries.len() >= MAX_WORKSPACE_SEARCH_RESULTS || scanned >= MAX_WORKSPACE_SEARCH_SCANNED { - break; - } - - scanned += 1; - let path = entry.path(); - let file_name = entry.file_name().to_string_lossy().into_owned(); - let Ok(metadata) = entry.metadata() else { - continue; - }; - - let is_dir = metadata.is_dir(); - let is_file = metadata.is_file(); - let name_matches = file_name.to_lowercase().contains(&needle) - || path - .strip_prefix(&base) - .ok() - .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) - .unwrap_or(false); - let content_matches = is_file && workspace_search_matches_content(&path, &needle); - - if (name_matches || content_matches) - && let Some(search_entry) = workspace_search_entry(&base, &path, is_dir) - { - entries.push(search_entry); - } + scanned += 1; + let Some(file_type) = entry.file_type() else { + continue; + }; - if is_dir { - stack.push(path); - } + let is_dir = file_type.is_dir(); + let is_file = file_type.is_file(); + let name_matches = entry.file_name().to_string_lossy().to_lowercase().contains(&needle) + || path + .strip_prefix(&base) + .ok() + .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) + .unwrap_or(false); + let content_matches = is_file && workspace_search_matches_content(path, &needle); + + if (name_matches || content_matches) + && let Some(search_entry) = workspace_search_entry(&base, path, is_dir) + { + entries.push(search_entry); } } diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index b06e0993..0b494ff5 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -2305,6 +2305,148 @@ async fn browse_workspace_search_matches_nested_file_contents() { assert_eq!(entries[0].entry_type, "file"); } +#[tokio::test] +async fn browse_workspace_search_skips_dependency_dirs_by_default() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("node_modules/pkg")).unwrap(); + std::fs::write( + user_workspace.join("node_modules/pkg/secret.txt"), + "DependencyOnlyNeedle should not be scanned by default", + ) + .unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("dependencyonlyneedle".into()), + }, + ) + .await + .unwrap(); + + assert!(entries.is_empty()); +} + +#[tokio::test] +async fn browse_workspace_search_allows_explicit_dependency_dir_root() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("node_modules/pkg")).unwrap(); + std::fs::write( + user_workspace.join("node_modules/pkg/secret.txt"), + "DependencyOnlyNeedle should be scanned when node_modules is explicit", + ) + .unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: "node_modules".into(), + search: Some("dependencyonlyneedle".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "node_modules/pkg/secret.txt"); + assert_eq!(entries[0].entry_type, "file"); +} + +#[tokio::test] +async fn browse_workspace_search_respects_gitignore_from_workspace_root() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("ignored")).unwrap(); + std::fs::write(user_workspace.join(".gitignore"), "ignored/\n").unwrap(); + std::fs::write(user_workspace.join("ignored/notes.md"), "IgnoredNeedle").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("ignoredneedle".into()), + }, + ) + .await + .unwrap(); + + assert!(entries.is_empty()); +} + +#[tokio::test] +async fn browse_workspace_search_allows_explicit_gitignored_root() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(user_workspace.join("ignored")).unwrap(); + std::fs::write(user_workspace.join(".gitignore"), "ignored/\n").unwrap(); + std::fs::write(user_workspace.join("ignored/notes.md"), "IgnoredNeedle").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: "ignored".into(), + search: Some("ignoredneedle".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "ignored/notes.md"); + assert_eq!(entries[0].entry_type, "file"); +} + #[tokio::test] async fn browse_workspace_without_search_keeps_shallow_directory_listing() { let temp = tempfile::tempdir().unwrap(); From 76dc69173ab0fc77855e85d1c90b64f4bacd25a6 Mon Sep 17 00:00:00 2001 From: 1-6 Date: Sun, 5 Jul 2026 22:36:24 +0800 Subject: [PATCH 3/4] feat(conversation): expose workspace search modes --- crates/aionui-api-types/src/acp.rs | 21 ++++ crates/aionui-api-types/src/lib.rs | 2 +- crates/aionui-conversation/src/service_ops.rs | 64 ++++++++--- .../aionui-conversation/src/service_test.rs | 100 +++++++++++++++++- 4 files changed, 172 insertions(+), 15 deletions(-) diff --git a/crates/aionui-api-types/src/acp.rs b/crates/aionui-api-types/src/acp.rs index 122853ce..31d7ffc6 100644 --- a/crates/aionui-api-types/src/acp.rs +++ b/crates/aionui-api-types/src/acp.rs @@ -172,6 +172,25 @@ pub struct WorkspaceBrowseQuery { pub path: String, #[serde(skip_serializing_if = "Option::is_none")] pub search: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_mode: Option, +} + +/// Search mode for workspace browse. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchMode { + All, + Name, + Content, +} + +/// How a workspace search result matched the query. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchMatchKind { + Name, + Content, } /// A file or directory entry in the workspace browse response. @@ -180,6 +199,8 @@ pub struct WorkspaceEntry { pub name: String, #[serde(rename = "type")] pub entry_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub match_kind: Option, } /// Request body for side question. diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 670f1dc5..e008d9fd 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -35,7 +35,7 @@ pub use acp::{ DetectCliRequest, DetectCliResponse, GetConfigOptionsResponse, GetModelInfoResponse, ModelInfoEntry, ModelInfoPayload, ProbeModelRequest, SetConfigOptionRequest, SetConfigOptionResponse, SetModeRequest, SetModelRequest, SideQuestionRequest, SideQuestionResponse, TryConnectCustomAgentRequest, - TryConnectCustomAgentResponse, WorkspaceBrowseQuery, WorkspaceEntry, + TryConnectCustomAgentResponse, WorkspaceBrowseQuery, WorkspaceEntry, WorkspaceSearchMatchKind, WorkspaceSearchMode, }; pub use acp_prompt_hook::AcpPromptHookWarningPayload; pub use agent_build_extra::{ diff --git a/crates/aionui-conversation/src/service_ops.rs b/crates/aionui-conversation/src/service_ops.rs index 74bece2e..5c55cb19 100644 --- a/crates/aionui-conversation/src/service_ops.rs +++ b/crates/aionui-conversation/src/service_ops.rs @@ -13,6 +13,7 @@ use aionui_ai_agent::{AcpError, AgentError}; use aionui_api_types::{ ConfigOptionConfirmation, GetConfigOptionsResponse, SetConfigOptionRequest, SetConfigOptionResponse, SideQuestionRequest, SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry, + WorkspaceSearchMatchKind, WorkspaceSearchMode, }; use aionui_common::{AgentKillReason, ErrorChain}; use ignore::{DirEntry, WalkBuilder}; @@ -54,7 +55,12 @@ fn workspace_search_matches_content(path: &Path, needle: &str) -> bool { content.to_lowercase().contains(needle) } -fn workspace_search_entry(base: &Path, path: &Path, is_dir: bool) -> Option { +fn workspace_search_entry( + base: &Path, + path: &Path, + is_dir: bool, + match_kind: WorkspaceSearchMatchKind, +) -> Option { let relative = path.strip_prefix(base).ok()?; let name = relative.to_string_lossy().replace('\\', "/"); if name.is_empty() { @@ -64,6 +70,7 @@ fn workspace_search_entry(base: &Path, path: &Path, is_dir: bool) -> Option WORKSPACE_SEARCH_PRUNED_DIRS.contains(&name) } -fn search_workspace_entries_sync(base: PathBuf, search_root: PathBuf, search: String) -> Vec { +fn search_workspace_entries_sync( + base: PathBuf, + search_root: PathBuf, + search: String, + search_mode: WorkspaceSearchMode, +) -> Vec { let needle = search.to_lowercase(); let mut entries = Vec::new(); let mut scanned = 0usize; @@ -119,22 +131,38 @@ fn search_workspace_entries_sync(base: PathBuf, search_root: PathBuf, search: St let is_dir = file_type.is_dir(); let is_file = file_type.is_file(); - let name_matches = entry.file_name().to_string_lossy().to_lowercase().contains(&needle) - || path - .strip_prefix(&base) - .ok() - .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) - .unwrap_or(false); - let content_matches = is_file && workspace_search_matches_content(path, &needle); - - if (name_matches || content_matches) - && let Some(search_entry) = workspace_search_entry(&base, path, is_dir) + let name_matches = !matches!(search_mode, WorkspaceSearchMode::Content) + && (entry.file_name().to_string_lossy().to_lowercase().contains(&needle) + || path + .strip_prefix(&base) + .ok() + .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) + .unwrap_or(false)); + let content_matches = !matches!(search_mode, WorkspaceSearchMode::Name) + && is_file + && workspace_search_matches_content(path, &needle); + + let match_kind = if name_matches { + Some(WorkspaceSearchMatchKind::Name) + } else if content_matches { + Some(WorkspaceSearchMatchKind::Content) + } else { + None + }; + + if let Some(match_kind) = match_kind + && let Some(search_entry) = workspace_search_entry(&base, path, is_dir, match_kind) { entries.push(search_entry); } } entries.sort_by(|a, b| { + let match_cmp = search_match_rank(a.match_kind).cmp(&search_match_rank(b.match_kind)); + if match_cmp != std::cmp::Ordering::Equal { + return match_cmp; + } + let type_cmp = a.entry_type.cmp(&b.entry_type); if type_cmp == std::cmp::Ordering::Equal { a.name.to_lowercase().cmp(&b.name.to_lowercase()) @@ -146,6 +174,14 @@ fn search_workspace_entries_sync(base: PathBuf, search_root: PathBuf, search: St entries } +fn search_match_rank(match_kind: Option) -> u8 { + match match_kind { + Some(WorkspaceSearchMatchKind::Name) => 0, + Some(WorkspaceSearchMatchKind::Content) => 1, + None => 2, + } +} + impl ConversationService { // ── Config Options ────────────────────────────────────────────── @@ -371,11 +407,12 @@ impl ConversationService { .map(|value| value.trim()) .filter(|value| !value.is_empty()) { + let search_mode = query.search_mode.unwrap_or(WorkspaceSearchMode::All); let entries = tokio::task::spawn_blocking({ let canonical_base = canonical_base.clone(); let canonical_browse = canonical_browse.clone(); let search = search.to_owned(); - move || search_workspace_entries_sync(canonical_base, canonical_browse, search) + move || search_workspace_entries_sync(canonical_base, canonical_browse, search, search_mode) }) .await .map_err(|e| ConversationError::internal(format!("Workspace search task failed: {e}")))?; @@ -400,6 +437,7 @@ impl ConversationService { entries.push(WorkspaceEntry { name, entry_type: entry_type.into(), + match_kind: None, }); } diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 0b494ff5..3439f3c3 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -19,7 +19,8 @@ use aionui_ai_agent::{ use aionui_api_types::{ AcpConfigOptionDto, AgentErrorCode, AgentModeResponse, ConfigOptionConfirmation, ConversationArtifactKind, ConversationResponse, GetConfigOptionsResponse, GetModelInfoResponse, ModelInfoEntry, ModelInfoPayload, - SetConfigOptionRequest, SetConfigOptionResponse, WorkspaceBrowseQuery, + SetConfigOptionRequest, SetConfigOptionResponse, WorkspaceBrowseQuery, WorkspaceSearchMatchKind, + WorkspaceSearchMode, }; use aionui_api_types::{ CloneConversationRequest, CreateConversationRequest, ListConversationsQuery, SearchMessagesQuery, @@ -2256,6 +2257,7 @@ async fn browse_workspace_search_matches_nested_file_names() { WorkspaceBrowseQuery { path: ".".into(), search: Some("searchpanel".into()), + search_mode: None, }, ) .await @@ -2264,6 +2266,7 @@ async fn browse_workspace_search_matches_nested_file_names() { assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "src/components/SearchPanel.tsx"); assert_eq!(entries[0].entry_type, "file"); + assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Name)); } #[tokio::test] @@ -2295,6 +2298,7 @@ async fn browse_workspace_search_matches_nested_file_contents() { WorkspaceBrowseQuery { path: ".".into(), search: Some("projectdelta".into()), + search_mode: None, }, ) .await @@ -2303,6 +2307,7 @@ async fn browse_workspace_search_matches_nested_file_contents() { assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "docs/notes.md"); assert_eq!(entries[0].entry_type, "file"); + assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Content)); } #[tokio::test] @@ -2333,6 +2338,7 @@ async fn browse_workspace_search_skips_dependency_dirs_by_default() { WorkspaceBrowseQuery { path: ".".into(), search: Some("dependencyonlyneedle".into()), + search_mode: None, }, ) .await @@ -2369,6 +2375,7 @@ async fn browse_workspace_search_allows_explicit_dependency_dir_root() { WorkspaceBrowseQuery { path: "node_modules".into(), search: Some("dependencyonlyneedle".into()), + search_mode: None, }, ) .await @@ -2404,6 +2411,7 @@ async fn browse_workspace_search_respects_gitignore_from_workspace_root() { WorkspaceBrowseQuery { path: ".".into(), search: Some("ignoredneedle".into()), + search_mode: None, }, ) .await @@ -2437,6 +2445,7 @@ async fn browse_workspace_search_allows_explicit_gitignored_root() { WorkspaceBrowseQuery { path: "ignored".into(), search: Some("ignoredneedle".into()), + search_mode: None, }, ) .await @@ -2447,6 +2456,94 @@ async fn browse_workspace_search_allows_explicit_gitignored_root() { assert_eq!(entries[0].entry_type, "file"); } +#[tokio::test] +async fn browse_workspace_search_prioritizes_name_matches_before_content_matches() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(&user_workspace).unwrap(); + std::fs::write(user_workspace.join("a-keyword-name.txt"), "ordinary content").unwrap(); + std::fs::write(user_workspace.join("b-content.txt"), "keyword").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("keyword".into()), + search_mode: Some(WorkspaceSearchMode::All), + }, + ) + .await + .unwrap(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "a-keyword-name.txt"); + assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Name)); + assert_eq!(entries[1].name, "b-content.txt"); + assert_eq!(entries[1].match_kind, Some(WorkspaceSearchMatchKind::Content)); +} + +#[tokio::test] +async fn browse_workspace_search_mode_limits_match_sources() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("aionui-data"); + let user_workspace = temp.path().join("user-project"); + std::fs::create_dir_all(&user_workspace).unwrap(); + std::fs::write(user_workspace.join("name-only-needle.txt"), "plain").unwrap(); + std::fs::write(user_workspace.join("content-only.txt"), "needle").unwrap(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_workspace_root(workspace_root); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { + "workspace": user_workspace, + "custom_workspace": true + } + })) + .unwrap(); + + let conv = svc.create("user_1", req).await.unwrap(); + let name_entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("needle".into()), + search_mode: Some(WorkspaceSearchMode::Name), + }, + ) + .await + .unwrap(); + let content_entries = svc + .browse_workspace( + &conv.id, + WorkspaceBrowseQuery { + path: ".".into(), + search: Some("needle".into()), + search_mode: Some(WorkspaceSearchMode::Content), + }, + ) + .await + .unwrap(); + + assert_eq!(name_entries.len(), 1); + assert_eq!(name_entries[0].name, "name-only-needle.txt"); + assert_eq!(name_entries[0].match_kind, Some(WorkspaceSearchMatchKind::Name)); + assert_eq!(content_entries.len(), 1); + assert_eq!(content_entries[0].name, "content-only.txt"); + assert_eq!(content_entries[0].match_kind, Some(WorkspaceSearchMatchKind::Content)); +} + #[tokio::test] async fn browse_workspace_without_search_keeps_shallow_directory_listing() { let temp = tempfile::tempdir().unwrap(); @@ -2472,6 +2569,7 @@ async fn browse_workspace_without_search_keeps_shallow_directory_listing() { WorkspaceBrowseQuery { path: ".".into(), search: None, + search_mode: None, }, ) .await From 91b1d2c5d00ada07783163b3760b66a0d4f6079d Mon Sep 17 00:00:00 2001 From: 1-6 Date: Tue, 7 Jul 2026 10:45:58 +0800 Subject: [PATCH 4/4] feat(conversation): count workspace search content matches --- crates/aionui-api-types/src/acp.rs | 2 ++ crates/aionui-conversation/src/service_ops.rs | 24 ++++++++++++------- .../aionui-conversation/src/service_test.rs | 7 +++++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/aionui-api-types/src/acp.rs b/crates/aionui-api-types/src/acp.rs index 31d7ffc6..c9edbec1 100644 --- a/crates/aionui-api-types/src/acp.rs +++ b/crates/aionui-api-types/src/acp.rs @@ -201,6 +201,8 @@ pub struct WorkspaceEntry { pub entry_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub match_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_match_count: Option, } /// Request body for side question. diff --git a/crates/aionui-conversation/src/service_ops.rs b/crates/aionui-conversation/src/service_ops.rs index 5c55cb19..ec1e2732 100644 --- a/crates/aionui-conversation/src/service_ops.rs +++ b/crates/aionui-conversation/src/service_ops.rs @@ -41,18 +41,18 @@ const WORKSPACE_SEARCH_PRUNED_DIRS: &[&str] = &[ "target", ]; -fn workspace_search_matches_content(path: &Path, needle: &str) -> bool { +fn workspace_search_content_match_count(path: &Path, needle: &str) -> usize { let Ok(metadata) = std::fs::metadata(path) else { - return false; + return 0; }; if !metadata.is_file() || metadata.len() > MAX_WORKSPACE_SEARCH_FILE_BYTES { - return false; + return 0; } let Ok(content) = std::fs::read_to_string(path) else { - return false; + return 0; }; - content.to_lowercase().contains(needle) + content.to_lowercase().matches(needle).count() } fn workspace_search_entry( @@ -60,6 +60,7 @@ fn workspace_search_entry( path: &Path, is_dir: bool, match_kind: WorkspaceSearchMatchKind, + content_match_count: usize, ) -> Option { let relative = path.strip_prefix(base).ok()?; let name = relative.to_string_lossy().replace('\\', "/"); @@ -71,6 +72,7 @@ fn workspace_search_entry( name, entry_type: if is_dir { "directory" } else { "file" }.into(), match_kind: Some(match_kind), + content_match_count: (content_match_count > 0).then_some(content_match_count), }) } @@ -138,9 +140,12 @@ fn search_workspace_entries_sync( .ok() .map(|relative| relative.to_string_lossy().to_lowercase().contains(&needle)) .unwrap_or(false)); - let content_matches = !matches!(search_mode, WorkspaceSearchMode::Name) - && is_file - && workspace_search_matches_content(path, &needle); + let content_match_count = if !matches!(search_mode, WorkspaceSearchMode::Name) && is_file { + workspace_search_content_match_count(path, &needle) + } else { + 0 + }; + let content_matches = content_match_count > 0; let match_kind = if name_matches { Some(WorkspaceSearchMatchKind::Name) @@ -151,7 +156,7 @@ fn search_workspace_entries_sync( }; if let Some(match_kind) = match_kind - && let Some(search_entry) = workspace_search_entry(&base, path, is_dir, match_kind) + && let Some(search_entry) = workspace_search_entry(&base, path, is_dir, match_kind, content_match_count) { entries.push(search_entry); } @@ -438,6 +443,7 @@ impl ConversationService { name, entry_type: entry_type.into(), match_kind: None, + content_match_count: None, }); } diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 3439f3c3..fb464d7d 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -2267,6 +2267,7 @@ async fn browse_workspace_search_matches_nested_file_names() { assert_eq!(entries[0].name, "src/components/SearchPanel.tsx"); assert_eq!(entries[0].entry_type, "file"); assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Name)); + assert_eq!(entries[0].content_match_count, None); } #[tokio::test] @@ -2277,7 +2278,7 @@ async fn browse_workspace_search_matches_nested_file_contents() { std::fs::create_dir_all(user_workspace.join("docs")).unwrap(); std::fs::write( user_workspace.join("docs/notes.md"), - "release checklist mentions ProjectDelta", + "release checklist mentions ProjectDelta\nProjectDelta rollout notes", ) .unwrap(); std::fs::write(user_workspace.join("unrelated.md"), "nothing here").unwrap(); @@ -2308,6 +2309,7 @@ async fn browse_workspace_search_matches_nested_file_contents() { assert_eq!(entries[0].name, "docs/notes.md"); assert_eq!(entries[0].entry_type, "file"); assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Content)); + assert_eq!(entries[0].content_match_count, Some(2)); } #[tokio::test] @@ -2490,8 +2492,10 @@ async fn browse_workspace_search_prioritizes_name_matches_before_content_matches assert_eq!(entries.len(), 2); assert_eq!(entries[0].name, "a-keyword-name.txt"); assert_eq!(entries[0].match_kind, Some(WorkspaceSearchMatchKind::Name)); + assert_eq!(entries[0].content_match_count, None); assert_eq!(entries[1].name, "b-content.txt"); assert_eq!(entries[1].match_kind, Some(WorkspaceSearchMatchKind::Content)); + assert_eq!(entries[1].content_match_count, Some(1)); } #[tokio::test] @@ -2542,6 +2546,7 @@ async fn browse_workspace_search_mode_limits_match_sources() { assert_eq!(content_entries.len(), 1); assert_eq!(content_entries[0].name, "content-only.txt"); assert_eq!(content_entries[0].match_kind, Some(WorkspaceSearchMatchKind::Content)); + assert_eq!(content_entries[0].content_match_count, Some(1)); } #[tokio::test]