Skip to main content

scooter_core/
app.rs

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    // TODO: make the view logic with scrolling etc. into a generic component
151    pub view_offset: usize,           // Updated by UI, not app
152    pub num_displayed: Option<usize>, // Updated by UI, not app
153
154    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    // TODO: add tests
262    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>, // Becomes Some when search begins
378    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        // TODO: is there a better way of doing this?
425        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        // NOTE: advanced regex lookarounds require the full haystack. If we run the
720        // regex against the matched substring only, lookbehind/lookahead checks fail
721        // and we silently "replace" with the original text. Using the full haystack
722        // keeps the TUI preview/replacement consistent with headless mode.
723        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            // Add some additional help text when using the default
819            " (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
831// Macro to get the background processing receiver from current_screen, needed because
832// methods can't express split borrows but macros can
833macro_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(), // TODO: avoid cloning
972            &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    /// Called when searching explicitly: shows error popup if there have been validation failures
996    //
997    /// NOTE: validation should have been performed (with `validate_fields`) before calling
998    // TODO: how can we enforce validation by type system?
999    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            // Check if search has been performed
1045            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    /// Called when searching in the background e.g. when entering chars into the search field: does not show
1056    /// error popup if there are validation errors
1057    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    /// NOTE: validation should have been performed (with `validate_fields`) before calling
1074    // TODO: how can we enforce validation by type system - e.g. pass in searcher?
1075    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; // Slightly random so that increments seem more natural in UI
1149
1150        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                // Handle race condition where search results are being updated
1198                // The new search results will already have the correct replacement so no need to update
1199                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, // Will get reset if we are not on `SearchComplete` screen
1216        ) {
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            // Slightly random duration so that time taken isn't a round number
1376            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    /// Should only be called on `Screen::SearchFields`, and when focussed section is `FocussedSection::SearchFields`
1389    #[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            // TODO: ideally this should only happen when the field is checked, but for now this will do
1429            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                // Immediately update replacement on the selected result; remaining results update async.
1455                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                // Debounce replacement requests
1468                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                // Note that cancel_preview_updates is called above, which cancels any existing preview updates
1478                search_fields_state.preview_update_state =
1479                    Some(PreviewUpdateStatus::new(handle, cancelled));
1480            }
1481        } else {
1482            // Debounce search requests
1483            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    /// Should only be called on `Screen::SearchFields`, and when focussed section is `FocussedSection::SearchResults`
1506    #[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        // Note that general commands are looked up after screen-specific commands in `.lookup`, so this if will only be hit
1612        // if there are no screen-specific commands
1613        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        // Quit should take precedent over closing popup etc.
1715        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 we're in SearchFields focus, treat unmatched keys as text input
1739            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                                // Ignore error - likely state reset, thread about to be killed
1811                                let _ = sender
1812                                    .send(BackgroundProcessingEvent::AddSearchResults(results));
1813                                WalkState::Continue
1814                            })
1815                        });
1816                    }
1817                    SearchStrategy::Text { haystack, config } => {
1818                        // When multiline is enabled, search the entire haystack at once
1819                        if config.multiline {
1820                            for result in search_multiline(&haystack, &config.search, None) {
1821                                if cancelled.load(Ordering::Relaxed) {
1822                                    break;
1823                                }
1824                                // Ignore error - likely state reset, thread about to be killed
1825                                let _ = sender_for_search
1826                                    .send(BackgroundProcessingEvent::AddSearchResult(result));
1827                            }
1828                        } else {
1829                            // Default line-by-line search
1830                            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                                    // Ignore error - likely state reset, thread about to be killed
1853                                    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)); // Slightly random duration so that time taken isn't a round number
1863            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                // Log and ignore error: likely have gone back to previous screen
1883                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), // TODO(key-remap): add to config?
1991                        ]);
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}