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}