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