use std::{mem, sync::Arc};
use async_trait::async_trait;
use color_eyre::Result;
use enum_cycling::EnumCycle;
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,
config::Theme,
errors::AppError,
format_msg,
model::Command,
process::ProcessOutput,
service::IntelliShellService,
widgets::{CustomTextArea, ErrorPopup, NewVersionBanner},
};
#[derive(strum::EnumIs)]
pub enum EditCommandComponentMode {
New { ai: bool },
Edit { parent: Box<dyn Component> },
EditMemory {
parent: Box<dyn Component>,
callback: Arc<dyn Fn(Command) -> Result<()> + Send + Sync>,
},
Empty,
}
pub struct EditCommandComponent {
theme: Theme,
service: IntelliShellService,
layout: Layout,
mode: EditCommandComponentMode,
global_cancellation_token: CancellationToken,
state: Arc<RwLock<EditCommandComponentState<'static>>>,
}
struct EditCommandComponentState<'a> {
command: Command,
active_field: ActiveField,
alias: CustomTextArea<'a>,
cmd: CustomTextArea<'a>,
description: CustomTextArea<'a>,
error: ErrorPopup<'a>,
}
#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
enum ActiveField {
Alias,
Command,
Description,
}
impl EditCommandComponent {
pub fn new(
service: IntelliShellService,
theme: Theme,
inline: bool,
command: Command,
mode: EditCommandComponentMode,
cancellation_token: CancellationToken,
) -> Self {
let alias = CustomTextArea::new(
theme.secondary,
inline,
false,
command.alias.clone().unwrap_or_default(),
)
.title(if inline { "Alias:" } else { " Alias " });
let mut cmd = CustomTextArea::new(
theme.primary,
inline,
false,
&command.cmd,
)
.title(if inline { "Command:" } else { " Command " });
let mut description = CustomTextArea::new(
theme.primary,
inline,
true,
command.description.clone().unwrap_or_default(),
)
.title(if inline { "Description:" } else { " Description " });
let active_field = if mode.is_new() && !command.cmd.is_empty() && command.description.is_none() {
description.set_focus(true);
ActiveField::Description
} else {
cmd.set_focus(true);
ActiveField::Command
};
let error = ErrorPopup::empty(&theme);
let layout = if inline {
Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(3)])
} else {
Layout::vertical([Constraint::Length(3), Constraint::Length(3), Constraint::Min(5)]).margin(1)
};
Self {
theme,
service,
layout,
mode,
global_cancellation_token: cancellation_token,
state: Arc::new(RwLock::new(EditCommandComponentState {
command,
active_field,
alias,
cmd,
description,
error,
})),
}
}
}
impl<'a> EditCommandComponentState<'a> {
fn active_input(&mut self) -> &mut CustomTextArea<'a> {
match self.active_field {
ActiveField::Alias => &mut self.alias,
ActiveField::Command => &mut self.cmd,
ActiveField::Description => &mut self.description,
}
}
fn update_focus(&mut self) {
self.alias.set_focus(false);
self.cmd.set_focus(false);
self.description.set_focus(false);
self.active_input().set_focus(true);
}
}
#[async_trait]
impl Component for EditCommandComponent {
fn name(&self) -> &'static str {
"EditCommandComponent"
}
fn min_inline_height(&self) -> u16 {
1 + 1 + 3
}
#[instrument(skip_all)]
async fn init_and_peek(&mut self) -> Result<Action> {
if let EditCommandComponentMode::New { ai } = &self.mode
&& *ai
{
self.prompt_ai().await?;
}
Ok(Action::NoOp)
}
#[instrument(skip_all)]
fn render(&mut self, frame: &mut Frame, area: Rect) {
let mut state = self.state.write();
let [alias_area, cmd_area, description_area] = self.layout.areas(area);
frame.render_widget(&state.alias, alias_area);
frame.render_widget(&state.cmd, cmd_area);
frame.render_widget(&state.description, description_area);
if let Some(new_version) = self.service.poll_new_version() {
NewVersionBanner::new(&self.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();
state.error.tick();
state.alias.tick();
state.cmd.tick();
state.description.tick();
Ok(Action::NoOp)
}
fn exit(&mut self) -> Result<Action> {
match &self.mode {
EditCommandComponentMode::New { .. } => {
let state = self.state.read();
Ok(Action::Quit(
ProcessOutput::success().fileout(state.cmd.lines_as_string()),
))
}
EditCommandComponentMode::Edit { .. } => Ok(Action::SwitchComponent(
match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
EditCommandComponentMode::Edit { parent } => parent,
EditCommandComponentMode::Empty
| EditCommandComponentMode::New { .. }
| EditCommandComponentMode::EditMemory { .. } => {
unreachable!()
}
},
)),
EditCommandComponentMode::EditMemory { .. } => Ok(Action::SwitchComponent(
match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
EditCommandComponentMode::EditMemory { parent, .. } => parent,
EditCommandComponentMode::Empty
| EditCommandComponentMode::New { .. }
| EditCommandComponentMode::Edit { .. } => {
unreachable!()
}
},
)),
EditCommandComponentMode::Empty => Ok(Action::NoOp),
}
}
fn move_up(&mut self) -> Result<Action> {
let mut state = self.state.write();
if !state.active_input().is_ai_loading() {
state.active_field = state.active_field.up();
state.update_focus();
}
Ok(Action::NoOp)
}
fn move_down(&mut self) -> Result<Action> {
let mut state = self.state.write();
if !state.active_input().is_ai_loading() {
state.active_field = state.active_field.down();
state.update_focus();
}
Ok(Action::NoOp)
}
fn move_left(&mut self, word: bool) -> Result<Action> {
let mut state = self.state.write();
state.active_input().move_cursor_left(word);
Ok(Action::NoOp)
}
fn move_right(&mut self, word: bool) -> Result<Action> {
let mut state = self.state.write();
state.active_input().move_cursor_right(word);
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();
state.active_input().move_home(absolute);
Ok(Action::NoOp)
}
fn move_end(&mut self, absolute: bool) -> Result<Action> {
let mut state = self.state.write();
state.active_input().move_end(absolute);
Ok(Action::NoOp)
}
fn undo(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.active_input().undo();
Ok(Action::NoOp)
}
fn redo(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.active_input().redo();
Ok(Action::NoOp)
}
fn insert_text(&mut self, text: String) -> Result<Action> {
let mut state = self.state.write();
state.active_input().insert_str(text);
Ok(Action::NoOp)
}
fn insert_char(&mut self, c: char) -> Result<Action> {
let mut state = self.state.write();
state.active_input().insert_char(c);
Ok(Action::NoOp)
}
fn insert_newline(&mut self) -> Result<Action> {
let mut state = self.state.write();
state.active_input().insert_newline();
Ok(Action::NoOp)
}
fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
let mut state = self.state.write();
state.active_input().delete(backspace, word);
Ok(Action::NoOp)
}
#[instrument(skip_all)]
async fn selection_confirm(&mut self) -> Result<Action> {
let command = {
let mut state = self.state.write();
if state.active_input().is_ai_loading() {
return Ok(Action::NoOp);
}
state
.command
.clone()
.with_alias(Some(state.alias.lines_as_string()))
.with_cmd(state.cmd.lines_as_string())
.with_description(Some(state.description.lines_as_string()))
};
match &self.mode {
EditCommandComponentMode::New { .. } => match self.service.insert_command(command).await {
Ok(command) => Ok(Action::Quit(
ProcessOutput::success()
.stderr(format_msg!(
self.theme,
"Command stored: {}",
self.theme.secondary.apply(&command.cmd)
))
.fileout(command.cmd),
)),
Err(AppError::UserFacing(err)) => {
tracing::warn!("{err}");
let mut state = self.state.write();
state.error.set_temp_message(err.to_string());
Ok(Action::NoOp)
}
Err(AppError::Unexpected(report)) => Err(report),
},
EditCommandComponentMode::Edit { .. } => {
match self.service.update_command(command).await {
Ok(_) => {
Ok(Action::SwitchComponent(
match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
EditCommandComponentMode::Edit { parent } => parent,
EditCommandComponentMode::Empty
| EditCommandComponentMode::New { .. }
| EditCommandComponentMode::EditMemory { .. } => {
unreachable!()
}
},
))
}
Err(AppError::UserFacing(err)) => {
tracing::warn!("{err}");
let mut state = self.state.write();
state.error.set_temp_message(err.to_string());
Ok(Action::NoOp)
}
Err(AppError::Unexpected(report)) => Err(report),
}
}
EditCommandComponentMode::EditMemory { callback, .. } => {
callback(command)?;
Ok(Action::SwitchComponent(
match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
EditCommandComponentMode::EditMemory { parent, .. } => parent,
EditCommandComponentMode::Empty
| EditCommandComponentMode::New { .. }
| EditCommandComponentMode::Edit { .. } => {
unreachable!()
}
},
))
}
EditCommandComponentMode::Empty => Ok(Action::NoOp),
}
}
async fn selection_execute(&mut self) -> Result<Action> {
self.selection_confirm().await
}
async fn prompt_ai(&mut self) -> Result<Action> {
let mut state = self.state.write();
if state.active_input().is_ai_loading() || state.active_field == ActiveField::Alias {
return Ok(Action::NoOp);
}
let cmd = state.cmd.lines_as_string();
let description = state.description.lines_as_string();
if cmd.trim().is_empty() && description.trim().is_empty() {
return Ok(Action::NoOp);
}
state.active_input().set_ai_loading(true);
let cloned_service = self.service.clone();
let cloned_state = self.state.clone();
let cloned_token = self.global_cancellation_token.clone();
tokio::spawn(async move {
let res = cloned_service.suggest_command(&cmd, &description, cloned_token).await;
let mut state = cloned_state.write();
match res {
Ok(Some(suggestion)) => {
state.cmd.set_focus(true);
state.cmd.set_ai_loading(false);
if !cmd.is_empty() {
state.cmd.select_all();
state.cmd.cut();
}
state.cmd.insert_str(&suggestion.cmd);
if let Some(suggested_description) = suggestion.description.as_deref() {
state.description.set_focus(true);
state.description.set_ai_loading(false);
if !description.is_empty() {
state.description.select_all();
state.description.cut();
}
state.description.insert_str(suggested_description);
}
}
Ok(None) => {
state
.error
.set_temp_message("AI did not return any suggestion".to_string());
}
Err(AppError::UserFacing(err)) => {
tracing::warn!("{err}");
state.error.set_temp_message(err.to_string());
}
Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
}
state.active_input().set_ai_loading(false);
state.update_focus();
});
Ok(Action::NoOp)
}
}