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
51fn 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#[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 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 pub view_offset: usize, pub num_displayed: Option<usize>, 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 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 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 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#[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>, pub search_debounce_timer: Option<JoinHandle<()>>,
482 pub preview_update_state: Option<PreviewUpdateStatus>,
483 pub last_scheduled_key: Option<Box<SearchKey>>,
488 next_search_generation: u64,
491 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 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 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 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 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 " (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
975macro_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(), &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 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 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 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 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 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; 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 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, ) {
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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 let _ = sender
2109 .send(BackgroundProcessingEvent::AddSearchResults(results));
2110 WalkState::Continue
2111 })
2112 });
2113 }
2114 SearchStrategy::Text { haystack, config } => {
2115 if config.multiline {
2117 for result in search_multiline(&haystack, &config.search, None) {
2118 if cancelled.load(Ordering::Relaxed) {
2119 break;
2120 }
2121 let _ = sender_for_search
2123 .send(BackgroundProcessingEvent::AddSearchResult(result));
2124 }
2125 } else {
2126 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 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)); 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 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), ]);
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 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}