intelli_shell/component/
completion_list.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use crossterm::event::{MouseEvent, MouseEventKind};
6use itertools::Itertools;
7use parking_lot::RwLock;
8use ratatui::{
9    Frame,
10    layout::{Constraint, Layout, Rect},
11    widgets::{Block, Borders, Paragraph, Wrap},
12};
13use tracing::instrument;
14
15use super::{
16    Component,
17    completion_edit::{EditCompletionComponent, EditCompletionComponentMode},
18};
19use crate::{
20    app::Action,
21    config::{Config, Theme},
22    errors::AppError,
23    format_msg,
24    model::{SOURCE_WORKSPACE, VariableCompletion},
25    process::ProcessOutput,
26    service::IntelliShellService,
27    utils::resolve_completion,
28    widgets::{CustomList, ErrorPopup, NewVersionBanner},
29};
30
31const GLOBAL_ROOT_CMD: &str = "[GLOBAL]";
32const EMPTY_STORAGE_MESSAGE: &str = "There are no stored variable completions!";
33
34/// A component for listing and managing [`VariableCompletion`]
35#[derive(Clone)]
36pub struct CompletionListComponent {
37    /// The visual theme for styling the component
38    theme: Theme,
39    /// Whether the TUI is in inline mode or not
40    inline: bool,
41    /// Service for interacting with storage
42    service: IntelliShellService,
43    /// The component layout
44    layout: Layout,
45    /// The state of the component
46    state: Arc<RwLock<CompletionListComponentState<'static>>>,
47}
48struct CompletionListComponentState<'a> {
49    /// The root cmd to be initially selected
50    initial_root_cmd: Option<String>,
51    /// The currently focused list of the component
52    active_list: ActiveList,
53    /// The list of root commands
54    root_cmds: CustomList<'a, String>,
55    /// The list of completions for the selected root command
56    completions: CustomList<'a, VariableCompletion>,
57    /// The output of the completion
58    preview: Option<Result<String, String>>,
59    /// Popup for displaying error messages
60    error: ErrorPopup<'a>,
61}
62
63/// Represents the currently active (focused) list
64#[derive(Copy, Clone, PartialEq, Eq)]
65enum ActiveList {
66    RootCmds,
67    Completions,
68}
69
70impl CompletionListComponent {
71    /// Creates a new [`CompletionListComponent`]
72    pub fn new(service: IntelliShellService, config: Config, inline: bool, root_cmd: Option<String>) -> Self {
73        let root_cmds = CustomList::new(config.theme.clone(), inline, Vec::new()).title(" Commands ");
74        let completions = CustomList::new(config.theme.clone(), inline, Vec::new()).title(" Completions ");
75
76        let error = ErrorPopup::empty(&config.theme);
77
78        let layout = if inline {
79            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(3), Constraint::Fill(2)])
80        } else {
81            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(3), Constraint::Fill(2)]).margin(1)
82        };
83
84        let mut state = CompletionListComponentState {
85            initial_root_cmd: root_cmd,
86            active_list: ActiveList::RootCmds,
87            root_cmds,
88            completions,
89            preview: None,
90            error,
91        };
92        state.update_active_list(ActiveList::RootCmds);
93
94        Self {
95            theme: config.theme,
96            inline,
97            service,
98            layout,
99            state: Arc::new(RwLock::new(state)),
100        }
101    }
102}
103impl<'a> CompletionListComponentState<'a> {
104    /// Updates the active list and the focused list
105    fn update_active_list(&mut self, active: ActiveList) {
106        self.active_list = active;
107
108        self.root_cmds.set_focus(active == ActiveList::RootCmds);
109        self.completions.set_focus(active == ActiveList::Completions);
110    }
111}
112
113#[async_trait]
114impl Component for CompletionListComponent {
115    fn name(&self) -> &'static str {
116        "CompletionListComponent"
117    }
118
119    fn min_inline_height(&self) -> u16 {
120        5
121    }
122
123    async fn init_and_peek(&mut self) -> Result<Action> {
124        self.refresh_lists(true).await
125    }
126
127    fn render(&mut self, frame: &mut Frame, area: Rect) {
128        // Split the area according to the layout
129        let [root_cmds_area, completions_area, preview_area] = self.layout.areas(area);
130
131        let mut state = self.state.write();
132
133        // Render the lists
134        frame.render_widget(&mut state.root_cmds, root_cmds_area);
135        frame.render_widget(&mut state.completions, completions_area);
136
137        // Render the preview
138        if let Some(res) = &state.preview {
139            let is_err = res.is_err();
140            let (output, style) = match res {
141                Ok(o) => (o, self.theme.secondary),
142                Err(err) => (err, self.theme.error),
143            };
144            let mut preview_paragraph = Paragraph::new(output.as_str()).style(style).wrap(Wrap { trim: is_err });
145            if !self.inline {
146                preview_paragraph =
147                    preview_paragraph.block(Block::default().borders(Borders::ALL).title(" Preview ").style(style));
148            }
149            frame.render_widget(preview_paragraph, preview_area);
150        }
151
152        // Render the new version banner and error message as an overlay
153        if let Some(new_version) = self.service.poll_new_version() {
154            NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
155        }
156        state.error.render_in(frame, area);
157    }
158
159    fn tick(&mut self) -> Result<Action> {
160        let mut state = self.state.write();
161        state.error.tick();
162        Ok(Action::NoOp)
163    }
164
165    fn exit(&mut self) -> Result<Action> {
166        let mut state = self.state.write();
167        match &state.active_list {
168            ActiveList::RootCmds => Ok(Action::Quit(ProcessOutput::success())),
169            ActiveList::Completions => {
170                state.update_active_list(ActiveList::RootCmds);
171                Ok(Action::NoOp)
172            }
173        }
174    }
175
176    fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
177        match mouse.kind {
178            MouseEventKind::ScrollDown => Ok(self.move_down()?),
179            MouseEventKind::ScrollUp => Ok(self.move_up()?),
180            _ => Ok(Action::NoOp),
181        }
182    }
183
184    fn move_up(&mut self) -> Result<Action> {
185        let mut state = self.state.write();
186        match &state.active_list {
187            ActiveList::RootCmds => state.root_cmds.select_prev(),
188            ActiveList::Completions => state.completions.select_prev(),
189        }
190        self.debounced_refresh_lists();
191        Ok(Action::NoOp)
192    }
193
194    fn move_down(&mut self) -> Result<Action> {
195        let mut state = self.state.write();
196        match &state.active_list {
197            ActiveList::RootCmds => state.root_cmds.select_next(),
198            ActiveList::Completions => state.completions.select_next(),
199        }
200        self.debounced_refresh_lists();
201
202        Ok(Action::NoOp)
203    }
204
205    fn move_left(&mut self, _word: bool) -> Result<Action> {
206        let mut state = self.state.write();
207        match &state.active_list {
208            ActiveList::RootCmds => (),
209            ActiveList::Completions => state.update_active_list(ActiveList::RootCmds),
210        }
211        Ok(Action::NoOp)
212    }
213
214    fn move_right(&mut self, _word: bool) -> Result<Action> {
215        let mut state = self.state.write();
216        match &state.active_list {
217            ActiveList::RootCmds => state.update_active_list(ActiveList::Completions),
218            ActiveList::Completions => (),
219        }
220        Ok(Action::NoOp)
221    }
222
223    fn move_prev(&mut self) -> Result<Action> {
224        self.move_up()
225    }
226
227    fn move_next(&mut self) -> Result<Action> {
228        self.move_down()
229    }
230
231    fn move_home(&mut self, absolute: bool) -> Result<Action> {
232        if absolute {
233            let mut state = self.state.write();
234            match &state.active_list {
235                ActiveList::RootCmds => state.root_cmds.select_first(),
236                ActiveList::Completions => state.completions.select_first(),
237            }
238            self.debounced_refresh_lists();
239        }
240        Ok(Action::NoOp)
241    }
242
243    fn move_end(&mut self, absolute: bool) -> Result<Action> {
244        if absolute {
245            let mut state = self.state.write();
246            match &state.active_list {
247                ActiveList::RootCmds => state.root_cmds.select_last(),
248                ActiveList::Completions => state.completions.select_last(),
249            }
250            self.debounced_refresh_lists();
251        }
252        Ok(Action::NoOp)
253    }
254
255    #[instrument(skip_all)]
256    async fn selection_delete(&mut self) -> Result<Action> {
257        let data = {
258            let mut state = self.state.write();
259            if state.active_list == ActiveList::Completions
260                && let Some(selected) = state.completions.selected()
261            {
262                if selected.source != SOURCE_WORKSPACE {
263                    state
264                        .completions
265                        .delete_selected()
266                        .map(|(_, c)| (c, state.completions.is_empty()))
267                } else {
268                    state.error.set_temp_message("Workspace completions can't be deleted");
269                    return Ok(Action::NoOp);
270                }
271            } else {
272                None
273            }
274        };
275        if let Some((completion, is_now_empty)) = data {
276            self.service
277                .delete_variable_completion(completion.id)
278                .await
279                .map_err(AppError::into_report)?;
280            if is_now_empty {
281                self.state.write().update_active_list(ActiveList::RootCmds);
282            }
283            return self.refresh_lists(false).await;
284        }
285
286        Ok(Action::NoOp)
287    }
288
289    #[instrument(skip_all)]
290    async fn selection_update(&mut self) -> Result<Action> {
291        let completion = {
292            let state = self.state.read();
293            if state.active_list == ActiveList::Completions {
294                state.completions.selected().cloned()
295            } else {
296                None
297            }
298        };
299        if let Some(completion) = completion {
300            if completion.source != SOURCE_WORKSPACE {
301                tracing::info!("Entering completion update for: {completion}");
302                Ok(Action::SwitchComponent(Box::new(EditCompletionComponent::new(
303                    self.service.clone(),
304                    self.theme.clone(),
305                    self.inline,
306                    completion,
307                    EditCompletionComponentMode::Edit {
308                        parent: Box::new(self.clone()),
309                    },
310                ))))
311            } else {
312                self.state
313                    .write()
314                    .error
315                    .set_temp_message("Workspace completions can't be updated");
316                Ok(Action::NoOp)
317            }
318        } else {
319            Ok(Action::NoOp)
320        }
321    }
322
323    #[instrument(skip_all)]
324    async fn selection_confirm(&mut self) -> Result<Action> {
325        self.move_right(false)
326    }
327
328    async fn selection_execute(&mut self) -> Result<Action> {
329        self.selection_confirm().await
330    }
331}
332
333impl CompletionListComponent {
334    /// Immediately starts a debounced refresh of the lists
335    fn debounced_refresh_lists(&self) {
336        let this = self.clone();
337        tokio::spawn(async move {
338            if let Err(err) = this.refresh_lists(false).await {
339                panic!("Error refreshing lists: {err:?}");
340            }
341        });
342    }
343
344    /// Refresh the lists
345    #[instrument(skip_all)]
346    async fn refresh_lists(&self, init: bool) -> Result<Action> {
347        // Refresh root cmds
348        let root_cmds = self
349            .service
350            .list_variable_completion_root_cmds()
351            .await
352            .map_err(AppError::into_report)?
353            .into_iter()
354            .map(|r| {
355                if r.trim().is_empty() {
356                    GLOBAL_ROOT_CMD.to_string()
357                } else {
358                    r
359                }
360            })
361            .collect::<Vec<_>>();
362        if root_cmds.is_empty() && init {
363            return Ok(Action::Quit(
364                ProcessOutput::success().stderr(format_msg!(self.theme, "{EMPTY_STORAGE_MESSAGE}")),
365            ));
366        } else if root_cmds.is_empty() {
367            return Ok(Action::Quit(ProcessOutput::success()));
368        }
369        let root_cmd = {
370            let mut state = self.state.write();
371            state.root_cmds.update_items(root_cmds, true);
372            if init && let Some(root_cmd) = state.initial_root_cmd.take() {
373                let mut irc = root_cmd.as_str();
374                if irc.is_empty() {
375                    irc = GLOBAL_ROOT_CMD;
376                }
377                if state.root_cmds.select_matching(|rc| rc == irc) {
378                    state.update_active_list(ActiveList::Completions);
379                }
380            }
381            let Some(root_cmd) = state.root_cmds.selected().cloned() else {
382                return Ok(Action::Quit(ProcessOutput::success()));
383            };
384            root_cmd
385        };
386
387        // Refresh completions
388        let root_cmd_filter = if root_cmd.is_empty() || root_cmd == GLOBAL_ROOT_CMD {
389            Some("")
390        } else {
391            Some(root_cmd.as_str())
392        };
393        let completions = self
394            .service
395            .list_variable_completions(root_cmd_filter)
396            .await
397            .map_err(AppError::into_report)?;
398        let completion = {
399            let mut state = self.state.write();
400            state.completions.update_items(completions, true);
401            let Some(completion) = state.completions.selected().cloned() else {
402                return Ok(Action::NoOp);
403            };
404            completion
405        };
406
407        // Refresh suggestions preview
408        self.state.write().preview = match resolve_completion(&completion, None).await {
409            Ok(suggestions) if suggestions.is_empty() => {
410                let msg = "... empty output ...";
411                Some(Ok(msg.to_string()))
412            }
413            Ok(suggestions) => Some(Ok(suggestions.iter().join("\n"))),
414            Err(err) => Some(Err(err)),
415        };
416
417        Ok(Action::NoOp)
418    }
419}