intelli_shell/component/
variable.rs

1use std::{
2    cmp::Ordering,
3    collections::HashSet,
4    sync::{Arc, Mutex},
5    time::Duration,
6};
7
8use async_trait::async_trait;
9use color_eyre::Result;
10use crossterm::event::{MouseEvent, MouseEventKind};
11use futures_util::StreamExt;
12use parking_lot::RwLock;
13use ratatui::{
14    Frame,
15    layout::{Constraint, Layout, Rect},
16};
17use tokio_util::sync::CancellationToken;
18use tracing::instrument;
19
20use super::Component;
21use crate::{
22    app::Action,
23    config::Theme,
24    errors::AppError,
25    format_msg,
26    model::{CommandTemplate, VariableValue},
27    process::ProcessOutput,
28    service::IntelliShellService,
29    widgets::{
30        CommandTemplateWidget, CustomList, CustomTextArea, ErrorPopup, LoadingSpinner, NewVersionBanner,
31        items::VariableSuggestionItem,
32    },
33};
34
35// If there's a completion stream, process fast completions before showing the list
36const INITIAL_COMPLETION_WAIT: Duration = Duration::from_millis(250);
37
38/// A component for replacing the variables of a command
39#[derive(Clone)]
40pub struct VariableReplacementComponent {
41    /// Visual theme for styling the component
42    theme: Theme,
43    /// Whether the TUI is in inline mode or not
44    inline: bool,
45    /// Service for interacting with command storage
46    service: IntelliShellService,
47    /// Layout for arranging the input fields
48    layout: Layout,
49    /// Whether the command must be executed after replacing thw variables or just output it
50    execute_mode: bool,
51    /// Whether this component is part of the replace process (or maybe rendered after another process)
52    replace_process: bool,
53    /// Global cancellation token
54    global_cancellation_token: CancellationToken,
55    /// Cancellation token for the background completions task
56    cancellation_token: Arc<Mutex<Option<CancellationToken>>>,
57    /// The state of the component
58    state: Arc<RwLock<VariableReplacementComponentState<'static>>>,
59}
60struct VariableReplacementComponentState<'a> {
61    /// The command with variables to be replaced
62    template: CommandTemplateWidget,
63    /// Flat name of the current variable being set and if it's secret
64    current_variable_ctx: (String, bool),
65    /// Full list of suggestions for the current variable
66    variable_suggestions: Vec<VariableSuggestionItem<'static>>,
67    /// Widget list of filtered suggestions for the variable value
68    suggestions: CustomList<'a, VariableSuggestionItem<'a>>,
69    /// Popup for displaying error messages
70    error: ErrorPopup<'a>,
71    /// A spinner to indicate that completions are being fetched
72    loading: Option<LoadingSpinner<'a>>,
73    /// The last value that was unset via undo, to be used by redo
74    last_unset_value: Option<String>,
75}
76
77impl VariableReplacementComponent {
78    /// Creates a new [`VariableReplacementComponent`]
79    pub fn new(
80        service: IntelliShellService,
81        theme: Theme,
82        inline: bool,
83        execute_mode: bool,
84        replace_process: bool,
85        command: CommandTemplate,
86        cancellation_token: CancellationToken,
87    ) -> Self {
88        let command = CommandTemplateWidget::new(&theme, inline, command);
89
90        let suggestions = CustomList::new(theme.clone(), inline, Vec::new());
91
92        let error = ErrorPopup::empty(&theme);
93
94        let layout = if inline {
95            Layout::vertical([Constraint::Length(1), Constraint::Min(3)])
96        } else {
97            Layout::vertical([Constraint::Length(3), Constraint::Min(5)]).margin(1)
98        };
99
100        Self {
101            theme,
102            inline,
103            service,
104            layout,
105            execute_mode,
106            replace_process,
107            cancellation_token: Arc::new(Mutex::new(None)),
108            global_cancellation_token: cancellation_token,
109            state: Arc::new(RwLock::new(VariableReplacementComponentState {
110                template: command,
111                current_variable_ctx: (String::new(), true),
112                variable_suggestions: Vec::new(),
113                suggestions,
114                error,
115                loading: None,
116                last_unset_value: None,
117            })),
118        }
119    }
120}
121
122#[async_trait]
123impl Component for VariableReplacementComponent {
124    fn name(&self) -> &'static str {
125        "VariableReplacementComponent"
126    }
127
128    fn min_inline_height(&self) -> u16 {
129        // Command + Values
130        1 + 5
131    }
132
133    #[instrument(skip_all)]
134    async fn init_and_peek(&mut self) -> Result<Action> {
135        self.update_variable_context(true).await
136    }
137
138    #[instrument(skip_all)]
139    fn render(&mut self, frame: &mut Frame, area: Rect) {
140        // Split the area according to the layout
141        let [cmd_area, suggestions_area] = self.layout.areas(area);
142
143        let mut state = self.state.write();
144
145        // Render the command widget
146        frame.render_widget(&state.template, cmd_area);
147
148        // Render the suggestions
149        frame.render_widget(&mut state.suggestions, suggestions_area);
150
151        // Render the new version banner and error message as an overlay
152        if let Some(new_version) = self.service.poll_new_version() {
153            NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
154        }
155        state.error.render_in(frame, area);
156        // Display the loading spinner, if any
157        if let Some(loading) = &state.loading {
158            let loading_area = if self.inline {
159                Rect {
160                    x: suggestions_area.x,
161                    y: suggestions_area.y + suggestions_area.height.saturating_sub(1),
162                    width: 1,
163                    height: 1,
164                }
165            } else {
166                Rect {
167                    x: suggestions_area.x.saturating_add(1),
168                    y: suggestions_area.y + suggestions_area.height.saturating_sub(2),
169                    width: 1,
170                    height: 1,
171                }
172            };
173            loading.render_in(frame, loading_area);
174        }
175    }
176
177    fn tick(&mut self) -> Result<Action> {
178        let mut state = self.state.write();
179        state.error.tick();
180        if let Some(loading) = &mut state.loading {
181            loading.tick();
182        }
183
184        Ok(Action::NoOp)
185    }
186
187    fn exit(&mut self) -> Result<Action> {
188        {
189            let mut token_guard = self.cancellation_token.lock().unwrap();
190            if let Some(token) = token_guard.take() {
191                token.cancel();
192            }
193        }
194        let mut state = self.state.write();
195        if let Some(VariableSuggestionItem::Existing { editing, .. }) = state.suggestions.selected_mut()
196            && editing.is_some()
197        {
198            tracing::debug!("Closing variable value edit mode: user request");
199            *editing = None;
200            Ok(Action::NoOp)
201        } else {
202            tracing::info!("User requested to exit");
203            Ok(Action::Quit(
204                ProcessOutput::success().fileout(state.template.to_string()),
205            ))
206        }
207    }
208
209    fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
210        match mouse.kind {
211            MouseEventKind::ScrollDown => Ok(self.move_next()?),
212            MouseEventKind::ScrollUp => Ok(self.move_prev()?),
213            _ => Ok(Action::NoOp),
214        }
215    }
216
217    fn move_up(&mut self) -> Result<Action> {
218        let mut state = self.state.write();
219        match state.suggestions.selected() {
220            Some(VariableSuggestionItem::Existing { editing: Some(_), .. }) => (),
221            _ => state.suggestions.select_prev(),
222        }
223        Ok(Action::NoOp)
224    }
225
226    fn move_down(&mut self) -> Result<Action> {
227        let mut state = self.state.write();
228        match state.suggestions.selected() {
229            Some(VariableSuggestionItem::Existing { editing: Some(_), .. }) => (),
230            _ => state.suggestions.select_next(),
231        }
232        Ok(Action::NoOp)
233    }
234
235    fn move_left(&mut self, word: bool) -> Result<Action> {
236        let mut state = self.state.write();
237        match state.suggestions.selected_mut() {
238            Some(VariableSuggestionItem::New { textarea, .. }) => {
239                textarea.move_cursor_left(word);
240            }
241            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
242                ta.move_cursor_left(word);
243            }
244            _ => (),
245        }
246        Ok(Action::NoOp)
247    }
248
249    fn move_right(&mut self, word: bool) -> Result<Action> {
250        let mut state = self.state.write();
251        match state.suggestions.selected_mut() {
252            Some(VariableSuggestionItem::New { textarea, .. }) => {
253                textarea.move_cursor_right(word);
254            }
255            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
256                ta.move_cursor_right(word);
257            }
258            _ => (),
259        }
260        Ok(Action::NoOp)
261    }
262
263    fn move_prev(&mut self) -> Result<Action> {
264        self.move_up()
265    }
266
267    fn move_next(&mut self) -> Result<Action> {
268        self.move_down()
269    }
270
271    fn move_home(&mut self, absolute: bool) -> Result<Action> {
272        let mut state = self.state.write();
273        match state.suggestions.selected_mut() {
274            Some(VariableSuggestionItem::New { textarea, .. }) => {
275                textarea.move_home(absolute);
276            }
277            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
278                ta.move_home(absolute);
279            }
280            _ => state.suggestions.select_first(),
281        }
282        Ok(Action::NoOp)
283    }
284
285    fn move_end(&mut self, absolute: bool) -> Result<Action> {
286        let mut state = self.state.write();
287        match state.suggestions.selected_mut() {
288            Some(VariableSuggestionItem::New { textarea, .. }) => {
289                textarea.move_end(absolute);
290            }
291            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
292                ta.move_end(absolute);
293            }
294            _ => state.suggestions.select_last(),
295        }
296        Ok(Action::NoOp)
297    }
298
299    fn undo(&mut self) -> Result<Action> {
300        let mut state = self.state.write();
301        match state.suggestions.selected_mut() {
302            Some(VariableSuggestionItem::New { textarea, .. }) => {
303                textarea.undo();
304                let query = textarea.lines_as_string();
305                state.filter_suggestions(&query);
306            }
307            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
308                ta.undo();
309            }
310            _ => {
311                if let Some(unset_value) = state.template.unset_last_variable() {
312                    state.last_unset_value = Some(unset_value);
313                    self.debounced_update_variable_context();
314                }
315            }
316        }
317        Ok(Action::NoOp)
318    }
319
320    fn redo(&mut self) -> Result<Action> {
321        let mut state = self.state.write();
322        match state.suggestions.selected_mut() {
323            Some(VariableSuggestionItem::New { textarea, .. }) => {
324                textarea.redo();
325                let query = textarea.lines_as_string();
326                state.filter_suggestions(&query);
327            }
328            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
329                ta.redo();
330            }
331            _ => {
332                if let Some(value_to_set) = state.last_unset_value.take() {
333                    state.template.set_next_variable(value_to_set);
334                    self.debounced_update_variable_context();
335                }
336            }
337        }
338        Ok(Action::NoOp)
339    }
340
341    fn insert_text(&mut self, mut text: String) -> Result<Action> {
342        let mut state = self.state.write();
343        if let Some(variable) = state.template.current_variable() {
344            text = variable.apply_functions_to(text);
345        }
346        match state.suggestions.selected_mut() {
347            Some(VariableSuggestionItem::New { textarea, .. }) => {
348                textarea.insert_str(text);
349                let query = textarea.lines_as_string();
350                state.filter_suggestions(&query);
351            }
352            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
353                ta.insert_str(text);
354            }
355            _ => (),
356        }
357        Ok(Action::NoOp)
358    }
359
360    fn insert_char(&mut self, c: char) -> Result<Action> {
361        let mut state = self.state.write();
362        let maybe_replacement = state
363            .template
364            .current_variable()
365            .and_then(|variable| variable.check_functions_char(c));
366        let insert_content = |ta: &mut CustomTextArea<'_>| {
367            if let Some(r) = &maybe_replacement {
368                ta.insert_str(r);
369            } else {
370                ta.insert_char(c);
371            }
372        };
373        match state.suggestions.selected_mut() {
374            Some(VariableSuggestionItem::New { textarea, .. }) => {
375                insert_content(textarea);
376                let query = textarea.lines_as_string();
377                state.filter_suggestions(&query);
378            }
379            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
380                insert_content(ta);
381            }
382            _ => {
383                if let Some(VariableSuggestionItem::New { .. }) = state.suggestions.items().iter().next() {
384                    state.suggestions.select_first();
385                    if let Some(VariableSuggestionItem::New { textarea, .. }) = state.suggestions.selected_mut() {
386                        insert_content(textarea);
387                        let query = textarea.lines_as_string();
388                        state.filter_suggestions(&query);
389                    }
390                }
391            }
392        }
393        Ok(Action::NoOp)
394    }
395
396    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
397        let mut state = self.state.write();
398        match state.suggestions.selected_mut() {
399            Some(VariableSuggestionItem::New { textarea, .. }) => {
400                textarea.delete(backspace, word);
401                let query = textarea.lines_as_string();
402                state.filter_suggestions(&query);
403            }
404            Some(VariableSuggestionItem::Existing { editing: Some(ta), .. }) => {
405                ta.delete(backspace, word);
406            }
407            _ => (),
408        }
409        Ok(Action::NoOp)
410    }
411
412    #[instrument(skip_all)]
413    async fn selection_delete(&mut self) -> Result<Action> {
414        let deleted_id = {
415            let mut state = self.state.write();
416            match state.suggestions.selected_mut() {
417                Some(VariableSuggestionItem::New { .. }) => return Ok(Action::NoOp),
418                Some(VariableSuggestionItem::Existing {
419                    value: VariableValue { id: Some(id), .. },
420                    editing,
421                    ..
422                }) => {
423                    if editing.is_none() {
424                        let id = *id;
425                        state.suggestions.delete_selected();
426                        id
427                    } else {
428                        return Ok(Action::NoOp);
429                    }
430                }
431                _ => {
432                    state.error.set_temp_message("This value is not yet stored");
433                    return Ok(Action::NoOp);
434                }
435            }
436        };
437
438        self.service
439            .delete_variable_value(deleted_id)
440            .await
441            .map_err(AppError::into_report)?;
442
443        self.state
444            .write()
445            .variable_suggestions
446            .retain(|s| !matches!(s, VariableSuggestionItem::Existing { value, .. } if value.id == Some(deleted_id)));
447
448        Ok(Action::NoOp)
449    }
450
451    #[instrument(skip_all)]
452    async fn selection_update(&mut self) -> Result<Action> {
453        let mut state = self.state.write();
454
455        match state.suggestions.selected_mut() {
456            Some(VariableSuggestionItem::New { .. }) => (),
457            Some(i @ VariableSuggestionItem::Existing { .. }) => {
458                if let VariableSuggestionItem::Existing { value, editing, .. } = i {
459                    if let Some(id) = value.id {
460                        if editing.is_none() {
461                            tracing::debug!("Entering edit mode for existing variable value: {id}");
462                            i.enter_edit_mode();
463                        }
464                    } else {
465                        state.error.set_temp_message("This value is not yet stored");
466                    }
467                }
468            }
469            _ => state.error.set_temp_message("This value is not yet stored"),
470        }
471        Ok(Action::NoOp)
472    }
473
474    async fn selection_confirm(&mut self) -> Result<Action> {
475        {
476            let mut token_guard = self.cancellation_token.lock().unwrap();
477            if let Some(token) = token_guard.take() {
478                token.cancel();
479            }
480        }
481
482        // Helper enum to hold the data extracted from the lock
483        enum NextAction {
484            NoOp,
485            ConfirmNewSecret(String),
486            ConfirmNewRegular(String),
487            ConfirmExistingEdition(VariableValue, String),
488            ConfirmExistingValue(VariableValue),
489            ConfirmLiteral(String, bool),
490        }
491
492        let next_action = {
493            let mut state = self.state.write();
494            let (_, is_secret) = state.current_variable_ctx;
495            match state.suggestions.selected_mut() {
496                None => NextAction::NoOp,
497                Some(VariableSuggestionItem::New {
498                    textarea,
499                    is_secret: true,
500                    ..
501                }) => NextAction::ConfirmNewSecret(textarea.lines_as_string()),
502                Some(VariableSuggestionItem::New {
503                    textarea,
504                    is_secret: false,
505                    ..
506                }) => NextAction::ConfirmNewRegular(textarea.lines_as_string()),
507                Some(VariableSuggestionItem::Existing { value, editing, .. }) => match editing.take() {
508                    Some(ta) => NextAction::ConfirmExistingEdition(value.clone(), ta.lines_as_string()),
509                    None => NextAction::ConfirmExistingValue(value.clone()),
510                },
511                Some(VariableSuggestionItem::Environment {
512                    content,
513                    is_value: false,
514                    ..
515                }) => NextAction::ConfirmLiteral(content.clone(), false),
516                Some(VariableSuggestionItem::Environment {
517                    content: value,
518                    is_value: true,
519                    ..
520                })
521                | Some(VariableSuggestionItem::Previous { value, .. })
522                | Some(VariableSuggestionItem::Completion { value, .. })
523                | Some(VariableSuggestionItem::Derived { value, .. }) => {
524                    NextAction::ConfirmLiteral(value.clone(), !is_secret)
525                }
526            }
527        };
528
529        match next_action {
530            NextAction::NoOp => Ok(Action::NoOp),
531            NextAction::ConfirmNewSecret(value) => self.confirm_new_secret_value(value).await,
532            NextAction::ConfirmNewRegular(value) => self.confirm_new_regular_value(value).await,
533            NextAction::ConfirmExistingEdition(val, new_val) => self.confirm_existing_edition(val, new_val).await,
534            NextAction::ConfirmExistingValue(val) => self.confirm_existing_value(val, false).await,
535            NextAction::ConfirmLiteral(val, is_value) => self.confirm_literal_value(val, is_value).await,
536        }
537    }
538
539    async fn selection_execute(&mut self) -> Result<Action> {
540        self.selection_confirm().await
541    }
542}
543
544impl<'a> VariableReplacementComponentState<'a> {
545    /// Filters the suggestions widget based on the query
546    fn filter_suggestions(&mut self, query: &str) {
547        tracing::debug!("Filtering suggestions for: {query}");
548        // From the original variable suggestions, keep those matching the query only
549        let mut filtered_suggestions = self.variable_suggestions.clone();
550        filtered_suggestions.retain(|s| match s {
551            VariableSuggestionItem::New { .. } => false,
552            VariableSuggestionItem::Existing { value, .. } => value_matches_filter_query(&value.value, query),
553            VariableSuggestionItem::Previous { value, .. }
554            | VariableSuggestionItem::Environment { content: value, .. }
555            | VariableSuggestionItem::Completion { value, .. }
556            | VariableSuggestionItem::Derived { value, .. } => value_matches_filter_query(value, query),
557        });
558
559        // Find and insert the new row, which contains the query
560        let new_row = self
561            .suggestions
562            .items()
563            .iter()
564            .find(|s| matches!(s, VariableSuggestionItem::New { .. }));
565        if let Some(new_row) = new_row.cloned() {
566            filtered_suggestions.insert(0, new_row);
567        }
568        // Retrieve the identifier for the selected item
569        let selected_id = self.suggestions.selected().map(|s| s.identifier());
570        // Update the items
571        self.suggestions.update_items(filtered_suggestions, false);
572        // Restore the same selected item
573        if let Some(selected_id) = selected_id {
574            self.suggestions.select_matching(|i| i.identifier() == selected_id);
575        }
576    }
577
578    /// Merges a new set of completion suggestions into the master list, re-sorts, and re-filters
579    fn merge_completions(&mut self, score_boost: f64, completion_suggestions: Vec<String>) {
580        // Retrieve the current set of suggestions
581        let master_suggestions = &mut self.variable_suggestions;
582
583        // Remove all `Derived` items that are about to be added as a `Completion`
584        let completion_set = completion_suggestions.iter().collect::<HashSet<_>>();
585        master_suggestions.retain_mut(|item| {
586            !matches!(
587                item,
588                VariableSuggestionItem::Derived { value, .. }
589                    if completion_set.contains(value)
590            )
591        });
592
593        // For each new suggestion given by the completion
594        for suggestion in completion_suggestions {
595            // Check if there's already a suggestion for the same value
596            let mut skip_completion = false;
597            for item in master_suggestions.iter_mut() {
598                match item {
599                    // `New` items don't affect completions
600                    VariableSuggestionItem::New { .. } => (),
601                    // `Derived` are already handled above
602                    VariableSuggestionItem::Derived { .. } => (),
603                    // If already a previous value, skip completion
604                    VariableSuggestionItem::Previous { value, .. } => {
605                        if value == &suggestion {
606                            skip_completion = true;
607                            break;
608                        }
609                    }
610                    // If already an environment value, skip completion
611                    VariableSuggestionItem::Environment { content, is_value, .. } => {
612                        if *is_value && content == &suggestion {
613                            skip_completion = true;
614                            break;
615                        }
616                    }
617                    // If already an existing value, boost its score and skip completion
618                    VariableSuggestionItem::Existing {
619                        value,
620                        score,
621                        completion_merged,
622                        ..
623                    } => {
624                        if value.value == suggestion {
625                            if !*completion_merged {
626                                *score += score_boost;
627                                *completion_merged = true;
628                            }
629                            skip_completion = true;
630                            break;
631                        }
632                    }
633                    // If already a completion, keep the maximum score and skip this one
634                    VariableSuggestionItem::Completion { value, score, .. } => {
635                        if value == &suggestion {
636                            *score = score.max(score_boost);
637                            skip_completion = true;
638                            break;
639                        }
640                    }
641                }
642            }
643            if skip_completion {
644                continue;
645            }
646
647            // Add the new suggestion
648            master_suggestions.push(VariableSuggestionItem::Completion {
649                sort_index: 3,
650                value: suggestion,
651                score: score_boost,
652            });
653        }
654
655        // Re-sort suggestions
656        master_suggestions.sort_by(|a, b| {
657            a.sort_index()
658                .cmp(&b.sort_index())
659                .then_with(|| b.score().partial_cmp(&a.score()).unwrap_or(Ordering::Equal))
660        });
661
662        // After sorting, filter suggestions based on the current query in the "new" item textarea
663        let query = self
664            .suggestions
665            .items()
666            .iter()
667            .find_map(|s| match s {
668                VariableSuggestionItem::New { textarea, .. } => Some(textarea.lines_as_string()),
669                _ => None,
670            })
671            .unwrap_or_default();
672        self.filter_suggestions(&query);
673    }
674}
675
676impl VariableReplacementComponent {
677    /// Immediately starts a debounced task to update the variable context
678    fn debounced_update_variable_context(&self) {
679        let this = self.clone();
680        tokio::spawn(async move {
681            if let Err(err) = this.update_variable_context(false).await {
682                tracing::error!("Error updating variable context: {err:?}");
683            }
684        });
685    }
686
687    /// Updates the variable context and the suggestions widget, or returns an acton
688    async fn update_variable_context(&self, peek: bool) -> Result<Action> {
689        // Cancels previous completion task and issue a new one
690        let cancellation_token = {
691            let mut token_guard = self.cancellation_token.lock().unwrap();
692            if let Some(token) = token_guard.take() {
693                token.cancel();
694            }
695            let new_token = CancellationToken::new();
696            *token_guard = Some(new_token.clone());
697            new_token
698        };
699
700        // Retrieves the current variable and its context
701        let (flat_root_cmd, previous_values, current_variable, context) = {
702            let state = self.state.read();
703            match state.template.current_variable().cloned() {
704                Some(variable) => (
705                    state.template.flat_root_cmd.clone(),
706                    state.template.previous_values_for(&variable.flat_name),
707                    variable,
708                    state.template.current_variable_context(),
709                ),
710                None => {
711                    if peek {
712                        tracing::info!("There are no variables to replace");
713                    } else {
714                        tracing::info!("There are no more variables");
715                    }
716                    return self.quit_action(peek, state.template.to_string());
717                }
718            }
719        };
720
721        // Search for the variable suggestions
722        let (initial_suggestions, completion_stream) = self
723            .service
724            .search_variable_suggestions(&flat_root_cmd, &current_variable, previous_values, context)
725            .await
726            .map_err(AppError::into_report)?;
727
728        // Update the context with initial suggestions
729        {
730            let mut state = self.state.write();
731            let suggestions = initial_suggestions
732                .into_iter()
733                .map(VariableSuggestionItem::from)
734                .collect::<Vec<_>>();
735            state.current_variable_ctx = (current_variable.flat_name.clone(), current_variable.secret);
736            state.variable_suggestions = suggestions.clone();
737            state.suggestions.update_items(suggestions, false);
738        }
739
740        // If there's a completion stream, process fast completions before showing the list
741        let remaining_stream = if let Some(mut stream) = completion_stream {
742            let sleep = tokio::time::sleep(INITIAL_COMPLETION_WAIT);
743            tokio::pin!(sleep);
744
745            let mut has_more_items = true;
746
747            loop {
748                tokio::select! {
749                    biased;
750                    _ = &mut sleep => {
751                        tracing::debug!(
752                            "There are pending completions after initial {}ms wait, spawning a background task",
753                            INITIAL_COMPLETION_WAIT.as_millis()
754                        );
755                        break;
756                    }
757                    item = stream.next() => {
758                        if let Some((score_boost, result)) = item {
759                            match result {
760                                // If an error happens while resolving the completion, display the first line
761                                Err(err) => {
762                                    if let Some(line) = err.lines().next() {
763                                        self.state.write().error.set_temp_message(line.to_string());
764                                    }
765                                }
766                                // Otherwise, merge suggestions
767                                Ok(completion_suggestions) => {
768                                    self.state.write().merge_completions(score_boost, completion_suggestions);
769                                }
770                            }
771                        } else {
772                            // Stream finished before timeout
773                            tracing::debug!(
774                                "All completions were resolved on the initial {}ms window",
775                                INITIAL_COMPLETION_WAIT.as_millis()
776                            );
777                            has_more_items = false;
778                            break;
779                        }
780                    }
781                }
782            }
783            if has_more_items { Some(stream) } else { None }
784        } else {
785            None
786        };
787
788        // Pre-select the first non-derived suggestion
789        {
790            let mut state = self.state.write();
791            if let Some(idx) = state.suggestions.items().iter().position(|s| {
792                !matches!(
793                    s,
794                    VariableSuggestionItem::New { .. } | VariableSuggestionItem::Derived { .. }
795                )
796            }) {
797                state.suggestions.select(idx);
798            }
799        }
800
801        // If there are still pending completions, spawn a background task for them
802        if let Some(mut stream) = remaining_stream {
803            let token = cancellation_token.clone();
804            let global_token = self.global_cancellation_token.clone();
805            let state_clone = self.state.clone();
806
807            // Show the loading spinner
808            self.state.write().loading = Some(LoadingSpinner::new(&self.theme));
809
810            // Spawn a background task to wait for them
811            tokio::spawn(async move {
812                while let Some((score_boost, result)) = tokio::select! {
813                    biased;
814                    _ = token.cancelled() => None,
815                    _ = global_token.cancelled() => None,
816                    item = stream.next() => item,
817                } {
818                    match result {
819                        // If an error happens while resolving the completion, display the first line
820                        Err(err) => {
821                            if let Some(line) = err.lines().next() {
822                                state_clone.write().error.set_temp_message(line.to_string());
823                            }
824                        }
825                        // Otherwise, merge suggestions
826                        Ok(completion_suggestions) => {
827                            state_clone
828                                .write()
829                                .merge_completions(score_boost, completion_suggestions);
830                        }
831                    }
832                }
833                state_clone.write().loading = None;
834            });
835        }
836
837        Ok(Action::NoOp)
838    }
839
840    #[instrument(skip_all)]
841    async fn confirm_new_secret_value(&mut self, value: String) -> Result<Action> {
842        tracing::debug!("Secret variable value selected");
843        self.state.write().template.set_next_variable(value);
844        self.update_variable_context(false).await
845    }
846
847    #[instrument(skip_all)]
848    async fn confirm_new_regular_value(&mut self, value: String) -> Result<Action> {
849        if !value.trim().is_empty() {
850            let variable_value = {
851                let state = self.state.read();
852                let (flat_variable_name, _) = &state.current_variable_ctx;
853                state.template.new_variable_value_for(flat_variable_name, &value)
854            };
855            match self.service.insert_variable_value(variable_value).await {
856                Ok(v) => {
857                    tracing::debug!("New variable value stored");
858                    self.confirm_existing_value(v, true).await
859                }
860                Err(AppError::UserFacing(err)) => {
861                    tracing::warn!("{err}");
862                    self.state.write().error.set_temp_message(err.to_string());
863                    Ok(Action::NoOp)
864                }
865                Err(AppError::Unexpected(report)) => Err(report),
866            }
867        } else {
868            tracing::debug!("New empty variable value selected");
869            self.state.write().template.set_next_variable(value);
870            self.update_variable_context(false).await
871        }
872    }
873
874    #[instrument(skip_all)]
875    async fn confirm_existing_edition(&mut self, mut value: VariableValue, new_value: String) -> Result<Action> {
876        value.value = new_value;
877        match self.service.update_variable_value(value).await {
878            Ok(v) => {
879                let mut state = self.state.write();
880                if let VariableSuggestionItem::Existing { value, .. } = state.suggestions.selected_mut().unwrap() {
881                    *value = v;
882                };
883                Ok(Action::NoOp)
884            }
885            Err(AppError::UserFacing(err)) => {
886                tracing::warn!("{err}");
887                self.state.write().error.set_temp_message(err.to_string());
888                Ok(Action::NoOp)
889            }
890            Err(AppError::Unexpected(report)) => Err(report),
891        }
892    }
893
894    #[instrument(skip_all)]
895    async fn confirm_existing_value(&mut self, mut value: VariableValue, new: bool) -> Result<Action> {
896        let value_id = match value.id {
897            Some(id) => id,
898            None => {
899                value = self
900                    .service
901                    .insert_variable_value(value)
902                    .await
903                    .map_err(AppError::into_report)?;
904                value.id.expect("just inserted")
905            }
906        };
907        let context = self.state.read().template.current_variable_context();
908        match self
909            .service
910            .increment_variable_value_usage(value_id, context)
911            .await
912            .map_err(AppError::into_report)
913        {
914            Ok(_) => {
915                if !new {
916                    tracing::debug!("Existing variable value selected");
917                }
918                self.state.write().template.set_next_variable(value.value);
919                self.update_variable_context(false).await
920            }
921            Err(report) => Err(report),
922        }
923    }
924
925    #[instrument(skip_all)]
926    async fn confirm_literal_value(&mut self, value: String, store: bool) -> Result<Action> {
927        if store && !value.trim().is_empty() {
928            let variable_value = {
929                let state = self.state.read();
930                let (flat_variable_name, _) = &state.current_variable_ctx;
931                state.template.new_variable_value_for(flat_variable_name, &value)
932            };
933            match self.service.insert_variable_value(variable_value).await {
934                Ok(v) => {
935                    tracing::debug!("Literal variable value selected and stored");
936                    self.confirm_existing_value(v, true).await
937                }
938                Err(AppError::UserFacing(err)) => {
939                    tracing::debug!("Literal variable value selected but couldn't be stored: {err}");
940                    self.state.write().template.set_next_variable(value);
941                    self.update_variable_context(false).await
942                }
943                Err(AppError::Unexpected(report)) => Err(report),
944            }
945        } else {
946            tracing::debug!("Literal variable value selected");
947            self.state.write().template.set_next_variable(value);
948            self.update_variable_context(false).await
949        }
950    }
951
952    /// Returns an action to quit the component, with the current variable content
953    fn quit_action(&self, peek: bool, cmd: String) -> Result<Action> {
954        if self.execute_mode {
955            Ok(Action::Quit(ProcessOutput::execute(cmd)))
956        } else if self.replace_process && peek {
957            Ok(Action::Quit(
958                ProcessOutput::success()
959                    .stderr(format_msg!(self.theme, "There are no variables to replace"))
960                    .stdout(&cmd)
961                    .fileout(cmd),
962            ))
963        } else {
964            Ok(Action::Quit(ProcessOutput::success().stdout(&cmd).fileout(cmd)))
965        }
966    }
967}
968
969/// Checks if `value` contains all space-separated words from `query` in the same order.
970///
971/// This is a case-sensitive search.
972///
973/// ### Arguments
974///
975/// * `value`: The string to search within.
976/// * `query`: A string of space-separated words to find in `value`.
977///
978/// ### Returns
979///
980/// `true` if all words in `query` are found in `value` in the correct order,
981/// `false` otherwise.
982fn value_matches_filter_query(value: &str, query: &str) -> bool {
983    // This offset tracks our position in the `value` string
984    let mut search_offset = 0;
985    query.split_whitespace().all(|word| {
986        // We search for the current `word` only in the slice of `value` that starts from our current `search_offset`
987        if let Some(relative_pos) = value[search_offset..].find(word) {
988            // If the word is found, we update the offset for the next search
989            search_offset += relative_pos + 1;
990            true
991        } else {
992            // If the word isn't found, return `false` immediately
993            false
994        }
995    })
996}