Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion resources/flashgrep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Place the prebuilt `flashgrep` daemon binary in this directory.

Pinned release:

- `v0.2.10` from `wgqqqqq/flashgrep`
- `v0.2.15` from `wgqqqqq/flashgrep`

Expected filenames:

Expand Down
4 changes: 2 additions & 2 deletions resources/flashgrep/VERSION.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"repo": "wgqqqqq/flashgrep",
"tag": "v0.2.10",
"published_at": "2026-06-17T08:15:11Z"
"tag": "v0.2.15",
"published_at": "2026-08-18T07:47:30Z"
}
Binary file modified resources/flashgrep/flashgrep-aarch64-apple-darwin
Binary file not shown.
Binary file modified resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe
Binary file not shown.
Binary file modified resources/flashgrep/flashgrep-aarch64-unknown-linux-musl
Binary file not shown.
Binary file modified resources/flashgrep/flashgrep-x86_64-apple-darwin
Binary file not shown.
Binary file modified resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe
Binary file not shown.
Binary file modified resources/flashgrep/flashgrep-x86_64-unknown-linux-musl
Binary file not shown.
52 changes: 41 additions & 11 deletions scripts/core-boundaries/rules/source/required-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8424,31 +8424,65 @@ export const requiredContentRules = [
regex: /\bpub struct WorkspaceSearchRepoConfig\b/,
message: 'missing stable workspace-search repo config contract',
},
{
regex: /\bwith_scan_fallback\b/,
message: 'missing flashgrep scan fallback request flag',
},
],
},
{
path: 'src/crates/services/services-integrations/src/workspace_search/result_mapping.rs',
reason:
'services-integrations workspace_search result mapping must own shared flashgrep preview/result conversion',
'services-integrations workspace_search result mapping must own flashgrep result conversion and delegate content previews to line_hydration',
patterns: [
{
regex: /\bconvert_hits_to_file_search_results\b/,
message: 'missing hit-to-file-result conversion owner',
},
{
regex: /\bsplit_preview\b/,
message: 'missing preview split contract',
regex: /\bline_hydration\b/,
message: 'missing preview hydration ownership note',
},
{
regex: /\bpreview_inside\b/,
message: 'missing preview-inside rendering contract',
},
],
},
{
path: 'src/crates/services/services-integrations/src/workspace_search/line_hydration.rs',
reason:
'services-integrations workspace_search must own disk hydration of daemon line positions, because flashgrep never returns line text',
patterns: [
{
regex: /\bpub\(crate\)\s+fn\s+hydrate_grouped_line_matches\b/,
message: 'missing grouped line-match hydration owner',
},
{
regex: /\bMAX_HYDRATED_LINE_COLUMNS\b/,
message: 'missing long-line clamp shared with the ripgrep path',
},
{
regex: /\bContentMatchPreviewBuilder\b/,
message: 'previews must be built with the shared services-core builder',
},
],
},
{
path: 'src/crates/services/services-core/src/filesystem/content_preview.rs',
reason:
'services-core filesystem must own the content preview primitives shared by the ripgrep and indexed search paths',
patterns: [
{
regex: /\bpub fn compile_content_search_regex\b/,
message: 'missing shared content-search regex compiler',
},
{
regex: /\bpub fn build_content_match_preview\b/,
message: 'missing shared content preview builder',
},
{
regex: /\bpub struct ContentMatchPreviewBuilder\b/,
message: 'missing reusable preview builder for batched hydration',
},
],
},
{
path: 'src/crates/assembly/core/src/service/search/service.rs',
reason:
Expand Down Expand Up @@ -8532,10 +8566,6 @@ export const requiredContentRules = [
regex: /\bensure_remote_search_context\b/,
message: 'missing remote search context lifecycle owner',
},
{
regex: /\ballow_scan_fallback:\s*true\b/,
message: 'missing remote scan fallback contract',
},
{
regex: /\bfallback_query\b/,
message: 'missing FilesWithMatches fallback query',
Expand Down
14 changes: 11 additions & 3 deletions scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4395,11 +4395,19 @@ export function runManifestParserSelfTest({
},
{
path: 'src/crates/services/services-integrations/src/workspace_search/service.rs',
contracts: ['WorkspaceSearchRepoConfig', 'with_scan_fallback'],
contracts: ['WorkspaceSearchRepoConfig'],
},
{
path: 'src/crates/services/services-integrations/src/workspace_search/result_mapping.rs',
contracts: ['convert_hits_to_file_search_results', 'split_preview', 'preview_inside'],
contracts: ['convert_hits_to_file_search_results', 'line_hydration', 'preview_inside'],
},
{
path: 'src/crates/services/services-integrations/src/workspace_search/line_hydration.rs',
contracts: ['hydrate_grouped_line_matches', 'MAX_HYDRATED_LINE_COLUMNS', 'ContentMatchPreviewBuilder'],
},
{
path: 'src/crates/services/services-core/src/filesystem/content_preview.rs',
contracts: ['compile_content_search_regex', 'build_content_match_preview', 'ContentMatchPreviewBuilder'],
},
{
path: 'src/crates/assembly/core/src/service/search/service.rs',
Expand All @@ -4423,7 +4431,7 @@ export function runManifestParserSelfTest({
},
{
path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/service.rs',
contracts: ['RemoteWorkspaceSearchProvider', 'RemoteWorkspaceSearchService', 'RemoteWorkspaceSearchStdioProtocol', 'REMOTE_STDIO_SESSIONS', 'ensure_remote_search_context', 'allow_scan_fallback', 'fallback_query', 'remote_search_rejects_non_linux_before_stdio_open'],
contracts: ['RemoteWorkspaceSearchProvider', 'RemoteWorkspaceSearchService', 'RemoteWorkspaceSearchStdioProtocol', 'REMOTE_STDIO_SESSIONS', 'ensure_remote_search_context', 'fallback_query', 'remote_search_rejects_non_linux_before_stdio_open'],
},
{
path: 'src/crates/assembly/core/src/service/search/mod.rs',
Expand Down
31 changes: 29 additions & 2 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Application state management

use crate::api::workspace_activation::spawn_workspace_background_warmup;
use crate::api::workspace_activation::{
spawn_restored_workspace_auto_index, spawn_workspace_background_warmup,
};
use bitfun_core::agentic::side_question::SideQuestionRuntime;
use bitfun_core::agentic::{agents, tools};
use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory};
Expand All @@ -15,7 +17,7 @@ use bitfun_core::util::errors::*;
use bitfun_services_integrations::speech::{SpeechService, SpeechStoragePaths};

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use thiserror::Error;
Expand Down Expand Up @@ -208,9 +210,33 @@ impl AppState {
}));

let initial_workspace = workspace_service.get_current_workspace().await;
let mut restored_workspaces = workspace_service.get_opened_workspaces().await;
if let Some(initial_workspace) = initial_workspace.as_ref() {
if !restored_workspaces
.iter()
.any(|workspace| workspace.id == initial_workspace.id)
{
restored_workspaces.push(initial_workspace.clone());
}
}
let initial_workspace_path = initial_workspace
.as_ref()
.map(|workspace| workspace.root_path.clone());
let mut index_budget_roots = workspace_service
.get_recent_workspaces()
.await
.into_iter()
.filter(|workspace| workspace.workspace_kind != workspace::WorkspaceKind::Remote)
.map(|workspace| workspace.root_path)
.collect::<Vec<_>>();
let mut known_index_roots = index_budget_roots.iter().cloned().collect::<HashSet<_>>();
for workspace in workspace_service.list_workspace_infos().await {
if workspace.workspace_kind != workspace::WorkspaceKind::Remote
&& known_index_roots.insert(workspace.root_path.clone())
{
index_budget_roots.push(workspace.root_path);
}
}

// Initialize SSH Remote services synchronously so they're ready before app starts
let ssh_data_dir = dirs::data_local_dir()
Expand Down Expand Up @@ -320,6 +346,7 @@ impl AppState {
if let Some(workspace_info) = initial_workspace {
spawn_workspace_background_warmup(&app_state, workspace_info);
}
spawn_restored_workspace_auto_index(&app_state, restored_workspaces, index_budget_roots);

log::info!("AppState initialized successfully");
Ok(app_state)
Expand Down
49 changes: 43 additions & 6 deletions src/apps/desktop/src/api/search_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ pub struct SearchRepoIndexRequest {
pub struct SearchMetadataResponse {
pub backend: WorkspaceSearchBackend,
pub repo_phase: WorkspaceSearchRepoPhase,
pub rebuild_recommended: bool,
pub base_advance_in_progress: bool,
/// `true` when the daemon still owed a worktree reconcile at query time, so these results
/// describe the worktree as of the last observation. No query path waits for that reconcile —
/// stale-but-instant beats correct-but-blocked — so this is reported rather than waited out.
pub workspace_probe_pending: bool,
pub candidate_docs: usize,
pub matched_lines: usize,
pub matched_occurrences: usize,
Expand Down Expand Up @@ -73,6 +77,20 @@ pub(crate) async fn remote_workspace_search_service(
remote_workspace_search_service_for_path(root_path, preferred_connection_id).await
}

/// flashgrep refuses to open a directory that is not a Git worktree with a HEAD commit.
/// That is a property of the workspace, not a failure of the index, so it is normalized into a
/// stable BitFun-owned sentence the UI can recognize instead of leaking the raw daemon error.
pub(crate) const NON_GIT_WORKSPACE_MESSAGE: &str =
"Workspace search requires a Git worktree with a HEAD commit";

fn repo_status_error_message(error: impl std::fmt::Display) -> String {
let message = error.to_string();
if message.contains("requires a Git worktree with a HEAD commit") {
return NON_GIT_WORKSPACE_MESSAGE.to_string();
}
format!("Failed to get search repository status: {message}")
}

async fn workspace_search_unavailable_message(
state: &State<'_, AppState>,
root_path: &str,
Expand Down Expand Up @@ -169,8 +187,6 @@ pub(crate) fn build_content_search_request(
use_regex,
whole_word,
multiline: false,
before_context: 0,
after_context: 0,
max_results: Some(max_results),
globs: Vec::new(),
file_types: Vec::new(),
Expand Down Expand Up @@ -226,7 +242,8 @@ pub(crate) fn search_metadata_from_content_result(
SearchMetadataResponse {
backend: result.backend,
repo_phase: result.repo_status.phase,
rebuild_recommended: result.repo_status.rebuild_recommended,
base_advance_in_progress: result.repo_status.base_advance_in_progress,
workspace_probe_pending: result.repo_status.workspace_probe_pending,
candidate_docs: result.candidate_docs,
matched_lines: result.matched_lines,
matched_occurrences: result.matched_occurrences,
Expand All @@ -248,15 +265,15 @@ pub async fn search_get_repo_status(
.get_index_status(&request.root_path)
.await
.map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({})))
.map_err(|error| format!("Failed to get search repository status: {}", error));
.map_err(repo_status_error_message);
}

state
.workspace_search_service
.get_index_status(&request.root_path)
.await
.map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({})))
.map_err(|error| format!("Failed to get search repository status: {}", error))
.map_err(repo_status_error_message)
}

#[tauri::command]
Expand Down Expand Up @@ -310,3 +327,23 @@ pub async fn search_rebuild_index(
.map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({})))
.map_err(|error| format!("Failed to rebuild workspace index: {}", error))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn non_git_workspace_error_is_normalized() {
let raw = "protocol error: protocol error: indexed daemon workspace requires a Git worktree with a HEAD commit: /home";
assert_eq!(repo_status_error_message(raw), NON_GIT_WORKSPACE_MESSAGE);
}

#[test]
fn other_errors_keep_their_prefix() {
let raw = "SSH handshake timed out after 30 seconds";
assert_eq!(
repo_status_error_message(raw),
"Failed to get search repository status: SSH handshake timed out after 30 seconds"
);
}
}
70 changes: 69 additions & 1 deletion src/apps/desktop/src/api/workspace_activation.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::api::app_state::AppState;
use bitfun_core::service::search::workspace_search_runtime_available;
use bitfun_core::service::search::{
workspace_search_runtime_available, WorkspaceSearchAutoIndexPriority,
};
use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind};
use log::{debug, info, warn};
use std::path::{Path, PathBuf};
Expand All @@ -23,6 +25,62 @@ pub fn spawn_workspace_background_warmup(state: &AppState, workspace_info: Works
});
}

