@@ -27,6 +27,7 @@ use log::{info, warn};
2727use objc2:: rc:: Retained ;
2828use objc2_app_kit:: NSRunningApplication ;
2929use rayon:: iter:: { IntoParallelRefIterator , ParallelIterator } ;
30+ use tokio:: io:: AsyncBufReadExt ;
3031use tray_icon:: TrayIcon ;
3132
3233use std:: collections:: HashMap ;
@@ -130,6 +131,7 @@ pub struct Tile {
130131 sender : Option < ExtSender > ,
131132 page : Page ,
132133 pub height : f32 ,
134+ pub file_search_sender : Option < tokio:: sync:: watch:: Sender < ( String , Vec < String > ) > > ,
133135 debouncer : Debouncer ,
134136}
135137
@@ -182,6 +184,7 @@ impl Tile {
182184 Subscription :: run ( check_version) ,
183185 Subscription :: run ( handle_hot_reloading) ,
184186 Subscription :: run ( handle_clipboard_history) ,
187+ Subscription :: run ( handle_file_search) ,
185188 window:: close_events ( ) . map ( Message :: HideWindow ) ,
186189 keyboard:: listen ( ) . filter_map ( |event| {
187190 if let keyboard:: Event :: KeyPressed { key, modifiers, .. } = event {
@@ -377,6 +380,145 @@ fn handle_clipboard_history() -> impl futures::Stream<Item = Message> {
377380 } )
378381}
379382
383+ /// Read mdfind stdout line-by-line, sending batched results to the UI.
384+ ///
385+ /// Returns when stdout reaches EOF, the receiver signals a new query, or
386+ /// max results are reached. Caller is responsible for process lifetime.
387+ async fn read_mdfind_results (
388+ stdout : tokio:: process:: ChildStdout ,
389+ home_dir : & str ,
390+ receiver : & mut tokio:: sync:: watch:: Receiver < ( String , Vec < String > ) > ,
391+ output : & mut iced:: futures:: channel:: mpsc:: Sender < Message > ,
392+ ) {
393+ use crate :: app:: { FILE_SEARCH_BATCH_SIZE , FILE_SEARCH_MAX_RESULTS } ;
394+
395+ let mut reader = tokio:: io:: BufReader :: new ( stdout) ;
396+ let mut batch: Vec < crate :: app:: apps:: App > = Vec :: with_capacity ( FILE_SEARCH_BATCH_SIZE as usize ) ;
397+ let mut total_sent: u32 = 0 ;
398+
399+ loop {
400+ let mut line = String :: new ( ) ;
401+ let read_result = tokio:: select! {
402+ result = reader. read_line( & mut line) => result,
403+ _ = receiver. changed( ) => {
404+ // New query arrived — caller will handle it.
405+ break ;
406+ }
407+ } ;
408+
409+ match read_result {
410+ Ok ( 0 ) => {
411+ // EOF — flush remaining batch.
412+ if !batch. is_empty ( ) {
413+ output
414+ . send ( Message :: FileSearchResult ( std:: mem:: take ( & mut batch) ) )
415+ . await
416+ . ok ( ) ;
417+ }
418+ break ;
419+ }
420+ Ok ( _) => {
421+ if let Some ( app) = crate :: commands:: path_to_app ( line. trim ( ) , home_dir) {
422+ batch. push ( app) ;
423+ total_sent += 1 ;
424+ }
425+ if batch. len ( ) as u32 >= FILE_SEARCH_BATCH_SIZE {
426+ output
427+ . send ( Message :: FileSearchResult ( std:: mem:: take ( & mut batch) ) )
428+ . await
429+ . ok ( ) ;
430+ }
431+ if total_sent >= FILE_SEARCH_MAX_RESULTS {
432+ if !batch. is_empty ( ) {
433+ output
434+ . send ( Message :: FileSearchResult ( std:: mem:: take ( & mut batch) ) )
435+ . await
436+ . ok ( ) ;
437+ }
438+ break ;
439+ }
440+ }
441+ Err ( _) => break ,
442+ }
443+ }
444+ }
445+
446+ /// Async subscription that spawns `mdfind` for file search queries.
447+ ///
448+ /// Uses a `watch` channel so the Tile can push new (query, dirs) pairs.
449+ /// Each query change cancels any running `mdfind` and starts a fresh one.
450+ fn handle_file_search ( ) -> impl futures:: Stream < Item = Message > {
451+ stream:: channel ( 100 , async |mut output| {
452+ let ( sender, mut receiver) =
453+ tokio:: sync:: watch:: channel ( ( String :: new ( ) , Vec :: < String > :: new ( ) ) ) ;
454+ output
455+ . send ( Message :: SetFileSearchSender ( sender) )
456+ . await
457+ . expect ( "Failed to send file search sender." ) ;
458+
459+ let home_dir = std:: env:: var ( "HOME" ) . unwrap_or_else ( |_| "/" . to_string ( ) ) ;
460+ assert ! ( !home_dir. is_empty( ) , "HOME must not be empty." ) ;
461+
462+ let mut child: Option < tokio:: process:: Child > = None ;
463+
464+ loop {
465+ if receiver. changed ( ) . await . is_err ( ) {
466+ return ;
467+ }
468+ receiver. borrow_and_update ( ) ;
469+
470+ // Kill previous mdfind if still running.
471+ if let Some ( ref mut proc) = child {
472+ proc. kill ( ) . await . ok ( ) ;
473+ proc. wait ( ) . await . ok ( ) ;
474+ }
475+ child = None ;
476+
477+ let ( query, dirs) = receiver. borrow ( ) . clone ( ) ;
478+ assert ! ( query. len( ) < 1024 , "Query too long." ) ;
479+
480+ if query. len ( ) < 2 {
481+ output. send ( Message :: FileSearchClear ) . await . ok ( ) ;
482+ continue ;
483+ }
484+
485+ // The query is passed as a -name argument to mdfind. mdfind interprets
486+ // this as a substring match on filenames — not as a glob or shell expression.
487+ // Passed via args (not shell), so no shell injection risk.
488+ // When dirs is empty, omit -onlyin so mdfind searches system-wide.
489+ let mut args: Vec < String > = vec ! [ "-name" . to_string( ) , query. clone( ) ] ;
490+ for dir in & dirs {
491+ let expanded = dir. replace ( "~" , & home_dir) ;
492+ args. push ( "-onlyin" . to_string ( ) ) ;
493+ args. push ( expanded) ;
494+ }
495+
496+ let spawn_result = tokio:: process:: Command :: new ( "mdfind" )
497+ . args ( & args)
498+ . stdout ( std:: process:: Stdio :: piped ( ) )
499+ . stderr ( std:: process:: Stdio :: null ( ) )
500+ . kill_on_drop ( true )
501+ . spawn ( ) ;
502+
503+ let mut proc = match spawn_result {
504+ Ok ( p) => p,
505+ Err ( err) => {
506+ warn ! ( "Failed to spawn mdfind: {err}" ) ;
507+ continue ;
508+ }
509+ } ;
510+
511+ let stdout = match proc. stdout . take ( ) {
512+ Some ( s) => s,
513+ None => continue ,
514+ } ;
515+ child = Some ( proc) ;
516+
517+ read_mdfind_results ( stdout, & home_dir, & mut receiver, & mut output) . await ;
518+ }
519+ } )
520+ }
521+
380522/// Handles the rx / receiver for sending and receiving messages
381523fn handle_recipient ( ) -> impl futures:: Stream < Item = Message > {
382524 stream:: channel ( 100 , async |mut output| {
0 commit comments