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