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