Skip to main content

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