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