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