intelli_shell/component/
pick.rs

1use std::{collections::HashSet, sync::Arc};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
6use futures_util::{StreamExt, TryStreamExt, stream};
7use parking_lot::RwLock;
8use ratatui::{
9    Frame,
10    layout::{Constraint, Layout, Rect},
11};
12use tracing::instrument;
13
14use super::Component;
15use crate::{
16    app::Action,
17    cli::{ExportCommandsProcess, ImportCommandsProcess},
18    component::edit::{EditCommandComponent, EditCommandComponentMode},
19    config::{Config, KeyBindingsConfig},
20    errors::{AppError, UserFacingError},
21    format_error, format_msg,
22    model::Command,
23    process::ProcessOutput,
24    service::IntelliShellService,
25    widgets::{CommandWidget, CustomList, ErrorPopup, HighlightSymbolMode, LoadingSpinner, NewVersionBanner},
26};
27
28/// Defines the operational mode of the [`CommandsPickerComponent`]
29#[derive(Clone, strum::EnumIs)]
30pub enum CommandsPickerComponentMode {
31    /// The component is used to import picked commands
32    Import { input: ImportCommandsProcess },
33    /// The component is used to export picked commands
34    Export { input: ExportCommandsProcess },
35}
36
37/// A component for interactive picking [`Command`]
38#[derive(Clone)]
39pub struct CommandsPickerComponent {
40    /// The app config
41    config: Config,
42    /// Service for interacting with command storage
43    service: IntelliShellService,
44    /// Whether the component is displayed inline
45    inline: bool,
46    /// The component layout
47    layout: Layout,
48    /// The operational mode
49    mode: CommandsPickerComponentMode,
50    /// Whether the component has been initialized
51    initialized: bool,
52    /// The state of the component
53    state: Arc<RwLock<CommandsPickerComponentState<'static>>>,
54}
55struct CommandsPickerComponentState<'a> {
56    /// The list of commands to be imported
57    commands: CustomList<'a, CommandWidget>,
58    /// Popup for displaying error messages
59    error: ErrorPopup<'a>,
60    /// Widget for displaying a loading spinner
61    loading_spinner: LoadingSpinner<'a>,
62    /// The indices of the commands discarded
63    discarded_indices: HashSet<usize>,
64    /// Whether the component is currently fetching commands
65    is_loading: bool,
66    /// The result of the loading process, if any
67    loading_result: Option<Result<ProcessOutput, AppError>>,
68}
69
70impl CommandsPickerComponent {
71    /// Creates a new [`CommandsPickerComponent`]
72    pub fn new(service: IntelliShellService, config: Config, inline: bool, mode: CommandsPickerComponentMode) -> Self {
73        let commands = CustomList::new(config.theme.primary, inline, Vec::new())
74            .title(" Commands (Space to discard, Enter to continue) ")
75            .highlight_symbol(config.theme.highlight_symbol.clone())
76            .highlight_symbol_mode(HighlightSymbolMode::Last)
77            .highlight_symbol_style(config.theme.highlight_primary_full().into());
78
79        let error = ErrorPopup::empty(&config.theme);
80        let loading_spinner = LoadingSpinner::new(&config.theme).with_message("Loading");
81
82        let layout = if inline {
83            Layout::vertical([Constraint::Min(1)])
84        } else {
85            Layout::vertical([Constraint::Min(3)]).margin(1)
86        };
87
88        Self {
89            config,
90            service,
91            inline,
92            layout,
93            mode,
94            initialized: false,
95            state: Arc::new(RwLock::new(CommandsPickerComponentState {
96                commands,
97                error,
98                loading_spinner,
99                discarded_indices: HashSet::new(),
100                is_loading: false,
101                loading_result: None,
102            })),
103        }
104    }
105
106    fn toggle_discard(&mut self, toggle_all: bool) {
107        let mut state = self.state.write();
108        let items_len = state.commands.items().len();
109        if let Some(selected_index) = state.commands.selected_index() {
110            // Check if the command is already in the discarded set
111            if state.discarded_indices.contains(&selected_index) {
112                // If so, "un-discard"
113                if toggle_all {
114                    state.discarded_indices.clear();
115                    for widget in state.commands.items_mut() {
116                        widget.set_discarded(false);
117                    }
118                } else {
119                    state.discarded_indices.remove(&selected_index);
120                    if let Some(widget) = state.commands.selected_mut() {
121                        widget.set_discarded(false);
122                    }
123                }
124            } else {
125                // Otherwise, add to the "discard" set
126                if toggle_all {
127                    state.discarded_indices.extend(0..items_len);
128                    for widget in state.commands.items_mut() {
129                        widget.set_discarded(true);
130                    }
131                } else {
132                    state.discarded_indices.insert(selected_index);
133                    if let Some(widget) = state.commands.selected_mut() {
134                        widget.set_discarded(true);
135                    }
136                }
137            }
138        }
139    }
140}
141
142#[async_trait]
143impl Component for CommandsPickerComponent {
144    fn name(&self) -> &'static str {
145        "CommandsPickerComponent"
146    }
147
148    fn min_inline_height(&self) -> u16 {
149        // 10 Commands
150        10
151    }
152
153    #[instrument(skip_all)]
154    async fn init_and_peek(&mut self) -> Result<Action> {
155        if self.initialized {
156            // If already initialized, just return no action
157            return Ok(Action::NoOp);
158        }
159
160        // Initialize the component state based on the mode
161        match &self.mode {
162            CommandsPickerComponentMode::Import { input } => {
163                self.state.write().is_loading = true;
164
165                // Spawn a background task to fetch commands, which can be slow on ai mode
166                let this = self.clone();
167                let input = input.clone();
168                tokio::spawn(async move {
169                    // Fetch commands from the given import location
170                    let commands: Result<Vec<Command>, AppError> = match this
171                        .service
172                        .get_commands_from_location(input, this.config.gist.clone())
173                        .await
174                    {
175                        Ok(c) => c.try_collect().await,
176                        Err(err) => Err(err),
177                    };
178                    match commands {
179                        Ok(commands) => {
180                            // If commands were fetched successfully, update the state
181                            let mut state = this.state.write();
182                            if commands.is_empty() {
183                                state.loading_result = Some(Ok(ProcessOutput::fail()
184                                    .stderr(format_error!(this.config.theme, "No commands were found"))));
185                            } else {
186                                state.commands.update_items(
187                                    commands
188                                        .into_iter()
189                                        .map(|c| {
190                                            CommandWidget::new(&this.config.theme, this.inline, c).discarded(false)
191                                        })
192                                        .collect(),
193                                );
194                            }
195                            state.is_loading = false;
196                        }
197                        Err(err) => {
198                            // If an error occurred, set the error message and stop loading
199                            let mut state = this.state.write();
200                            state.loading_result = Some(Err(err));
201                            state.is_loading = false;
202                        }
203                    }
204                });
205            }
206            CommandsPickerComponentMode::Export { input } => {
207                // Prepare commands for export
208                let res = match self.service.prepare_commands_export(input.filter.clone()).await {
209                    Ok(s) => s.try_collect().await,
210                    Err(err) => Err(err),
211                };
212                let commands: Vec<Command> = match res {
213                    Ok(c) => c,
214                    Err(AppError::UserFacing(err)) => {
215                        return Ok(Action::Quit(
216                            ProcessOutput::fail().stderr(format_error!(self.config.theme, "{err}")),
217                        ));
218                    }
219                    Err(AppError::Unexpected(report)) => return Err(report),
220                };
221
222                if commands.is_empty() {
223                    return Ok(Action::Quit(
224                        ProcessOutput::fail().stderr(format_error!(self.config.theme, "No commands to export")),
225                    ));
226                } else {
227                    let mut state = self.state.write();
228                    state.commands.update_items(
229                        commands
230                            .into_iter()
231                            .map(|c| CommandWidget::new(&self.config.theme, self.inline, c).discarded(false))
232                            .collect(),
233                    );
234                }
235            }
236        }
237
238        // Mark the component as initialized, as it doesn't fetch commands again on component switch
239        self.initialized = true;
240        Ok(Action::NoOp)
241    }
242
243    #[instrument(skip_all)]
244    fn render(&mut self, frame: &mut Frame, area: Rect) {
245        // Split the area according to the layout
246        let [main_area] = self.layout.areas(area);
247
248        let mut state = self.state.write();
249
250        if state.is_loading {
251            // Render the loading spinner widget
252            state.loading_spinner.render_in(frame, main_area);
253        } else {
254            // Render the commands list when not loading
255            frame.render_widget(&mut state.commands, main_area);
256        }
257
258        // Render the new version banner and error message as an overlay
259        if let Some(new_version) = self.service.check_new_version() {
260            NewVersionBanner::new(&self.config.theme, new_version).render_in(frame, area);
261        }
262        state.error.render_in(frame, area);
263    }
264
265    fn tick(&mut self) -> Result<Action> {
266        let mut state = self.state.write();
267
268        // If there is a loading result, handle it
269        if let Some(res) = state.loading_result.take() {
270            return match res {
271                Ok(output) => Ok(Action::Quit(output)),
272                Err(AppError::UserFacing(err)) => Ok(Action::Quit(
273                    ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
274                )),
275                Err(AppError::Unexpected(err)) => Err(err),
276            };
277        }
278
279        state.error.tick();
280        state.loading_spinner.tick();
281        Ok(Action::NoOp)
282    }
283
284    fn exit(&mut self) -> Result<Action> {
285        Ok(Action::Quit(ProcessOutput::success()))
286    }
287
288    async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
289        // If space was hit, toggle discard status
290        if key.code == KeyCode::Char(' ') {
291            self.toggle_discard(key.modifiers == KeyModifiers::CONTROL);
292            Ok(Action::NoOp)
293        } else {
294            // Otherwise, process default actions
295            Ok(self
296                .default_process_key_event(keybindings, key)
297                .await?
298                .unwrap_or_default())
299        }
300    }
301
302    fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
303        match mouse.kind {
304            MouseEventKind::ScrollDown => Ok(self.move_down()?),
305            MouseEventKind::ScrollUp => Ok(self.move_up()?),
306            _ => Ok(Action::NoOp),
307        }
308    }
309
310    fn move_up(&mut self) -> Result<Action> {
311        let mut state = self.state.write();
312        state.commands.select_prev();
313        Ok(Action::NoOp)
314    }
315
316    fn move_down(&mut self) -> Result<Action> {
317        let mut state = self.state.write();
318        state.commands.select_next();
319        Ok(Action::NoOp)
320    }
321
322    fn move_prev(&mut self) -> Result<Action> {
323        self.move_up()
324    }
325
326    fn move_next(&mut self) -> Result<Action> {
327        self.move_down()
328    }
329
330    fn move_home(&mut self, absolute: bool) -> Result<Action> {
331        let mut state = self.state.write();
332        if absolute {
333            state.commands.select_first();
334        }
335        Ok(Action::NoOp)
336    }
337
338    fn move_end(&mut self, absolute: bool) -> Result<Action> {
339        let mut state = self.state.write();
340        if absolute {
341            state.commands.select_last();
342        }
343        Ok(Action::NoOp)
344    }
345
346    async fn selection_delete(&mut self) -> Result<Action> {
347        self.toggle_discard(false);
348        Ok(Action::NoOp)
349    }
350
351    #[instrument(skip_all)]
352    async fn selection_update(&mut self) -> Result<Action> {
353        // Do nothing if the component is in a loading state
354        if self.state.read().is_loading {
355            return Ok(Action::NoOp);
356        }
357
358        // Get the selected command and its index, if any
359        let selected_data = {
360            let state = self.state.read();
361            state.commands.selected_with_index().map(|(index, widget)| {
362                let command: Command = widget.clone().into();
363                (index, command)
364            })
365        };
366
367        if let Some((index, command)) = selected_data {
368            // Clone the current component to serve as the parent to return to after editing is done
369            let parent_component = Box::new(self.clone());
370
371            // Prepare the callback to be run after the command is updated
372            let this = self.clone();
373            let callback = Arc::new(move |updated_command: Command| -> Result<()> {
374                let mut state = this.state.write();
375
376                // Preserve the 'discarded' status of the command across edits
377                let is_discarded = state.discarded_indices.contains(&index);
378
379                // Replace the old widget with the new one at the same position in the list
380                if let Some(widget_ref) = state.commands.items_mut().get_mut(index) {
381                    *widget_ref =
382                        CommandWidget::new(&this.config.theme, this.inline, updated_command).discarded(is_discarded);
383                }
384
385                Ok(())
386            });
387
388            // Switch to the editor component
389            Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
390                self.service.clone(),
391                self.config.theme.clone(),
392                self.inline,
393                command,
394                EditCommandComponentMode::EditMemory {
395                    parent: parent_component,
396                    callback,
397                },
398            ))))
399        } else {
400            // No item was selected, do nothing
401            Ok(Action::NoOp)
402        }
403    }
404
405    #[instrument(skip_all)]
406    async fn selection_confirm(&mut self) -> Result<Action> {
407        // Collect all commands that were NOT discarded by the user
408        let non_discarded_commands: Vec<Command> = {
409            let state = self.state.read();
410            // Do nothing if the component is in a loading state
411            if state.is_loading {
412                return Ok(Action::NoOp);
413            }
414            state
415                .commands
416                .items()
417                .iter()
418                .enumerate()
419                .filter_map(|(index, widget)| {
420                    // Skip discarded commands
421                    if state.discarded_indices.contains(&index) {
422                        None
423                    } else {
424                        Some(widget.clone().into())
425                    }
426                })
427                .collect()
428        };
429        match &self.mode {
430            CommandsPickerComponentMode::Import { input } => {
431                let output = if input.dry_run {
432                    // If dry run, just print the commands to the console
433                    let mut commands = String::new();
434                    for command in non_discarded_commands {
435                        commands += &command.to_string();
436                        commands += "\n";
437                    }
438                    if commands.is_empty() {
439                        ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands were found"))
440                    } else {
441                        ProcessOutput::success().stdout(commands)
442                    }
443                } else {
444                    // If not dry run, import the commands
445                    match self
446                        .service
447                        .import_commands(stream::iter(non_discarded_commands.into_iter().map(Ok)).boxed(), false)
448                        .await
449                    {
450                        Ok((0, 0)) => {
451                            ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands were found"))
452                        }
453                        Ok((0, skipped)) => ProcessOutput::success().stderr(format_msg!(
454                            &self.config.theme,
455                            "No commands imported, {skipped} already existed"
456                        )),
457                        Ok((imported, 0)) => ProcessOutput::success()
458                            .stderr(format_msg!(&self.config.theme, "Imported {imported} new commands")),
459                        Ok((imported, skipped)) => ProcessOutput::success().stderr(format_msg!(
460                            &self.config.theme,
461                            "Imported {imported} new commands {}",
462                            &self
463                                .config
464                                .theme
465                                .secondary
466                                .apply(format!("({skipped} already existed)"))
467                        )),
468                        Err(AppError::UserFacing(err)) => {
469                            ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}"))
470                        }
471                        Err(AppError::Unexpected(report)) => return Err(report),
472                    }
473                };
474
475                Ok(Action::Quit(output))
476            }
477            CommandsPickerComponentMode::Export { input } => {
478                match self
479                    .service
480                    .export_commands(
481                        stream::iter(non_discarded_commands.into_iter().map(Ok)).boxed(),
482                        input.clone(),
483                        self.config.gist.clone(),
484                    )
485                    .await
486                {
487                    Ok((0, _)) => Ok(Action::Quit(
488                        ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands to export")),
489                    )),
490                    Ok((exported, None)) => Ok(Action::Quit(
491                        ProcessOutput::success()
492                            .stderr(format_msg!(&self.config.theme, "Exported {exported} commands")),
493                    )),
494                    Ok((exported, Some(stdout))) => {
495                        Ok(Action::Quit(ProcessOutput::success().stdout(stdout).stderr(
496                            format_msg!(&self.config.theme, "Exported {exported} commands"),
497                        )))
498                    }
499                    Err(AppError::UserFacing(UserFacingError::FileBrokenPipe)) => {
500                        Ok(Action::Quit(ProcessOutput::success()))
501                    }
502                    Err(AppError::UserFacing(err)) => Ok(Action::Quit(
503                        ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
504                    )),
505                    Err(AppError::Unexpected(report)) => Err(report),
506                }
507            }
508        }
509    }
510
511    async fn selection_execute(&mut self) -> Result<Action> {
512        self.selection_confirm().await
513    }
514}