use std::sync::Arc;
use async_trait::async_trait;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use futures_util::{StreamExt, TryStreamExt, stream};
use parking_lot::RwLock;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
};
use tokio_util::sync::CancellationToken;
use tracing::instrument;
use super::Component;
use crate::{
app::Action,
cli::{ExportItemsProcess, ImportItemsProcess},
component::{
completion_edit::{EditCompletionComponent, EditCompletionComponentMode},
edit::{EditCommandComponent, EditCommandComponentMode},
},
config::{Config, KeyBindingsConfig},
errors::{AppError, UserFacingError},
format_error,
model::{Command, ImportExportItem, VariableCompletion},
process::ProcessOutput,
service::IntelliShellService,
widgets::{CustomList, ErrorPopup, LoadingSpinner, NewVersionBanner, items::PlainStyleImportExportItem},
};
#[derive(Clone, strum::EnumIs)]
pub enum ImportExportPickerComponentMode {
Import { input: ImportItemsProcess },
Export { input: ExportItemsProcess },
}
#[derive(Clone)]
pub struct ImportExportPickerComponent {
config: Config,
service: IntelliShellService,
inline: bool,
layout: Layout,
mode: ImportExportPickerComponentMode,
initialized: bool,
global_cancellation_token: CancellationToken,
state: Arc<RwLock<ImportExportPickerComponentState<'static>>>,
}
struct ImportExportPickerComponentState<'a> {
items: CustomList<'a, PlainStyleImportExportItem>,
error: ErrorPopup<'a>,
loading_spinner: LoadingSpinner<'a>,
is_loading: bool,
loading_result: Option<Result<ProcessOutput, AppError>>,
}
impl ImportExportPickerComponent {
pub fn new(
service: IntelliShellService,
config: Config,
inline: bool,
mode: ImportExportPickerComponentMode,
cancellation_token: CancellationToken,
) -> Self {
let title = match &mode {
ImportExportPickerComponentMode::Import { .. } => " Import (Space to discard, Enter to continue) ",
ImportExportPickerComponentMode::Export { .. } => " Export (Space to discard, Enter to continue) ",
};
let items = CustomList::new(config.theme.clone(), inline, Vec::new()).title(title);
let error = ErrorPopup::empty(&config.theme);
let loading_spinner = LoadingSpinner::new(&config.theme).with_message("Loading");
let layout = if inline {
Layout::vertical([Constraint::Min(1)])
} else {
Layout::vertical([Constraint::Min(3)]).margin(1)
};
Self {
config,
service,
inline,
layout,
mode,
initialized: false,
global_cancellation_token: cancellation_token,
state: Arc::new(RwLock::new(ImportExportPickerComponentState {
items,
error,
loading_spinner,
is_loading: false,
loading_result: None,
})),
}
}
}
#[async_trait]
impl Component for ImportExportPickerComponent {
fn name(&self) -> &'static str {
"ImportExportPickerComponent"
}
fn min_inline_height(&self) -> u16 {
10
}
#[instrument(skip_all)]
async fn init_and_peek(&mut self) -> Result<Action> {
if self.initialized {
return Ok(Action::NoOp);
}
match &self.mode {
ImportExportPickerComponentMode::Import { input } => {
self.state.write().is_loading = true;
let this = self.clone();
let input = input.clone();
tokio::spawn(async move {
let items: Result<Vec<ImportExportItem>, AppError> = match this
.service
.get_items_from_location(
input,
this.config.gist.clone(),
this.global_cancellation_token.clone(),
)
.await
{
Ok(c) => c.try_collect().await,
Err(err) => Err(err),
};
match items {
Ok(items) => {
let mut state = this.state.write();
if items.is_empty() {
state.loading_result = Some(Ok(ProcessOutput::fail().stderr(format_error!(
this.config.theme,
"No commands or completions were found"
))));
} else {
state.items.update_items(
items.into_iter().map(PlainStyleImportExportItem::from).collect(),
false,
);
}
state.is_loading = false;
}
Err(err) => {
let mut state = this.state.write();
state.loading_result = Some(Err(err));
state.is_loading = false;
}
}
});
}
ImportExportPickerComponentMode::Export { input } => {
let res = match self.service.prepare_items_export(input.filter.clone()).await {
Ok(s) => s.try_collect().await,
Err(err) => Err(err),
};
let items: Vec<ImportExportItem> = match res {
Ok(c) => c,
Err(AppError::UserFacing(err)) => {
return Ok(Action::Quit(
ProcessOutput::fail().stderr(format_error!(self.config.theme, "{err}")),
));
}
Err(AppError::Unexpected(report)) => return Err(report),
};
if items.is_empty() {
return Ok(Action::Quit(ProcessOutput::fail().stderr(format_error!(
self.config.theme,
"No commands or completions to export"
))));
} else {
let mut state = self.state.write();
state
.items
.update_items(items.into_iter().map(PlainStyleImportExportItem::from).collect(), false);
}
}
}
self.initialized = true;
Ok(Action::NoOp)
}
#[instrument(skip_all)]
fn render(&mut self, frame: &mut Frame, area: Rect) {
let [main_area] = self.layout.areas(area);
let mut state = self.state.write();
if state.is_loading {
state.loading_spinner.render_in(frame, main_area);
} else {
frame.render_widget(&mut state.items, main_area);
}
if let Some(new_version) = self.service.poll_new_version() {
NewVersionBanner::new(&self.config.theme, new_version).render_in(frame, area);
}
state.error.render_in(frame, area);
}
fn tick(&mut self) -> Result<Action> {
let mut state = self.state.write();
if let Some(res) = state.loading_result.take() {
return match res {
Ok(output) => Ok(Action::Quit(output)),
Err(AppError::UserFacing(err)) => Ok(Action::Quit(
ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
)),
Err(AppError::Unexpected(err)) => Err(err),
};
}
state.error.tick();
state.loading_spinner.tick();
Ok(Action::NoOp)
}
fn exit(&mut self) -> Result<Action> {
Ok(Action::Quit(ProcessOutput::success()))
}
async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
if key.code == KeyCode::Char(' ') {
let mut state = self.state.write();
if key.modifiers == KeyModifiers::CONTROL {
state.items.toggle_discard_all();
} else {
state.items.toggle_discard_selected();
}
Ok(Action::NoOp)
} else {
Ok(self
.default_process_key_event(keybindings, key)
.await?
.unwrap_or_default())
}
}
fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::ScrollDown => Ok(self.move_down()?),
MouseEventKind::ScrollUp => Ok(self.move_up()?),
_ => Ok(Action::NoOp),
}
}
fn move_up(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.items.select_prev();
Ok(Action::NoOp)
}
fn move_down(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.items.select_next();
Ok(Action::NoOp)
}
fn move_prev(&mut self) -> Result<Action> {
self.move_up()
}
fn move_next(&mut self) -> Result<Action> {
self.move_down()
}
fn move_home(&mut self, absolute: bool) -> Result<Action> {
let mut state = self.state.write();
if absolute {
state.items.select_first();
}
Ok(Action::NoOp)
}
fn move_end(&mut self, absolute: bool) -> Result<Action> {
let mut state = self.state.write();
if absolute {
state.items.select_last();
}
Ok(Action::NoOp)
}
async fn selection_delete(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.items.delete_selected();
Ok(Action::NoOp)
}
#[instrument(skip_all)]
async fn selection_update(&mut self) -> Result<Action> {
if self.state.read().is_loading {
return Ok(Action::NoOp);
}
let selected_data = {
let state = self.state.read();
state
.items
.selected_with_index()
.map(|(index, item)| (index, ImportExportItem::from(item.clone())))
};
if let Some((index, item)) = selected_data {
let parent_component = Box::new(self.clone());
let this = self.clone();
match item {
ImportExportItem::Command(command) => {
let callback = Arc::new(move |updated_command: Command| -> Result<()> {
let mut state = this.state.write();
if let Some(widget_ref) = state.items.items_mut().get_mut(index) {
*widget_ref = PlainStyleImportExportItem::Command(updated_command.into());
}
Ok(())
});
Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
self.service.clone(),
self.config.theme.clone(),
self.inline,
command,
EditCommandComponentMode::EditMemory {
parent: parent_component,
callback,
},
self.global_cancellation_token.clone(),
))))
}
ImportExportItem::Completion(completion) => {
let callback = Arc::new(move |updated_completion: VariableCompletion| -> Result<()> {
let mut state = this.state.write();
if let Some(widget_ref) = state.items.items_mut().get_mut(index) {
*widget_ref = PlainStyleImportExportItem::Completion(updated_completion.into());
}
Ok(())
});
Ok(Action::SwitchComponent(Box::new(EditCompletionComponent::new(
self.service.clone(),
self.config.theme.clone(),
self.inline,
completion,
EditCompletionComponentMode::EditMemory {
parent: parent_component,
callback,
},
self.global_cancellation_token.clone(),
))))
}
}
} else {
Ok(Action::NoOp)
}
}
#[instrument(skip_all)]
async fn selection_confirm(&mut self) -> Result<Action> {
let non_discarded_items: Vec<ImportExportItem> = {
let state = self.state.read();
if state.is_loading {
return Ok(Action::NoOp);
}
state
.items
.non_discarded_items()
.cloned()
.map(ImportExportItem::from)
.collect()
};
match &self.mode {
ImportExportPickerComponentMode::Import { input } => {
let output = if input.dry_run {
let mut items = String::new();
for item in non_discarded_items {
items += &item.to_string();
items += "\n";
}
if items.is_empty() {
ProcessOutput::fail().stderr(format_error!(
&self.config.theme,
"No commands or completions were found"
))
} else {
ProcessOutput::success().stdout(items)
}
} else {
match self
.service
.import_items(stream::iter(non_discarded_items.into_iter().map(Ok)).boxed(), false)
.await
{
Ok(stats) => stats.into_output(&self.config.theme),
Err(AppError::UserFacing(err)) => {
ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}"))
}
Err(AppError::Unexpected(report)) => return Err(report),
}
};
Ok(Action::Quit(output))
}
ImportExportPickerComponentMode::Export { input } => {
match self
.service
.export_items(
stream::iter(non_discarded_items.into_iter().map(Ok)).boxed(),
input.clone(),
self.config.gist.clone(),
)
.await
{
Ok(stats) => Ok(Action::Quit(stats.into_output(&self.config.theme))),
Err(AppError::UserFacing(UserFacingError::FileBrokenPipe)) => {
Ok(Action::Quit(ProcessOutput::success()))
}
Err(AppError::UserFacing(err)) => Ok(Action::Quit(
ProcessOutput::fail().stderr(format_error!(&self.config.theme, "{err}")),
)),
Err(AppError::Unexpected(report)) => Err(report),
}
}
}
}
async fn selection_execute(&mut self) -> Result<Action> {
self.selection_confirm().await
}
}