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