1use std::{collections::HashSet, sync::Arc};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
6use futures_util::{StreamExt, TryStreamExt, stream};
7use parking_lot::RwLock;
8use ratatui::{
9 Frame,
10 layout::{Constraint, Layout, Rect},
11};
12use tracing::instrument;
13
14use super::Component;
15use crate::{
16 app::Action,
17 cli::{ExportCommandsProcess, ImportCommandsProcess},
18 component::edit::{EditCommandComponent, EditCommandComponentMode},
19 config::{Config, KeyBindingsConfig},
20 errors::{AppError, UserFacingError},
21 format_error, format_msg,
22 model::Command,
23 process::ProcessOutput,
24 service::IntelliShellService,
25 widgets::{CommandWidget, CustomList, ErrorPopup, HighlightSymbolMode, LoadingSpinner, NewVersionBanner},
26};
27
28#[derive(Clone, strum::EnumIs)]
30pub enum CommandsPickerComponentMode {
31 Import { input: ImportCommandsProcess },
33 Export { input: ExportCommandsProcess },
35}
36
37#[derive(Clone)]
39pub struct CommandsPickerComponent {
40 config: Config,
42 service: IntelliShellService,
44 inline: bool,
46 layout: Layout,
48 mode: CommandsPickerComponentMode,
50 initialized: bool,
52 state: Arc<RwLock<CommandsPickerComponentState<'static>>>,
54}
55struct CommandsPickerComponentState<'a> {
56 commands: CustomList<'a, CommandWidget>,
58 error: ErrorPopup<'a>,
60 loading_spinner: LoadingSpinner<'a>,
62 discarded_indices: HashSet<usize>,
64 is_loading: bool,
66 loading_result: Option<Result<ProcessOutput, AppError>>,
68}
69
70impl CommandsPickerComponent {
71 pub fn new(service: IntelliShellService, config: Config, inline: bool, mode: CommandsPickerComponentMode) -> Self {
73 let commands = CustomList::new(config.theme.primary, inline, Vec::new())
74 .title(" Commands (Space to discard, Enter to continue) ")
75 .highlight_symbol(config.theme.highlight_symbol.clone())
76 .highlight_symbol_mode(HighlightSymbolMode::Last)
77 .highlight_symbol_style(config.theme.highlight_primary_full().into());
78
79 let error = ErrorPopup::empty(&config.theme);
80 let loading_spinner = LoadingSpinner::new(&config.theme).with_message("Loading");
81
82 let layout = if inline {
83 Layout::vertical([Constraint::Min(1)])
84 } else {
85 Layout::vertical([Constraint::Min(3)]).margin(1)
86 };
87
88 Self {
89 config,
90 service,
91 inline,
92 layout,
93 mode,
94 initialized: false,
95 state: Arc::new(RwLock::new(CommandsPickerComponentState {
96 commands,
97 error,
98 loading_spinner,
99 discarded_indices: HashSet::new(),
100 is_loading: false,
101 loading_result: None,
102 })),
103 }
104 }
105
106 fn toggle_discard(&mut self, toggle_all: bool) {
107 let mut state = self.state.write();
108 let items_len = state.commands.items().len();
109 if let Some(selected_index) = state.commands.selected_index() {
110 if state.discarded_indices.contains(&selected_index) {
112 if toggle_all {
114 state.discarded_indices.clear();
115 for widget in state.commands.items_mut() {
116 widget.set_discarded(false);
117 }
118 } else {
119 state.discarded_indices.remove(&selected_index);
120 if let Some(widget) = state.commands.selected_mut() {
121 widget.set_discarded(false);
122 }
123 }
124 } else {
125 if toggle_all {
127 state.discarded_indices.extend(0..items_len);
128 for widget in state.commands.items_mut() {
129 widget.set_discarded(true);
130 }
131 } else {
132 state.discarded_indices.insert(selected_index);
133 if let Some(widget) = state.commands.selected_mut() {
134 widget.set_discarded(true);
135 }
136 }
137 }
138 }
139 }
140}
141
142#[async_trait]
143impl Component for CommandsPickerComponent {
144 fn name(&self) -> &'static str {
145 "CommandsPickerComponent"
146 }
147
148 fn min_inline_height(&self) -> u16 {
149 10
151 }
152
153 #[instrument(skip_all)]
154 async fn init_and_peek(&mut self) -> Result<Action> {
155 if self.initialized {
156 return Ok(Action::NoOp);
158 }
159
160 match &self.mode {
162 CommandsPickerComponentMode::Import { input } => {
163 self.state.write().is_loading = true;
164
165 let this = self.clone();
167 let input = input.clone();
168 tokio::spawn(async move {
169 let commands: Result<Vec<Command>, AppError> = match this
171 .service
172 .get_commands_from_location(input, this.config.gist.clone())
173 .await
174 {
175 Ok(c) => c.try_collect().await,
176 Err(err) => Err(err),
177 };
178 match commands {
179 Ok(commands) => {
180 let mut state = this.state.write();
182 if commands.is_empty() {
183 state.loading_result = Some(Ok(ProcessOutput::fail()
184 .stderr(format_error!(this.config.theme, "No commands were found"))));
185 } else {
186 state.commands.update_items(
187 commands
188 .into_iter()
189 .map(|c| {
190 CommandWidget::new(&this.config.theme, this.inline, c).discarded(false)
191 })
192 .collect(),
193 );
194 }
195 state.is_loading = false;
196 }
197 Err(err) => {
198 let mut state = this.state.write();
200 state.loading_result = Some(Err(err));
201 state.is_loading = false;
202 }
203 }
204 });
205 }
206 CommandsPickerComponentMode::Export { input } => {
207 let res = match self.service.prepare_commands_export(input.filter.clone()).await {
209 Ok(s) => s.try_collect().await,
210 Err(err) => Err(err),
211 };
212 let commands: Vec<Command> = match res {
213 Ok(c) => c,
214 Err(AppError::UserFacing(err)) => {
215 return Ok(Action::Quit(
216 ProcessOutput::fail().stderr(format_error!(self.config.theme, "{err}")),
217 ));
218 }
219 Err(AppError::Unexpected(report)) => return Err(report),
220 };
221
222 if commands.is_empty() {
223 return Ok(Action::Quit(
224 ProcessOutput::fail().stderr(format_error!(self.config.theme, "No commands to export")),
225 ));
226 } else {
227 let mut state = self.state.write();
228 state.commands.update_items(
229 commands
230 .into_iter()
231 .map(|c| CommandWidget::new(&self.config.theme, self.inline, c).discarded(false))
232 .collect(),
233 );
234 }
235 }
236 }
237
238 self.initialized = true;
240 Ok(Action::NoOp)
241 }
242
243 #[instrument(skip_all)]
244 fn render(&mut self, frame: &mut Frame, area: Rect) {
245 let [main_area] = self.layout.areas(area);
247
248 let mut state = self.state.write();
249
250 if state.is_loading {
251 state.loading_spinner.render_in(frame, main_area);
253 } else {
254 frame.render_widget(&mut state.commands, main_area);
256 }
257
258 if let Some(new_version) = self.service.check_new_version() {
260 NewVersionBanner::new(&self.config.theme, new_version).render_in(frame, area);
261 }
262 state.error.render_in(frame, area);
263 }
264
265 fn tick(&mut self) -> Result<Action> {
266 let mut state = self.state.write();
267
268 if let Some(res) = state.loading_result.take() {
270 return match res {
271 Ok(output) => Ok(Action::Quit(output)),
272 Err(AppError::UserFacing(err)) => Ok(Action::Quit(
273 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
274 )),
275 Err(AppError::Unexpected(err)) => Err(err),
276 };
277 }
278
279 state.error.tick();
280 state.loading_spinner.tick();
281 Ok(Action::NoOp)
282 }
283
284 fn exit(&mut self) -> Result<Action> {
285 Ok(Action::Quit(ProcessOutput::success()))
286 }
287
288 async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
289 if key.code == KeyCode::Char(' ') {
291 self.toggle_discard(key.modifiers == KeyModifiers::CONTROL);
292 Ok(Action::NoOp)
293 } else {
294 Ok(self
296 .default_process_key_event(keybindings, key)
297 .await?
298 .unwrap_or_default())
299 }
300 }
301
302 fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
303 match mouse.kind {
304 MouseEventKind::ScrollDown => Ok(self.move_down()?),
305 MouseEventKind::ScrollUp => Ok(self.move_up()?),
306 _ => Ok(Action::NoOp),
307 }
308 }
309
310 fn move_up(&mut self) -> Result<Action> {
311 let mut state = self.state.write();
312 state.commands.select_prev();
313 Ok(Action::NoOp)
314 }
315
316 fn move_down(&mut self) -> Result<Action> {
317 let mut state = self.state.write();
318 state.commands.select_next();
319 Ok(Action::NoOp)
320 }
321
322 fn move_prev(&mut self) -> Result<Action> {
323 self.move_up()
324 }
325
326 fn move_next(&mut self) -> Result<Action> {
327 self.move_down()
328 }
329
330 fn move_home(&mut self, absolute: bool) -> Result<Action> {
331 let mut state = self.state.write();
332 if absolute {
333 state.commands.select_first();
334 }
335 Ok(Action::NoOp)
336 }
337
338 fn move_end(&mut self, absolute: bool) -> Result<Action> {
339 let mut state = self.state.write();
340 if absolute {
341 state.commands.select_last();
342 }
343 Ok(Action::NoOp)
344 }
345
346 async fn selection_delete(&mut self) -> Result<Action> {
347 self.toggle_discard(false);
348 Ok(Action::NoOp)
349 }
350
351 #[instrument(skip_all)]
352 async fn selection_update(&mut self) -> Result<Action> {
353 if self.state.read().is_loading {
355 return Ok(Action::NoOp);
356 }
357
358 let selected_data = {
360 let state = self.state.read();
361 state.commands.selected_with_index().map(|(index, widget)| {
362 let command: Command = widget.clone().into();
363 (index, command)
364 })
365 };
366
367 if let Some((index, command)) = selected_data {
368 let parent_component = Box::new(self.clone());
370
371 let this = self.clone();
373 let callback = Arc::new(move |updated_command: Command| -> Result<()> {
374 let mut state = this.state.write();
375
376 let is_discarded = state.discarded_indices.contains(&index);
378
379 if let Some(widget_ref) = state.commands.items_mut().get_mut(index) {
381 *widget_ref =
382 CommandWidget::new(&this.config.theme, this.inline, updated_command).discarded(is_discarded);
383 }
384
385 Ok(())
386 });
387
388 Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
390 self.service.clone(),
391 self.config.theme.clone(),
392 self.inline,
393 command,
394 EditCommandComponentMode::EditMemory {
395 parent: parent_component,
396 callback,
397 },
398 ))))
399 } else {
400 Ok(Action::NoOp)
402 }
403 }
404
405 #[instrument(skip_all)]
406 async fn selection_confirm(&mut self) -> Result<Action> {
407 let non_discarded_commands: Vec<Command> = {
409 let state = self.state.read();
410 if state.is_loading {
412 return Ok(Action::NoOp);
413 }
414 state
415 .commands
416 .items()
417 .iter()
418 .enumerate()
419 .filter_map(|(index, widget)| {
420 if state.discarded_indices.contains(&index) {
422 None
423 } else {
424 Some(widget.clone().into())
425 }
426 })
427 .collect()
428 };
429 match &self.mode {
430 CommandsPickerComponentMode::Import { input } => {
431 let output = if input.dry_run {
432 let mut commands = String::new();
434 for command in non_discarded_commands {
435 commands += &command.to_string();
436 commands += "\n";
437 }
438 if commands.is_empty() {
439 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands were found"))
440 } else {
441 ProcessOutput::success().stdout(commands)
442 }
443 } else {
444 match self
446 .service
447 .import_commands(stream::iter(non_discarded_commands.into_iter().map(Ok)).boxed(), false)
448 .await
449 {
450 Ok((0, 0)) => {
451 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands were found"))
452 }
453 Ok((0, skipped)) => ProcessOutput::success().stderr(format_msg!(
454 &self.config.theme,
455 "No commands imported, {skipped} already existed"
456 )),
457 Ok((imported, 0)) => ProcessOutput::success()
458 .stderr(format_msg!(&self.config.theme, "Imported {imported} new commands")),
459 Ok((imported, skipped)) => ProcessOutput::success().stderr(format_msg!(
460 &self.config.theme,
461 "Imported {imported} new commands {}",
462 &self
463 .config
464 .theme
465 .secondary
466 .apply(format!("({skipped} already existed)"))
467 )),
468 Err(AppError::UserFacing(err)) => {
469 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}"))
470 }
471 Err(AppError::Unexpected(report)) => return Err(report),
472 }
473 };
474
475 Ok(Action::Quit(output))
476 }
477 CommandsPickerComponentMode::Export { input } => {
478 match self
479 .service
480 .export_commands(
481 stream::iter(non_discarded_commands.into_iter().map(Ok)).boxed(),
482 input.clone(),
483 self.config.gist.clone(),
484 )
485 .await
486 {
487 Ok((0, _)) => Ok(Action::Quit(
488 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "No commands to export")),
489 )),
490 Ok((exported, None)) => Ok(Action::Quit(
491 ProcessOutput::success()
492 .stderr(format_msg!(&self.config.theme, "Exported {exported} commands")),
493 )),
494 Ok((exported, Some(stdout))) => {
495 Ok(Action::Quit(ProcessOutput::success().stdout(stdout).stderr(
496 format_msg!(&self.config.theme, "Exported {exported} commands"),
497 )))
498 }
499 Err(AppError::UserFacing(UserFacingError::FileBrokenPipe)) => {
500 Ok(Action::Quit(ProcessOutput::success()))
501 }
502 Err(AppError::UserFacing(err)) => Ok(Action::Quit(
503 ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
504 )),
505 Err(AppError::Unexpected(report)) => Err(report),
506 }
507 }
508 }
509 }
510
511 async fn selection_execute(&mut self) -> Result<Action> {
512 self.selection_confirm().await
513 }
514}