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