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