Skip to main content

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