pub fn spawn_restored_workspace_auto_index(
state: &AppState,
workspaces: Vec<WorkspaceInfo>,
budget_roots: Vec<PathBuf>,
) {
let workspace_path = state.workspace_path.clone();
let workspace_search_service = state.workspace_search_service.clone();
tokio::spawn(async move {
if !workspace_search_runtime_available().await {
return;
}

let focused_path = workspace_path.read().await.clone();
let protected_roots = focused_path
.as_ref()
.filter(|path| {
workspaces.iter().any(|workspace| {
workspace.workspace_kind != WorkspaceKind::Remote
&& workspace.root_path == **path
})
})
.cloned()
.into_iter()
.collect();
workspace_search_service
.enforce_index_disk_budget(budget_roots, protected_roots)
.await;

if let Some(focused) = workspaces.iter().find(|workspace| {
workspace.workspace_kind != WorkspaceKind::Remote
&& focused_path.as_ref() == Some(&workspace.root_path)
}) {
workspace_search_service
.schedule_auto_index(
&focused.root_path,
WorkspaceSearchAutoIndexPriority::Focused,
)
.await;
}

for workspace in workspaces {
if workspace.workspace_kind == WorkspaceKind::Remote
|| focused_path.as_ref() == Some(&workspace.root_path)
{
continue;
}
workspace_search_service
.schedule_auto_index(
workspace.root_path,
WorkspaceSearchAutoIndexPriority::Background,
)
.await;
}
});
}

async fn warm_workspace_background_services(
workspace_path: Arc<RwLock<Option<PathBuf>>>,
agent_registry: Arc<bitfun_core::agentic::agents::AgentRegistry>,
Expand Down Expand Up @@ -50,6 +108,16 @@ async fn warm_workspace_background_services(
match workspace_search_service.open_repo(&target_path).await {
Ok(_) => {
let still_active = is_workspace_active(&workspace_path, &target_path).await;
workspace_search_service
.schedule_auto_index(
target_path.clone(),
if still_active {
WorkspaceSearchAutoIndexPriority::Focused
} else {
WorkspaceSearchAutoIndexPriority::Background
},
)
.await;
if !still_active {
workspace_search_service.schedule_repo_release(target_path.clone());
debug!(
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2645,6 +2645,10 @@ fn spawn_workspace_search_feature_listener(app_handle: tauri::AppHandle) {
{
match workspace_search_service.open_repo(&current_workspace).await {
Ok(_) => {
workspace_search_service.schedule_auto_index(
&current_workspace,
bitfun_core::service::search::WorkspaceSearchAutoIndexPriority::Focused,
).await;
log::info!(
"Workspace search feature enabled; warmed current workspace: path={}",
current_workspace.display()
Expand Down
Loading