Skip to content

Commit b4c3753

Browse files
committed
feat: auto-sync incremental search index
1 parent 880078b commit b4c3753

5 files changed

Lines changed: 433 additions & 5 deletions

File tree

src-tauri/src/app/command_registry.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub(crate) fn command_handler() -> Box<tauri::ipc::InvokeHandler<tauri::Wry>> {
55
ataru::api::ataru_search,
66
cancel_session_stream,
77
copy_to_clipboard,
8+
get_incremental_search_index_sync_status,
89
get_search_index_status,
910
get_semantic_search_status,
1011
initialize_semantic_search,
@@ -16,6 +17,7 @@ pub(crate) fn command_handler() -> Box<tauri::ipc::InvokeHandler<tauri::Wry>> {
1617
reveal_session_in_finder,
1718
search_chats,
1819
semantic_search_chats,
20+
set_incremental_search_index_sync_enabled,
1921
set_semantic_search_enabled,
2022
start_search_index_build,
2123
])

src-tauri/src/app/run.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ fn watch_conversation_directory(app: tauri::AppHandle, directory: PathBuf) {
3131
match notify::recommended_watcher(move |event: Result<Event, notify::Error>| {
3232
if let Ok(event) = event {
3333
if event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove() {
34-
let _ = sender.send(());
34+
let _ = sender.send(event.paths);
3535
}
3636
}
3737
}) {
@@ -47,9 +47,14 @@ fn watch_conversation_directory(app: tauri::AppHandle, directory: PathBuf) {
4747
return;
4848
}
4949

50-
while receiver.recv().is_ok() {
51-
while receiver.recv_timeout(Duration::from_millis(400)).is_ok() {}
50+
while let Ok(mut changed_paths) = receiver.recv() {
51+
while let Ok(paths) = receiver.recv_timeout(Duration::from_millis(400)) {
52+
changed_paths.extend(paths);
53+
}
54+
changed_paths.sort();
55+
changed_paths.dedup();
5256
emit_sessions_changed_with_fresh_cache(app.clone());
57+
notify_incremental_search_index_source_changes(app.clone(), &changed_paths);
5358
}
5459
});
5560
}

src-tauri/src/app/search.rs

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ pub(crate) struct SearchIndexBuildStatus {
4949
pub error: Option<String>,
5050
}
5151

