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