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