Skip to main content

intelli_shell/component/
completion_edit.rs

1use std::{mem, sync::Arc};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use enum_cycling::EnumCycle;
6use itertools::Itertools;
7use parking_lot::RwLock;
8use ratatui::{
9    Frame,
10    backend::FromCrossterm,
11    layout::{Alignment, Constraint, Layout, Rect},
12    style::Style,
13    widgets::{Block, Borders, Paragraph, Wrap},
14};
15use tokio_util::sync::CancellationToken;
16use tracing::instrument;
17
18use super::Component;
19use crate::{
20    app::Action,
21    config::Theme,
22    errors::AppError,
23    format_msg,
24    model::VariableCompletion,
25    process::ProcessOutput,
26    service::{FORBIDDEN_COMPLETION_ROOT_CMD_CHARS, FORBIDDEN_COMPLETION_VARIABLE_CHARS, IntelliShellService},
27    utils::resolve_completion,
28    widgets::{CustomTextArea, ErrorPopup, NewVersionBanner},
29};
30
31/// Defines the operational mode of the [`EditCompletionComponent`]
32#[derive(strum::EnumIs)]
33pub enum EditCompletionComponentMode {
34    /// The component is used to create a new completion
35    New { ai: bool },
36    /// The component is used to edit an existing completion
37    /// It holds the parent component to switch back to after editing is complete
38    Edit { parent: Box<dyn Component> },
39    /// The component is used to edit a completion in memory.
40    /// It holds the parent component to switch back to after editing is complete and a callback to run.
41    EditMemory {
42        parent: Box<dyn Component>,
43        callback: Arc<dyn Fn(VariableCompletion) -> Result<()> + Send + Sync>,
44    },
45    /// A special case to be used in mem::replace to extract the owned variables
46    Empty,
47}
48
49/// A component for creating or editing a [`VariableCompletion`]
50pub struct EditCompletionComponent {
51    /// The visual theme for styling the component
52    theme: Theme,
53    /// Whether the TUI is in inline mode or not
54    inline: bool,
55    /// Service for interacting with storage
56    service: IntelliShellService,
57    /// The layout for arranging the input fields
58    layout: Layout,
59    /// The operational mode
60    mode: EditCompletionComponentMode,
61    /// Global cancellation token
62    global_cancellation_token: CancellationToken,
63    /// The state of the component
64    state: Arc<RwLock<EditCompletionComponentState<'static>>>,
65}
66struct EditCompletionComponentState<'a> {
67    /// The completion being edited or created
68    completion: VariableCompletion,
69    /// The currently focused input field
70    active_field: ActiveField,
71    /// Text area for the completion root cmd
72    root_cmd: CustomTextArea<'a>,
73    /// Text area for the completion variable
74    variable: CustomTextArea<'a>,
75    /// Text area for the command to provide suggestions
76    suggestions_provider: CustomTextArea<'a>,
77    /// The output of the last execution
78    last_output: Option<Result<String, String>>,
79    /// A flag to track if the text content has been modified since the last test
80    is_dirty: bool,
81    /// Popup for displaying error messages
82    error: ErrorPopup<'a>,
83}
84
85/// Represents the currently active (focused) input field
86#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
87enum ActiveField {
88    RootCmd,
89    Variable,
90    SuggestionsCommand,
91}
92
93impl EditCompletionComponent {
94    /// Creates a new [`EditCompletionComponent`]
95    pub fn new(
96        service: IntelliShellService,
97        theme: Theme,
98        inline: bool,
99        completion: VariableCompletion,
100        mode: EditCompletionComponentMode,
101        cancellation_token: CancellationToken,
102    ) -> Self {
103        let mut root_cmd = CustomTextArea::new(Style::from_crossterm(theme.secondary), inline, false, "")
104            .title(if inline { "Command:" } else { " Command " })
105            .forbidden_chars_regex(FORBIDDEN_COMPLETION_ROOT_CMD_CHARS.clone())
106            .focused();
107        let mut variable = CustomTextArea::new(Style::from_crossterm(theme.primary), inline, false, "")
108            .title(if inline { "Variable:" } else { " Variable " })
109            .forbidden_chars_regex(FORBIDDEN_COMPLETION_VARIABLE_CHARS.clone())
110            .focused();
111        let mut suggestions_provider = CustomTextArea::new(Style::from_crossterm(theme.primary), inline, false, "")
112            .title(if inline {
113                "Suggestions Provider:"
114            } else {
115                " Suggestions Provider "
116            })
117            .focused();
118
119        root_cmd.insert_str(&completion.root_cmd);
120        variable.insert_str(&completion.variable);
121        suggestions_provider.insert_str(&completion.suggestions_provider);
122
123        let active_field = if completion.root_cmd.is_empty() && completion.variable.is_empty() {
124            root_cmd.set_focus(true);
125            variable.set_focus(false);
126            suggestions_provider.set_focus(false);
127            ActiveField::RootCmd
128        } else if completion.variable.is_empty() {
129            root_cmd.set_focus(false);
130            variable.set_focus(true);
131            suggestions_provider.set_focus(false);
132            ActiveField::Variable
133        } else {
134            root_cmd.set_focus(false);
135            variable.set_focus(false);
136            suggestions_provider.set_focus(true);
137            ActiveField::SuggestionsCommand
138        };
139
140        let error = ErrorPopup::empty(&theme);
141
142        let layout = if inline {
143            Layout::vertical([
144                Constraint::Length(1),
145                Constraint::Length(1),
146                Constraint::Length(1),
147                Constraint::Min(3),
148            ])
149        } else {
150            Layout::vertical([
151                Constraint::Length(3),
152                Constraint::Length(3),
153                Constraint::Length(3),
154                Constraint::Min(3),
155            ])
156            .margin(1)
157        };
158
159        Self {
160            theme,
161            inline,
162            service,
163            layout,
164            mode,
165            global_cancellation_token: cancellation_token,
166            state: Arc::new(RwLock::new(EditCompletionComponentState {
167                completion,
168                active_field,
169                root_cmd,
170                variable,
171                suggestions_provider,
172                last_output: None,
173                is_dirty: true,
174                error,
175            })),
176        }
177    }
178}
179impl<'a> EditCompletionComponentState<'a> {
180    /// Returns a mutable reference to the currently active input
181    fn active_input(&mut self) -> &mut CustomTextArea<'a> {
182        match self.active_field {
183            ActiveField::RootCmd => &mut self.root_cmd,
184            ActiveField::Variable => &mut self.variable,
185            ActiveField::SuggestionsCommand => &mut self.suggestions_provider,
186        }
187    }
188
189    /// Updates the focus state of the input fields based on `active_field`
190    fn update_focus(&mut self) {
191        self.root_cmd.set_focus(false);
192        self.variable.set_focus(false);
193        self.suggestions_provider.set_focus(false);
194
195        self.active_input().set_focus(true);
196    }
197
198    /// Marks the completion as dirty, that means the provider has to be tested before completion
199    fn mark_as_dirty(&mut self) {
200        self.is_dirty = true;
201        self.last_output = None;
202    }
203}
204
205#[async_trait]
206impl Component for EditCompletionComponent {
207    fn name(&self) -> &'static str {
208        "CompletionEditComponent"
209    }
210
211    fn min_inline_height(&self) -> u16 {
212        // Root Cmd + Variable + Suggestions Provider + Output
213        1 + 1 + 1 + 5
214    }
215
216    #[instrument(skip_all)]
217    async fn init_and_peek(&mut self) -> Result<Action> {
218        // If AI mode is enabled, prompt for command suggestions
219        if let EditCompletionComponentMode::New { ai } = &self.mode
220            && *ai
221        {
222            self.prompt_ai().await?;
223        }
224        Ok(Action::NoOp)
225    }
226
227    #[instrument(skip_all)]
228    fn render(&mut self, frame: &mut Frame, area: Rect) {
229        let mut state = self.state.write();
230
231        // Split the area according to the layout
232        let [root_cmd_area, variable_area, suggestions_provider_area, output_area] = self.layout.areas(area);
233
234        // Render widgets
235        frame.render_widget(&state.root_cmd, root_cmd_area);
236        frame.render_widget(&state.variable, variable_area);
237        frame.render_widget(&state.suggestions_provider, suggestions_provider_area);
238
239        // Render the output
240        if let Some(out) = &state.last_output {
241            let is_err = out.is_err();
242            let (output, style) = match out {
243                Ok(o) => (o, self.theme.secondary),
244                Err(err) => (err, self.theme.error),
245            };
246            let output_paragraph = Paragraph::new(output.as_str())
247                .style(Style::from_crossterm(style))
248                .block(
249                    Block::default()
250                        .borders(Borders::ALL)
251                        .title(" Preview ")
252                        .title_alignment(if self.inline { Alignment::Right } else { Alignment::Left })
253                        .style(Style::from_crossterm(style)),
254                )
255                .wrap(Wrap { trim: is_err });
256            frame.render_widget(output_paragraph, output_area);
257        }
258
259        // Render the new version banner and error message as an overlay
260        if let Some(new_version) = self.service.poll_new_version() {
261            NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
262        }
263        state.error.render_in(frame, area);
264    }
265
266    fn tick(&mut self) -> Result<Action> {
267        let mut state = self.state.write();
268        state.error.tick();
269        state.root_cmd.tick();
270        state.variable.tick();
271        state.suggestions_provider.tick();
272
273        Ok(Action::NoOp)
274    }
275
276    fn exit(&mut self) -> Result<Action> {
277        // Based on the component mode
278        match &self.mode {
279            // Quit the component without saving
280            EditCompletionComponentMode::New { .. } => Ok(Action::Quit(ProcessOutput::success())),
281            // Switch back to the parent, without storing the completion
282            EditCompletionComponentMode::Edit { .. } => Ok(Action::SwitchComponent(
283                match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
284                    EditCompletionComponentMode::Edit { parent } => parent,
285                    EditCompletionComponentMode::Empty
286                    | EditCompletionComponentMode::New { .. }
287                    | EditCompletionComponentMode::EditMemory { .. } => {
288                        unreachable!()
289                    }
290                },
291            )),
292            // Switch back to the parent, without calling the callback
293            EditCompletionComponentMode::EditMemory { .. } => Ok(Action::SwitchComponent(
294                match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
295                    EditCompletionComponentMode::EditMemory { parent, .. } => parent,
296                    EditCompletionComponentMode::Empty
297                    | EditCompletionComponentMode::New { .. }
298                    | EditCompletionComponentMode::Edit { .. } => {
299                        unreachable!()
300                    }
301                },
302            )),
303            // This should never happen, but just in case
304            EditCompletionComponentMode::Empty => Ok(Action::NoOp),
305        }
306    }
307
308    fn move_up(&mut self) -> Result<Action> {
309        let mut state = self.state.write();
310        if !state.active_input().is_ai_loading() {
311            state.active_field = state.active_field.up();
312            state.update_focus();
313        }
314
315        Ok(Action::NoOp)
316    }
317
318    fn move_down(&mut self) -> Result<Action> {
319        let mut state = self.state.write();
320        if !state.active_input().is_ai_loading() {
321            state.active_field = state.active_field.down();
322            state.update_focus();
323        }
324
325        Ok(Action::NoOp)
326    }
327
328    fn move_left(&mut self, word: bool) -> Result<Action> {
329        let mut state = self.state.write();
330        state.active_input().move_cursor_left(word);
331
332        Ok(Action::NoOp)
333    }
334
335    fn move_right(&mut self, word: bool) -> Result<Action> {
336        let mut state = self.state.write();
337        state.active_input().move_cursor_right(word);
338
339        Ok(Action::NoOp)
340    }
341
342    fn move_prev(&mut self) -> Result<Action> {
343        self.move_up()
344    }
345
346    fn move_next(&mut self) -> Result<Action> {
347        self.move_down()
348    }
349
350    fn move_home(&mut self, absolute: bool) -> Result<Action> {
351        let mut state = self.state.write();
352        state.active_input().move_home(absolute);
353
354        Ok(Action::NoOp)
355    }
356
357    fn move_end(&mut self, absolute: bool) -> Result<Action> {
358        let mut state = self.state.write();
359        state.active_input().move_end(absolute);
360
361        Ok(Action::NoOp)
362    }
363
364    fn undo(&mut self) -> Result<Action> {
365        let mut state = self.state.write();
366        state.active_input().undo();
367        state.mark_as_dirty();
368
369        Ok(Action::NoOp)
370    }
371
372    fn redo(&mut self) -> Result<Action> {
373        let mut state = self.state.write();
374        state.active_input().redo();
375        state.mark_as_dirty();
376
377        Ok(Action::NoOp)
378    }
379
380    fn insert_text(&mut self, text: String) -> Result<Action> {
381        let mut state = self.state.write();
382        state.active_input().insert_str(text);
383        state.mark_as_dirty();
384
385        Ok(Action::NoOp)
386    }
387
388    fn insert_char(&mut self, c: char) -> Result<Action> {
389        let mut state = self.state.write();
390        state.active_input().insert_char(c);
391        state.mark_as_dirty();
392
393        Ok(Action::NoOp)
394    }
395
396    fn insert_newline(&mut self) -> Result<Action> {
397        let mut state = self.state.write();
398        state.active_input().insert_newline();
399        state.mark_as_dirty();
400
401        Ok(Action::NoOp)
402    }
403
404    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
405        let mut state = self.state.write();
406        state.active_input().delete(backspace, word);
407        state.mark_as_dirty();
408
409        Ok(Action::NoOp)
410    }
411
412    #[instrument(skip_all)]
413    async fn selection_confirm(&mut self) -> Result<Action> {
414        let completion = {
415            let mut state = self.state.write();
416            if state.active_input().is_ai_loading() {
417                return Ok(Action::NoOp);
418            }
419
420            // Update the completion with the input data
421            state
422                .completion
423                .clone()
424                .with_root_cmd(state.root_cmd.lines_as_string())
425                .with_variable(state.variable.lines_as_string())
426                .with_suggestions_provider(state.suggestions_provider.lines_as_string())
427        };
428
429        if self.state.read().is_dirty {
430            self.test_provider_command(&completion).await?;
431            self.state.write().is_dirty = false;
432            return Ok(Action::NoOp);
433        }
434
435        // Based on the component mode
436        match &self.mode {
437            // Insert the new completion
438            EditCompletionComponentMode::New { .. } => {
439                match self.service.create_variable_completion(completion).await {
440                    Ok(c) if c.is_global() => Ok(Action::Quit(ProcessOutput::success().stderr(format_msg!(
441                        self.theme,
442                        "Completion for global {} variable stored: {}",
443                        self.theme.secondary.apply(&c.flat_variable),
444                        self.theme.secondary.apply(&c.suggestions_provider)
445                    )))),
446                    Ok(c) => Ok(Action::Quit(ProcessOutput::success().stderr(format_msg!(
447                        self.theme,
448                        "Completion for {} variable within {} commands stored: {}",
449                        self.theme.secondary.apply(&c.flat_variable),
450                        self.theme.secondary.apply(&c.flat_root_cmd),
451                        self.theme.secondary.apply(&c.suggestions_provider)
452                    )))),
453                    Err(AppError::UserFacing(err)) => {
454                        tracing::warn!("{err}");
455                        let mut state = self.state.write();
456                        state.error.set_temp_message(err.to_string());
457                        Ok(Action::NoOp)
458                    }
459                    Err(AppError::Unexpected(report)) => Err(report),
460                }
461            }
462            // Edit the existing completion
463            EditCompletionComponentMode::Edit { .. } => {
464                match self.service.update_variable_completion(completion).await {
465                    Ok(_) => {
466                        // Extract the owned parent component, leaving a placeholder on its place
467                        Ok(Action::SwitchComponent(
468                            match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
469                                EditCompletionComponentMode::Edit { parent } => parent,
470                                EditCompletionComponentMode::Empty
471                                | EditCompletionComponentMode::New { .. }
472                                | EditCompletionComponentMode::EditMemory { .. } => {
473                                    unreachable!()
474                                }
475                            },
476                        ))
477                    }
478                    Err(AppError::UserFacing(err)) => {
479                        tracing::warn!("{err}");
480                        let mut state = self.state.write();
481                        state.error.set_temp_message(err.to_string());
482                        Ok(Action::NoOp)
483                    }
484                    Err(AppError::Unexpected(report)) => Err(report),
485                }
486            }
487            // Edit the completion in memory and run the callback
488            EditCompletionComponentMode::EditMemory { callback, .. } => {
489                // Run the callback
490                callback(completion)?;
491
492                // Extract the owned parent component, leaving a placeholder on its place
493                Ok(Action::SwitchComponent(
494                    match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
495                        EditCompletionComponentMode::EditMemory { parent, .. } => parent,
496                        EditCompletionComponentMode::Empty
497                        | EditCompletionComponentMode::New { .. }
498                        | EditCompletionComponentMode::Edit { .. } => {
499                            unreachable!()
500                        }
501                    },
502                ))
503            }
504            // This should never happen, but just in case
505            EditCompletionComponentMode::Empty => Ok(Action::NoOp),
506        }
507    }
508
509    async fn selection_execute(&mut self) -> Result<Action> {
510        self.selection_confirm().await
511    }
512
513    async fn prompt_ai(&mut self) -> Result<Action> {
514        let mut state = self.state.write();
515        if state.active_field != ActiveField::SuggestionsCommand || state.active_input().is_ai_loading() {
516            return Ok(Action::NoOp);
517        }
518
519        let root_cmd = state.root_cmd.lines_as_string();
520        let variable = state.variable.lines_as_string();
521        let suggestions_provider = state.suggestions_provider.lines_as_string();
522
523        state.suggestions_provider.set_ai_loading(true);
524        let cloned_service = self.service.clone();
525        let cloned_state = self.state.clone();
526        let cloned_token = self.global_cancellation_token.clone();
527        tokio::spawn(async move {
528            let res = cloned_service
529                .suggest_completion(&root_cmd, &variable, &suggestions_provider, cloned_token)
530                .await;
531            let mut state = cloned_state.write();
532            state.suggestions_provider.set_ai_loading(false);
533            match res {
534                Ok(s) if s.is_empty() => {
535                    state.error.set_temp_message("AI generated an empty response");
536                }
537                Ok(suggestion) => {
538                    if !suggestions_provider.is_empty() {
539                        state.suggestions_provider.select_all();
540                        state.suggestions_provider.cut();
541                    }
542                    state.suggestions_provider.insert_str(&suggestion);
543                    state.mark_as_dirty();
544                }
545                Err(AppError::UserFacing(err)) => {
546                    tracing::warn!("{err}");
547                    state.error.set_temp_message(err.to_string());
548                }
549                Err(AppError::Unexpected(err)) => {
550                    panic!("Error prompting for completion suggestions: {err:?}")
551                }
552            }
553        });
554
555        Ok(Action::NoOp)
556    }
557}
558
559impl EditCompletionComponent {
560    /// Runs the provider command and updates the state with the output
561    async fn test_provider_command(&mut self, completion: &VariableCompletion) -> Result<bool> {
562        match resolve_completion(completion, None).await {
563            Ok(suggestions) if suggestions.is_empty() => {
564                let mut state = self.state.write();
565                state.last_output = Some(Ok("... empty output ...".to_string()));
566                Ok(true)
567            }
568            Ok(suggestions) => {
569                let mut state = self.state.write();
570                state.last_output = Some(Ok(suggestions.iter().join("\n")));
571                Ok(true)
572            }
573            Err(err) => {
574                let mut state = self.state.write();
575                state.last_output = Some(Err(err));
576                Ok(false)
577            }
578        }
579    }
580}