1use std::{
2 cmp::{max, min},
3 collections::HashMap,
4 io::Cursor,
5 iter::{self, Iterator},
6 mem,
7 path::{Path, PathBuf},
8 sync::{
9 Arc,
10 atomic::{AtomicBool, AtomicUsize, Ordering},
11 },
12 time::{Duration, Instant},
13};
14
15use fancy_regex::Regex as FancyRegex;
16use ignore::WalkState;
17use log::{debug, warn};
18use tokio::{
19 sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
20 task::{self, JoinHandle},
21};
22
23use crate::{
24 commands::{
25 Command, CommandGeneral, CommandSearchFields, CommandSearchFocusFields,
26 CommandSearchFocusResults, KeyMap, display_conflict_errors,
27 },
28 config::Config,
29 errors::AppError,
30 fields::{FieldName, SearchFieldValues, SearchFields},
31 file_content::{FileContentProvider, default_file_content_provider},
32 keyboard::{KeyCode, KeyEvent, KeyModifiers},
33 line_reader::{BufReadExt, LineEnding},
34 replace::{self, PerformingReplacementState, ReplaceState},
35 replace::{replace_all_if_match, replacement_for_match, replacement_for_match_in_haystack},
36 search::Searcher,
37 search::{
38 FileSearcher, MatchContent, ParsedSearchConfig, SearchResult, SearchResultWithReplacement,
39 SearchType, contains_search, search_multiline,
40 },
41 utils::{Either, Either::Left, Either::Right, ceil_div},
42 validation::{
43 DirConfig, SearchConfig, ValidationErrorHandler, ValidationResult,
44 validate_search_configuration,
45 },
46};
47
48#[derive(Debug, Clone)]
49pub enum InputSource {
50 Directory(PathBuf),
51 Stdin(Arc<String>),
52}
53
54#[derive(Debug)]
55pub enum ExitState {
56 Stats(ReplaceState),
57 StdinState(ExitAndReplaceState),
58}
59
60#[derive(Debug)]
61pub enum EventHandlingResult {
62 Rerender,
63 Exit(Option<Box<ExitState>>),
64 None,
65}
66
67impl EventHandlingResult {
68 pub(crate) fn new_exit_stats(stats: ReplaceState) -> EventHandlingResult {
69 Self::new_exit(ExitState::Stats(stats))
70 }
71
72 fn new_exit(exit_state: ExitState) -> EventHandlingResult {
73 EventHandlingResult::Exit(Some(Box::new(exit_state)))
74 }
75}
76
77#[derive(Debug)]
78pub enum BackgroundProcessingEvent {
79 AddSearchResult(SearchResult),
80 AddSearchResults(Vec<SearchResult>),
81 SearchCompleted,
82 ReplacementCompleted(ReplaceState),
83 UpdateReplacements {
84 start: usize,
85 end: usize,
86 cancelled: Arc<AtomicBool>,
87 },
88 UpdateAllReplacements {
89 cancelled: Arc<AtomicBool>,
90 },
91}
92
93#[derive(Debug)]
94pub enum AppEvent {
95 PerformSearch,
96 DismissToast { generation: u64 },
97}
98
99#[derive(Debug)]
100pub enum InternalEvent {
101 App(AppEvent),
102 Background(BackgroundProcessingEvent),
103}
104
105#[derive(Debug)]
106pub struct ExitAndReplaceState {
107 pub stdin: Arc<String>,
108 pub search_config: ParsedSearchConfig,
109 pub replace_results: Vec<SearchResultWithReplacement>,
110}
111
112#[derive(Debug)]
113pub enum Event {
114 LaunchEditor((PathBuf, usize)),
115 ExitAndReplace(ExitAndReplaceState),
116 Rerender,
117 Internal(InternalEvent),
118}
119
120#[derive(Debug, PartialEq, Eq)]
121struct MultiSelected {
122 anchor: usize,
123 primary: usize,
124}
125impl MultiSelected {
126 fn ordered(&self) -> (usize, usize) {
127 if self.anchor < self.primary {
128 (self.anchor, self.primary)
129 } else {
130 (self.primary, self.anchor)
131 }
132 }
133
134 fn flip_direction(&mut self) {
135 (self.anchor, self.primary) = (self.primary, self.anchor);
136 }
137}
138
139#[derive(Debug, PartialEq, Eq)]
140enum Selected {
141 Single(usize),
142 Multi(MultiSelected),
143}
144
145#[derive(Debug)]
146pub struct SearchState {
147 pub results: Vec<SearchResultWithReplacement>,
148
149 selected: Selected,
150 pub view_offset: usize, pub num_displayed: Option<usize>, processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
155 processing_sender: UnboundedSender<BackgroundProcessingEvent>,
156
157 pub last_render: Instant,
158 pub search_started: Instant,
159 pub search_completed: Option<Instant>,
160 pub cancelled: Arc<AtomicBool>,
161}
162
163impl SearchState {
164 pub fn new(
165 processing_sender: UnboundedSender<BackgroundProcessingEvent>,
166 processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
167 cancelled: Arc<AtomicBool>,
168 ) -> Self {
169 Self {
170 results: vec![],
171 selected: Selected::Single(0),
172 view_offset: 0,
173 num_displayed: None,
174 processing_sender,
175 processing_receiver,
176 last_render: Instant::now(),
177 search_started: Instant::now(),
178 search_completed: None,
179 cancelled,
180 }
181 }
182
183 fn move_selected_up_by(&mut self, n: usize) {
184 let primary_selected_pos = self.primary_selected_pos();
185 if primary_selected_pos == 0 {
186 self.selected = Selected::Single(self.results.len().saturating_sub(1));
187 } else {
188 self.move_primary_sel(primary_selected_pos.saturating_sub(n));
189 }
190 }
191
192 fn move_selected_down_by(&mut self, n: usize) {
193 let primary_selected_pos = self.primary_selected_pos();
194 let end = self.results.len().saturating_sub(1);
195 if primary_selected_pos >= end {
196 self.selected = Selected::Single(0);
197 } else {
198 self.move_primary_sel(min(primary_selected_pos + n, end));
199 }
200 }
201
202 fn move_selected_up(&mut self) {
203 self.move_selected_up_by(1);
204 }
205
206 fn move_selected_down(&mut self) {
207 self.move_selected_down_by(1);
208 }
209
210 fn move_selected_up_full_page(&mut self) {
211 self.move_selected_up_by(max(self.num_displayed.unwrap(), 1));
212 }
213
214 fn move_selected_down_full_page(&mut self) {
215 self.move_selected_down_by(max(self.num_displayed.unwrap(), 1));
216 }
217
218 fn move_selected_up_half_page(&mut self) {
219 self.move_selected_up_by(max(ceil_div(self.num_displayed.unwrap(), 2), 1));
220 }
221
222 fn move_selected_down_half_page(&mut self) {
223 self.move_selected_down_by(max(ceil_div(self.num_displayed.unwrap(), 2), 1));
224 }
225
226 fn move_selected_top(&mut self) {
227 self.move_primary_sel(0);
228 }
229
230 fn move_selected_bottom(&mut self) {
231 self.move_primary_sel(self.results.len().saturating_sub(1));
232 }
233
234 fn move_primary_sel(&mut self, idx: usize) {
235 self.selected = match &self.selected {
236 Selected::Single(_) => Selected::Single(idx),
237 Selected::Multi(MultiSelected { anchor, .. }) => Selected::Multi(MultiSelected {
238 anchor: *anchor,
239 primary: idx,
240 }),
241 };
242 }
243
244 fn toggle_selected_inclusion(&mut self) {
245 let all_included = self
246 .selected_fields()
247 .iter()
248 .all(|res| res.search_result.included);
249 self.selected_fields_mut().iter_mut().for_each(|selected| {
250 selected.search_result.included = !all_included;
251 });
252 }
253
254 fn toggle_all_selected(&mut self) {
255 let all_included = self.results.iter().all(|res| res.search_result.included);
256 self.results
257 .iter_mut()
258 .for_each(|res| res.search_result.included = !all_included);
259 }
260
261 fn selected_range(&self) -> (usize, usize) {
263 match &self.selected {
264 Selected::Single(sel) => (*sel, *sel),
265 Selected::Multi(ms) => ms.ordered(),
266 }
267 }
268
269 fn selected_fields(&self) -> &[SearchResultWithReplacement] {
270 if self.results.is_empty() {
271 return &[];
272 }
273 let (low, high) = self.selected_range();
274 &self.results[low..=high]
275 }
276
277 fn selected_fields_mut(&mut self) -> &mut [SearchResultWithReplacement] {
278 if self.results.is_empty() {
279 return &mut [];
280 }
281 let (low, high) = self.selected_range();
282 &mut self.results[low..=high]
283 }
284
285 pub fn primary_selected_field_mut(&mut self) -> Option<&mut SearchResultWithReplacement> {
286 let sel = self.primary_selected_pos();
287 if !self.results.is_empty() {
288 Some(&mut self.results[sel])
289 } else {
290 None
291 }
292 }
293
294 pub fn primary_selected_pos(&self) -> usize {
295 match self.selected {
296 Selected::Single(sel) => sel,
297 Selected::Multi(MultiSelected { primary, .. }) => primary,
298 }
299 }
300
301 fn toggle_multiselect_mode(&mut self) {
302 self.selected = match &self.selected {
303 Selected::Single(sel) => Selected::Multi(MultiSelected {
304 anchor: *sel,
305 primary: *sel,
306 }),
307 Selected::Multi(MultiSelected { primary, .. }) => Selected::Single(*primary),
308 };
309 }
310
311 pub fn is_selected(&self, idx: usize) -> bool {
312 match &self.selected {
313 Selected::Single(sel) => idx == *sel,
314 Selected::Multi(ms) => {
315 let (low, high) = ms.ordered();
316 idx >= low && idx <= high
317 }
318 }
319 }
320
321 fn multiselect_enabled(&self) -> bool {
322 match &self.selected {
323 Selected::Single(_) => false,
324 Selected::Multi(_) => true,
325 }
326 }
327
328 pub fn is_primary_selected(&self, idx: usize) -> bool {
329 idx == self.primary_selected_pos()
330 }
331
332 fn flip_multiselect_direction(&mut self) {
333 match &mut self.selected {
334 Selected::Single(_) => {}
335 Selected::Multi(ms) => {
336 ms.flip_direction();
337 }
338 }
339 }
340
341 pub fn set_search_completed_now(&mut self) {
342 self.search_completed = Some(Instant::now());
343 }
344}
345
346#[derive(Clone, Debug, Eq, PartialEq)]
347pub enum FocussedSection {
348 SearchFields,
349 SearchResults,
350}
351
352#[derive(Debug)]
353pub struct PreviewUpdateStatus {
354 replace_debounce_timer: JoinHandle<()>,
355 update_replacement_cancelled: Arc<AtomicBool>,
356 replacements_updated: usize,
357 total_replacements_to_update: usize,
358}
359
360impl PreviewUpdateStatus {
361 fn new(
362 replace_debounce_timer: JoinHandle<()>,
363 update_replacement_cancelled: Arc<AtomicBool>,
364 ) -> Self {
365 Self {
366 replace_debounce_timer,
367 update_replacement_cancelled,
368 replacements_updated: 0,
369 total_replacements_to_update: 0,
370 }
371 }
372}
373
374#[derive(Debug)]
375pub struct SearchFieldsState {
376 pub focussed_section: FocussedSection,
377 pub search_state: Option<SearchState>, pub search_debounce_timer: Option<JoinHandle<()>>,
379 pub preview_update_state: Option<PreviewUpdateStatus>,
380}
381
382impl Default for SearchFieldsState {
383 fn default() -> Self {
384 Self {
385 focussed_section: FocussedSection::SearchFields,
386 search_state: None,
387 search_debounce_timer: None,
388 preview_update_state: None,
389 }
390 }
391}
392
393impl SearchFieldsState {
394 pub fn replacements_in_progress(&self) -> Option<(usize, usize)> {
395 self.preview_update_state.as_ref().and_then(|p| {
396 if p.replacements_updated != p.total_replacements_to_update {
397 Some((p.replacements_updated, p.total_replacements_to_update))
398 } else {
399 None
400 }
401 })
402 }
403
404 pub fn cancel_preview_updates(&mut self) {
405 if let Some(ref mut state) = self.preview_update_state {
406 state.replace_debounce_timer.abort();
407 state
408 .update_replacement_cancelled
409 .store(true, Ordering::Relaxed);
410 }
411 self.preview_update_state = None;
412 }
413}
414
415#[derive(Debug)]
416pub enum Screen {
417 SearchFields(SearchFieldsState),
418 PerformingReplacement(PerformingReplacementState),
419 Results(ReplaceState),
420}
421
422impl Screen {
423 fn name(&self) -> &str {
424 match &self {
426 Screen::SearchFields(_) => "SearchFields",
427 Screen::PerformingReplacement(_) => "PerformingReplacement",
428 Screen::Results(_) => "Results",
429 }
430 }
431
432 fn unwrap_search_fields_state_mut(&mut self) -> &mut SearchFieldsState {
433 let name = self.name().to_owned();
434 let Screen::SearchFields(search_fields_state) = self else {
435 panic!("Expected current_screen to be SearchFields, found {name}");
436 };
437 search_fields_state
438 }
439}
440
441#[derive(Debug)]
442pub enum Popup {
443 Error,
444 Help,
445 Text { title: String, body: String },
446}
447
448#[derive(Debug, Clone)]
449struct Toast {
450 message: String,
451 generation: u64,
452}
453
454#[derive(Clone, Debug, PartialEq, Eq)]
455#[allow(clippy::struct_excessive_bools)]
456pub struct AppRunConfig {
457 pub include_hidden: bool,
458 pub include_git_folders: bool,
459 pub advanced_regex: bool,
460 pub multiline: bool,
461 pub immediate_search: bool,
462 pub immediate_replace: bool,
463 pub print_results: bool,
464 pub print_on_exit: bool,
465 pub interpret_escape_sequences: bool,
466}
467
468#[allow(clippy::derivable_impls)]
469impl Default for AppRunConfig {
470 fn default() -> Self {
471 Self {
472 include_hidden: false,
473 include_git_folders: false,
474 advanced_regex: false,
475 multiline: false,
476 immediate_search: false,
477 immediate_replace: false,
478 print_results: false,
479 print_on_exit: false,
480 interpret_escape_sequences: false,
481 }
482 }
483}
484
485#[derive(Debug)]
486pub struct EventChannels {
487 pub sender: UnboundedSender<Event>,
488 receiver: UnboundedReceiver<Event>,
489}
490
491impl EventChannels {
492 pub fn new() -> Self {
493 let (sender, receiver) = mpsc::unbounded_channel();
494 Self { sender, receiver }
495 }
496
497 pub async fn recv(&mut self) -> Option<Event> {
498 self.receiver.recv().await
499 }
500}
501
502impl Default for EventChannels {
503 fn default() -> Self {
504 Self::new()
505 }
506}
507
508#[derive(Debug, Default)]
509struct HintState {
510 has_shown_multiline_hint: bool,
511}
512
513#[derive(Debug)]
514pub struct UIState {
515 pub current_screen: Screen,
516 pub popup: Option<Popup>,
517 toast: Option<Toast>,
518 errors: Vec<AppError>,
519 hints: HintState,
520}
521
522impl UIState {
523 pub fn new(current_screen: Screen) -> Self {
524 Self {
525 current_screen,
526 popup: None,
527 toast: None,
528 errors: Vec::new(),
529 hints: HintState::default(),
530 }
531 }
532
533 pub fn add_error(&mut self, error: AppError) {
534 self.errors.push(error);
535 }
536
537 pub fn errors(&self) -> &[AppError] {
538 &self.errors
539 }
540
541 pub fn clear_errors(&mut self) {
542 self.errors.clear();
543 }
544}
545
546pub struct App {
547 pub config: Config,
548 key_map: KeyMap,
549 pub search_fields: SearchFields,
550 pub searcher: Option<Searcher>,
551 pub input_source: InputSource,
552 pub run_config: AppRunConfig,
553 pub event_channels: EventChannels,
554 pub ui_state: UIState,
555 file_content_provider: Arc<dyn FileContentProvider>,
556}
557
558impl std::fmt::Debug for App {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 f.debug_struct("App")
561 .field("config", &self.config)
562 .field("key_map", &self.key_map)
563 .field("search_fields", &self.search_fields)
564 .field("searcher", &self.searcher)
565 .field("input_source", &self.input_source)
566 .field("run_config", &self.run_config)
567 .field("event_channels", &self.event_channels)
568 .field("ui_state", &self.ui_state)
569 .finish_non_exhaustive()
570 }
571}
572
573#[derive(Debug)]
574enum SearchStrategy {
575 Files(FileSearcher),
576 Text {
577 haystack: Arc<String>,
578 config: ParsedSearchConfig,
579 },
580}
581
582#[derive(Clone, Debug, Eq, PartialEq, Hash)]
583enum ReplacementCacheKey {
584 File(PathBuf),
585 Stdin,
586}
587
588#[derive(Clone, Debug, Eq, PartialEq)]
589enum PreviewOutcome {
590 Replacement(String),
591 NoMatch,
592 Error(String),
593}
594
595fn result_with_outcome(
596 search_result: SearchResult,
597 outcome: PreviewOutcome,
598) -> Option<SearchResultWithReplacement> {
599 match outcome {
600 PreviewOutcome::Replacement(replacement) => Some(SearchResultWithReplacement {
601 search_result,
602 replacement,
603 replace_result: None,
604 preview_error: None,
605 }),
606 PreviewOutcome::Error(error) => Some(SearchResultWithReplacement {
607 search_result,
608 replacement: String::new(),
609 replace_result: None,
610 preview_error: Some(error),
611 }),
612 PreviewOutcome::NoMatch => None,
613 }
614}
615
616fn apply_outcome(result: &mut SearchResultWithReplacement, outcome: PreviewOutcome) -> bool {
617 match outcome {
618 PreviewOutcome::Replacement(replacement) => {
619 result.replacement = replacement;
620 result.preview_error = None;
621 true
622 }
623 PreviewOutcome::Error(error) => {
624 result.replacement.clear();
625 result.preview_error = Some(error);
626 true
627 }
628 PreviewOutcome::NoMatch => false,
629 }
630}
631
632struct ReplacementContext<'a> {
633 input_source: &'a InputSource,
634 searcher: &'a Searcher,
635 needs_context: bool,
636 file_content_provider: Arc<dyn FileContentProvider>,
637 file_cache: HashMap<PathBuf, Arc<String>>,
638 replacement_cache: HashMap<ReplacementCacheKey, HashMap<(usize, usize), String>>,
639}
640
641impl<'a> ReplacementContext<'a> {
642 fn new(
643 input_source: &'a InputSource,
644 searcher: &'a Searcher,
645 needs_context: bool,
646 file_content_provider: Arc<dyn FileContentProvider>,
647 ) -> Self {
648 Self {
649 input_source,
650 searcher,
651 needs_context,
652 file_content_provider,
653 file_cache: HashMap::new(),
654 replacement_cache: HashMap::new(),
655 }
656 }
657
658 fn replacement_for_search_result(&mut self, res: &SearchResult) -> PreviewOutcome {
659 match &res.content {
660 MatchContent::Line { content, .. } => {
661 replace_all_if_match(content, self.searcher.search(), self.searcher.replace())
662 .map_or(PreviewOutcome::NoMatch, PreviewOutcome::Replacement)
663 }
664 MatchContent::ByteRange {
665 content,
666 byte_start,
667 byte_end,
668 ..
669 } => {
670 if self.needs_context {
671 return self.replacement_for_byte_range_with_context(
672 res,
673 content,
674 *byte_start,
675 *byte_end,
676 );
677 }
678
679 if contains_search(content, self.searcher.search()) {
680 return PreviewOutcome::Replacement(replacement_for_match(
681 content,
682 self.searcher.search(),
683 self.searcher.replace(),
684 ));
685 }
686
687 PreviewOutcome::NoMatch
688 }
689 }
690 }
691
692 fn replacement_for_byte_range_with_context(
693 &mut self,
694 res: &SearchResult,
695 content: &str,
696 byte_start: usize,
697 byte_end: usize,
698 ) -> PreviewOutcome {
699 let haystack = match self.haystack_for_result(res) {
700 Ok(haystack) => haystack,
701 Err(error) => return PreviewOutcome::Error(error),
702 };
703
704 if haystack.get(byte_start..byte_end) != Some(content) {
705 let message = if res.path.is_some() {
706 "File changed since search".to_string()
707 } else {
708 "Input changed since search".to_string()
709 };
710 return PreviewOutcome::Error(message);
711 }
712
713 if let Some(map) = self.replacement_map_for_result(res, haystack.as_str())
714 && let Some(replacement) = map.get(&(byte_start, byte_end))
715 {
716 return PreviewOutcome::Replacement(replacement.clone());
717 }
718
719 if let Some(replacement) = replacement_for_match_in_haystack(
724 self.searcher.search(),
725 self.searcher.replace(),
726 haystack.as_str(),
727 byte_start,
728 byte_end,
729 ) {
730 return PreviewOutcome::Replacement(replacement);
731 }
732
733 PreviewOutcome::NoMatch
734 }
735
736 fn replacement_map_for_result(
737 &mut self,
738 res: &SearchResult,
739 haystack: &str,
740 ) -> Option<&HashMap<(usize, usize), String>> {
741 let SearchType::PatternAdvanced(pattern) = self.searcher.search() else {
742 return None;
743 };
744 let key = self.replacement_cache_key(res)?;
745 let replace = self.searcher.replace();
746 Some(
747 self.replacement_cache
748 .entry(key)
749 .or_insert_with(|| build_replacement_map(pattern, replace, haystack)),
750 )
751 }
752
753 fn replacement_cache_key(&self, res: &SearchResult) -> Option<ReplacementCacheKey> {
754 if let Some(path) = res.path.as_ref() {
755 Some(ReplacementCacheKey::File(path.clone()))
756 } else if matches!(self.input_source, InputSource::Stdin(_)) {
757 Some(ReplacementCacheKey::Stdin)
758 } else {
759 None
760 }
761 }
762
763 fn haystack_for_result(&mut self, res: &SearchResult) -> Result<Arc<String>, String> {
764 if let Some(path) = res.path.as_ref() {
765 if let Some(cached) = self.file_cache.get(path) {
766 return Ok(Arc::clone(cached));
767 }
768
769 match self.read_file_content(path) {
770 Ok(contents) => {
771 self.file_cache.insert(path.clone(), Arc::clone(&contents));
772 Ok(contents)
773 }
774 Err(err) => {
775 let message = format!("Failed to read file for replacement preview: {err}");
776 warn!(
777 "Failed to read file for multiline replacement preview {path}: {err}",
778 path = path.display()
779 );
780 Err(message)
781 }
782 }
783 } else if let InputSource::Stdin(stdin) = self.input_source {
784 Ok(Arc::clone(stdin))
785 } else {
786 Err("Missing input source for replacement preview".to_string())
787 }
788 }
789
790 fn read_file_content(&self, path: &Path) -> anyhow::Result<Arc<String>> {
791 self.file_content_provider.read_to_string(path)
792 }
793}
794
795fn build_replacement_map(
796 pattern: &FancyRegex,
797 replace: &str,
798 haystack: &str,
799) -> HashMap<(usize, usize), String> {
800 let mut map = HashMap::new();
801 for caps in pattern.captures_iter(haystack).flatten() {
802 if let Some(mat) = caps.get(0) {
803 let mut out = String::new();
804 caps.expand(replace, &mut out);
805 map.insert((mat.start(), mat.end()), out);
806 }
807 }
808 map
809}
810
811fn generate_escape_deprecation_message(quit_keymap: Option<KeyEvent>) -> String {
812 let quit_keymap_str = quit_keymap.map_or("".to_string(), |keymap| {
813 let optional_help = if let KeyEvent {
814 code: KeyCode::Char('c'),
815 modifiers: KeyModifiers::CONTROL,
816 } = keymap
817 {
818 " (i.e. `ctrl + c`)"
820 } else {
821 ""
822 };
823 format!(": use `{keymap}`{optional_help} instead")
824 });
825
826 format!(
827 "Pressing escape to quit is no longer enabled by default{quit_keymap_str}.\n\nYou can remap this in your scooter config.",
828 )
829}
830
831macro_rules! get_bg_receiver {
834 ($self:expr) => {
835 match &mut $self.ui_state.current_screen {
836 Screen::SearchFields(SearchFieldsState { search_state, .. }) => {
837 search_state.as_mut().map(|s| &mut s.processing_receiver)
838 }
839 Screen::PerformingReplacement(PerformingReplacementState {
840 processing_receiver,
841 ..
842 }) => Some(processing_receiver),
843 Screen::Results(_) => None,
844 }
845 };
846}
847
848macro_rules! recv_optional {
849 ($opt_receiver:expr) => {
850 async {
851 match $opt_receiver {
852 Some(r) => r.recv().await,
853 None => None,
854 }
855 }
856 };
857}
858
859impl<'a> App {
860 pub fn new(
861 input_source: InputSource,
862 search_field_values: &SearchFieldValues<'a>,
863 app_run_config: AppRunConfig,
864 config: Config,
865 ) -> anyhow::Result<Self> {
866 let search_fields = SearchFields::with_values(
867 search_field_values,
868 config.search.disable_prepopulated_fields,
869 );
870
871 let mut search_fields_state = SearchFieldsState::default();
872 if app_run_config.immediate_search {
873 search_fields_state.focussed_section = FocussedSection::SearchResults;
874 }
875
876 let key_map = KeyMap::from_config(&config.keys).map_err(display_conflict_errors)?;
877
878 let search_immediately =
879 app_run_config.immediate_search || !search_field_values.search.value.is_empty();
880
881 let mut app = Self {
882 config,
883 key_map,
884 search_fields,
885 searcher: None,
886 input_source,
887 run_config: app_run_config,
888 event_channels: EventChannels::new(),
889 ui_state: UIState::new(Screen::SearchFields(search_fields_state)),
890 file_content_provider: default_file_content_provider(),
891 };
892
893 if search_immediately {
894 app.perform_search_background();
895 }
896
897 Ok(app)
898 }
899
900 pub fn set_file_content_provider(&mut self, provider: Arc<dyn FileContentProvider>) {
901 self.file_content_provider = provider;
902 }
903
904 fn replacement_context<'b>(
905 input_source: &'b InputSource,
906 searcher: &'b Searcher,
907 file_content_provider: Arc<dyn FileContentProvider>,
908 ) -> ReplacementContext<'b> {
909 let needs_context = searcher.search().needs_haystack_context();
910 ReplacementContext::new(input_source, searcher, needs_context, file_content_provider)
911 }
912
913 pub fn handle_internal_event(&mut self, event: InternalEvent) -> EventHandlingResult {
914 match event {
915 InternalEvent::App(app_event) => self.handle_app_event(app_event),
916 InternalEvent::Background(bg_event) => {
917 self.handle_background_processing_event(bg_event)
918 }
919 }
920 }
921
922 #[allow(clippy::needless_pass_by_value)]
923 fn handle_app_event(&mut self, app_event: AppEvent) -> EventHandlingResult {
924 match app_event {
925 AppEvent::PerformSearch => {
926 self.perform_search_already_validated();
927 EventHandlingResult::Rerender
928 }
929 AppEvent::DismissToast { generation } => {
930 self.dismiss_toast_if_generation_matches(generation);
931 EventHandlingResult::Rerender
932 }
933 }
934 }
935
936 fn cancel_search(&mut self) {
937 if let Screen::SearchFields(SearchFieldsState {
938 search_state: Some(SearchState { cancelled, .. }),
939 ..
940 }) = &mut self.ui_state.current_screen
941 {
942 cancelled.store(true, Ordering::Relaxed);
943 }
944 }
945
946 fn cancel_replacement(&mut self) {
947 if let Screen::PerformingReplacement(PerformingReplacementState { cancelled, .. }) =
948 &mut self.ui_state.current_screen
949 {
950 cancelled.store(true, Ordering::Relaxed);
951 }
952 }
953
954 pub fn cancel_in_progress_tasks(&mut self) {
955 self.cancel_search();
956 self.cancel_replacement();
957
958 if let Screen::SearchFields(ref mut search_fields_state) = self.ui_state.current_screen {
959 search_fields_state.cancel_preview_updates();
960 }
961 }
962
963 pub fn reset(&mut self) {
964 self.cancel_in_progress_tasks();
965 let mut run_config = self.run_config.clone();
966 run_config.immediate_search = false;
967 self.file_content_provider.clear();
968 let provider = Arc::clone(&self.file_content_provider);
969
970 *self = Self::new(
971 self.input_source.clone(), &SearchFieldValues::default(),
973 run_config,
974 std::mem::take(&mut self.config),
975 )
976 .expect("App initialisation errors should have been detected on initial construction");
977 self.file_content_provider = provider;
978 }
979
980 pub async fn event_recv(&mut self) -> Event {
981 tokio::select! {
982 Some(event) = self.event_channels.recv() => event,
983 Some(bg_event) = recv_optional!(get_bg_receiver!(self)) => {
984 Event::Internal(InternalEvent::Background(bg_event))
985 }
986 }
987 }
988
989 pub fn background_processing_reciever(
990 &mut self,
991 ) -> Option<&mut UnboundedReceiver<BackgroundProcessingEvent>> {
992 get_bg_receiver!(self)
993 }
994
995 fn perform_search_foreground(&mut self) {
1000 if !matches!(self.ui_state.current_screen, Screen::SearchFields(_)) {
1001 log::warn!(
1002 "Called perform_search_with_error_popup on screen {}",
1003 self.ui_state.current_screen.name()
1004 );
1005 return;
1006 }
1007
1008 if !self.errors().is_empty() {
1009 self.set_popup(Popup::Error);
1010 } else if self.search_fields.search().text().is_empty() {
1011 self.add_error(AppError {
1012 name: "Search field must not be empty".to_string(),
1013 long: "Please enter some search text".to_string(),
1014 });
1015 } else {
1016 if !self.run_config.multiline
1017 && !self.search_fields.fixed_strings().checked
1018 && self.search_fields.search().text().contains(r"\n")
1019 && !self.ui_state.hints.has_shown_multiline_hint
1020 {
1021 let key_hint = self
1022 .config
1023 .keys
1024 .search
1025 .toggle_multiline
1026 .first()
1027 .map(|k| format!(" Press {k} to enable."))
1028 .unwrap_or_default();
1029 self.show_toast(
1030 format!(r"Search contains \n but multiline is off.{key_hint}"),
1031 Duration::from_secs(5),
1032 );
1033 self.ui_state.hints.has_shown_multiline_hint = true;
1034 }
1035
1036 let Screen::SearchFields(ref mut search_fields_state) = self.ui_state.current_screen
1037 else {
1038 panic!(
1039 "Expected SearchFields, found {:?}",
1040 self.ui_state.current_screen.name()
1041 );
1042 };
1043 search_fields_state.focussed_section = FocussedSection::SearchResults;
1044 if search_fields_state.search_state.is_some() {
1046 if self.run_config.immediate_replace && self.search_has_completed() {
1047 self.perform_replacement();
1048 }
1049 } else {
1050 self.perform_search_background();
1051 }
1052 }
1053 }
1054
1055 pub fn perform_search_background(&mut self) {
1058 if !matches!(self.ui_state.current_screen, Screen::SearchFields(_)) {
1059 log::warn!(
1060 "Called perform_search_if_valid on screen {}",
1061 self.ui_state.current_screen.name()
1062 );
1063 return;
1064 }
1065
1066 let Some(search_config) = self.validate_fields().unwrap() else {
1067 return;
1068 };
1069 self.searcher = Some(search_config);
1070 self.perform_search_already_validated();
1071 }
1072
1073 fn perform_search_already_validated(&mut self) {
1076 self.cancel_search();
1077 self.file_content_provider.clear();
1078 let Screen::SearchFields(ref mut search_fields_state) = self.ui_state.current_screen else {
1079 log::warn!(
1080 "Called perform_search_unwrap on screen {}",
1081 self.ui_state.current_screen.name()
1082 );
1083 return;
1084 };
1085 search_fields_state.cancel_preview_updates();
1086 if let Some(timer) = search_fields_state.search_debounce_timer.take() {
1087 timer.abort();
1088 }
1089
1090 if self.search_fields.search().text().is_empty() {
1091 search_fields_state.search_state = None;
1092 }
1093
1094 let (background_processing_sender, background_processing_receiver) =
1095 mpsc::unbounded_channel();
1096 let cancelled = Arc::new(AtomicBool::new(false));
1097 let search_state = SearchState::new(
1098 background_processing_sender.clone(),
1099 background_processing_receiver,
1100 cancelled.clone(),
1101 );
1102
1103 let strategy = match &self.searcher {
1104 Some(Searcher::FileSearcher(file_searcher)) => {
1105 SearchStrategy::Files(file_searcher.clone())
1106 }
1107 Some(Searcher::TextSearcher { search_config }) => {
1108 let InputSource::Stdin(ref stdin) = self.input_source else {
1109 panic!("Expected InputSource::Stdin, found {:?}", self.input_source);
1110 };
1111 SearchStrategy::Text {
1112 haystack: Arc::clone(stdin),
1113 config: search_config.clone(),
1114 }
1115 }
1116 None => {
1117 panic!("Fields should have been parsed")
1118 }
1119 };
1120
1121 Self::spawn_search_task(
1122 strategy,
1123 background_processing_sender.clone(),
1124 self.event_channels.sender.clone(),
1125 cancelled,
1126 );
1127
1128 search_fields_state.search_state = Some(search_state);
1129 }
1130
1131 #[allow(clippy::needless_pass_by_value)]
1132 fn update_all_replacements(&mut self, cancelled: Arc<AtomicBool>) -> EventHandlingResult {
1133 if cancelled.load(Ordering::Relaxed) {
1134 return EventHandlingResult::None;
1135 }
1136 let Screen::SearchFields(SearchFieldsState {
1137 search_state: Some(search_state),
1138 preview_update_state: Some(preview_update_state),
1139 ..
1140 }) = &mut self.ui_state.current_screen
1141 else {
1142 return EventHandlingResult::None;
1143 };
1144
1145 preview_update_state.total_replacements_to_update = search_state.results.len();
1146
1147 #[allow(clippy::items_after_statements)]
1148 static STEP: usize = 7919; let num_results = search_state.results.len();
1151 for start in (0..num_results).step_by(STEP) {
1152 let end = (start + STEP - 1).min(num_results.saturating_sub(1));
1153 let _ = search_state.processing_sender.send(
1154 BackgroundProcessingEvent::UpdateReplacements {
1155 start,
1156 end,
1157 cancelled: cancelled.clone(),
1158 },
1159 );
1160 }
1161
1162 EventHandlingResult::Rerender
1163 }
1164
1165 #[allow(clippy::needless_pass_by_value)]
1166 fn update_replacements(
1167 &mut self,
1168 start: usize,
1169 end: usize,
1170 cancelled: Arc<AtomicBool>,
1171 ) -> EventHandlingResult {
1172 if cancelled.load(Ordering::Relaxed) {
1173 return EventHandlingResult::None;
1174 }
1175 let searcher = self
1176 .searcher
1177 .as_ref()
1178 .expect("Fields should have been parsed");
1179 let mut context = Self::replacement_context(
1180 &self.input_source,
1181 searcher,
1182 Arc::clone(&self.file_content_provider),
1183 );
1184 let Screen::SearchFields(SearchFieldsState {
1185 search_state: Some(search_state),
1186 preview_update_state: Some(preview_update_state),
1187 ..
1188 }) = &mut self.ui_state.current_screen
1189 else {
1190 return EventHandlingResult::None;
1191 };
1192 for res in &mut search_state.results[start..=end] {
1193 if !apply_outcome(
1194 res,
1195 context.replacement_for_search_result(&res.search_result),
1196 ) {
1197 return EventHandlingResult::Rerender;
1200 }
1201 }
1202 preview_update_state.replacements_updated += end - start + 1;
1203
1204 EventHandlingResult::Rerender
1205 }
1206
1207 pub fn perform_replacement(&mut self) {
1208 if !self.ready_to_replace() {
1209 return;
1210 }
1211
1212 let temp_placeholder = Screen::SearchFields(SearchFieldsState::default());
1213 match mem::replace(
1214 &mut self.ui_state.current_screen,
1215 temp_placeholder, ) {
1217 Screen::SearchFields(SearchFieldsState {
1218 search_state: Some(state),
1219 ..
1220 }) => {
1221 let (background_processing_sender, background_processing_receiver) =
1222 mpsc::unbounded_channel();
1223 let cancelled = Arc::new(AtomicBool::new(false));
1224 let total_replacements = state
1225 .results
1226 .iter()
1227 .filter(|r| r.search_result.included)
1228 .count();
1229 let replacements_completed = Arc::new(AtomicUsize::new(0));
1230
1231 let Some(searcher) = self.validate_fields().unwrap() else {
1232 panic!("Attempted to replace with invalid fields");
1233 };
1234 match searcher {
1235 Searcher::FileSearcher(file_searcher) => {
1236 replace::perform_replacement(
1237 state.results,
1238 background_processing_sender.clone(),
1239 cancelled.clone(),
1240 replacements_completed.clone(),
1241 self.event_channels.sender.clone(),
1242 Some(file_searcher),
1243 self.file_content_provider.clone(),
1244 );
1245 }
1246 Searcher::TextSearcher { search_config } => {
1247 let InputSource::Stdin(ref stdin) = self.input_source else {
1248 panic!("Expected stdin input source, found {:?}", self.input_source)
1249 };
1250 self.event_channels
1251 .sender
1252 .send(Event::ExitAndReplace(ExitAndReplaceState {
1253 stdin: Arc::clone(stdin),
1254 replace_results: state.results,
1255 search_config,
1256 }))
1257 .expect("Failed to send ExitAndReplace event");
1258 }
1259 }
1260
1261 self.ui_state.current_screen =
1262 Screen::PerformingReplacement(PerformingReplacementState::new(
1263 background_processing_receiver,
1264 cancelled,
1265 replacements_completed,
1266 total_replacements,
1267 ));
1268 }
1269 screen => self.ui_state.current_screen = screen,
1270 }
1271 }
1272
1273 fn ready_to_replace(&mut self) -> bool {
1274 if !self.search_has_completed() {
1275 self.add_error(AppError {
1276 name: "Search still in progress".to_string(),
1277 long: "Try again when search is complete".to_string(),
1278 });
1279 return false;
1280 } else if !self.is_preview_updated() {
1281 self.add_error(AppError {
1282 name: "Updating replacement preview".to_string(),
1283 long: "Try again when complete".to_string(),
1284 });
1285 return false;
1286 } else if !self
1287 .background_processing_reciever()
1288 .is_some_and(|r| r.is_empty())
1289 {
1290 self.add_error(AppError {
1291 name: "Background processing in progress".to_string(),
1292 long: "Try again in a moment".to_string(),
1293 });
1294 return false;
1295 }
1296 true
1297 }
1298
1299 pub fn handle_background_processing_event(
1300 &mut self,
1301 event: BackgroundProcessingEvent,
1302 ) -> EventHandlingResult {
1303 match event {
1304 BackgroundProcessingEvent::AddSearchResult(result) => {
1305 self.add_search_results(iter::once(result))
1306 }
1307 BackgroundProcessingEvent::AddSearchResults(results) => {
1308 self.add_search_results(results)
1309 }
1310 BackgroundProcessingEvent::SearchCompleted => {
1311 if let Screen::SearchFields(SearchFieldsState {
1312 search_state: Some(state),
1313 focussed_section,
1314 ..
1315 }) = &mut self.ui_state.current_screen
1316 {
1317 state.set_search_completed_now();
1318 if self.run_config.immediate_replace
1319 && *focussed_section == FocussedSection::SearchResults
1320 {
1321 self.perform_replacement();
1322 }
1323 }
1324 EventHandlingResult::Rerender
1325 }
1326 BackgroundProcessingEvent::ReplacementCompleted(replace_state) => {
1327 if self.run_config.print_results {
1328 EventHandlingResult::new_exit_stats(replace_state)
1329 } else {
1330 self.ui_state.current_screen = Screen::Results(replace_state);
1331 EventHandlingResult::Rerender
1332 }
1333 }
1334 BackgroundProcessingEvent::UpdateAllReplacements { cancelled } => {
1335 self.update_all_replacements(cancelled)
1336 }
1337 BackgroundProcessingEvent::UpdateReplacements {
1338 start,
1339 end,
1340 cancelled,
1341 } => self.update_replacements(start, end, cancelled),
1342 }
1343 }
1344
1345 fn add_search_results<I>(&mut self, results: I) -> EventHandlingResult
1346 where
1347 I: IntoIterator<Item = SearchResult>,
1348 {
1349 let mut rerender = false;
1350 let searcher = self
1351 .searcher
1352 .as_ref()
1353 .expect("searcher should not be None when adding search results");
1354 let mut context = Self::replacement_context(
1355 &self.input_source,
1356 searcher,
1357 Arc::clone(&self.file_content_provider),
1358 );
1359 if let Screen::SearchFields(SearchFieldsState {
1360 search_state: Some(search_in_progress_state),
1361 ..
1362 }) = &mut self.ui_state.current_screen
1363 {
1364 let mut results_with_replacements = Vec::new();
1365 for res in results {
1366 let outcome = context.replacement_for_search_result(&res);
1367 if let Some(updated) = result_with_outcome(res, outcome) {
1368 results_with_replacements.push(updated);
1369 }
1370 }
1371 search_in_progress_state
1372 .results
1373 .append(&mut results_with_replacements);
1374
1375 if search_in_progress_state.last_render.elapsed() >= Duration::from_millis(92) {
1377 rerender = true;
1378 search_in_progress_state.last_render = Instant::now();
1379 }
1380 }
1381 if rerender {
1382 EventHandlingResult::Rerender
1383 } else {
1384 EventHandlingResult::None
1385 }
1386 }
1387
1388 #[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1390 fn handle_command_search_fields(
1391 &mut self,
1392 event: CommandSearchFocusFields,
1393 ) -> EventHandlingResult {
1394 match event {
1395 CommandSearchFocusFields::UnlockPrepopulatedFields => {
1396 self.unlock_prepopulated_fields();
1397 EventHandlingResult::Rerender
1398 }
1399 CommandSearchFocusFields::TriggerSearch => {
1400 self.perform_search_foreground();
1401 EventHandlingResult::Rerender
1402 }
1403 CommandSearchFocusFields::FocusPreviousField => {
1404 self.search_fields
1405 .focus_prev(self.config.search.disable_prepopulated_fields);
1406 EventHandlingResult::Rerender
1407 }
1408 CommandSearchFocusFields::FocusNextField => {
1409 self.search_fields
1410 .focus_next(self.config.search.disable_prepopulated_fields);
1411 EventHandlingResult::Rerender
1412 }
1413 CommandSearchFocusFields::EnterChars(key_code, key_modifiers) => {
1414 self.enter_chars_into_field(key_code, key_modifiers)
1415 }
1416 }
1417 }
1418
1419 fn enter_chars_into_field(
1420 &mut self,
1421 key_code: KeyCode,
1422 key_modifiers: KeyModifiers,
1423 ) -> EventHandlingResult {
1424 let Screen::SearchFields(ref mut search_fields_state) = self.ui_state.current_screen else {
1425 return EventHandlingResult::None;
1426 };
1427 if let FieldName::FixedStrings = self.search_fields.highlighted_field().name {
1428 self.search_fields.search_mut().clear_error();
1430 }
1431
1432 search_fields_state.cancel_preview_updates();
1433
1434 self.search_fields.highlighted_field_mut().handle_keys(
1435 key_code,
1436 key_modifiers,
1437 self.config.search.disable_prepopulated_fields,
1438 );
1439 if let Some(search_config) = self.validate_fields().unwrap() {
1440 self.searcher = Some(search_config);
1441 } else {
1442 return EventHandlingResult::Rerender;
1443 }
1444 let Screen::SearchFields(ref mut search_fields_state) = self.ui_state.current_screen else {
1445 return EventHandlingResult::None;
1446 };
1447 let file_searcher = self
1448 .searcher
1449 .as_ref()
1450 .expect("Fields should have been parsed");
1451
1452 if let FieldName::Replace = self.search_fields.highlighted_field().name {
1453 if let Some(ref mut state) = search_fields_state.search_state {
1454 let mut context = Self::replacement_context(
1456 &self.input_source,
1457 file_searcher,
1458 Arc::clone(&self.file_content_provider),
1459 );
1460 if let Some(highlighted) = state.primary_selected_field_mut() {
1461 let _ = apply_outcome(
1462 highlighted,
1463 context.replacement_for_search_result(&highlighted.search_result),
1464 );
1465 }
1466
1467 let sender = state.processing_sender.clone();
1469 let cancelled = Arc::new(AtomicBool::new(false));
1470 let cancelled_clone = cancelled.clone();
1471 let handle = tokio::spawn(async move {
1472 tokio::time::sleep(Duration::from_millis(300)).await;
1473 let _ = sender.send(BackgroundProcessingEvent::UpdateAllReplacements {
1474 cancelled: cancelled_clone,
1475 });
1476 });
1477 search_fields_state.preview_update_state =
1479 Some(PreviewUpdateStatus::new(handle, cancelled));
1480 }
1481 } else {
1482 if let Some(timer) = search_fields_state.search_debounce_timer.take() {
1484 timer.abort();
1485 }
1486 let event_sender = self.event_channels.sender.clone();
1487 search_fields_state.search_debounce_timer = Some(tokio::spawn(async move {
1488 tokio::time::sleep(Duration::from_millis(300)).await;
1489 let _ =
1490 event_sender.send(Event::Internal(InternalEvent::App(AppEvent::PerformSearch)));
1491 }));
1492 }
1493 EventHandlingResult::Rerender
1494 }
1495
1496 fn get_search_state_unwrap(&mut self) -> &mut SearchState {
1497 self.ui_state
1498 .current_screen
1499 .unwrap_search_fields_state_mut()
1500 .search_state
1501 .as_mut()
1502 .expect("Focussed on search results but search_state is None")
1503 }
1504
1505 #[allow(clippy::needless_pass_by_value)]
1507 fn handle_command_search_results(
1508 &mut self,
1509 event: CommandSearchFocusResults,
1510 ) -> EventHandlingResult {
1511 assert!(
1512 matches!(self.ui_state.current_screen, Screen::SearchFields(_)),
1513 "Expected current_screen to be SearchFields, found {}",
1514 self.ui_state.current_screen.name()
1515 );
1516
1517 match event {
1518 CommandSearchFocusResults::TriggerReplacement => {
1519 self.perform_replacement();
1520 EventHandlingResult::Rerender
1521 }
1522 CommandSearchFocusResults::BackToFields => {
1523 self.cancel_search();
1524 let search_fields_state = self
1525 .ui_state
1526 .current_screen
1527 .unwrap_search_fields_state_mut();
1528 search_fields_state.focussed_section = FocussedSection::SearchFields;
1529 EventHandlingResult::Rerender
1530 }
1531 CommandSearchFocusResults::OpenInEditor => {
1532 let search_fields_state = self
1533 .ui_state
1534 .current_screen
1535 .unwrap_search_fields_state_mut();
1536 if let Some(ref mut search_in_progress_state) = search_fields_state.search_state {
1537 let selected = search_in_progress_state
1538 .primary_selected_field_mut()
1539 .expect("Expected to find selected field");
1540 if let Some(ref path) = selected.search_result.path {
1541 self.event_channels
1542 .sender
1543 .send(Event::LaunchEditor((
1544 path.clone(),
1545 selected.search_result.start_line_number(),
1546 )))
1547 .expect("Failed to send event");
1548 }
1549 }
1550 EventHandlingResult::Rerender
1551 }
1552 CommandSearchFocusResults::MoveDown => {
1553 self.get_search_state_unwrap().move_selected_down();
1554 EventHandlingResult::Rerender
1555 }
1556 CommandSearchFocusResults::MoveUp => {
1557 self.get_search_state_unwrap().move_selected_up();
1558 EventHandlingResult::Rerender
1559 }
1560 CommandSearchFocusResults::MoveDownHalfPage => {
1561 self.get_search_state_unwrap()
1562 .move_selected_down_half_page();
1563 EventHandlingResult::Rerender
1564 }
1565 CommandSearchFocusResults::MoveDownFullPage => {
1566 self.get_search_state_unwrap()
1567 .move_selected_down_full_page();
1568 EventHandlingResult::Rerender
1569 }
1570 CommandSearchFocusResults::MoveUpHalfPage => {
1571 self.get_search_state_unwrap().move_selected_up_half_page();
1572 EventHandlingResult::Rerender
1573 }
1574 CommandSearchFocusResults::MoveUpFullPage => {
1575 self.get_search_state_unwrap().move_selected_up_full_page();
1576 EventHandlingResult::Rerender
1577 }
1578 CommandSearchFocusResults::MoveTop => {
1579 self.get_search_state_unwrap().move_selected_top();
1580 EventHandlingResult::Rerender
1581 }
1582 CommandSearchFocusResults::MoveBottom => {
1583 self.get_search_state_unwrap().move_selected_bottom();
1584 EventHandlingResult::Rerender
1585 }
1586 CommandSearchFocusResults::ToggleSelectedInclusion => {
1587 self.get_search_state_unwrap().toggle_selected_inclusion();
1588 EventHandlingResult::Rerender
1589 }
1590 CommandSearchFocusResults::ToggleAllSelected => {
1591 self.get_search_state_unwrap().toggle_all_selected();
1592 EventHandlingResult::Rerender
1593 }
1594 CommandSearchFocusResults::ToggleMultiselectMode => {
1595 self.get_search_state_unwrap().toggle_multiselect_mode();
1596 EventHandlingResult::Rerender
1597 }
1598 CommandSearchFocusResults::FlipMultiselectDirection => {
1599 self.get_search_state_unwrap().flip_multiselect_direction();
1600 EventHandlingResult::Rerender
1601 }
1602 }
1603 }
1604
1605 pub fn handle_key_event(&mut self, key_event: KeyEvent) -> EventHandlingResult {
1606 let command = match self.handle_special_cases(key_event) {
1607 Left(command) => command,
1608 Right(event_handling_result) => return event_handling_result,
1609 };
1610
1611 if let Command::General(command) = command {
1614 match command {
1615 CommandGeneral::Quit => {
1616 self.reset();
1617 return EventHandlingResult::Exit(None);
1618 }
1619 CommandGeneral::Reset => {
1620 self.reset();
1621 return EventHandlingResult::Rerender;
1622 }
1623 CommandGeneral::ShowHelpMenu => {
1624 self.set_popup(Popup::Help);
1625 return EventHandlingResult::Rerender;
1626 }
1627 }
1628 }
1629
1630 match &mut self.ui_state.current_screen {
1631 Screen::SearchFields(search_fields_state) => {
1632 let Command::SearchFields(command) = command else {
1633 panic!("Expected SearchFields command, found {command:?}");
1634 };
1635
1636 match command {
1637 CommandSearchFields::TogglePreviewWrapping => {
1638 self.config.preview.wrap_text = !self.config.preview.wrap_text;
1639 self.show_toggle_toast("Text wrapping", self.config.preview.wrap_text);
1640 EventHandlingResult::Rerender
1641 }
1642 CommandSearchFields::ToggleHiddenFiles => {
1643 if matches!(self.input_source, InputSource::Stdin(_)) {
1644 return EventHandlingResult::None;
1645 }
1646 self.run_config.include_hidden = !self.run_config.include_hidden;
1647 self.show_toggle_toast("Hidden files", self.run_config.include_hidden);
1648 self.perform_search_background();
1649 EventHandlingResult::Rerender
1650 }
1651 CommandSearchFields::ToggleMultiline => {
1652 self.run_config.multiline = !self.run_config.multiline;
1653 if self.run_config.multiline {
1654 self.ui_state.hints.has_shown_multiline_hint = false;
1655 }
1656 self.show_toggle_toast("Multiline", self.run_config.multiline);
1657 self.perform_search_background();
1658 EventHandlingResult::Rerender
1659 }
1660 CommandSearchFields::ToggleInterpretEscapeSequences => {
1661 self.run_config.interpret_escape_sequences =
1662 !self.run_config.interpret_escape_sequences;
1663 self.show_toggle_toast(
1664 "Escape sequences",
1665 self.run_config.interpret_escape_sequences,
1666 );
1667 self.perform_search_background();
1668 EventHandlingResult::Rerender
1669 }
1670 CommandSearchFields::SearchFocusFields(command) => {
1671 if !matches!(
1672 search_fields_state.focussed_section,
1673 FocussedSection::SearchFields
1674 ) {
1675 panic!(
1676 "Expected FocussedSection::SearchFields, found {:?}",
1677 search_fields_state.focussed_section
1678 );
1679 }
1680 self.handle_command_search_fields(command)
1681 }
1682 CommandSearchFields::SearchFocusResults(command) => {
1683 if !matches!(
1684 search_fields_state.focussed_section,
1685 FocussedSection::SearchResults
1686 ) {
1687 panic!(
1688 "Expected FocussedSection::SearchResults, found {:?}",
1689 search_fields_state.focussed_section
1690 );
1691 }
1692 self.handle_command_search_results(command)
1693 }
1694 }
1695 }
1696 Screen::PerformingReplacement(_) => EventHandlingResult::None,
1697 Screen::Results(replace_state) => {
1698 let Command::Results(command) = command else {
1699 panic!("Expected SearchFields event, found {command:?}");
1700 };
1701 replace_state.handle_command_results(command)
1702 }
1703 }
1704 }
1705
1706 fn handle_special_cases(
1707 &mut self,
1708 key_event: KeyEvent,
1709 ) -> Either<Command, EventHandlingResult> {
1710 let maybe_event = self
1711 .key_map
1712 .lookup(&self.ui_state.current_screen, key_event);
1713
1714 if !matches!(maybe_event, Some(Command::General(CommandGeneral::Quit))) {
1716 if self.ui_state.popup.is_some() {
1717 self.clear_popup();
1718 return Right(EventHandlingResult::Rerender);
1719 }
1720 if key_event.code == KeyCode::Esc && self.multiselect_enabled() {
1721 self.toggle_multiselect_mode();
1722 return Right(EventHandlingResult::Rerender);
1723 }
1724 }
1725
1726 let event = if let Some(event) = maybe_event {
1727 event
1728 } else {
1729 if key_event.code == KeyCode::Esc {
1730 let quit_keymap = self.config.keys.general.quit.first().copied();
1731 self.set_popup(Popup::Text {
1732 title: "Key mapping deprecated".to_string(),
1733 body: generate_escape_deprecation_message(quit_keymap),
1734 });
1735 return Right(EventHandlingResult::Rerender);
1736 }
1737
1738 if let Screen::SearchFields(state) = &self.ui_state.current_screen {
1740 if state.focussed_section == FocussedSection::SearchFields {
1741 Command::SearchFields(CommandSearchFields::SearchFocusFields(
1742 CommandSearchFocusFields::EnterChars(key_event.code, key_event.modifiers),
1743 ))
1744 } else {
1745 return Right(EventHandlingResult::None);
1746 }
1747 } else {
1748 return Right(EventHandlingResult::None);
1749 }
1750 };
1751 Left(event)
1752 }
1753
1754 pub fn validate_fields(&mut self) -> anyhow::Result<Option<Searcher>> {
1755 let search_config = SearchConfig {
1756 search_text: self.search_fields.search().text(),
1757 replacement_text: self.search_fields.replace().text(),
1758 fixed_strings: self.search_fields.fixed_strings().checked,
1759 advanced_regex: self.run_config.advanced_regex,
1760 match_whole_word: self.search_fields.whole_word().checked,
1761 match_case: self.search_fields.match_case().checked,
1762 multiline: self.run_config.multiline,
1763 interpret_escape_sequences: self.run_config.interpret_escape_sequences,
1764 };
1765 let dir_config = match &self.input_source {
1766 InputSource::Directory(directory) => Some(DirConfig {
1767 include_globs: Some(self.search_fields.include_files().text()),
1768 exclude_globs: Some(self.search_fields.exclude_files().text()),
1769 include_hidden: self.run_config.include_hidden,
1770 include_git_folders: self.run_config.include_git_folders,
1771 directory: directory.clone(),
1772 }),
1773 InputSource::Stdin(_) => None,
1774 };
1775
1776 let mut error_handler = AppErrorHandler::new();
1777 let result = validate_search_configuration(search_config, dir_config, &mut error_handler)?;
1778 error_handler.apply_to_app(self);
1779
1780 let maybe_searcher = match result {
1781 ValidationResult::Success((search_config, dir_config)) => match &self.input_source {
1782 InputSource::Directory(_) => {
1783 let file_searcher = FileSearcher::new(
1784 search_config,
1785 dir_config.expect("Found None dir_config when searching through files"),
1786 );
1787 Some(Searcher::FileSearcher(file_searcher))
1788 }
1789 InputSource::Stdin(_) => Some(Searcher::TextSearcher { search_config }),
1790 },
1791 ValidationResult::ValidationErrors => None,
1792 };
1793 Ok(maybe_searcher)
1794 }
1795
1796 fn spawn_search_task(
1797 strategy: SearchStrategy,
1798 background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
1799 event_sender: UnboundedSender<Event>,
1800 cancelled: Arc<AtomicBool>,
1801 ) -> JoinHandle<()> {
1802 tokio::spawn(async move {
1803 let sender_for_search = background_processing_sender.clone();
1804 let mut search_handle = task::spawn_blocking(move || {
1805 match strategy {
1806 SearchStrategy::Files(file_searcher) => {
1807 file_searcher.walk_files(Some(&cancelled), || {
1808 let sender = sender_for_search.clone();
1809 Box::new(move |results| {
1810 let _ = sender
1812 .send(BackgroundProcessingEvent::AddSearchResults(results));
1813 WalkState::Continue
1814 })
1815 });
1816 }
1817 SearchStrategy::Text { haystack, config } => {
1818 if config.multiline {
1820 for result in search_multiline(&haystack, &config.search, None) {
1821 if cancelled.load(Ordering::Relaxed) {
1822 break;
1823 }
1824 let _ = sender_for_search
1826 .send(BackgroundProcessingEvent::AddSearchResult(result));
1827 }
1828 } else {
1829 let cursor = Cursor::new(haystack.as_bytes());
1831 for (idx, line_result) in cursor.lines_with_endings().enumerate() {
1832 if cancelled.load(Ordering::Relaxed) {
1833 break;
1834 }
1835
1836 let (line_ending, line) = match read_line(line_result) {
1837 Ok(res) => res,
1838 Err(e) => {
1839 debug!("Error when reading line {idx}: {e}");
1840 continue;
1841 }
1842 };
1843 if contains_search(&line, &config.search) {
1844 let line_number = idx + 1;
1845 let result = SearchResult::new_line(
1846 None,
1847 line_number,
1848 line,
1849 line_ending,
1850 true,
1851 );
1852 let _ = sender_for_search
1854 .send(BackgroundProcessingEvent::AddSearchResult(result));
1855 }
1856 }
1857 }
1858 }
1859 }
1860 });
1861
1862 let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); rerender_interval.tick().await;
1864
1865 loop {
1866 tokio::select! {
1867 res = &mut search_handle => {
1868 if let Err(e) = res {
1869 warn!("Search thread panicked: {e}");
1870 }
1871 break;
1872 },
1873 _ = rerender_interval.tick() => {
1874 let _ = event_sender.send(Event::Rerender);
1875 }
1876 }
1877 }
1878
1879 if let Err(err) =
1880 background_processing_sender.send(BackgroundProcessingEvent::SearchCompleted)
1881 {
1882 warn!("Found error when attempting to send SearchCompleted event: {err}");
1884 }
1885 })
1886 }
1887
1888 pub fn show_popup(&self) -> bool {
1889 self.ui_state.popup.is_some()
1890 }
1891
1892 pub fn popup(&self) -> Option<&Popup> {
1893 self.ui_state.popup.as_ref()
1894 }
1895
1896 pub fn errors(&self) -> Vec<AppError> {
1897 let app_errors = self.ui_state.errors().iter().cloned();
1898 let field_errors = self.search_fields.errors().into_iter();
1899 app_errors.chain(field_errors).collect()
1900 }
1901
1902 pub fn add_error(&mut self, error: AppError) {
1903 self.ui_state.popup = Some(Popup::Error);
1904 self.ui_state.add_error(error);
1905 }
1906
1907 fn clear_popup(&mut self) {
1908 self.ui_state.popup = None;
1909 self.ui_state.clear_errors();
1910 }
1911
1912 fn set_popup(&mut self, popup: Popup) {
1913 self.ui_state.popup = Some(popup);
1914 }
1915
1916 pub fn toast_message(&self) -> Option<&str> {
1917 self.ui_state.toast.as_ref().map(|t| t.message.as_str())
1918 }
1919
1920 fn show_toast(&mut self, message: String, duration: Duration) {
1921 let generation = self.ui_state.toast.as_ref().map_or(1, |t| t.generation + 1);
1922 self.ui_state.toast = Some(Toast {
1923 message,
1924 generation,
1925 });
1926
1927 let event_sender = self.event_channels.sender.clone();
1928 tokio::spawn(async move {
1929 tokio::time::sleep(duration).await;
1930 let _ = event_sender.send(Event::Internal(InternalEvent::App(
1931 AppEvent::DismissToast { generation },
1932 )));
1933 });
1934 }
1935
1936 fn show_toggle_toast(&mut self, name: &str, enabled: bool) {
1937 let status = if enabled { "ON" } else { "OFF" };
1938 self.show_toast(format!("{name}: {status}"), Duration::from_millis(1500));
1939 }
1940
1941 fn dismiss_toast_if_generation_matches(&mut self, generation: u64) {
1942 if let Some(toast) = &self.ui_state.toast
1943 && toast.generation == generation
1944 {
1945 self.ui_state.toast = None;
1946 }
1947 }
1948
1949 pub fn keymaps_all(&self) -> Vec<(String, String)> {
1950 self.keymaps_impl(false)
1951 }
1952
1953 pub fn keymaps_compact(&self) -> Vec<(String, String)> {
1954 self.keymaps_impl(true)
1955 }
1956
1957 #[allow(clippy::too_many_lines)]
1958 fn keymaps_impl(&self, compact: bool) -> Vec<(String, String)> {
1959 enum Show {
1960 Both,
1961 FullOnly,
1962 #[allow(dead_code)]
1963 CompactOnly,
1964 }
1965
1966 macro_rules! keymap {
1967 ($($path:tt).+, $name:expr, $show:expr $(,)?) => {
1968 (
1969 format!("<{}>", self.config.keys.$($path).+.first()
1970 .map_or_else(|| "n/a".to_string(), std::string::ToString::to_string)),
1971 $name,
1972 $show,
1973 )
1974 };
1975 }
1976
1977 let current_screen_keys = match &self.ui_state.current_screen {
1978 Screen::SearchFields(search_fields_state) => {
1979 let mut keys = vec![];
1980 match search_fields_state.focussed_section {
1981 FocussedSection::SearchFields => {
1982 keys.extend([
1983 keymap!(search.fields.trigger_search, "jump to results", Show::Both),
1984 keymap!(search.fields.focus_next_field, "focus next", Show::Both),
1985 keymap!(
1986 search.fields.focus_previous_field,
1987 "focus previous",
1988 Show::FullOnly,
1989 ),
1990 ("<space>".to_string(), "toggle checkbox", Show::FullOnly), ]);
1992 if self.config.search.disable_prepopulated_fields {
1993 keys.push(keymap!(
1994 search.fields.unlock_prepopulated_fields,
1995 "unlock pre-populated fields",
1996 if self.search_fields.fields.iter().any(|f| f.set_by_cli) {
1997 Show::Both
1998 } else {
1999 Show::FullOnly
2000 },
2001 ));
2002 }
2003 }
2004 FocussedSection::SearchResults => {
2005 keys.extend([
2006 keymap!(
2007 search.results.toggle_selected_inclusion,
2008 "toggle",
2009 Show::Both,
2010 ),
2011 keymap!(
2012 search.results.toggle_all_selected,
2013 "toggle all",
2014 Show::FullOnly,
2015 ),
2016 keymap!(
2017 search.results.toggle_multiselect_mode,
2018 "toggle multi-select mode",
2019 Show::FullOnly,
2020 ),
2021 keymap!(
2022 search.results.flip_multiselect_direction,
2023 "flip multi-select direction",
2024 Show::FullOnly,
2025 ),
2026 keymap!(
2027 search.results.open_in_editor,
2028 "open in editor",
2029 Show::FullOnly,
2030 ),
2031 keymap!(
2032 search.results.back_to_fields,
2033 "back to search fields",
2034 Show::Both,
2035 ),
2036 keymap!(search.results.move_down, "down", Show::FullOnly),
2037 keymap!(search.results.move_up, "up", Show::FullOnly),
2038 keymap!(
2039 search.results.move_up_half_page,
2040 "up half a page",
2041 Show::FullOnly
2042 ),
2043 keymap!(
2044 search.results.move_down_half_page,
2045 "down half a page",
2046 Show::FullOnly
2047 ),
2048 keymap!(
2049 search.results.move_up_full_page,
2050 "up a full page",
2051 Show::FullOnly
2052 ),
2053 keymap!(
2054 search.results.move_down_full_page,
2055 "down a full page",
2056 Show::FullOnly
2057 ),
2058 keymap!(search.results.move_top, "jump to top", Show::FullOnly),
2059 keymap!(search.results.move_bottom, "jump to bottom", Show::FullOnly),
2060 ]);
2061 if self.search_has_completed() {
2062 keys.push(keymap!(
2063 search.results.trigger_replacement,
2064 "replace selected",
2065 Show::Both,
2066 ));
2067 }
2068 }
2069 }
2070 keys.push(keymap!(
2071 search.toggle_preview_wrapping,
2072 "toggle text wrapping in preview",
2073 Show::FullOnly,
2074 ));
2075 if matches!(self.input_source, InputSource::Directory(_)) {
2076 keys.push(keymap!(
2077 search.toggle_hidden_files,
2078 "toggle hidden files",
2079 Show::FullOnly,
2080 ));
2081 }
2082 keys.push(keymap!(
2083 search.toggle_multiline,
2084 "toggle multiline",
2085 Show::FullOnly,
2086 ));
2087 keys.push(keymap!(
2088 search.toggle_interpret_escape_sequences,
2089 "toggle escape sequences",
2090 Show::FullOnly,
2091 ));
2092 keys
2093 }
2094 Screen::PerformingReplacement(_) => vec![],
2095 Screen::Results(replace_state) => {
2096 if !replace_state.errors.is_empty() {
2097 vec![
2098 keymap!(results.scroll_errors_down, "down", Show::Both),
2099 keymap!(results.scroll_errors_up, "up", Show::Both),
2100 ]
2101 } else {
2102 vec![]
2103 }
2104 }
2105 };
2106
2107 let on_search_results = if let Screen::SearchFields(ref s) = self.ui_state.current_screen {
2108 s.focussed_section == FocussedSection::SearchResults
2109 } else {
2110 false
2111 };
2112
2113 let esc_help = format!(
2114 "close popup{}",
2115 if on_search_results {
2116 " / exit multi-select"
2117 } else {
2118 ""
2119 }
2120 );
2121
2122 let additional_keys = vec![
2123 keymap!(
2124 general.reset,
2125 "reset",
2126 if on_search_results {
2127 Show::FullOnly
2128 } else {
2129 Show::Both
2130 },
2131 ),
2132 keymap!(general.show_help_menu, "help", Show::Both),
2133 ("<esc>".to_string(), esc_help.as_str(), Show::FullOnly),
2134 keymap!(general.quit, "quit", Show::Both),
2135 ];
2136
2137 let all_keys = current_screen_keys.into_iter().chain(additional_keys);
2138
2139 all_keys
2140 .filter_map(move |(from, to, show)| {
2141 let include = match show {
2142 Show::Both => true,
2143 Show::CompactOnly => compact,
2144 Show::FullOnly => !compact,
2145 };
2146 if include {
2147 Some((from, to.to_owned()))
2148 } else {
2149 None
2150 }
2151 })
2152 .collect()
2153 }
2154
2155 fn multiselect_enabled(&self) -> bool {
2156 match &self.ui_state.current_screen {
2157 Screen::SearchFields(SearchFieldsState {
2158 search_state: Some(state),
2159 ..
2160 }) => state.multiselect_enabled(),
2161 _ => false,
2162 }
2163 }
2164
2165 fn toggle_multiselect_mode(&mut self) {
2166 match &mut self.ui_state.current_screen {
2167 Screen::SearchFields(SearchFieldsState {
2168 search_state: Some(state),
2169 ..
2170 }) => state.toggle_multiselect_mode(),
2171 _ => panic!(
2172 "Tried to disable multi-select on {:?}",
2173 self.ui_state.current_screen.name()
2174 ),
2175 }
2176 }
2177
2178 fn unlock_prepopulated_fields(&mut self) {
2179 for field in &mut self.search_fields.fields {
2180 field.set_by_cli = false;
2181 }
2182 }
2183
2184 pub fn search_has_completed(&self) -> bool {
2185 if let Screen::SearchFields(SearchFieldsState {
2186 search_state: Some(state),
2187 search_debounce_timer,
2188 ..
2189 }) = &self.ui_state.current_screen
2190 {
2191 state.search_completed.is_some()
2192 && search_debounce_timer
2193 .as_ref()
2194 .is_none_or(tokio::task::JoinHandle::is_finished)
2195 } else {
2196 false
2197 }
2198 }
2199
2200 pub fn is_preview_updated(&self) -> bool {
2201 if let Screen::SearchFields(SearchFieldsState {
2202 search_state:
2203 Some(SearchState {
2204 processing_receiver,
2205 ..
2206 }),
2207 preview_update_state,
2208 ..
2209 }) = &self.ui_state.current_screen
2210 {
2211 processing_receiver.is_empty()
2212 && preview_update_state
2213 .as_ref()
2214 .is_none_or(|p| p.replace_debounce_timer.is_finished())
2215 } else {
2216 false
2217 }
2218 }
2219}
2220
2221fn read_line(
2222 line_result: Result<(Vec<u8>, LineEnding), std::io::Error>,
2223) -> anyhow::Result<(LineEnding, String)> {
2224 let (line_bytes, line_ending) = line_result?;
2225 let line = String::from_utf8(line_bytes)?;
2226 Ok((line_ending, line))
2227}
2228
2229#[allow(clippy::struct_field_names)]
2230#[derive(Clone, Debug, PartialEq, Eq)]
2231struct AppErrorHandler {
2232 search_errors: Option<(String, String)>,
2233 include_errors: Option<(String, String)>,
2234 exclude_errors: Option<(String, String)>,
2235}
2236
2237impl AppErrorHandler {
2238 fn new() -> Self {
2239 Self {
2240 search_errors: None,
2241 include_errors: None,
2242 exclude_errors: None,
2243 }
2244 }
2245
2246 fn apply_to_app(&self, app: &mut App) {
2247 if let Some((error, detail)) = &self.search_errors {
2248 app.search_fields
2249 .search_mut()
2250 .set_error(error.clone(), detail.clone());
2251 }
2252
2253 if let Some((error, detail)) = &self.include_errors {
2254 app.search_fields
2255 .include_files_mut()
2256 .set_error(error.clone(), detail.clone());
2257 }
2258
2259 if let Some((error, detail)) = &self.exclude_errors {
2260 app.search_fields
2261 .exclude_files_mut()
2262 .set_error(error.clone(), detail.clone());
2263 }
2264 }
2265}
2266
2267impl ValidationErrorHandler for AppErrorHandler {
2268 fn handle_search_text_error(&mut self, error: &str, detail: &str) {
2269 self.search_errors = Some((error.to_owned(), detail.to_string()));
2270 }
2271
2272 fn handle_include_files_error(&mut self, error: &str, detail: &str) {
2273 self.include_errors = Some((error.to_owned(), detail.to_string()));
2274 }
2275
2276 fn handle_exclude_files_error(&mut self, error: &str, detail: &str) {
2277 self.exclude_errors = Some((error.to_owned(), detail.to_string()));
2278 }
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283 use crate::{
2284 line_reader::LineEnding,
2285 replace::{ReplaceResult, ReplaceStats},
2286 search::{SearchResult, SearchResultWithReplacement},
2287 };
2288 use rand::RngExt;
2289
2290 use super::*;
2291
2292 #[test]
2293 fn replacement_context_skips_stale_results() {
2294 let input_source = InputSource::Stdin(Arc::new(String::new()));
2295 let searcher = Searcher::TextSearcher {
2296 search_config: ParsedSearchConfig {
2297 search: SearchType::Fixed("foo".to_string()),
2298 replace: "bar".to_string(),
2299 multiline: false,
2300 },
2301 };
2302 let mut context = ReplacementContext::new(
2303 &input_source,
2304 &searcher,
2305 searcher.search().needs_haystack_context(),
2306 default_file_content_provider(),
2307 );
2308 let result = SearchResult::new_line(None, 1, "baz".to_string(), LineEnding::Lf, true);
2309
2310 assert!(matches!(
2311 context.replacement_for_search_result(&result),
2312 PreviewOutcome::NoMatch
2313 ));
2314 }
2315
2316 fn random_num() -> usize {
2317 let mut rng = rand::rng();
2318 rng.random_range(1..10000)
2319 }
2320
2321 fn search_result_with_replacement(included: bool) -> SearchResultWithReplacement {
2322 let line_num = random_num();
2323 SearchResultWithReplacement {
2324 search_result: SearchResult::new_line(
2325 Some(PathBuf::from("random/file")),
2326 line_num,
2327 "foo".to_owned(),
2328 LineEnding::Lf,
2329 included,
2330 ),
2331 replacement: "bar".to_owned(),
2332 replace_result: None,
2333 preview_error: None,
2334 }
2335 }
2336
2337 fn build_test_results(num_results: usize) -> Vec<SearchResultWithReplacement> {
2338 (0..num_results)
2339 .map(|i| SearchResultWithReplacement {
2340 search_result: SearchResult::new_line(
2341 Some(PathBuf::from(format!("test{i}.txt"))),
2342 1,
2343 format!("test line {i}").to_string(),
2344 LineEnding::Lf,
2345 true,
2346 ),
2347 replacement: format!("replacement {i}").to_string(),
2348 replace_result: None,
2349 preview_error: None,
2350 })
2351 .collect()
2352 }
2353
2354 fn build_test_search_state(num_results: usize) -> SearchState {
2355 let results = build_test_results(num_results);
2356 build_test_search_state_with_results(results)
2357 }
2358
2359 fn build_test_search_state_with_results(
2360 results: Vec<SearchResultWithReplacement>,
2361 ) -> SearchState {
2362 let (processing_sender, processing_receiver) = mpsc::unbounded_channel();
2363 SearchState {
2364 results,
2365 selected: Selected::Single(0),
2366 view_offset: 0,
2367 num_displayed: Some(5),
2368 processing_receiver,
2369 processing_sender,
2370 cancelled: Arc::new(AtomicBool::new(false)),
2371 last_render: Instant::now(),
2372 search_started: Instant::now(),
2373 search_completed: None,
2374 }
2375 }
2376
2377 #[test]
2378 fn test_toggle_all_selected_when_all_selected() {
2379 let mut search_state = build_test_search_state_with_results(vec![
2380 search_result_with_replacement(true),
2381 search_result_with_replacement(true),
2382 search_result_with_replacement(true),
2383 ]);
2384 search_state.toggle_all_selected();
2385 assert_eq!(
2386 search_state
2387 .results
2388 .iter()
2389 .map(|res| res.search_result.included)
2390 .collect::<Vec<_>>(),
2391 vec![false, false, false]
2392 );
2393 }
2394
2395 #[test]
2396 fn test_toggle_all_selected_when_none_selected() {
2397 let mut search_state = build_test_search_state_with_results(vec![
2398 search_result_with_replacement(false),
2399 search_result_with_replacement(false),
2400 search_result_with_replacement(false),
2401 ]);
2402 search_state.toggle_all_selected();
2403 assert_eq!(
2404 search_state
2405 .results
2406 .iter()
2407 .map(|res| res.search_result.included)
2408 .collect::<Vec<_>>(),
2409 vec![true, true, true]
2410 );
2411 }
2412
2413 #[test]
2414 fn test_toggle_all_selected_when_some_selected() {
2415 let mut search_state = build_test_search_state_with_results(vec![
2416 search_result_with_replacement(true),
2417 search_result_with_replacement(false),
2418 search_result_with_replacement(true),
2419 ]);
2420 search_state.toggle_all_selected();
2421 assert_eq!(
2422 search_state
2423 .results
2424 .iter()
2425 .map(|res| res.search_result.included)
2426 .collect::<Vec<_>>(),
2427 vec![true, true, true]
2428 );
2429 }
2430
2431 #[test]
2432 fn test_toggle_all_selected_when_no_results() {
2433 let mut search_state = build_test_search_state_with_results(vec![]);
2434 search_state.toggle_all_selected();
2435 assert_eq!(
2436 search_state
2437 .results
2438 .iter()
2439 .map(|res| res.search_result.included)
2440 .collect::<Vec<_>>(),
2441 vec![] as Vec<bool>
2442 );
2443 }
2444
2445 fn success_result() -> SearchResultWithReplacement {
2446 let line_num = random_num();
2447 SearchResultWithReplacement {
2448 search_result: SearchResult::new_line(
2449 Some(PathBuf::from("random/file")),
2450 line_num,
2451 "foo".to_owned(),
2452 LineEnding::Lf,
2453 true,
2454 ),
2455 replacement: "bar".to_owned(),
2456 replace_result: Some(ReplaceResult::Success),
2457 preview_error: None,
2458 }
2459 }
2460
2461 fn ignored_result() -> SearchResultWithReplacement {
2462 let line_num = random_num();
2463 SearchResultWithReplacement {
2464 search_result: SearchResult::new_line(
2465 Some(PathBuf::from("random/file")),
2466 line_num,
2467 "foo".to_owned(),
2468 LineEnding::Lf,
2469 false,
2470 ),
2471 replacement: "bar".to_owned(),
2472 replace_result: None,
2473 preview_error: None,
2474 }
2475 }
2476
2477 fn error_result() -> SearchResultWithReplacement {
2478 let line_num = random_num();
2479 SearchResultWithReplacement {
2480 search_result: SearchResult::new_line(
2481 Some(PathBuf::from("random/file")),
2482 line_num,
2483 "foo".to_owned(),
2484 LineEnding::Lf,
2485 true,
2486 ),
2487 replacement: "bar".to_owned(),
2488 replace_result: Some(ReplaceResult::Error("error".to_owned())),
2489 preview_error: None,
2490 }
2491 }
2492
2493 #[tokio::test]
2494 async fn test_calculate_statistics_all_success() {
2495 let search_results_with_replacements =
2496 vec![success_result(), success_result(), success_result()];
2497
2498 let (results, _preview_errored, _num_ignored) =
2499 crate::replace::split_results(search_results_with_replacements);
2500 let stats = crate::replace::calculate_statistics(results);
2501
2502 assert_eq!(
2503 stats,
2504 ReplaceStats {
2505 num_successes: 3,
2506 errors: vec![],
2507 }
2508 );
2509 }
2510
2511 #[tokio::test]
2512 async fn test_calculate_statistics_with_ignores_and_errors() {
2513 let error_result = error_result();
2514 let search_results_with_replacements = vec![
2515 success_result(),
2516 ignored_result(),
2517 success_result(),
2518 error_result.clone(),
2519 ignored_result(),
2520 ];
2521
2522 let (results, _preview_errored, _num_ignored) =
2523 crate::replace::split_results(search_results_with_replacements);
2524 let stats = crate::replace::calculate_statistics(results);
2525
2526 assert_eq!(
2527 stats,
2528 ReplaceStats {
2529 num_successes: 2,
2530 errors: vec![error_result],
2531 }
2532 );
2533 }
2534
2535 #[tokio::test]
2536 async fn test_search_state_toggling() {
2537 fn included(state: &SearchState) -> Vec<bool> {
2538 state
2539 .results
2540 .iter()
2541 .map(|r| r.search_result.included)
2542 .collect::<Vec<_>>()
2543 }
2544
2545 let mut state = build_test_search_state(3);
2546
2547 assert_eq!(included(&state), [true, true, true]);
2548 state.toggle_selected_inclusion();
2549 assert_eq!(included(&state), [false, true, true]);
2550 state.toggle_selected_inclusion();
2551 assert_eq!(included(&state), [true, true, true]);
2552 state.toggle_selected_inclusion();
2553 assert_eq!(included(&state), [false, true, true]);
2554 state.move_selected_down();
2555 state.toggle_selected_inclusion();
2556 assert_eq!(included(&state), [false, false, true]);
2557 state.toggle_selected_inclusion();
2558 assert_eq!(included(&state), [false, true, true]);
2559 }
2560
2561 #[tokio::test]
2562 async fn test_search_state_movement_single() {
2563 let mut state = build_test_search_state(3);
2564
2565 assert_eq!(state.selected, Selected::Single(0));
2566 state.move_selected_down();
2567 assert_eq!(state.selected, Selected::Single(1));
2568 state.move_selected_down();
2569 assert_eq!(state.selected, Selected::Single(2));
2570 state.move_selected_down();
2571 assert_eq!(state.selected, Selected::Single(0));
2572 state.move_selected_down();
2573 assert_eq!(state.selected, Selected::Single(1));
2574 state.move_selected_up();
2575 assert_eq!(state.selected, Selected::Single(0));
2576 state.move_selected_up();
2577 assert_eq!(state.selected, Selected::Single(2));
2578 state.move_selected_up();
2579 assert_eq!(state.selected, Selected::Single(1));
2580 }
2581
2582 #[tokio::test]
2583 async fn test_search_state_movement_top_bottom() {
2584 let mut state = build_test_search_state(3);
2585
2586 state.move_selected_top();
2587 assert_eq!(state.selected, Selected::Single(0));
2588 state.move_selected_bottom();
2589 assert_eq!(state.selected, Selected::Single(2));
2590 state.move_selected_bottom();
2591 assert_eq!(state.selected, Selected::Single(2));
2592 state.move_selected_top();
2593 assert_eq!(state.selected, Selected::Single(0));
2594 }
2595
2596 #[tokio::test]
2597 async fn test_search_state_movement_half_page_increments() {
2598 let mut state = build_test_search_state(8);
2599
2600 assert_eq!(state.selected, Selected::Single(0));
2601 state.move_selected_down_half_page();
2602 assert_eq!(state.selected, Selected::Single(3));
2603 state.move_selected_down_half_page();
2604 assert_eq!(state.selected, Selected::Single(6));
2605 state.move_selected_down_half_page();
2606 assert_eq!(state.selected, Selected::Single(7));
2607 state.move_selected_up_half_page();
2608 assert_eq!(state.selected, Selected::Single(4));
2609 state.move_selected_up_half_page();
2610 assert_eq!(state.selected, Selected::Single(1));
2611 state.move_selected_up_half_page();
2612 assert_eq!(state.selected, Selected::Single(0));
2613 state.move_selected_up_half_page();
2614 assert_eq!(state.selected, Selected::Single(7));
2615 state.move_selected_up_half_page();
2616 assert_eq!(state.selected, Selected::Single(4));
2617 state.move_selected_down_half_page();
2618 assert_eq!(state.selected, Selected::Single(7));
2619 state.move_selected_down_half_page();
2620 assert_eq!(state.selected, Selected::Single(0));
2621 }
2622
2623 #[tokio::test]
2624 async fn test_search_state_movement_page_increments() {
2625 let mut state = build_test_search_state(12);
2626
2627 assert_eq!(state.selected, Selected::Single(0));
2628 state.move_selected_down_full_page();
2629 assert_eq!(state.selected, Selected::Single(5));
2630 state.move_selected_down_full_page();
2631 assert_eq!(state.selected, Selected::Single(10));
2632 state.move_selected_down_full_page();
2633 assert_eq!(state.selected, Selected::Single(11));
2634 state.move_selected_down_full_page();
2635 assert_eq!(state.selected, Selected::Single(0));
2636 state.move_selected_up_full_page();
2637 assert_eq!(state.selected, Selected::Single(11));
2638 state.move_selected_up_full_page();
2639 assert_eq!(state.selected, Selected::Single(6));
2640 state.move_selected_up_full_page();
2641 assert_eq!(state.selected, Selected::Single(1));
2642 state.move_selected_up_full_page();
2643 assert_eq!(state.selected, Selected::Single(0));
2644 state.move_selected_up_full_page();
2645 assert_eq!(state.selected, Selected::Single(11));
2646 state.move_selected_up_full_page();
2647 assert_eq!(state.selected, Selected::Single(6));
2648 state.move_selected_up();
2649 assert_eq!(state.selected, Selected::Single(5));
2650 state.move_selected_up();
2651 assert_eq!(state.selected, Selected::Single(4));
2652 state.move_selected_up_full_page();
2653 assert_eq!(state.selected, Selected::Single(0));
2654 }
2655
2656 #[test]
2657 fn test_selected_fields_movement() {
2658 let mut results = build_test_results(10);
2659 let mut state = build_test_search_state_with_results(results.clone());
2660
2661 assert_eq!(state.selected, Selected::Single(0));
2662 assert_eq!(state.selected_fields(), &mut results[0..=0]);
2663
2664 state.toggle_multiselect_mode();
2665 assert_eq!(
2666 state.selected,
2667 Selected::Multi(MultiSelected {
2668 anchor: 0,
2669 primary: 0,
2670 })
2671 );
2672 assert_eq!(state.selected_fields(), &mut results[0..=0]);
2673
2674 state.move_selected_down();
2675 state.move_selected_down();
2676 assert_eq!(
2677 state.selected,
2678 Selected::Multi(MultiSelected {
2679 anchor: 0,
2680 primary: 2,
2681 })
2682 );
2683 assert_eq!(state.selected_fields(), &mut results[0..=2]);
2684
2685 state.toggle_multiselect_mode();
2686 assert_eq!(state.selected, Selected::Single(2));
2687 assert_eq!(state.selected_fields(), &mut results[2..=2]);
2688
2689 state.toggle_multiselect_mode();
2690 assert_eq!(
2691 state.selected,
2692 Selected::Multi(MultiSelected {
2693 anchor: 2,
2694 primary: 2,
2695 })
2696 );
2697 assert_eq!(state.selected_fields(), &mut results[2..=2]);
2698 }
2699
2700 #[test]
2701 fn test_selected_fields_toggling() {
2702 let mut state = build_test_search_state(6);
2703
2704 assert_eq!(state.selected, Selected::Single(0));
2705 state.move_selected_down();
2706 state.move_selected_down();
2707 state.move_selected_down();
2708 state.move_selected_down();
2709 assert_eq!(state.selected, Selected::Single(4));
2710 state.toggle_multiselect_mode();
2711 assert_eq!(
2712 state.selected,
2713 Selected::Multi(MultiSelected {
2714 anchor: 4,
2715 primary: 4,
2716 })
2717 );
2718 assert_eq!(state.selected_fields(), &state.results[4..=4]);
2719 state.move_selected_up();
2720 state.move_selected_up();
2721 assert_eq!(
2722 state.selected,
2723 Selected::Multi(MultiSelected {
2724 anchor: 4,
2725 primary: 2,
2726 })
2727 );
2728 assert_eq!(state.selected_fields(), &state.results[2..=4]);
2729 assert_eq!(
2730 state
2731 .results
2732 .iter()
2733 .map(|res| res.search_result.included)
2734 .collect::<Vec<_>>(),
2735 vec![true, true, true, true, true, true]
2736 );
2737 state.toggle_selected_inclusion();
2738 assert_eq!(
2739 state
2740 .results
2741 .iter()
2742 .map(|res| res.search_result.included)
2743 .collect::<Vec<_>>(),
2744 vec![true, true, false, false, false, true]
2745 );
2746 assert_eq!(
2747 state.selected,
2748 Selected::Multi(MultiSelected {
2749 anchor: 4,
2750 primary: 2,
2751 })
2752 );
2753 assert_eq!(state.selected_fields(), &state.results[2..=4]);
2754 state.toggle_multiselect_mode();
2755 assert_eq!(state.selected, Selected::Single(2));
2756 assert_eq!(state.selected_fields(), &state.results[2..=2]);
2757 state.move_selected_up();
2758 state.move_selected_up();
2759 assert_eq!(state.selected, Selected::Single(0));
2760 assert_eq!(state.selected_fields(), &state.results[0..=0]);
2761 state.toggle_selected_inclusion();
2762 assert_eq!(
2763 state
2764 .results
2765 .iter()
2766 .map(|res| res.search_result.included)
2767 .collect::<Vec<_>>(),
2768 vec![false, true, false, false, false, true]
2769 );
2770 }
2771
2772 #[test]
2773 fn test_flip_multi_select_direction() {
2774 let mut state = build_test_search_state(10);
2775 assert_eq!(state.selected, Selected::Single(0));
2776 state.flip_multiselect_direction();
2777 assert_eq!(state.selected, Selected::Single(0));
2778 state.move_selected_down();
2779 assert_eq!(state.selected, Selected::Single(1));
2780 state.toggle_multiselect_mode();
2781 state.move_selected_down();
2782 state.move_selected_down();
2783 assert_eq!(
2784 state.selected,
2785 Selected::Multi(MultiSelected {
2786 anchor: 1,
2787 primary: 3,
2788 })
2789 );
2790 state.flip_multiselect_direction();
2791 assert_eq!(
2792 state.selected,
2793 Selected::Multi(MultiSelected {
2794 anchor: 3,
2795 primary: 1,
2796 })
2797 );
2798 state.move_selected_up();
2799 assert_eq!(
2800 state.selected,
2801 Selected::Multi(MultiSelected {
2802 anchor: 3,
2803 primary: 0,
2804 })
2805 );
2806 state.flip_multiselect_direction();
2807 assert_eq!(
2808 state.selected,
2809 Selected::Multi(MultiSelected {
2810 anchor: 0,
2811 primary: 3,
2812 })
2813 );
2814 state.move_selected_bottom();
2815 assert_eq!(
2816 state.selected,
2817 Selected::Multi(MultiSelected {
2818 anchor: 0,
2819 primary: 9,
2820 })
2821 );
2822 state.move_selected_down();
2823 assert_eq!(state.selected, Selected::Single(0));
2824 }
2825
2826 #[test]
2827 fn test_key_handling_quit_takes_precedent() {
2828 let mut app = App::new(
2829 InputSource::Directory(std::env::current_dir().unwrap()),
2830 &SearchFieldValues::default(),
2831 AppRunConfig::default(),
2832 Config::default(),
2833 )
2834 .unwrap();
2835 app.set_popup(Popup::Text {
2836 title: "Error title".to_owned(),
2837 body: "some text in the body".to_owned(),
2838 });
2839 let res = app.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
2840 assert!(matches!(res, EventHandlingResult::Exit(None)));
2841 }
2842
2843 #[test]
2844 fn test_key_handling_unmapped_key_closes_popup() {
2845 let mut app = App::new(
2846 InputSource::Directory(std::env::current_dir().unwrap()),
2847 &SearchFieldValues::default(),
2848 AppRunConfig::default(),
2849 Config::default(),
2850 )
2851 .unwrap();
2852 app.set_popup(Popup::Text {
2853 title: "Error title".to_owned(),
2854 body: "some text in the body".to_owned(),
2855 });
2856 let res = app.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
2857 assert!(matches!(res, EventHandlingResult::Rerender));
2858 assert!(app.popup().is_none());
2859 }
2860
2861 #[test]
2862 fn test_escape_deprecation_message_with_default() {
2863 let keymap = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2864 let message = generate_escape_deprecation_message(Some(keymap));
2865 assert_eq!(
2866 message,
2867 "Pressing escape to quit is no longer enabled by default: use `C-c` \
2868 (i.e. `ctrl + c`) instead.\n\nYou can remap this in your scooter config."
2869 );
2870 }
2871
2872 #[test]
2873 fn test_escape_deprecation_message_with_no_mapping() {
2874 let message = generate_escape_deprecation_message(None);
2875 assert_eq!(
2876 message,
2877 "Pressing escape to quit is no longer enabled by default.\n\n\
2878 You can remap this in your scooter config."
2879 );
2880 }
2881
2882 #[test]
2883 fn test_escape_deprecation_message_with_f_key() {
2884 let keymap = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
2885 let message = generate_escape_deprecation_message(Some(keymap));
2886 assert_eq!(
2887 message,
2888 "Pressing escape to quit is no longer enabled by default: use `F1` instead.\n\n\
2889 You can remap this in your scooter config."
2890 );
2891 }
2892
2893 #[test]
2894 fn test_escape_deprecation_message_with_ctrl_alt_q_keymap() {
2895 let keymap = KeyEvent::new(
2896 KeyCode::Char('q'),
2897 KeyModifiers::CONTROL | KeyModifiers::ALT,
2898 );
2899 let message = generate_escape_deprecation_message(Some(keymap));
2900 assert_eq!(
2901 message,
2902 "Pressing escape to quit is no longer enabled by default: use `C-A-q` instead.\n\n\
2903 You can remap this in your scooter config."
2904 );
2905 }
2906}