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#[derive(Clone)]
44pub struct SearchCommandsComponent {
45 theme: Theme,
47 inline: bool,
49 service: IntelliShellService,
51 layout: Layout,
53 new_version: NewVersionBanner,
55 search_delay: Duration,
57 refresh_token: Arc<Mutex<Option<CancellationToken>>>,
59 state: Arc<RwLock<SearchCommandsComponentState<'static>>>,
61}
62struct SearchCommandsComponentState<'a> {
63 mode: SearchMode,
65 user_only: bool,
67 query: CustomTextArea<'a>,
69 tags: Option<CustomList<'a, TagWidget>>,
71 alias_match: bool,
73 commands: CustomList<'a, CommandWidget>,
75 error: ErrorPopup<'a>,
77}
78
79impl SearchCommandsComponent {
80 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 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 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 let [query_area, suggestions_area] = self.layout.areas(area);
199
200 let mut state = self.state.write();
201
202 frame.render_widget(&state.query, query_area);
204
205 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 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 key.code == KeyCode::Char(' ') && key.modifiers == KeyModifiers::CONTROL {
245 self.debounced_refresh_tags();
246 Ok(Action::NoOp)
247 } else {
248 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 fn schedule_debounced_command_refresh(&self) {
504 let cancellation_token = {
505 let mut token_guard = self.refresh_token.lock().unwrap();
507 if let Some(token) = token_guard.take() {
508 token.cancel();
509 }
510 let new_token = CancellationToken::new();
512 *token_guard = Some(new_token.clone());
513 new_token
514 };
515
516 let this = self.clone();
518 tokio::spawn(async move {
519 tokio::select! {
520 biased;
521 _ = cancellation_token.cancelled() => {}
523 _ = 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 #[instrument(skip_all)]
535 async fn refresh_commands(&self) -> Result<()> {
536 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 let res = self.service.search_commands(mode, user_only, &query).await;
544
545 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 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 #[instrument(skip_all)]
585 async fn refresh_tags(&self) -> Result<()> {
586 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 let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
599
600 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 #[instrument(skip_all)]
682 async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
683 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 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 #[instrument(skip_all)]
714 async fn confirm_command(&mut self, command: Command, execute: bool) -> Result<Action> {
715 if command.source != SOURCE_WORKSPACE {
717 self.service
718 .increment_command_usage(command.id)
719 .await
720 .map_err(UpdateError::into_report)?;
721 }
722 let dynamic = DynamicCommand::parse(&command.cmd);
724 if dynamic.has_pending_variable() {
725 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 Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
738 } else {
739 Ok(Action::Quit(
741 ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
742 ))
743 }
744 }
745}