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 tokio_util::sync::CancellationToken;
16use tracing::instrument;
17use tui_textarea::CursorMove;
18
19use super::Component;
20use crate::{
21 app::Action,
22 component::{
23 edit::{EditCommandComponent, EditCommandComponentMode},
24 variable::VariableReplacementComponent,
25 },
26 config::{Config, KeyBindingsConfig, SearchConfig, Theme},
27 errors::AppError,
28 format_msg,
29 model::{Command, CommandTemplate, SOURCE_WORKSPACE, SearchMode},
30 process::ProcessOutput,
31 service::IntelliShellService,
32 widgets::{CustomList, CustomTextArea, ErrorPopup, NewVersionBanner, items::string::CommentString},
33};
34
35const EMPTY_STORAGE_MESSAGE: &str = r#"There are no stored commands yet!
36 - Try to bookmark some command with 'Ctrl + B'
37 - Or execute 'intelli-shell tldr fetch' to download a bunch of tldr's useful commands"#;
38
39#[derive(Clone)]
41pub struct SearchCommandsComponent {
42 theme: Theme,
44 inline: bool,
46 exec_on_alias_match: bool,
48 service: IntelliShellService,
50 layout: Layout,
52 search_delay: Duration,
54 refresh_token: Arc<Mutex<Option<CancellationToken>>>,
56 state: Arc<RwLock<SearchCommandsComponentState<'static>>>,
58}
59struct SearchCommandsComponentState<'a> {
60 initialize_with_ai: bool,
62 mode: SearchMode,
64 user_only: bool,
66 query: CustomTextArea<'a>,
68 ai_mode: bool,
70 tags: Option<CustomList<'a, CommentString>>,
72 alias_match: bool,
74 commands: CustomList<'a, Command>,
76 error: ErrorPopup<'a>,
78}
79
80impl SearchCommandsComponent {
81 pub fn new(
83 service: IntelliShellService,
84 config: Config,
85 inline: bool,
86 query: impl Into<String>,
87 initialize_with_ai: bool,
88 ) -> Self {
89 let query = CustomTextArea::new(config.theme.primary, inline, false, query.into()).focused();
90
91 let commands = CustomList::new(config.theme.clone(), inline, Vec::new());
92
93 let error = ErrorPopup::empty(&config.theme);
94
95 let layout = if inline {
96 Layout::vertical([Constraint::Length(1), Constraint::Min(3)])
97 } else {
98 Layout::vertical([Constraint::Length(3), Constraint::Min(5)]).margin(1)
99 };
100
101 let SearchConfig {
102 delay,
103 mode,
104 user_only,
105 exec_on_alias_match,
106 } = config.search;
107
108 let ret = Self {
109 theme: config.theme,
110 inline,
111 exec_on_alias_match,
112 service,
113 layout,
114 search_delay: Duration::from_millis(delay),
115 refresh_token: Arc::new(Mutex::new(None)),
116 state: Arc::new(RwLock::new(SearchCommandsComponentState {
117 initialize_with_ai,
118 mode,
119 user_only,
120 query,
121 ai_mode: false,
122 tags: None,
123 alias_match: false,
124 commands,
125 error,
126 })),
127 };
128
129 ret.update_config(None, None, None);
130
131 ret
132 }
133
134 fn update_config(&self, search_mode: Option<SearchMode>, user_only: Option<bool>, ai_mode: Option<bool>) {
136 let mut state = self.state.write();
137 if let Some(search_mode) = search_mode {
138 state.mode = search_mode;
139 }
140 if let Some(user_only) = user_only {
141 state.user_only = user_only;
142 }
143 if let Some(ai_mode) = ai_mode {
144 state.ai_mode = ai_mode;
145 }
146
147 let search_mode = state.mode;
148 let title = match (state.ai_mode, self.inline, state.user_only) {
149 (true, true, _) => String::from("(ai)"),
150 (false, true, true) => format!("({search_mode},user)"),
151 (false, true, false) => format!("({search_mode})"),
152 (true, false, _) => String::from(" Query (ai) "),
153 (false, false, true) => format!(" Query ({search_mode},user) "),
154 (false, false, false) => format!(" Query ({search_mode}) "),
155 };
156
157 state.query.set_title(title);
158 }
159}
160
161#[async_trait]
162impl Component for SearchCommandsComponent {
163 fn name(&self) -> &'static str {
164 "SearchCommandsComponent"
165 }
166
167 fn min_inline_height(&self) -> u16 {
168 1 + 10
170 }
171
172 #[instrument(skip_all)]
173 async fn init_and_peek(&mut self) -> Result<Action> {
174 let initialize_with_ai = self.state.read().initialize_with_ai;
176 if initialize_with_ai {
177 let res = self.prompt_ai().await;
178 self.state.write().initialize_with_ai = false;
179 return res;
180 }
181 if self.service.is_storage_empty().await.map_err(AppError::into_report)? {
183 Ok(Action::Quit(
184 ProcessOutput::success().stderr(format_msg!(self.theme, "{EMPTY_STORAGE_MESSAGE}")),
185 ))
186 } else {
187 let tags = {
189 let state = self.state.read();
190 state.query.lines_as_string() == "#"
191 };
192 if tags {
193 self.refresh_tags().await?;
194 } else {
195 self.refresh_commands().await?;
196 let command = {
198 let state = self.state.read();
199 if state.alias_match && state.commands.len() == 1 {
200 state.commands.selected().cloned()
201 } else {
202 None
203 }
204 };
205 if let Some(command) = command {
206 tracing::info!("Found a single alias command: {}", command.cmd);
207 return self.confirm_command(command, self.exec_on_alias_match, false).await;
208 }
209 }
210 Ok(Action::NoOp)
211 }
212 }
213
214 #[instrument(skip_all)]
215 fn render(&mut self, frame: &mut Frame, area: Rect) {
216 let [query_area, suggestions_area] = self.layout.areas(area);
218
219 let mut state = self.state.write();
220
221 frame.render_widget(&state.query, query_area);
223
224 if let Some(ref mut tags) = state.tags {
226 frame.render_widget(tags, suggestions_area);
227 } else {
228 frame.render_widget(&mut state.commands, suggestions_area);
229 }
230
231 if let Some(new_version) = self.service.poll_new_version() {
233 NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
234 }
235 state.error.render_in(frame, area);
236 }
237
238 fn tick(&mut self) -> Result<Action> {
239 let mut state = self.state.write();
240 state.query.tick();
241 state.error.tick();
242 Ok(Action::NoOp)
243 }
244
245 fn exit(&mut self) -> Result<Action> {
246 let (ai_mode, tags) = {
247 let state = self.state.read();
248 (state.ai_mode, state.tags.is_some())
249 };
250 if ai_mode {
251 tracing::debug!("Closing ai mode: user request");
252 self.update_config(None, None, Some(false));
253 self.schedule_debounced_command_refresh();
254 Ok(Action::NoOp)
255 } else if tags {
256 tracing::debug!("Closing tag mode: user request");
257 let mut state = self.state.write();
258 state.tags = None;
259 state.commands.set_focus(true);
260 self.schedule_debounced_command_refresh();
261 Ok(Action::NoOp)
262 } else {
263 tracing::info!("User requested to exit");
264 let state = self.state.read();
265 let query = state.query.lines_as_string();
266 Ok(Action::Quit(if query.trim().is_empty() {
267 ProcessOutput::success()
268 } else {
269 ProcessOutput::success().fileout(query)
270 }))
271 }
272 }
273
274 async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
275 if key.code == KeyCode::Char(' ') && key.modifiers == KeyModifiers::CONTROL {
277 self.debounced_refresh_tags();
278 Ok(Action::NoOp)
279 } else {
280 Ok(self
282 .default_process_key_event(keybindings, key)
283 .await?
284 .unwrap_or_default())
285 }
286 }
287
288 fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
289 match mouse.kind {
290 MouseEventKind::ScrollDown => Ok(self.move_down()?),
291 MouseEventKind::ScrollUp => Ok(self.move_up()?),
292 _ => Ok(Action::NoOp),
293 }
294 }
295
296 fn move_up(&mut self) -> Result<Action> {
297 let mut state = self.state.write();
298 if !state.query.is_ai_loading() {
299 if let Some(ref mut tags) = state.tags {
300 tags.select_prev();
301 } else {
302 state.commands.select_prev();
303 }
304 }
305 Ok(Action::NoOp)
306 }
307
308 fn move_down(&mut self) -> Result<Action> {
309 let mut state = self.state.write();
310 if !state.query.is_ai_loading() {
311 if let Some(ref mut tags) = state.tags {
312 tags.select_next();
313 } else {
314 state.commands.select_next();
315 }
316 }
317 Ok(Action::NoOp)
318 }
319
320 fn move_left(&mut self, word: bool) -> Result<Action> {
321 let mut state = self.state.write();
322 if state.tags.is_none() {
323 state.query.move_cursor_left(word);
324 }
325 Ok(Action::NoOp)
326 }
327
328 fn move_right(&mut self, word: bool) -> Result<Action> {
329 let mut state = self.state.write();
330 if state.tags.is_none() {
331 state.query.move_cursor_right(word);
332 }
333 Ok(Action::NoOp)
334 }
335
336 fn move_prev(&mut self) -> Result<Action> {
337 self.move_up()
338 }
339
340 fn move_next(&mut self) -> Result<Action> {
341 self.move_down()
342 }
343
344 fn move_home(&mut self, absolute: bool) -> Result<Action> {
345 let mut state = self.state.write();
346 if !state.query.is_ai_loading() {
347 if let Some(ref mut tags) = state.tags {
348 tags.select_first();
349 } else if absolute {
350 state.commands.select_first();
351 } else {
352 state.query.move_home(false);
353 }
354 }
355 Ok(Action::NoOp)
356 }
357
358 fn move_end(&mut self, absolute: bool) -> Result<Action> {
359 let mut state = self.state.write();
360 if !state.query.is_ai_loading() {
361 if let Some(ref mut tags) = state.tags {
362 tags.select_last();
363 } else if absolute {
364 state.commands.select_last();
365 } else {
366 state.query.move_end(false);
367 }
368 }
369 Ok(Action::NoOp)
370 }
371
372 fn undo(&mut self) -> Result<Action> {
373 let mut state = self.state.write();
374 if !state.query.is_ai_loading() {
375 state.query.undo();
376 if state.tags.is_some() {
377 self.debounced_refresh_tags();
378 } else {
379 self.schedule_debounced_command_refresh();
380 }
381 }
382 Ok(Action::NoOp)
383 }
384
385 fn redo(&mut self) -> Result<Action> {
386 let mut state = self.state.write();
387 if !state.query.is_ai_loading() {
388 state.query.redo();
389 if state.tags.is_some() {
390 self.debounced_refresh_tags();
391 } else {
392 self.schedule_debounced_command_refresh();
393 }
394 }
395 Ok(Action::NoOp)
396 }
397
398 fn insert_text(&mut self, text: String) -> Result<Action> {
399 let mut state = self.state.write();
400 state.query.insert_str(text);
401 if state.tags.is_some() {
402 self.debounced_refresh_tags();
403 } else {
404 self.schedule_debounced_command_refresh();
405 }
406 Ok(Action::NoOp)
407 }
408
409 fn insert_char(&mut self, c: char) -> Result<Action> {
410 let mut state = self.state.write();
411 state.query.insert_char(c);
412 if c == '#' || state.tags.is_some() {
413 self.debounced_refresh_tags();
414 } else {
415 self.schedule_debounced_command_refresh();
416 }
417 Ok(Action::NoOp)
418 }
419
420 fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
421 let mut state = self.state.write();
422 state.query.delete(backspace, word);
423 if state.tags.is_some() {
424 self.debounced_refresh_tags();
425 } else {
426 self.schedule_debounced_command_refresh();
427 }
428 Ok(Action::NoOp)
429 }
430
431 fn toggle_search_mode(&mut self) -> Result<Action> {
432 let (search_mode, ai_mode, tags) = {
433 let state = self.state.read();
434 if state.query.is_ai_loading() {
435 return Ok(Action::NoOp);
436 }
437 (state.mode, state.ai_mode, state.tags.is_some())
438 };
439 if ai_mode {
440 tracing::debug!("Closing ai mode: user toggled search mode");
441 self.update_config(None, None, Some(false));
442 } else {
443 self.update_config(Some(search_mode.down()), None, None);
444 }
445 if tags {
446 self.debounced_refresh_tags();
447 } else {
448 self.schedule_debounced_command_refresh();
449 }
450 Ok(Action::NoOp)
451 }
452
453 fn toggle_search_user_only(&mut self) -> Result<Action> {
454 let (user_only, ai_mode, tags) = {
455 let state = self.state.read();
456 (state.user_only, state.ai_mode, state.tags.is_some())
457 };
458 if !ai_mode {
459 self.update_config(None, Some(!user_only), None);
460 if tags {
461 self.debounced_refresh_tags();
462 } else {
463 self.schedule_debounced_command_refresh();
464 }
465 }
466 Ok(Action::NoOp)
467 }
468
469 #[instrument(skip_all)]
470 async fn selection_delete(&mut self) -> Result<Action> {
471 let command = {
472 let mut state = self.state.write();
473 if !state.ai_mode
474 && let Some(selected) = state.commands.selected()
475 {
476 if selected.source != SOURCE_WORKSPACE {
477 state.commands.delete_selected()
478 } else {
479 state.error.set_temp_message("Workspace commands can't be deleted");
480 return Ok(Action::NoOp);
481 }
482 } else {
483 None
484 }
485 };
486
487 if let Some((_, command)) = command {
488 self.service
489 .delete_command(command.id)
490 .await
491 .map_err(AppError::into_report)?;
492 }
493
494 Ok(Action::NoOp)
495 }
496
497 #[instrument(skip_all)]
498 async fn selection_update(&mut self) -> Result<Action> {
499 let command = {
500 let state = self.state.read();
501 if state.ai_mode {
502 return Ok(Action::NoOp);
503 }
504 state.commands.selected().cloned()
505 };
506 if let Some(command) = command {
507 if command.source != SOURCE_WORKSPACE {
508 tracing::info!("Entering command update for: {}", command.cmd);
509 Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
510 self.service.clone(),
511 self.theme.clone(),
512 self.inline,
513 command,
514 EditCommandComponentMode::Edit {
515 parent: Box::new(self.clone()),
516 },
517 ))))
518 } else {
519 self.state
520 .write()
521 .error
522 .set_temp_message("Workspace commands can't be updated");
523 Ok(Action::NoOp)
524 }
525 } else {
526 Ok(Action::NoOp)
527 }
528 }
529
530 #[instrument(skip_all)]
531 async fn selection_confirm(&mut self) -> Result<Action> {
532 let (selected_tag, cursor_pos, query, command, ai_mode) = {
533 let state = self.state.read();
534 if state.query.is_ai_loading() {
535 return Ok(Action::NoOp);
536 }
537 let selected_tag = state.tags.as_ref().and_then(|s| s.selected().cloned());
538 (
539 selected_tag.map(String::from),
540 state.query.cursor().1,
541 state.query.lines_as_string(),
542 state.commands.selected().cloned(),
543 state.ai_mode,
544 )
545 };
546
547 if let Some(tag) = selected_tag {
548 tracing::debug!("Selected tag: {tag}");
549 self.confirm_tag(tag, query, cursor_pos).await
550 } else if let Some(command) = command {
551 tracing::info!("Selected command: {}", command.cmd);
552 self.confirm_command(command, false, ai_mode).await
553 } else {
554 Ok(Action::NoOp)
555 }
556 }
557
558 #[instrument(skip_all)]
559 async fn selection_execute(&mut self) -> Result<Action> {
560 let (command, ai_mode) = {
561 let state = self.state.read();
562 if state.query.is_ai_loading() {
563 return Ok(Action::NoOp);
564 }
565 (state.commands.selected().cloned(), state.ai_mode)
566 };
567 if let Some(command) = command {
568 tracing::info!("Selected command to execute: {}", command.cmd);
569 self.confirm_command(command, true, ai_mode).await
570 } else {
571 Ok(Action::NoOp)
572 }
573 }
574
575 async fn prompt_ai(&mut self) -> Result<Action> {
576 let mut state = self.state.write();
577 if state.tags.is_some() || state.query.is_ai_loading() {
578 return Ok(Action::NoOp);
579 }
580 let query = state.query.lines_as_string();
581 if !query.is_empty() {
582 state.query.set_ai_loading(true);
583 drop(state);
584 self.update_config(None, None, Some(true));
585 let this = self.clone();
586 tokio::spawn(async move {
587 let res = this.service.suggest_commands(&query).await;
588 let mut state = this.state.write();
589 let commands = match res {
590 Ok(suggestions) => {
591 if !suggestions.is_empty() {
592 state.error.clear_message();
593 state.alias_match = false;
594 suggestions
595 } else {
596 state
597 .error
598 .set_temp_message("AI did not return any suggestion".to_string());
599 Vec::new()
600 }
601 }
602 Err(AppError::UserFacing(err)) => {
603 tracing::warn!("{err}");
604 state.error.set_temp_message(err.to_string());
605 Vec::new()
606 }
607 Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
608 };
609 state.commands.update_items(commands, true);
610 state.query.set_ai_loading(false);
611 });
612 }
613 Ok(Action::NoOp)
614 }
615}
616
617impl SearchCommandsComponent {
618 fn schedule_debounced_command_refresh(&self) {
620 let cancellation_token = {
621 let mut token_guard = self.refresh_token.lock().unwrap();
623 if let Some(token) = token_guard.take() {
624 token.cancel();
625 }
626 let new_token = CancellationToken::new();
628 *token_guard = Some(new_token.clone());
629 new_token
630 };
631
632 let this = self.clone();
634 tokio::spawn(async move {
635 tokio::select! {
636 biased;
637 _ = cancellation_token.cancelled() => {}
639 _ = tokio::time::sleep(this.search_delay) => {
641 if let Err(err) = this.refresh_commands().await {
642 panic!("Error refreshing commands: {err:?}");
643 }
644 }
645 }
646 });
647 }
648
649 #[instrument(skip_all)]
651 async fn refresh_commands(&self) -> Result<()> {
652 let (mode, user_only, ai_mode, query) = {
654 let state = self.state.read();
655 (
656 state.mode,
657 state.user_only,
658 state.ai_mode,
659 state.query.lines_as_string(),
660 )
661 };
662
663 if ai_mode {
665 return Ok(());
666 }
667
668 let res = self.service.search_commands(mode, user_only, &query).await;
670
671 let mut state = self.state.write();
673 let commands = match res {
674 Ok((commands, alias_match)) => {
675 state.error.clear_message();
676 state.alias_match = alias_match;
677 commands
678 }
679 Err(AppError::UserFacing(err)) => {
680 tracing::warn!("{err}");
681 state.error.set_perm_message(err.to_string());
682 Vec::new()
683 }
684 Err(AppError::Unexpected(err)) => return Err(err),
685 };
686 state.commands.update_items(commands, true);
687
688 Ok(())
689 }
690
691 fn debounced_refresh_tags(&self) {
693 let this = self.clone();
694 tokio::spawn(async move {
695 if let Err(err) = this.refresh_tags().await {
696 panic!("Error refreshing tags: {err:?}");
697 }
698 });
699 }
700
701 #[instrument(skip_all)]
703 async fn refresh_tags(&self) -> Result<()> {
704 let (mode, user_only, ai_mode, query, cursor_pos) = {
706 let state = self.state.read();
707 (
708 state.mode,
709 state.user_only,
710 state.ai_mode,
711 state.query.lines_as_string(),
712 state.query.cursor().1,
713 )
714 };
715
716 if ai_mode {
718 return Ok(());
719 }
720
721 let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
723
724 let mut state = self.state.write();
726 match res {
727 Ok(None) => {
728 tracing::trace!("No editing tags");
729 if state.tags.is_some() {
730 tracing::debug!("Closing tag mode: no editing tag");
731 state.tags = None;
732 state.commands.set_focus(true);
733 }
734 self.schedule_debounced_command_refresh();
735 Ok(())
736 }
737 Ok(Some(tags)) if tags.is_empty() => {
738 tracing::trace!("No tags found");
739 if state.tags.is_some() {
740 tracing::debug!("Closing tag mode: no tags found");
741 state.tags = None;
742 state.commands.set_focus(true);
743 }
744 self.schedule_debounced_command_refresh();
745 Ok(())
746 }
747 Ok(Some(tags)) => {
748 state.error.clear_message();
749 if tags.len() == 1 && tags.iter().all(|(_, _, exact_match)| *exact_match) {
750 tracing::trace!("Exact tag found only");
751 if state.tags.is_some() {
752 tracing::debug!("Closing tag mode: exact tag found");
753 state.tags = None;
754 state.commands.set_focus(true);
755 }
756 self.schedule_debounced_command_refresh();
757 } else {
758 tracing::trace!("Found {} tags", tags.len());
759 let tags = tags.into_iter().map(|(tag, _, _)| CommentString::from(tag)).collect();
760 let tags_list = if let Some(ref mut list) = state.tags {
761 list
762 } else {
763 tracing::debug!("Entering tag mode");
764 state
765 .tags
766 .insert(CustomList::new(self.theme.clone(), self.inline, Vec::new()))
767 };
768 tags_list.update_items(tags, true);
769 state.commands.set_focus(false);
770 }
771
772 Ok(())
773 }
774 Err(AppError::UserFacing(err)) => {
775 tracing::warn!("{err}");
776 state.error.set_perm_message(err.to_string());
777 if state.tags.is_some() {
778 tracing::debug!("Closing tag mode");
779 state.tags = None;
780 state.commands.set_focus(true);
781 }
782 Ok(())
783 }
784 Err(AppError::Unexpected(err)) => Err(err),
785 }
786 }
787
788 #[instrument(skip_all)]
790 async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
791 let mut tag_start = cursor_pos.wrapping_sub(1);
793 let chars: Vec<_> = query.chars().collect();
794 while tag_start > 0 && chars[tag_start] != '#' {
795 tag_start -= 1;
796 }
797 let mut tag_end = cursor_pos;
798 while tag_end < chars.len() && chars[tag_end] != ' ' {
799 tag_end += 1;
800 }
801 let mut state = self.state.write();
802 if chars[tag_start] == '#' {
803 state.query.select_all();
805 state.query.cut();
806 state
807 .query
808 .insert_str(format!("{}{} {}", &query[..tag_start], tag, &query[tag_end..]));
809 state
810 .query
811 .move_cursor(CursorMove::Jump(0, (tag_start + tag.len() + 1) as u16));
812 }
813 state.tags = None;
814 state.commands.set_focus(true);
815 self.schedule_debounced_command_refresh();
816 Ok(Action::NoOp)
817 }
818
819 #[instrument(skip_all)]
822 async fn confirm_command(&mut self, command: Command, execute: bool, ai_command: bool) -> Result<Action> {
823 if !ai_command && command.source != SOURCE_WORKSPACE {
825 self.service
826 .increment_command_usage(command.id)
827 .await
828 .map_err(AppError::into_report)?;
829 }
830 let template = CommandTemplate::parse(&command.cmd, false);
832 if template.has_pending_variable() {
833 Ok(Action::SwitchComponent(Box::new(VariableReplacementComponent::new(
835 self.service.clone(),
836 self.theme.clone(),
837 self.inline,
838 execute,
839 false,
840 template,
841 ))))
842 } else if execute {
843 Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
845 } else {
846 Ok(Action::Quit(
848 ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
849 ))
850 }
851 }
852}