Skip to main content

intelli_shell/component/
edit.rs

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