intelli_shell/component/
search.rs

1use std::{
2    sync::{Arc, Mutex},
3    time::Duration,
4};
5
6use async_trait::async_trait;
7use color_eyre::Result;
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
9use enum_cycling::EnumCycle;
10use parking_lot::RwLock;
11use ratatui::{
12    Frame,
13    layout::{Constraint, Layout, Rect},
14};
15use semver::Version;
16use tokio_util::sync::CancellationToken;
17use tracing::instrument;
18use tui_textarea::CursorMove;
19
20use super::Component;
21use crate::{
22    app::Action,
23    component::{
24        edit::{EditCommandComponent, EditCommandComponentMode},
25        variable::VariableReplacementComponent,
26    },
27    config::{Config, KeyBindingsConfig, SearchConfig, Theme},
28    errors::{SearchError, UpdateError},
29    format_msg,
30    model::{Command, DynamicCommand, SOURCE_WORKSPACE, SearchMode},
31    process::ProcessOutput,
32    service::IntelliShellService,
33    widgets::{
34        CommandWidget, CustomList, CustomTextArea, ErrorPopup, HighlightSymbolMode, NewVersionBanner, TagWidget,
35    },
36};
37
38const EMPTY_STORAGE_MESSAGE: &str = r#"There are no stored commands yet!
39    - Try to bookmark some command with 'Ctrl + B'
40    - Or execute 'intelli-shell tldr fetch' to download a bunch of tldr's useful commands"#;
41
42/// A component for searching [`Command`]
43#[derive(Clone)]
44pub struct SearchCommandsComponent {
45    /// The visual theme for styling the component
46    theme: Theme,
47    /// Whether the TUI is in inline mode or not
48    inline: bool,
49    /// Service for interacting with command storage
50    service: IntelliShellService,
51    /// The component layout
52    layout: Layout,
53    /// The new version banner
54    new_version: NewVersionBanner,
55    /// The delay before triggering a search after user input
56    search_delay: Duration,
57    /// Cancellation token for the current refresh task
58    refresh_token: Arc<Mutex<Option<CancellationToken>>>,
59    /// The state of the component
60    state: Arc<RwLock<SearchCommandsComponentState<'static>>>,
61}
62struct SearchCommandsComponentState<'a> {
63    /// The default search mode
64    mode: SearchMode,
65    /// Whether to search for user commands only by default (excluding tldr)
66    user_only: bool,
67    /// The active query
68    query: CustomTextArea<'a>,
69    /// List of tags, if currently editing a tag
70    tags: Option<CustomList<'a, TagWidget>>,
71    /// Whether the command search was an alias match
72    alias_match: bool,
73    /// The list of commands
74    commands: CustomList<'a, CommandWidget>,
75    /// Popup for displaying error messages
76    error: ErrorPopup<'a>,
77}
78
79impl SearchCommandsComponent {
80    /// Creates a new [`SearchCommandsComponent`]
81    pub fn new(
82        service: IntelliShellService,
83        config: Config,
84        inline: bool,
85        new_version: Option<Version>,
86        query: impl Into<String>,
87    ) -> Self {
88        let query = CustomTextArea::new(config.theme.primary, inline, false, query.into()).focused();
89
90        let commands = CustomList::new(config.theme.primary, inline, Vec::new())
91            .highlight_symbol(config.theme.highlight_symbol.clone())
92            .highlight_symbol_mode(HighlightSymbolMode::Last)
93            .highlight_symbol_style(config.theme.highlight_primary_full().into());
94
95        let new_version = NewVersionBanner::new(&config.theme, new_version);
96        let error = ErrorPopup::empty(&config.theme);
97
98        let layout = if inline {
99            Layout::vertical([Constraint::Length(1), Constraint::Min(3)])
100        } else {
101            Layout::vertical([Constraint::Length(3), Constraint::Min(5)]).margin(1)
102        };
103
104        let SearchConfig { delay, mode, user_only } = config.search;
105
106        let ret = Self {
107            theme: config.theme,
108            inline,
109            service,
110            layout,
111            new_version,
112            search_delay: Duration::from_millis(delay),
113            refresh_token: Arc::new(Mutex::new(None)),
114            state: Arc::new(RwLock::new(SearchCommandsComponentState {
115                mode,
116                user_only,
117                query,
118                tags: None,
119                alias_match: false,
120                commands,
121                error,
122            })),
123        };
124
125        ret.update_config(config.search.mode, config.search.user_only);
126
127        ret
128    }
129
130    /// Updates the search config
131    fn update_config(&self, search_mode: SearchMode, user_only: bool) {
132        let inline = self.inline;
133        let mut state = self.state.write();
134        state.mode = search_mode;
135        state.user_only = user_only;
136
137        let title = match (inline, user_only) {
138            (true, true) => format!("({search_mode},user)"),
139            (true, false) => format!("({search_mode})"),
140            (false, true) => format!(" Query ({search_mode},user) "),
141            (false, false) => format!(" Query ({search_mode}) "),
142        };
143
144        state.query.set_title(title);
145    }
146}
147
148#[async_trait]
149impl Component for SearchCommandsComponent {
150    fn name(&self) -> &'static str {
151        "SearchCommandsComponent"
152    }
153
154    fn min_inline_height(&self) -> u16 {
155        // Query + 10 Commands
156        1 + 10
157    }
158
159    async fn init(&mut self) -> Result<()> {
160        let tags = {
161            let state = self.state.read();
162            state.query.lines_as_string() == "#"
163        };
164        if tags {
165            self.refresh_tags().await
166        } else {
167            self.refresh_commands().await
168        }
169    }
170
171    #[instrument(skip_all)]
172    async fn peek(&mut self) -> Result<Action> {
173        if self.service.is_storage_empty().await? {
174            Ok(Action::Quit(
175                ProcessOutput::success().stderr(format_msg!(self.theme, "{EMPTY_STORAGE_MESSAGE}")),
176            ))
177        } else {
178            let command = {
179                let state = self.state.read();
180                if state.alias_match && state.commands.len() == 1 {
181                    state.commands.selected().cloned().map(Command::from)
182                } else {
183                    None
184                }
185            };
186            if let Some(command) = command {
187                tracing::info!("Found a single alias command: {command}");
188                self.confirm_command(command, false).await
189            } else {
190                Ok(Action::NoOp)
191            }
192        }
193    }
194
195    #[instrument(skip_all)]
196    fn render(&mut self, frame: &mut Frame, area: Rect) {
197        // Split the area according to the layout
198        let [query_area, suggestions_area] = self.layout.areas(area);
199
200        let mut state = self.state.write();
201
202        // Render the query widget
203        frame.render_widget(&state.query, query_area);
204
205        // Render the suggestions
206        if let Some(ref mut tags) = state.tags {
207            frame.render_widget(tags, suggestions_area);
208        } else {
209            frame.render_widget(&mut state.commands, suggestions_area);
210        }
211
212        // Render the new version banner and error message as an overlay
213        self.new_version.render_in(frame, area);
214        state.error.render_in(frame, area);
215    }
216
217    fn tick(&mut self) -> Result<Action> {
218        let mut state = self.state.write();
219        state.error.tick();
220        Ok(Action::NoOp)
221    }
222
223    fn exit(&mut self) -> Result<Option<ProcessOutput>> {
224        let mut state = self.state.write();
225        if state.tags.is_some() {
226            tracing::debug!("Closing tag mode: user request");
227            state.tags = None;
228            state.commands.set_focus(true);
229            self.schedule_debounced_command_refresh();
230            Ok(None)
231        } else {
232            tracing::info!("User requested to exit");
233            let query = state.query.lines_as_string();
234            Ok(Some(if query.is_empty() {
235                ProcessOutput::success()
236            } else {
237                ProcessOutput::success().fileout(query)
238            }))
239        }
240    }
241
242    async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
243        // If `ctrl+space` was hit, attempt to refresh tags if the cursor is on it
244        if key.code == KeyCode::Char(' ') && key.modifiers == KeyModifiers::CONTROL {
245            self.debounced_refresh_tags();
246            Ok(Action::NoOp)
247        } else {
248            // Otherwise, process default actions
249            Ok(self
250                .default_process_key_event(keybindings, key)
251                .await?
252                .unwrap_or_default())
253        }
254    }
255
256    fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
257        match mouse.kind {
258            MouseEventKind::ScrollDown => Ok(self.move_next()?),
259            MouseEventKind::ScrollUp => Ok(self.move_prev()?),
260            _ => Ok(Action::NoOp),
261        }
262    }
263
264    fn move_up(&mut self) -> Result<Action> {
265        let mut state = self.state.write();
266        if let Some(ref mut tags) = state.tags {
267            tags.select_prev();
268        } else {
269            state.commands.select_prev();
270        }
271        Ok(Action::NoOp)
272    }
273
274    fn move_down(&mut self) -> Result<Action> {
275        let mut state = self.state.write();
276        if let Some(ref mut tags) = state.tags {
277            tags.select_next();
278        } else {
279            state.commands.select_next();
280        }
281        Ok(Action::NoOp)
282    }
283
284    fn move_left(&mut self, word: bool) -> Result<Action> {
285        let mut state = self.state.write();
286        if state.tags.is_none() {
287            state.query.move_cursor_left(word);
288        }
289        Ok(Action::NoOp)
290    }
291
292    fn move_right(&mut self, word: bool) -> Result<Action> {
293        let mut state = self.state.write();
294        if state.tags.is_none() {
295            state.query.move_cursor_right(word);
296        }
297        Ok(Action::NoOp)
298    }
299
300    fn move_prev(&mut self) -> Result<Action> {
301        self.move_up()
302    }
303
304    fn move_next(&mut self) -> Result<Action> {
305        self.move_down()
306    }
307
308    fn move_home(&mut self, absolute: bool) -> Result<Action> {
309        let mut state = self.state.write();
310        if let Some(ref mut tags) = state.tags {
311            tags.select_first();
312        } else if absolute {
313            state.commands.select_first();
314        } else {
315            state.query.move_home(false);
316        }
317        Ok(Action::NoOp)
318    }
319
320    fn move_end(&mut self, absolute: bool) -> Result<Action> {
321        let mut state = self.state.write();
322        if let Some(ref mut tags) = state.tags {
323            tags.select_last();
324        } else if absolute {
325            state.commands.select_last();
326        } else {
327            state.query.move_end(false);
328        }
329        Ok(Action::NoOp)
330    }
331
332    fn undo(&mut self) -> Result<Action> {
333        let mut state = self.state.write();
334        state.query.undo();
335        if state.tags.is_some() {
336            self.debounced_refresh_tags();
337        } else {
338            self.schedule_debounced_command_refresh();
339        }
340        Ok(Action::NoOp)
341    }
342
343    fn redo(&mut self) -> Result<Action> {
344        let mut state = self.state.write();
345        state.query.redo();
346        if state.tags.is_some() {
347            self.debounced_refresh_tags();
348        } else {
349            self.schedule_debounced_command_refresh();
350        }
351        Ok(Action::NoOp)
352    }
353
354    fn insert_text(&mut self, text: String) -> Result<Action> {
355        let mut state = self.state.write();
356        state.query.insert_str(text);
357        if state.tags.is_some() {
358            self.debounced_refresh_tags();
359        } else {
360            self.schedule_debounced_command_refresh();
361        }
362        Ok(Action::NoOp)
363    }
364
365    fn insert_char(&mut self, c: char) -> Result<Action> {
366        let mut state = self.state.write();
367        state.query.insert_char(c);
368        if c == '#' || state.tags.is_some() {
369            self.debounced_refresh_tags();
370        } else {
371            self.schedule_debounced_command_refresh();
372        }
373        Ok(Action::NoOp)
374    }
375
376    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
377        let mut state = self.state.write();
378        state.query.delete(backspace, word);
379        if state.tags.is_some() {
380            self.debounced_refresh_tags();
381        } else {
382            self.schedule_debounced_command_refresh();
383        }
384        Ok(Action::NoOp)
385    }
386
387    fn toggle_search_mode(&mut self) -> Result<Action> {
388        let (search_mode, user_only, tags) = {
389            let state = self.state.read();
390            (state.mode.down(), state.user_only, state.tags.is_some())
391        };
392        self.update_config(search_mode, user_only);
393        if tags {
394            self.debounced_refresh_tags();
395        } else {
396            self.schedule_debounced_command_refresh();
397        }
398        Ok(Action::NoOp)
399    }
400
401    fn toggle_search_user_only(&mut self) -> Result<Action> {
402        let (search_mode, user_only, tags) = {
403            let state = self.state.read();
404            (state.mode, !state.user_only, state.tags.is_some())
405        };
406        self.update_config(search_mode, user_only);
407        if tags {
408            self.debounced_refresh_tags();
409        } else {
410            self.schedule_debounced_command_refresh();
411        }
412        Ok(Action::NoOp)
413    }
414
415    #[instrument(skip_all)]
416    async fn selection_delete(&mut self) -> Result<Action> {
417        let command = {
418            let mut state = self.state.write();
419            if let Some(selected) = state.commands.selected() {
420                if selected.source != SOURCE_WORKSPACE {
421                    state.commands.delete_selected()
422                } else {
423                    None
424                }
425            } else {
426                None
427            }
428        };
429
430        if let Some(command) = command {
431            self.service.delete_command(command.id).await?;
432        }
433
434        Ok(Action::NoOp)
435    }
436
437    #[instrument(skip_all)]
438    async fn selection_update(&mut self) -> Result<Action> {
439        let command = {
440            let state = self.state.read();
441            state.commands.selected().cloned().map(Command::from)
442        };
443        if let Some(command) = command
444            && command.source != SOURCE_WORKSPACE
445        {
446            tracing::info!("Entering command update for: {command}");
447            Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
448                self.service.clone(),
449                self.theme.clone(),
450                self.inline,
451                self.new_version.inner().clone(),
452                command,
453                EditCommandComponentMode::Edit {
454                    parent: Box::new(self.clone()),
455                },
456            ))))
457        } else {
458            Ok(Action::NoOp)
459        }
460    }
461
462    #[instrument(skip_all)]
463    async fn selection_confirm(&mut self) -> Result<Action> {
464        let (selected_tag, cursor_pos, query, command) = {
465            let state = self.state.read();
466            let selected_tag = state.tags.as_ref().and_then(|s| s.selected().map(TagWidget::text));
467            (
468                selected_tag.map(String::from),
469                state.query.cursor().1,
470                state.query.lines_as_string(),
471                state.commands.selected().cloned().map(Command::from),
472            )
473        };
474
475        if let Some(tag) = selected_tag {
476            tracing::debug!("Selected tag: {tag}");
477            self.confirm_tag(tag, query, cursor_pos).await
478        } else if let Some(command) = command {
479            tracing::info!("Selected command: {command}");
480            self.confirm_command(command, false).await
481        } else {
482            Ok(Action::NoOp)
483        }
484    }
485
486    #[instrument(skip_all)]
487    async fn selection_execute(&mut self) -> Result<Action> {
488        let command = {
489            let state = self.state.read();
490            state.commands.selected().cloned().map(Command::from)
491        };
492        if let Some(command) = command {
493            tracing::info!("Selected command to execute: {command}");
494            self.confirm_command(command, true).await
495        } else {
496            Ok(Action::NoOp)
497        }
498    }
499}
500
501impl SearchCommandsComponent {
502    /// Schedule a debounced refresh of the commands list
503    fn schedule_debounced_command_refresh(&self) {
504        let cancellation_token = {
505            // Cancel previous token (if any)
506            let mut token_guard = self.refresh_token.lock().unwrap();
507            if let Some(token) = token_guard.take() {
508                token.cancel();
509            }
510            // Issue a new one
511            let new_token = CancellationToken::new();
512            *token_guard = Some(new_token.clone());
513            new_token
514        };
515
516        // Spawn a new task
517        let this = self.clone();
518        tokio::spawn(async move {
519            tokio::select! {
520                biased;
521                // That completes when the token is canceled
522                _ = cancellation_token.cancelled() => {}
523                // Or performs a command search after the configured delay
524                _ = tokio::time::sleep(this.search_delay) => {
525                    if let Err(err) = this.refresh_commands().await {
526                        panic!("Error refreshing commands: {err:?}");
527                    }
528                }
529            }
530        });
531    }
532
533    /// Refresh the command list
534    #[instrument(skip_all)]
535    async fn refresh_commands(&self) -> Result<()> {
536        // Retrieve the user query
537        let (mode, user_only, query) = {
538            let state = self.state.read();
539            (state.mode, state.user_only, state.query.lines_as_string())
540        };
541
542        // Search for commands
543        let res = self.service.search_commands(mode, user_only, &query).await;
544
545        // Update the command list or display an error
546        let mut state = self.state.write();
547        let command_widgets = match res {
548            Ok((commands, alias_match)) => {
549                state.error.clear_message();
550                state.alias_match = alias_match;
551                commands
552                    .into_iter()
553                    .map(|c| CommandWidget::new(&self.theme, self.inline, c))
554                    .collect()
555            }
556            Err(SearchError::InvalidFuzzy) => {
557                tracing::warn!("Invalid fuzzy search");
558                state.error.set_perm_message("Invalid fuzzy seach");
559                Vec::new()
560            }
561            Err(SearchError::InvalidRegex(err)) => {
562                tracing::warn!("Invalid regex search: {}", err);
563                state.error.set_perm_message("Invalid regex search");
564                Vec::new()
565            }
566            Err(SearchError::Unexpected(err)) => return Err(err),
567        };
568        state.commands.update_items(command_widgets);
569
570        Ok(())
571    }
572
573    /// Immediately starts a debounced refresh of the tags list
574    fn debounced_refresh_tags(&self) {
575        let this = self.clone();
576        tokio::spawn(async move {
577            if let Err(err) = this.refresh_tags().await {
578                panic!("Error refreshing tags: {err:?}");
579            }
580        });
581    }
582
583    /// Refresh the suggested tags list
584    #[instrument(skip_all)]
585    async fn refresh_tags(&self) -> Result<()> {
586        // Retrieve the user query
587        let (mode, user_only, query, cursor_pos) = {
588            let state = self.state.read();
589            (
590                state.mode,
591                state.user_only,
592                state.query.lines_as_string(),
593                state.query.cursor().1,
594            )
595        };
596
597        // Find tags for that query
598        let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
599
600        // Update the tags list
601        let mut state = self.state.write();
602        match res {
603            Ok(None) => {
604                tracing::trace!("No editing tags");
605                if state.tags.is_some() {
606                    tracing::debug!("Closing tag mode: no editing tag");
607                    state.tags = None;
608                    state.commands.set_focus(true);
609                }
610                self.schedule_debounced_command_refresh();
611                Ok(())
612            }
613            Ok(Some(tags)) if tags.is_empty() => {
614                tracing::trace!("No tags found");
615                if state.tags.is_some() {
616                    tracing::debug!("Closing tag mode: no tags found");
617                    state.tags = None;
618                    state.commands.set_focus(true);
619                }
620                self.schedule_debounced_command_refresh();
621                Ok(())
622            }
623            Ok(Some(tags)) => {
624                state.error.clear_message();
625                if tags.len() == 1 && tags.iter().all(|(_, _, exact_match)| *exact_match) {
626                    tracing::trace!("Exact tag found only");
627                    if state.tags.is_some() {
628                        tracing::debug!("Closing tag mode: exact tag found");
629                        state.tags = None;
630                        state.commands.set_focus(true);
631                    }
632                    self.schedule_debounced_command_refresh();
633                } else {
634                    tracing::trace!("Found {} tags", tags.len());
635                    let tag_widgets = tags
636                        .into_iter()
637                        .map(|(tag, _, _)| TagWidget::new(&self.theme, tag))
638                        .collect();
639                    let tags_list = if let Some(ref mut list) = state.tags {
640                        list
641                    } else {
642                        tracing::debug!("Entering tag mode");
643                        state.tags.insert(
644                            CustomList::new(self.theme.primary, self.inline, Vec::new())
645                                .highlight_symbol(self.theme.highlight_symbol.clone())
646                                .highlight_symbol_mode(HighlightSymbolMode::Last)
647                                .highlight_symbol_style(self.theme.highlight_primary_full().into()),
648                        )
649                    };
650                    tags_list.update_items(tag_widgets);
651                    state.commands.set_focus(false);
652                }
653
654                Ok(())
655            }
656            Err(SearchError::InvalidFuzzy) => {
657                tracing::warn!("Invalid fuzzy search");
658                state.error.set_perm_message("Invalid fuzzy seach");
659                if state.tags.is_some() {
660                    tracing::debug!("Closing tag mode: invalid fuzzy search");
661                    state.tags = None;
662                    state.commands.set_focus(true);
663                }
664                Ok(())
665            }
666            Err(SearchError::InvalidRegex(err)) => {
667                tracing::warn!("Invalid regex search: {}", err);
668                state.error.set_perm_message("Invalid regex search");
669                if state.tags.is_some() {
670                    tracing::debug!("Closing tag mode: invalid regex search");
671                    state.tags = None;
672                    state.commands.set_focus(true);
673                }
674                Ok(())
675            }
676            Err(SearchError::Unexpected(err)) => Err(err),
677        }
678    }
679
680    /// Confirms the tag by replacing the editing tag with the selected one
681    #[instrument(skip_all)]
682    async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
683        // Find the start and end of the current tag by looking both sides of the cursor
684        let mut tag_start = cursor_pos.wrapping_sub(1);
685        let chars: Vec<_> = query.chars().collect();
686        while tag_start > 0 && chars[tag_start] != '#' {
687            tag_start -= 1;
688        }
689        let mut tag_end = cursor_pos;
690        while tag_end < chars.len() && chars[tag_end] != ' ' {
691            tag_end += 1;
692        }
693        let mut state = self.state.write();
694        if chars[tag_start] == '#' {
695            // Replace the partial tag with the selected one
696            state.query.select_all();
697            state.query.cut();
698            state
699                .query
700                .insert_str(format!("{}{} {}", &query[..tag_start], tag, &query[tag_end..]));
701            state
702                .query
703                .move_cursor(CursorMove::Jump(0, (tag_start + tag.len() + 1) as u16));
704        }
705        state.tags = None;
706        state.commands.set_focus(true);
707        self.schedule_debounced_command_refresh();
708        Ok(Action::NoOp)
709    }
710
711    /// Confirms the command by increasing the usage counter storing it and quits or switches to the variable
712    /// replacement component if needed
713    #[instrument(skip_all)]
714    async fn confirm_command(&mut self, command: Command, execute: bool) -> Result<Action> {
715        // Increment usage count
716        if command.source != SOURCE_WORKSPACE {
717            self.service
718                .increment_command_usage(command.id)
719                .await
720                .map_err(UpdateError::into_report)?;
721        }
722        // Determine if the command has some variables
723        let dynamic = DynamicCommand::parse(&command.cmd);
724        if dynamic.has_pending_variable() {
725            // If it does, switch to the variable replacement component
726            Ok(Action::SwitchComponent(Box::new(VariableReplacementComponent::new(
727                self.service.clone(),
728                self.theme.clone(),
729                self.inline,
730                execute,
731                false,
732                self.new_version.inner().clone(),
733                dynamic,
734            ))))
735        } else if execute {
736            // If it doesn't and execute is true, execute the command
737            Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
738        } else {
739            // Otherwise just output it
740            Ok(Action::Quit(
741                ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
742            ))
743        }
744    }
745}