intelli_shell/component/
edit.rs

1use std::mem;
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use enum_cycling::EnumCycle;
6use ratatui::{
7    Frame,
8    layout::{Constraint, Layout, Rect},
9};
10use semver::Version;
11use tracing::instrument;
12
13use super::Component;
14use crate::{
15    app::Action,
16    config::Theme,
17    errors::{InsertError, UpdateError},
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,
30    /// The component is 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}
34
35/// A component for creating or editing a [`Command`]
36pub struct EditCommandComponent {
37    /// The visual theme for styling the component
38    theme: Theme,
39    /// The operational mode
40    mode: EditCommandComponentMode,
41    /// Service for interacting with command storage
42    service: IntelliShellService,
43    /// The command being edited or created
44    command: Command,
45    /// The layout for arranging the input fields
46    layout: Layout,
47    /// The currently focused input field
48    active_field: ActiveField,
49    /// Text area for the command's alias
50    alias: CustomTextArea<'static>,
51    /// Text area for the command itself
52    cmd: CustomTextArea<'static>,
53    /// Text area for the command's description
54    description: CustomTextArea<'static>,
55    /// The new version banner
56    new_version: NewVersionBanner,
57    /// Popup for displaying error messages
58    error: ErrorPopup<'static>,
59}
60
61/// Represents the currently active (focused) input field
62#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
63enum ActiveField {
64    Alias,
65    Command,
66    Description,
67}
68
69impl EditCommandComponent {
70    /// Creates a new [`EditCommandComponent`]
71    pub fn new(
72        service: IntelliShellService,
73        theme: Theme,
74        inline: bool,
75        new_version: Option<Version>,
76        command: Command,
77        mode: EditCommandComponentMode,
78    ) -> Self {
79        let alias = CustomTextArea::new(
80            theme.secondary,
81            inline,
82            false,
83            command.alias.clone().unwrap_or_default(),
84        )
85        .title(if inline { "Alias:" } else { " Alias " });
86        let mut cmd = CustomTextArea::new(
87            // Primary style
88            theme.primary,
89            inline,
90            false,
91            &command.cmd,
92        )
93        .title(if inline { "Command:" } else { " Command " });
94        let mut description = CustomTextArea::new(
95            theme.primary,
96            inline,
97            true,
98            command.description.clone().unwrap_or_default(),
99        )
100        .title(if inline { "Description:" } else { " Description " });
101
102        let active_field = if mode.is_new() && !command.cmd.is_empty() && command.description.is_none() {
103            description.set_focus(true);
104            ActiveField::Description
105        } else {
106            cmd.set_focus(true);
107            ActiveField::Command
108        };
109
110        let new_version = NewVersionBanner::new(&theme, new_version);
111        let error = ErrorPopup::empty(&theme);
112
113        let layout = if inline {
114            Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(3)])
115        } else {
116            Layout::vertical([Constraint::Length(3), Constraint::Length(3), Constraint::Min(5)]).margin(1)
117        };
118
119        Self {
120            theme,
121            service,
122            command,
123            mode,
124            layout,
125            active_field,
126            alias,
127            cmd,
128            description,
129            new_version,
130            error,
131        }
132    }
133
134    /// Returns a mutable reference to the currently active input
135    fn active_input(&mut self) -> &mut CustomTextArea<'static> {
136        match self.active_field {
137            ActiveField::Alias => &mut self.alias,
138            ActiveField::Command => &mut self.cmd,
139            ActiveField::Description => &mut self.description,
140        }
141    }
142
143    /// Updates the focus state of the input fields based on `active_field`
144    fn update_focus(&mut self) {
145        self.alias.set_focus(false);
146        self.cmd.set_focus(false);
147        self.description.set_focus(false);
148
149        self.active_input().set_focus(true);
150    }
151}
152
153#[async_trait]
154impl Component for EditCommandComponent {
155    fn name(&self) -> &'static str {
156        "EditCommandComponent"
157    }
158
159    fn min_inline_height(&self) -> u16 {
160        // Alias + Command + Description
161        1 + 1 + 3
162    }
163
164    #[instrument(skip_all)]
165    fn render(&mut self, frame: &mut Frame, area: Rect) {
166        // Split the area according to the layout
167        let [alias_area, cmd_area, description_area] = self.layout.areas(area);
168
169        // Render widgets
170        frame.render_widget(&self.alias, alias_area);
171        frame.render_widget(&self.cmd, cmd_area);
172        frame.render_widget(&self.description, description_area);
173
174        // Render the new version banner and error message as an overlay
175        self.new_version.render_in(frame, area);
176        self.error.render_in(frame, area);
177    }
178
179    fn tick(&mut self) -> Result<Action> {
180        self.error.tick();
181
182        Ok(Action::NoOp)
183    }
184
185    fn exit(&mut self) -> Result<Option<ProcessOutput>> {
186        Ok(Some(ProcessOutput::success().fileout(self.cmd.lines_as_string())))
187    }
188
189    fn move_up(&mut self) -> Result<Action> {
190        self.active_field = self.active_field.up();
191        self.update_focus();
192
193        Ok(Action::NoOp)
194    }
195
196    fn move_down(&mut self) -> Result<Action> {
197        self.active_field = self.active_field.down();
198        self.update_focus();
199
200        Ok(Action::NoOp)
201    }
202
203    fn move_left(&mut self, word: bool) -> Result<Action> {
204        self.active_input().move_cursor_left(word);
205
206        Ok(Action::NoOp)
207    }
208
209    fn move_right(&mut self, word: bool) -> Result<Action> {
210        self.active_input().move_cursor_right(word);
211
212        Ok(Action::NoOp)
213    }
214
215    fn move_prev(&mut self) -> Result<Action> {
216        self.move_up()
217    }
218
219    fn move_next(&mut self) -> Result<Action> {
220        self.move_down()
221    }
222
223    fn move_home(&mut self, absolute: bool) -> Result<Action> {
224        self.active_input().move_home(absolute);
225
226        Ok(Action::NoOp)
227    }
228
229    fn move_end(&mut self, absolute: bool) -> Result<Action> {
230        self.active_input().move_end(absolute);
231
232        Ok(Action::NoOp)
233    }
234
235    fn undo(&mut self) -> Result<Action> {
236        self.active_input().undo();
237
238        Ok(Action::NoOp)
239    }
240
241    fn redo(&mut self) -> Result<Action> {
242        self.active_input().redo();
243
244        Ok(Action::NoOp)
245    }
246
247    fn insert_text(&mut self, text: String) -> Result<Action> {
248        self.active_input().insert_str(text);
249
250        Ok(Action::NoOp)
251    }
252
253    fn insert_char(&mut self, c: char) -> Result<Action> {
254        self.active_input().insert_char(c);
255
256        Ok(Action::NoOp)
257    }
258
259    fn insert_newline(&mut self) -> Result<Action> {
260        self.active_input().insert_newline();
261
262        Ok(Action::NoOp)
263    }
264
265    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
266        self.active_input().delete(backspace, word);
267
268        Ok(Action::NoOp)
269    }
270
271    #[instrument(skip_all)]
272    async fn selection_confirm(&mut self) -> Result<Action> {
273        // Update the command with the input data
274        let command = self
275            .command
276            .clone()
277            .with_alias(Some(self.alias.lines_as_string()))
278            .with_cmd(self.cmd.lines_as_string())
279            .with_description(Some(self.description.lines_as_string()));
280
281        // Based on the component mode
282        match &self.mode {
283            // Insert the new command
284            EditCommandComponentMode::New => match self.service.insert_command(command).await {
285                Ok(command) => Ok(Action::Quit(
286                    ProcessOutput::success()
287                        .stderr(format_msg!(
288                            self.theme,
289                            "Command stored: {}",
290                            self.theme.secondary.apply(&command.cmd)
291                        ))
292                        .fileout(command.cmd),
293                )),
294                Err(InsertError::Invalid(err)) => {
295                    tracing::warn!("{err}");
296                    self.error.set_temp_message(err);
297                    Ok(Action::NoOp)
298                }
299                Err(InsertError::AlreadyExists) => {
300                    tracing::warn!("The command is already bookmarked");
301                    self.error.set_temp_message("The command is already bookmarked");
302                    Ok(Action::NoOp)
303                }
304                Err(InsertError::Unexpected(report)) => Err(report),
305            },
306            // Edit the existing command
307            EditCommandComponentMode::Edit { .. } => {
308                match self.service.update_command(command).await {
309                    Ok(_) => {
310                        // Extract the owned parent component, leaving a placeholder on its place
311                        Ok(Action::SwitchComponent(
312                            match mem::replace(&mut self.mode, EditCommandComponentMode::New) {
313                                EditCommandComponentMode::Edit { parent } => parent,
314                                EditCommandComponentMode::New => unreachable!(),
315                            },
316                        ))
317                    }
318                    Err(UpdateError::Invalid(err)) => {
319                        tracing::warn!("{err}");
320                        self.error.set_temp_message(err);
321                        Ok(Action::NoOp)
322                    }
323                    Err(UpdateError::AlreadyExists) => {
324                        tracing::warn!("The updated command already exists");
325                        self.error.set_temp_message("The updated command already exists");
326                        Ok(Action::NoOp)
327                    }
328                    Err(UpdateError::Unexpected(report)) => Err(report),
329                }
330            }
331        }
332    }
333
334    async fn selection_execute(&mut self) -> Result<Action> {
335        self.selection_confirm().await
336    }
337}