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