@@ -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+
5270impl Default for SearchIndexBuildStatus {
5371 fn default ( ) -> Self {
5472 Self {
@@ -168,6 +186,43 @@ const SEARCH_INDEX_BUILD_PENDING: u8 = 2;
168186static 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+
242322fn 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+
534737fn 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]
719943pub ( 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 ( ) ;
0 commit comments