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