Skip to main content

intelli_shell/component/
variable.rs

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