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#[derive(Clone)]
37pub struct CompletionListComponent {
38 theme: Theme,
40 inline: bool,
42 service: IntelliShellService,
44 layout: Layout,
46 global_cancellation_token: CancellationToken,
48 state: Arc<RwLock<CompletionListComponentState<'static>>>,
50}
51struct CompletionListComponentState<'a> {
52 initial_root_cmd: Option<String>,
54 active_list: ActiveList,
56 root_cmds: CustomList<'a, String>,
58 completions: CustomList<'a, VariableCompletion>,
60 preview: Option<Result<String, String>>,
62 error: ErrorPopup<'a>,
64}
65
66#[derive(Copy, Clone, PartialEq, Eq)]
68enum ActiveList {
69 RootCmds,
70 Completions,
71}
72
73impl CompletionListComponent {
74 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 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 let [root_cmds_area, completions_area, preview_area] = self.layout.areas(area);
140
141 let mut state = self.state.write();
142
143 frame.render_widget(&mut state.root_cmds, root_cmds_area);
145 frame.render_widget(&mut state.completions, completions_area);
146
147 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 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 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 #[instrument(skip_all)]
357 async fn refresh_lists(&self, init: bool) -> Result<Action> {
358 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 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 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}