1use std::{mem, sync::Arc};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use enum_cycling::EnumCycle;
6use parking_lot::RwLock;
7use ratatui::{
8 Frame,
9 layout::{Constraint, Layout, Rect},
10};
11use tracing::instrument;
12
13use super::Component;
14use crate::{
15 app::Action,
16 config::Theme,
17 errors::AppError,
18 format_msg,
19 model::Command,
20 process::ProcessOutput,
21 service::IntelliShellService,
22 widgets::{CustomTextArea, ErrorPopup, NewVersionBanner},
23};
24
25#[derive(strum::EnumIs)]
27pub enum EditCommandComponentMode {
28 New { ai: bool },
30 Edit { parent: Box<dyn Component> },
33 EditMemory {
36 parent: Box<dyn Component>,
37 callback: Arc<dyn Fn(Command) -> Result<()> + Send + Sync>,
38 },
39 Empty,
41}
42
43pub struct EditCommandComponent {
45 theme: Theme,
47 service: IntelliShellService,
49 layout: Layout,
51 mode: EditCommandComponentMode,
53 state: Arc<RwLock<EditCommandComponentState<'static>>>,
55}
56struct EditCommandComponentState<'a> {
57 command: Command,
59 active_field: ActiveField,
61 alias: CustomTextArea<'a>,
63 cmd: CustomTextArea<'a>,
65 description: CustomTextArea<'a>,
67 error: ErrorPopup<'a>,
69}
70
71#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
73enum ActiveField {
74 Alias,
75 Command,
76 Description,
77}
78
79impl EditCommandComponent {
80 pub fn new(
82 service: IntelliShellService,
83 theme: Theme,
84 inline: bool,
85 command: Command,
86 mode: EditCommandComponentMode,
87 ) -> Self {
88 let alias = CustomTextArea::new(
89 theme.secondary,
90 inline,
91 false,
92 command.alias.clone().unwrap_or_default(),
93 )
94 .title(if inline { "Alias:" } else { " Alias " });
95 let mut cmd = CustomTextArea::new(
96 theme.primary,
98 inline,
99 false,
100 &command.cmd,
101 )
102 .title(if inline { "Command:" } else { " Command " });
103 let mut description = CustomTextArea::new(
104 theme.primary,
105 inline,
106 true,
107 command.description.clone().unwrap_or_default(),
108 )
109 .title(if inline { "Description:" } else { " Description " });
110
111 let active_field = if mode.is_new() && !command.cmd.is_empty() && command.description.is_none() {
112 description.set_focus(true);
113 ActiveField::Description
114 } else {
115 cmd.set_focus(true);
116 ActiveField::Command
117 };
118
119 let error = ErrorPopup::empty(&theme);
120
121 let layout = if inline {
122 Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(3)])
123 } else {
124 Layout::vertical([Constraint::Length(3), Constraint::Length(3), Constraint::Min(5)]).margin(1)
125 };
126
127 Self {
128 theme,
129 service,
130 layout,
131 mode,
132 state: Arc::new(RwLock::new(EditCommandComponentState {
133 command,
134 active_field,
135 alias,
136 cmd,
137 description,
138 error,
139 })),
140 }
141 }
142}
143impl<'a> EditCommandComponentState<'a> {
144 fn active_input(&mut self) -> &mut CustomTextArea<'a> {
146 match self.active_field {
147 ActiveField::Alias => &mut self.alias,
148 ActiveField::Command => &mut self.cmd,
149 ActiveField::Description => &mut self.description,
150 }
151 }
152
153 fn update_focus(&mut self) {
155 self.alias.set_focus(false);
156 self.cmd.set_focus(false);
157 self.description.set_focus(false);
158
159 self.active_input().set_focus(true);
160 }
161}
162
163#[async_trait]
164impl Component for EditCommandComponent {
165 fn name(&self) -> &'static str {
166 "EditCommandComponent"
167 }
168
169 fn min_inline_height(&self) -> u16 {
170 1 + 1 + 3
172 }
173
174 #[instrument(skip_all)]
175 async fn init_and_peek(&mut self) -> Result<Action> {
176 if let EditCommandComponentMode::New { ai } = &self.mode
178 && *ai
179 {
180 self.prompt_ai().await?;
181 }
182 Ok(Action::NoOp)
183 }
184
185 #[instrument(skip_all)]
186 fn render(&mut self, frame: &mut Frame, area: Rect) {
187 let mut state = self.state.write();
188
189 let [alias_area, cmd_area, description_area] = self.layout.areas(area);
191
192 frame.render_widget(&state.alias, alias_area);
194 frame.render_widget(&state.cmd, cmd_area);
195 frame.render_widget(&state.description, description_area);
196
197 if let Some(new_version) = self.service.check_new_version() {
199 NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
200 }
201 state.error.render_in(frame, area);
202 }
203
204 fn tick(&mut self) -> Result<Action> {
205 let mut state = self.state.write();
206 state.error.tick();
207 state.alias.tick();
208 state.cmd.tick();
209 state.description.tick();
210
211 Ok(Action::NoOp)
212 }
213
214 fn exit(&mut self) -> Result<Action> {
215 match &self.mode {
217 EditCommandComponentMode::New { .. } => {
219 let state = self.state.read();
220 Ok(Action::Quit(
221 ProcessOutput::success().fileout(state.cmd.lines_as_string()),
222 ))
223 }
224 EditCommandComponentMode::Edit { .. } => Ok(Action::SwitchComponent(
226 match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
227 EditCommandComponentMode::Edit { parent } => parent,
228 EditCommandComponentMode::Empty
229 | EditCommandComponentMode::New { .. }
230 | EditCommandComponentMode::EditMemory { .. } => {
231 unreachable!()
232 }
233 },
234 )),
235 EditCommandComponentMode::EditMemory { .. } => Ok(Action::SwitchComponent(
237 match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
238 EditCommandComponentMode::EditMemory { parent, .. } => parent,
239 EditCommandComponentMode::Empty
240 | EditCommandComponentMode::New { .. }
241 | EditCommandComponentMode::Edit { .. } => {
242 unreachable!()
243 }
244 },
245 )),
246 EditCommandComponentMode::Empty => Ok(Action::NoOp),
248 }
249 }
250
251 fn move_up(&mut self) -> Result<Action> {
252 let mut state = self.state.write();
253 if !state.active_input().is_ai_loading() {
254 state.active_field = state.active_field.up();
255 state.update_focus();
256 }
257
258 Ok(Action::NoOp)
259 }
260
261 fn move_down(&mut self) -> Result<Action> {
262 let mut state = self.state.write();
263 if !state.active_input().is_ai_loading() {
264 state.active_field = state.active_field.down();
265 state.update_focus();
266 }
267
268 Ok(Action::NoOp)
269 }
270
271 fn move_left(&mut self, word: bool) -> Result<Action> {
272 let mut state = self.state.write();
273 state.active_input().move_cursor_left(word);
274
275 Ok(Action::NoOp)
276 }
277
278 fn move_right(&mut self, word: bool) -> Result<Action> {
279 let mut state = self.state.write();
280 state.active_input().move_cursor_right(word);
281
282 Ok(Action::NoOp)
283 }
284
285 fn move_prev(&mut self) -> Result<Action> {
286 self.move_up()
287 }
288
289 fn move_next(&mut self) -> Result<Action> {
290 self.move_down()
291 }
292
293 fn move_home(&mut self, absolute: bool) -> Result<Action> {
294 let mut state = self.state.write();
295 state.active_input().move_home(absolute);
296
297 Ok(Action::NoOp)
298 }
299
300 fn move_end(&mut self, absolute: bool) -> Result<Action> {
301 let mut state = self.state.write();
302 state.active_input().move_end(absolute);
303
304 Ok(Action::NoOp)
305 }
306
307 fn undo(&mut self) -> Result<Action> {
308 let mut state = self.state.write();
309 state.active_input().undo();
310
311 Ok(Action::NoOp)
312 }
313
314 fn redo(&mut self) -> Result<Action> {
315 let mut state = self.state.write();
316 state.active_input().redo();
317
318 Ok(Action::NoOp)
319 }
320
321 fn insert_text(&mut self, text: String) -> Result<Action> {
322 let mut state = self.state.write();
323 state.active_input().insert_str(text);
324
325 Ok(Action::NoOp)
326 }
327
328 fn insert_char(&mut self, c: char) -> Result<Action> {
329 let mut state = self.state.write();
330 state.active_input().insert_char(c);
331
332 Ok(Action::NoOp)
333 }
334
335 fn insert_newline(&mut self) -> Result<Action> {
336 let mut state = self.state.write();
337 state.active_input().insert_newline();
338
339 Ok(Action::NoOp)
340 }
341
342 fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
343 let mut state = self.state.write();
344 state.active_input().delete(backspace, word);
345
346 Ok(Action::NoOp)
347 }
348
349 #[instrument(skip_all)]
350 async fn selection_confirm(&mut self) -> Result<Action> {
351 let command = {
352 let mut state = self.state.write();
353 if state.active_input().is_ai_loading() {
354 return Ok(Action::NoOp);
355 }
356
357 state
359 .command
360 .clone()
361 .with_alias(Some(state.alias.lines_as_string()))
362 .with_cmd(state.cmd.lines_as_string())
363 .with_description(Some(state.description.lines_as_string()))
364 };
365
366 match &self.mode {
368 EditCommandComponentMode::New { .. } => match self.service.insert_command(command).await {
370 Ok(command) => Ok(Action::Quit(
371 ProcessOutput::success()
372 .stderr(format_msg!(
373 self.theme,
374 "Command stored: {}",
375 self.theme.secondary.apply(&command.cmd)
376 ))
377 .fileout(command.cmd),
378 )),
379 Err(AppError::UserFacing(err)) => {
380 tracing::warn!("{err}");
381 let mut state = self.state.write();
382 state.error.set_temp_message(err.to_string());
383 Ok(Action::NoOp)
384 }
385 Err(AppError::Unexpected(report)) => Err(report),
386 },
387 EditCommandComponentMode::Edit { .. } => {
389 match self.service.update_command(command).await {
390 Ok(_) => {
391 Ok(Action::SwitchComponent(
393 match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
394 EditCommandComponentMode::Edit { parent } => parent,
395 EditCommandComponentMode::Empty
396 | EditCommandComponentMode::New { .. }
397 | EditCommandComponentMode::EditMemory { .. } => {
398 unreachable!()
399 }
400 },
401 ))
402 }
403 Err(AppError::UserFacing(err)) => {
404 tracing::warn!("{err}");
405 let mut state = self.state.write();
406 state.error.set_temp_message(err.to_string());
407 Ok(Action::NoOp)
408 }
409 Err(AppError::Unexpected(report)) => Err(report),
410 }
411 }
412 EditCommandComponentMode::EditMemory { callback, .. } => {
414 callback(command)?;
416
417 Ok(Action::SwitchComponent(
419 match mem::replace(&mut self.mode, EditCommandComponentMode::Empty) {
420 EditCommandComponentMode::EditMemory { parent, .. } => parent,
421 EditCommandComponentMode::Empty
422 | EditCommandComponentMode::New { .. }
423 | EditCommandComponentMode::Edit { .. } => {
424 unreachable!()
425 }
426 },
427 ))
428 }
429 EditCommandComponentMode::Empty => Ok(Action::NoOp),
431 }
432 }
433
434 async fn selection_execute(&mut self) -> Result<Action> {
435 self.selection_confirm().await
436 }
437
438 async fn prompt_ai(&mut self) -> Result<Action> {
439 let mut state = self.state.write();
440 if state.active_input().is_ai_loading() || state.active_field == ActiveField::Alias {
441 return Ok(Action::NoOp);
442 }
443
444 let cmd = state.cmd.lines_as_string();
445 let description = state.description.lines_as_string();
446
447 if cmd.trim().is_empty() && description.trim().is_empty() {
448 return Ok(Action::NoOp);
449 }
450
451 state.active_input().set_ai_loading(true);
452 let cloned_service = self.service.clone();
453 let cloned_state = self.state.clone();
454 tokio::spawn(async move {
455 let res = cloned_service.suggest_command(&cmd, &description).await;
456 let mut state = cloned_state.write();
457 match res {
458 Ok(Some(suggestion)) => {
459 state.cmd.set_focus(true);
460 state.cmd.set_ai_loading(false);
461 if !cmd.is_empty() {
462 state.cmd.select_all();
463 state.cmd.cut();
464 }
465 state.cmd.insert_str(&suggestion.cmd);
466 if let Some(suggested_description) = suggestion.description.as_deref() {
467 state.description.set_focus(true);
468 state.description.set_ai_loading(false);
469 if !description.is_empty() {
470 state.description.select_all();
471 state.description.cut();
472 }
473 state.description.insert_str(suggested_description);
474 }
475 }
476 Ok(None) => {
477 state
478 .error
479 .set_temp_message("AI did not return any suggestion".to_string());
480 }
481 Err(AppError::UserFacing(err)) => {
482 tracing::warn!("{err}");
483 state.error.set_temp_message(err.to_string());
484 }
485 Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
486 }
487 state.active_input().set_ai_loading(false);
489 state.update_focus();
490 });
491
492 Ok(Action::NoOp)
493 }
494}