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