intelli_shell/component/
pick.rs

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