52+
#[derive(Clone, Debug, Serialize)]
53+
#[serde(rename_all = "camelCase")]
54+
pub(crate) struct SearchIndexWatchTarget {
55+
pub source: String,
56+
pub root_path: String,
57+
pub files: Vec<String>,
58+
}
59+
60+
#[derive(Clone, Debug, Serialize)]
61+
#[serde(rename_all = "camelCase")]
62+
pub(crate) struct IncrementalSearchIndexSyncStatus {
63+
pub enabled: bool,
64+
pub monitoring: bool,
65+
pub targets: Vec<SearchIndexWatchTarget>,
66+
pub last_change_at: Option<u64>,
67+
pub last_sync_requested_at: Option<u64>,
68+
}
69+
5270
impl Default for SearchIndexBuildStatus {
5371
fn default() -> Self {
5472
Self {
@@ -168,6 +186,43 @@ const SEARCH_INDEX_BUILD_PENDING: u8 = 2;
168186
static SEARCH_INDEX_BUILD_STATE: std::sync::atomic::AtomicU8 =
169187
std::sync::atomic::AtomicU8::new(SEARCH_INDEX_BUILD_IDLE);
170188

189+
#[derive(Clone, Debug, Deserialize, Serialize)]
190+
#[serde(rename_all = "camelCase")]
191+
struct IncrementalSearchIndexSyncSettings {
192+
#[serde(default = "incremental_search_index_sync_default_enabled")]
193+
enabled: bool,
194+
}
195+
196+
impl Default for IncrementalSearchIndexSyncSettings {
197+
fn default() -> Self {
198+
Self { enabled: true }
199+
}
200+
}
201+
202+
const fn incremental_search_index_sync_default_enabled() -> bool {
203+
true
204+
}
205+
206+
#[derive(Clone, Debug)]
207+
struct IncrementalSearchIndexSyncRuntime {
208+
enabled: bool,
209+
last_change_at: Option<u64>,
210+
last_sync_requested_at: Option<u64>,
211+
}
212+
213+
impl From<IncrementalSearchIndexSyncSettings> for IncrementalSearchIndexSyncRuntime {
214+
fn from(settings: IncrementalSearchIndexSyncSettings) -> Self {
215+
Self {
216+
enabled: settings.enabled,
217+
last_change_at: None,
218+
last_sync_requested_at: None,
219+
}
220+
}
221+
}
222+
223+
static INCREMENTAL_SEARCH_INDEX_SYNC_RUNTIME: LazyLock<Mutex<IncrementalSearchIndexSyncRuntime>> =
224+
LazyLock::new(|| Mutex::new(load_incremental_search_index_sync_settings().into()));
225+
171226
// Bump the manifest when indexed Turn semantics change. The next startup will
172227
// rebuild once so atomic content blocks, tool roles, and block identities are
173228
// backfilled.
@@ -239,6 +294,31 @@ fn search_index_manifest_path() -> PathBuf {
239294
.unwrap_or_else(|| PathBuf::from("search-index-manifest.json"))
240295
}
241296

297+
fn incremental_search_index_sync_settings_path() -> PathBuf {
298+
get_index_dir()
299+
.parent()
300+
.map(|parent| parent.join("incremental-search-index-sync-settings.json"))
301+
.unwrap_or_else(|| PathBuf::from("incremental-search-index-sync-settings.json"))
302+
}
303+
304+
fn load_incremental_search_index_sync_settings() -> IncrementalSearchIndexSyncSettings {
305+
fs::read_to_string(incremental_search_index_sync_settings_path())
306+
.ok()
307+
.and_then(|value| serde_json::from_str(&value).ok())
308+
.unwrap_or_default()
309+
}
310+
311+
fn save_incremental_search_index_sync_settings(
312+
settings: &IncrementalSearchIndexSyncSettings,
313+
) -> Result<(), String> {
314+
let path = incremental_search_index_sync_settings_path();
315+
if let Some(parent) = path.parent() {
316+
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
317+
}
318+
let value = serde_json::to_vec_pretty(settings).map_err(|error| error.to_string())?;
319+
fs::write(path, value).map_err(|error| error.to_string())
320+
}
321+
242322
fn search_index_path_signature(path: &Path) -> Option<SearchIndexPathSignature> {
243323
let metadata = fs::metadata(path).ok()?;
244324
let modified_nanos = metadata
@@ -531,6 +611,129 @@ fn collect_codex_search_sources() -> Vec<CodexSearchSource> {
531611
.collect()
532612
}
533613

614+
fn current_search_index_watch_targets() -> Result<Vec<SearchIndexWatchTarget>, String> {
615+
let claude_root = get_claude_dir().join("projects");
616+
let mut claude_files = collect_claude_search_sources(&claude_root)?
617+
.into_iter()
618+
.map(|source| source_path_key(&source.path))
619+
.collect::<Vec<_>>();
620+
claude_files.sort();
621+
622+
let codex_root = get_codex_dir();
623+
let mut codex_files = collect_codex_search_sources()
624+
.into_iter()
625+
.map(|source| source_path_key(&source.path))
626+
.collect::<Vec<_>>();
627+
codex_files.sort();
628+
629+
Ok(vec![
630+
SearchIndexWatchTarget {
631+
source: "Claude Code".to_string(),
632+
root_path: source_path_key(&claude_root),
633+
files: claude_files,
634+
},
635+
SearchIndexWatchTarget {
636+
source: "Codex".to_string(),
637+
root_path: source_path_key(&codex_root),
638+
files: codex_files,
639+
},
640+
])
641+
}
642+
643+
fn current_incremental_search_index_sync_status() -> Result<IncrementalSearchIndexSyncStatus, String>
644+
{
645+
let targets = current_search_index_watch_targets()?;
646+
let runtime = INCREMENTAL_SEARCH_INDEX_SYNC_RUNTIME
647+
.lock()
648+
.map_err(|error| error.to_string())?
649+
.clone();
650+
let monitoring = runtime.enabled
651+
&& targets
652+
.iter()
653+
.any(|target| Path::new(&target.root_path).exists());
654+
655+
Ok(IncrementalSearchIndexSyncStatus {
656+
enabled: runtime.enabled,
657+
monitoring,
658+
targets,
659+
last_change_at: runtime.last_change_at,
660+
last_sync_requested_at: runtime.last_sync_requested_at,
661+
})
662+
}
663+
664+
fn incremental_search_index_sync_enabled() -> bool {
665+
INCREMENTAL_SEARCH_INDEX_SYNC_RUNTIME
666+
.lock()
667+
.map(|runtime| runtime.enabled)
668+
.unwrap_or(true)
669+
}
670+
671+
fn is_incremental_search_index_source_path(path: &Path) -> bool {
672+
if path.extension().and_then(|extension| extension.to_str()) != Some("jsonl") {
673+
return false;
674+
}
675+
676+
let is_claude_session = path.starts_with(get_claude_dir().join("projects"))
677+
&& !path
678+
.file_name()
679+
.and_then(|name| name.to_str())
680+
.is_some_and(|name| name.starts_with("agent-"));
681+
let is_codex_session = path.starts_with(get_codex_sessions_dir())
682+
|| path.starts_with(get_codex_archived_sessions_dir());
683+
684+
is_claude_session || is_codex_session
685+
}
686+
687+
#[cfg(test)]
688+
mod incremental_search_index_sync_tests {
689+
use super::*;
690+
691+
#[test]
692+
fn defaults_to_enabled_for_new_installations() {
693+
assert!(IncrementalSearchIndexSyncSettings::default().enabled);
694+
}
695+
696+
#[test]
697+
fn limits_auto_sync_to_indexable_session_sources() {
698+
let claude_session = get_claude_dir()
699+
.join("projects")
700+
.join("project")
701+
.join("session.jsonl");
702+
let claude_agent = get_claude_dir()
703+
.join("projects")
704+
.join("project")
705+
.join("agent-session.jsonl");
706+
let codex_session = get_codex_sessions_dir().join("2026").join("session.jsonl");
707+
let codex_config = get_codex_dir().join("config.jsonl");
708+
709+
assert!(is_incremental_search_index_source_path(&claude_session));
710+
assert!(is_incremental_search_index_source_path(&codex_session));
711+
assert!(!is_incremental_search_index_source_path(&claude_agent));
712+
assert!(!is_incremental_search_index_source_path(&codex_config));
713+
}
714+
}
715+
716+
pub(crate) fn notify_incremental_search_index_source_changes(
717+
app_handle: tauri::AppHandle,
718+
changed_paths: &[PathBuf],
719+
) {
720+
if !changed_paths
721+
.iter()
722+
.any(|path| is_incremental_search_index_source_path(path))
723+
|| !incremental_search_index_sync_enabled()
724+
{
725+
return;
726+
}
727+
728+
if let Ok(mut runtime) = INCREMENTAL_SEARCH_INDEX_SYNC_RUNTIME.lock() {
729+
let now = now_secs();
730+
runtime.last_change_at = Some(now);
731+
runtime.last_sync_requested_at = Some(now);
732+
}
733+
734+
let _ = request_search_index_build(app_handle, false);
735+
}
736+
534737
fn source_path_key(path: &Path) -> String {
535738
path.to_string_lossy().into_owned()
536739
}
@@ -715,17 +918,45 @@ pub(crate) async fn get_search_index_status() -> Result<SearchIndexBuildStatus,
715918
.map_err(|error| error.to_string())
716919
}
717920

921+
#[tauri::command]
922+
pub(crate) async fn get_incremental_search_index_sync_status(
923+
) -> Result<IncrementalSearchIndexSyncStatus, String> {
924+
tauri::async_runtime::spawn_blocking(current_incremental_search_index_sync_status)
925+
.await
926+
.map_err(|error| error.to_string())?
927+
}
928+
929+
#[tauri::command]
930+
pub(crate) fn set_incremental_search_index_sync_enabled(
931+
enabled: bool,
932+
) -> Result<IncrementalSearchIndexSyncStatus, String> {
933+
save_incremental_search_index_sync_settings(&IncrementalSearchIndexSyncSettings { enabled })?;
934+
let mut runtime = INCREMENTAL_SEARCH_INDEX_SYNC_RUNTIME
935+
.lock()
936+
.map_err(|error| error.to_string())?;
937+
runtime.enabled = enabled;
938+
drop(runtime);
939+
current_incremental_search_index_sync_status()
940+
}
941+
718942
#[tauri::command]
719943
pub(crate) fn start_search_index_build(
720944
app_handle: tauri::AppHandle,
721945
force: Option<bool>,
946+
) -> Result<SearchIndexBuildStatus, String> {
947+
request_search_index_build(app_handle, force.unwrap_or(false))
948+
}
949+
950+
fn request_search_index_build(
951+
app_handle: tauri::AppHandle,
952+
force: bool,
722953
) -> Result<SearchIndexBuildStatus, String> {
723954
let Some(mut running_guard) = try_mark_search_index_build_running() else {
724955
return Ok(current_search_index_status());
725956
};
726957

727958
let app_for_task = app_handle.clone();
728-
let mut force = force.unwrap_or(false);
959+
let mut force = force;
729960
tauri::async_runtime::spawn(async move {
730961
loop {
731962
let app_for_blocking = app_for_task.clone();

src/modules/api/ataru.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ export function startIncrementalSearchIndexBuild(): Promise<SearchIndexBuildStat
6868
return startSearchIndexBuild(false);
6969
}
7070

71+
export interface SearchIndexWatchTarget {
72+
source: string;
73+
rootPath: string;
74+
files: string[];
75+
}
76+
77+
export interface IncrementalSearchIndexSyncStatus {
78+
enabled: boolean;
79+
monitoring: boolean;
80+
targets: SearchIndexWatchTarget[];
81+
lastChangeAt?: number | null;
82+
lastSyncRequestedAt?: number | null;
83+
}
84+
85+
export function getIncrementalSearchIndexSyncStatus(): Promise<IncrementalSearchIndexSyncStatus> {
86+
return invoke<IncrementalSearchIndexSyncStatus>("get_incremental_search_index_sync_status");
87+
}
88+
89+
export function setIncrementalSearchIndexSyncEnabled(
90+
enabled: boolean,
91+
): Promise<IncrementalSearchIndexSyncStatus> {
92+
return invoke<IncrementalSearchIndexSyncStatus>("set_incremental_search_index_sync_enabled", { enabled });
93+
}
94+
7195
export interface SemanticSearchStatus {
7296
enabled: boolean;
7397
configured: boolean;

0 commit comments

Comments
 (0)