Skip to main content

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