1use std::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 tokio_util::sync::CancellationToken;
13use tracing::instrument;
14
15use super::Component;
16use crate::{
17 app::Action,
18 cli::{ExportItemsProcess, ImportItemsProcess},
19 component::{
20 completion_edit::{EditCompletionComponent, EditCompletionComponentMode},
21 edit::{EditCommandComponent, EditCommandComponentMode},
22 },
23 config::{DestructiveConfig, GistConfig, KeyBindingsConfig, Theme},
24 errors::{AppError, UserFacingError},
25 format_error,
26 model::{Command, ImportExportItem, VariableCompletion},
27 process::ProcessOutput,
28 service::IntelliShellService,
29 widgets::{CustomList, ErrorPopup, LoadingSpinner, NewVersionBanner, items::PlainStyleImportExportItem},
30};
31
32#[derive(Clone, strum::EnumIs)]
34pub enum ImportExportPickerComponentMode {
35 Import { input: ImportItemsProcess },
37 Export { input: ExportItemsProcess },
39}
40
41#[derive(Clone)]
43pub struct ImportExportPickerComponent {
44 theme: Theme,
46 destructive: DestructiveConfig,
48 gist: GistConfig,
50 service: IntelliShellService,
52 inline: bool,
54 layout: Layout,
56 mode: ImportExportPickerComponentMode,
58 initialized: bool,
60 global_cancellation_token: CancellationToken,
62 state: Arc<RwLock<ImportExportPickerComponentState<'static>>>,
64}
65struct ImportExportPickerComponentState<'a> {
66 items: CustomList<'a, PlainStyleImportExportItem>,
68 error: ErrorPopup<'a>,
70 loading_spinner: LoadingSpinner<'a>,
72 is_loading: bool,
74 loading_result: Option<Result<ProcessOutput, AppError>>,
76}
77
78impl ImportExportPickerComponent {
79 pub fn new(
81 service: IntelliShellService,
82 theme: Theme,
83 destructive: DestructiveConfig,
84 gist: GistConfig,
85 inline: bool,
86 mode: ImportExportPickerComponentMode,
87 cancellation_token: CancellationToken,
88 ) -> Self {
89 let title = match &mode {
90 ImportExportPickerComponentMode::Import { .. } => " Import (Space to discard, Enter to continue) ",
91 ImportExportPickerComponentMode::Export { .. } => " Export (Space to discard, Enter to continue) ",
92 };
93 let items = CustomList::new(theme.clone(), inline, Vec::new()).title(title);
94
95 let error = ErrorPopup::empty(&theme);
96 let loading_spinner = LoadingSpinner::new(&theme).with_message("Loading");
97
98 let layout = if inline {
99 Layout::vertical([Constraint::Min(1)])
100 } else {
101 Layout::vertical([Constraint::Min(3)]).margin(1)
102 };
103
104 Self {
105 theme,
106 destructive,
107 gist,
108 service,
109 inline,
110 layout,
111 mode,
112 initialized: false,
113 global_cancellation_token: cancellation_token,
114 state: Arc::new(RwLock::new(ImportExportPickerComponentState {
115 items,
116 error,
117 loading_spinner,
118 is_loading: false,
119 loading_result: None,
120 })),
121 }
122 }
123}
124
125#[async_trait]
126impl Component for ImportExportPickerComponent {
127 fn name(&self) -> &'static str {
128 "ImportExportPickerComponent"
129 }
130
131 fn min_inline_height(&self) -> u16 {
132 10
134 }
135
136 #[instrument(skip_all)]
137 async fn init_and_peek(&mut self) -> Result<Action> {
138 if self.initialized {
139 return Ok(Action::NoOp);
141 }
142
143 match &self.mode {
145 ImportExportPickerComponentMode::Import { input } => {
146 self.state.write().is_loading = true;
147
148 let this = self.clone();
150 let input = input.clone();
151 tokio::spawn(async move {
152 let items: Result<Vec<ImportExportItem>, AppError> = match this
154 .service
155 .get_items_from_location(input, this.gist.clone(), this.global_cancellation_token.clone())
156 .await
157 {
158 Ok(c) => c.try_collect().await,
159 Err(err) => Err(err),
160 };
161 match items {
162 Ok(items) => {
163 let mut state = this.state.write();
165 if items.is_empty() {
166 state.loading_result = Some(Ok(ProcessOutput::fail()
167 .stderr(format_error!(this.theme, "No commands or completions were found"))));
168 } else {
169 let plain_items = items
170 .into_iter()
171 .map(|item| {
172 let mut plain_item = PlainStyleImportExportItem::from(item);
173 if let PlainStyleImportExportItem::Command(ref mut cmd) = plain_item {
174 cmd.update_is_destructive(&this.destructive.patterns);
175 }
176 plain_item
177 })
178 .collect();
179 state.items.update_items(plain_items, false);
180 }
181 state.is_loading = false;
182 }
183 Err(err) => {
184 let mut state = this.state.write();
186 state.loading_result = Some(Err(err));
187 state.is_loading = false;
188 }
189 }
190 });
191 }
192 ImportExportPickerComponentMode::Export { input } => {
193 let res = match self.service.prepare_items_export(input.filter.clone()).await {
195 Ok(s) => s.try_collect().await,
196 Err(err) => Err(err),
197 };
198 let items: Vec<ImportExportItem> = match res {
199 Ok(c) => c,
200 Err(AppError::UserFacing(err)) => {
201 return Ok(Action::Quit(
202 ProcessOutput::fail().stderr(format_error!(self.theme, "{err}")),
203 ));
204 }
205 Err(AppError::Unexpected(report)) => return Err(report),
206 };
207
208 if items.is_empty() {
209 return Ok(Action::Quit(
210 ProcessOutput::fail().stderr(format_error!(self.theme, "No commands or completions to export")),
211 ));
212 } else {
213 let mut state = self.state.write();
214 let plain_items = items
215 .into_iter()
216 .map(|item| {
217 let mut plain_item = PlainStyleImportExportItem::from(item);
218 if let PlainStyleImportExportItem::Command(ref mut cmd) = plain_item {
219 cmd.update_is_destructive(&self.destructive.patterns);
220 }
221 plain_item
222 })
223 .collect();
224 state.items.update_items(plain_items, false);
225 }
226 }
227 }
228
229 self.initialized = true;
231 Ok(Action::NoOp)
232 }
233
234 #[instrument(skip_all)]
235 fn render(&mut self, frame: &mut Frame, area: Rect) {
236 let [main_area] = self.layout.areas(area);
238
239 let mut state = self.state.write();
240
241 if state.is_loading {
242 state.loading_spinner.render_in(frame, main_area);
244 } else {
245 frame.render_widget(&mut state.items, main_area);
247 }
248
249 if let Some(new_version) = self.service.poll_new_version() {
251 NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
252 }
253 state.error.render_in(frame, area);
254 }
255
256 fn tick(&mut self) -> Result<Action> {
257 let mut state = self.state.write();
258
259 if let Some(res) = state.loading_result.take() {
261 return match res {
262 Ok(output) => Ok(Action::Quit(output)),
263 Err(AppError::UserFacing(err)) => Ok(Action::Quit(
264 ProcessOutput::fail().stderr(format_error!(&self.theme, "{err}")),
265 )),
266 Err(AppError::Unexpected(err)) => Err(err),
267 };
268 }
269
270 state.error.tick();
271 state.loading_spinner.tick();
272 Ok(Action::NoOp)
273 }
274
275 fn exit(&mut self) -> Result<Action> {
276 Ok(Action::Quit(ProcessOutput::success()))
277 }
278
279 async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
280 if key.code == KeyCode::Char(' ') {
282 let mut state = self.state.write();
283 if key.modifiers == KeyModifiers::CONTROL {
284 state.items.toggle_discard_all();
285 } else {
286 state.items.toggle_discard_selected();
287 }
288 Ok(Action::NoOp)
289 } else {
290 Ok(self
292 .default_process_key_event(keybindings, key)
293 .await?
294 .unwrap_or_default())
295 }
296 }
297
298 fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
299 match mouse.kind {
300 MouseEventKind::ScrollDown => Ok(self.move_down()?),
301 MouseEventKind::ScrollUp => Ok(self.move_up()?),
302 _ => Ok(Action::NoOp),
303 }
304 }
305
306 fn move_up(&mut self) -> Result<Action> {
307 let mut state = self.state.write();
308 state.items.select_prev();
309 Ok(Action::NoOp)
310 }
311
312 fn move_down(&mut self) -> Result<Action> {
313 let mut state = self.state.write();
314 state.items.select_next();
315 Ok(Action::NoOp)
316 }
317
318 fn move_prev(&mut self) -> Result<Action> {
319 self.move_up()
320 }
321
322 fn move_next(&mut self) -> Result<Action> {
323 self.move_down()
324 }
325
326 fn move_home(&mut self, absolute: bool) -> Result<Action> {
327 let mut state = self.state.write();
328 if absolute {
329 state.items.select_first();
330 }
331 Ok(Action::NoOp)
332 }
333
334 fn move_end(&mut self, absolute: bool) -> Result<Action> {
335 let mut state = self.state.write();
336 if absolute {
337 state.items.select_last();
338 }
339 Ok(Action::NoOp)
340 }
341
342 async fn selection_delete(&mut self) -> Result<Action> {
343 let mut state = self.state.write();
344 state.items.delete_selected();
345 Ok(Action::NoOp)
346 }
347
348 #[instrument(skip_all)]
349 async fn selection_update(&mut self) -> Result<Action> {
350 if self.state.read().is_loading {
352 return Ok(Action::NoOp);
353 }
354
355 let selected_data = {
357 let state = self.state.read();
358 state
359 .items
360 .selected_with_index()
361 .map(|(index, item)| (index, ImportExportItem::from(item.clone())))
362 };
363
364 if let Some((index, item)) = selected_data {
365 let parent_component = Box::new(self.clone());
367
368 let this = self.clone();
369 match item {
370 ImportExportItem::Command(command) => {
371 let callback = Arc::new(move |updated_command: Command| -> Result<()> {
373 let mut state = this.state.write();
374
375 if let Some(widget_ref) = state.items.items_mut().get_mut(index) {
377 let mut plain_item = PlainStyleImportExportItem::Command(updated_command.into());
378 if let PlainStyleImportExportItem::Command(ref mut cmd) = plain_item {
379 cmd.update_is_destructive(&this.destructive.patterns);
380 }
381 *widget_ref = plain_item;
382 }
383
384 Ok(())
385 });
386
387 Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
389 self.service.clone(),
390 self.theme.clone(),
391 self.destructive.clone(),
392 self.inline,
393 command,
394 EditCommandComponentMode::EditMemory {
395 parent: parent_component,
396 callback,
397 },
398 self.global_cancellation_token.clone(),
399 ))))
400 }
401 ImportExportItem::Completion(completion) => {
402 let callback = Arc::new(move |updated_completion: VariableCompletion| -> Result<()> {
404 let mut state = this.state.write();
405
406 if let Some(widget_ref) = state.items.items_mut().get_mut(index) {
408 *widget_ref = PlainStyleImportExportItem::Completion(updated_completion.into());
409 }
410
411 Ok(())
412 });
413
414 Ok(Action::SwitchComponent(Box::new(EditCompletionComponent::new(
416 self.service.clone(),
417 self.theme.clone(),
418 self.inline,
419 completion,
420 EditCompletionComponentMode::EditMemory {
421 parent: parent_component,
422 callback,
423 },
424 self.global_cancellation_token.clone(),
425 ))))
426 }
427 }
428 } else {
429 Ok(Action::NoOp)
431 }
432 }
433
434 #[instrument(skip_all)]
435 async fn selection_confirm(&mut self) -> Result<Action> {
436 let non_discarded_items: Vec<ImportExportItem> = {
438 let state = self.state.read();
439 if state.is_loading {
441 return Ok(Action::NoOp);
442 }
443 state
444 .items
445 .non_discarded_items()
446 .cloned()
447 .map(ImportExportItem::from)
448 .collect()
449 };
450 match &self.mode {
451 ImportExportPickerComponentMode::Import { input } => {
452 let output = if input.dry_run {
453 let mut items = String::new();
455 for item in non_discarded_items {
456 items += &item.to_string();
457 items += "\n";
458 }
459 if items.is_empty() {
460 ProcessOutput::fail()
461 .stderr(format_error!(&self.theme, "No commands or completions were found"))
462 } else {
463 ProcessOutput::success().stdout(items)
464 }
465 } else {
466 match self
468 .service
469 .import_items(stream::iter(non_discarded_items.into_iter().map(Ok)).boxed(), false)
470 .await
471 {
472 Ok(stats) => stats.into_output(&self.theme),
473 Err(AppError::UserFacing(err)) => {
474 ProcessOutput::fail().stderr(format_error!(&self.theme, "{err}"))
475 }
476 Err(AppError::Unexpected(report)) => return Err(report),
477 }
478 };
479
480 Ok(Action::Quit(output))
481 }
482 ImportExportPickerComponentMode::Export { input } => {
483 match self
484 .service
485 .export_items(
486 stream::iter(non_discarded_items.into_iter().map(Ok)).boxed(),
487 input.clone(),
488 self.gist.clone(),
489 )
490 .await
491 {
492 Ok(stats) => Ok(Action::Quit(stats.into_output(&self.theme))),
493 Err(AppError::UserFacing(UserFacingError::FileBrokenPipe)) => {
494 Ok(Action::Quit(ProcessOutput::success()))
495 }
496 Err(AppError::UserFacing(err)) => Ok(Action::Quit(
497 ProcessOutput::fail().stderr(format_error!(&self.theme, "{err}")),
498 )),
499 Err(AppError::Unexpected(report)) => Err(report),
500 }
501 }
502 }
503 }
504
505 async fn selection_execute(&mut self) -> Result<Action> {
506 self.selection_confirm().await
507 }
508}