Skip to main content

binocular/search/
controller.rs

1use crate::app::{App, AppEvent, Search};
2use crate::infra::channel::{self, MapSender, Sender};
3use crate::search::matcher::{spawn_exact_matcher, spawn_matcher, MatcherCommand};
4use crate::search::sources::{
5    spawn_git_searcher, spawn_searcher_with_config, spawn_stdin_searcher,
6};
7use crate::search::types::{SearchConfig, SearchItem};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11enum SearchInputSource {
12    Filesystem,
13    GitSearch,
14    Stdin(Arc<[String]>),
15}
16
17impl SearchInputSource {
18    fn spawn_items(&self) -> Option<Vec<String>> {
19        match self {
20            Self::Filesystem => None,
21            Self::GitSearch => None,
22            Self::Stdin(items) => Some(items.iter().cloned().collect()),
23        }
24    }
25}
26
27struct ActiveSearchRun {
28    epoch: u64,
29    stop: Arc<AtomicBool>,
30    tx_cmd: channel::DefaultSender<MatcherCommand>,
31    _searcher_handle: std::thread::JoinHandle<()>,
32    _matcher_handle: std::thread::JoinHandle<()>,
33}
34
35impl ActiveSearchRun {
36    fn spawn(
37        search_config: SearchConfig,
38        stdin_items: Option<Vec<String>>,
39        tx_main: &channel::DefaultSender<AppEvent>,
40        epoch: u64,
41    ) -> Self {
42        let settings = search_config.settings;
43        let stop = Arc::new(AtomicBool::new(false));
44        let (tx_items, rx_items) = channel::unbounded_default::<Vec<SearchItem>>();
45        let (tx_cmd, rx_cmd) = channel::unbounded_default::<MatcherCommand>();
46        let tx_state = MapSender::new(tx_main.clone(), move |state| {
47            AppEvent::Matcher(state, epoch)
48        });
49
50        let searcher_handle = if let Some(scope) = search_config.git_search_scope.clone() {
51            spawn_git_searcher(scope, stop.clone(), tx_items)
52        } else if let Some(items) = stdin_items {
53            spawn_stdin_searcher(items, stop.clone(), tx_items)
54        } else {
55            spawn_searcher_with_config(search_config, stop.clone(), tx_items)
56        };
57
58        let matcher_handle = if settings.matcher.is_exact() {
59            spawn_exact_matcher(
60                rx_items,
61                rx_cmd,
62                stop.clone(),
63                tx_state,
64                settings.mode.is_file_name_only(),
65                settings.mode.is_content(),
66                String::new(),
67            )
68        } else {
69            spawn_matcher(
70                rx_items,
71                rx_cmd,
72                stop.clone(),
73                tx_state,
74                settings.mode.is_file_name_only(),
75                settings.mode.is_content(),
76            )
77        };
78
79        Self {
80            epoch,
81            stop,
82            tx_cmd,
83            _searcher_handle: searcher_handle,
84            _matcher_handle: matcher_handle,
85        }
86    }
87
88    fn epoch(&self) -> u64 {
89        self.epoch
90    }
91
92    fn command_sender(&self) -> &channel::DefaultSender<MatcherCommand> {
93        &self.tx_cmd
94    }
95
96    fn shutdown(self) {
97        self.stop.store(true, Ordering::Relaxed);
98    }
99}
100
101pub struct SearchController {
102    tx_main: channel::DefaultSender<AppEvent>,
103    source: SearchInputSource,
104    active: Option<ActiveSearchRun>,
105    next_epoch: u64,
106}
107
108impl SearchController {
109    pub fn from_search_config(
110        search_config: SearchConfig,
111        stdin_items: Option<Vec<String>>,
112        tx_main: &channel::DefaultSender<AppEvent>,
113        search_enabled: bool,
114    ) -> Self {
115        let source = if search_config.git_search_scope.is_some() {
116            SearchInputSource::GitSearch
117        } else {
118            stdin_items.map_or(SearchInputSource::Filesystem, |items| {
119                SearchInputSource::Stdin(Arc::<[String]>::from(items))
120            })
121        };
122        let mut manager = Self {
123            tx_main: tx_main.clone(),
124            source,
125            active: None,
126            next_epoch: 0,
127        };
128        if search_enabled {
129            manager.restart(search_config);
130        }
131        manager
132    }
133
134    pub fn command_sender(&self) -> Option<&channel::DefaultSender<MatcherCommand>> {
135        self.active.as_ref().map(ActiveSearchRun::command_sender)
136    }
137
138    pub fn send_query(&self, query: &str) {
139        if query.is_empty() {
140            return;
141        }
142        if let Some(tx_cmd) = self.command_sender() {
143            let _ = tx_cmd.send(MatcherCommand::Query(query.to_owned()));
144        }
145    }
146
147    pub fn accepts_epoch(&self, epoch: u64) -> bool {
148        self.active
149            .as_ref()
150            .map(ActiveSearchRun::epoch)
151            .is_some_and(|active_epoch| active_epoch == epoch)
152    }
153
154    pub fn reconcile(&mut self, app: &mut App, item_limit: &mut u32) {
155        if !app.ui.restart_search {
156            return;
157        }
158
159        app.ui.restart_search = false;
160        self.restart(app.search_config());
161        *item_limit = 100;
162        app.search_session.search = Search::default();
163        app.preview_session.preview.source = None;
164        app.preview_session.preview.content = None;
165        self.send_query(&app.search_session.query.text);
166    }
167
168    pub fn shutdown(&mut self) {
169        if let Some(session) = self.active.take() {
170            session.shutdown();
171        }
172    }
173
174    fn restart(&mut self, search_config: SearchConfig) {
175        if let Some(session) = self.active.take() {
176            session.shutdown();
177        }
178
179        let epoch = self.next_epoch;
180        self.next_epoch += 1;
181        self.active = Some(ActiveSearchRun::spawn(
182            search_config,
183            self.source.spawn_items(),
184            &self.tx_main,
185            epoch,
186        ));
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::config::LoadedAppConfig;
194    use crate::runtime::config::RunConfig;
195    use crate::search::types::{MatcherMode, SearchMode, SearchSettings};
196
197    fn run_config() -> RunConfig {
198        RunConfig {
199            headless: false,
200            output_format: crate::cli::args::OutputFormat::Plain,
201            output_file: None,
202            stdin: true,
203            log: false,
204            diff: None,
205            preview_command: None,
206            preview_delimiter: ":".to_string(),
207            split: None,
208            log_files: Vec::new(),
209        }
210    }
211
212    fn search_config() -> SearchConfig {
213        SearchConfig {
214            query: Some("alpha".to_string()),
215            locations: vec![],
216            search_pdf: false,
217            no_hidden: false,
218            no_git_ignore: false,
219            no_ignore: false,
220            no_default_ignore_dirs: false,
221            git_search_scope: None,
222            settings: SearchSettings {
223                mode: SearchMode::Path,
224                matcher: MatcherMode::Fuzzy,
225            },
226        }
227    }
228
229    fn app() -> App {
230        App::from_configs(run_config(), search_config(), LoadedAppConfig::default())
231    }
232
233    #[test]
234    fn repeated_search_mode_toggles_restart_sessions() {
235        let (tx_main, _rx_main) = channel::unbounded_default::<AppEvent>();
236        let mut manager = SearchController::from_search_config(
237            search_config(),
238            Some(vec!["alpha".to_string(), "beta".to_string()]),
239            &tx_main,
240            true,
241        );
242        let mut app = app();
243        let mut item_limit = 250;
244
245        assert!(manager.accepts_epoch(0));
246
247        app.apply_action(crate::app::AppAction::SetSearchMode(SearchMode::Files));
248        manager.reconcile(&mut app, &mut item_limit);
249        assert_eq!(app.search_session.settings.mode, SearchMode::Files);
250        assert_eq!(item_limit, 100);
251        assert!(manager.accepts_epoch(1));
252
253        app.apply_action(crate::app::AppAction::SetSearchMode(SearchMode::Grep));
254        manager.reconcile(&mut app, &mut item_limit);
255        assert_eq!(app.search_session.settings.mode, SearchMode::Grep);
256        assert!(manager.accepts_epoch(2));
257
258        manager.shutdown();
259    }
260
261    #[test]
262    fn exact_toggle_restarts_with_new_matcher_mode() {
263        let (tx_main, _rx_main) = channel::unbounded_default::<AppEvent>();
264        let mut manager = SearchController::from_search_config(
265            search_config(),
266            Some(vec!["alpha".to_string()]),
267            &tx_main,
268            true,
269        );
270        let mut app = app();
271        let mut item_limit = 100;
272
273        assert_eq!(app.search_session.settings.matcher, MatcherMode::Fuzzy);
274        app.apply_action(crate::app::AppAction::ToggleExactMatcher);
275        manager.reconcile(&mut app, &mut item_limit);
276        assert_eq!(app.search_session.settings.matcher, MatcherMode::Exact);
277        assert!(manager.accepts_epoch(1));
278
279        app.apply_action(crate::app::AppAction::ToggleExactMatcher);
280        manager.reconcile(&mut app, &mut item_limit);
281        assert_eq!(app.search_session.settings.matcher, MatcherMode::Fuzzy);
282        assert!(manager.accepts_epoch(2));
283
284        manager.shutdown();
285    }
286}