1use crossterm::event::KeyEvent;
10use ratatui::layout::Rect;
11
12use crate::cache::CacheConfig;
13use crate::change::Change;
14use crate::cli::Command;
15use crate::lock::NestedInput;
16
17use super::completions::uri_completion_items;
18use super::components::confirm::ConfirmAction;
19use super::components::input::{Input, InputAction, InputResult, InputState};
20use super::components::list::{ListAction, ListResult, ListState};
21use super::workflow::{AddPhase, ConfirmResultAction, FollowPhase, WorkflowData};
22
23pub use super::workflow::{AppResult, MultiSelectResultData, SingleSelectResult, UpdateResult};
25
26const MAX_LIST_HEIGHT: u16 = 12;
27
28#[derive(Debug, Clone)]
29pub struct App {
30 context: String,
31 flake_text: String,
32 show_diff: bool,
33 cache_config: CacheConfig,
34 screen: Screen,
35 data: WorkflowData,
36}
37
38#[derive(Debug, Clone)]
40pub enum Screen {
41 Input(InputScreen),
42 List(ListScreen),
43 Confirm(ConfirmScreen),
44}
45
46#[derive(Debug, Clone)]
48pub struct InputScreen {
49 pub state: InputState,
50 pub prompt: String,
51 pub label: Option<String>,
52}
53
54#[derive(Debug, Clone)]
56pub struct ListScreen {
57 pub state: ListState,
58 pub items: Vec<String>,
59 pub prompt: String,
60}
61
62impl ListScreen {
63 pub fn single(items: Vec<String>, prompt: impl Into<String>, show_diff: bool) -> Self {
64 let len = items.len();
65 Self {
66 state: ListState::new(len, false, show_diff),
67 items,
68 prompt: prompt.into(),
69 }
70 }
71
72 pub fn multi(items: Vec<String>, prompt: impl Into<String>, show_diff: bool) -> Self {
73 let len = items.len();
74 Self {
75 state: ListState::new(len, true, show_diff),
76 items,
77 prompt: prompt.into(),
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84pub struct ConfirmScreen {
85 pub diff: String,
86}
87
88impl App {
89 pub fn add(
94 context: impl Into<String>,
95 flake_text: impl Into<String>,
96 prefill_uri: Option<&str>,
97 cache_config: CacheConfig,
98 ) -> Self {
99 let completions = uri_completion_items(None, &cache_config);
100 Self {
101 context: context.into(),
102 flake_text: flake_text.into(),
103 show_diff: false,
104 cache_config,
105 screen: Screen::Input(InputScreen {
106 state: InputState::with_completions(prefill_uri, completions),
107 prompt: "Enter flake URI".into(),
108 label: None,
109 }),
110 data: WorkflowData::Add {
111 phase: AddPhase::Uri,
112 uri: None,
113 id: None,
114 },
115 }
116 }
117
118 pub fn change(
123 context: impl Into<String>,
124 flake_text: impl Into<String>,
125 inputs: Vec<(String, String)>,
126 cache_config: CacheConfig,
127 ) -> Self {
128 let input_ids: Vec<String> = inputs.iter().map(|(id, _)| id.clone()).collect();
129 let input_uris: std::collections::HashMap<String, String> = inputs.into_iter().collect();
130 Self {
131 context: context.into(),
132 flake_text: flake_text.into(),
133 show_diff: false,
134 cache_config,
135 screen: Screen::List(ListScreen::single(
136 input_ids.clone(),
137 "Select input to change",
138 false,
139 )),
140 data: WorkflowData::Change {
141 selected_input: None,
142 uri: None,
143 input_uris,
144 all_inputs: input_ids,
145 },
146 }
147 }
148
149 pub fn remove(
151 context: impl Into<String>,
152 flake_text: impl Into<String>,
153 inputs: Vec<String>,
154 ) -> Self {
155 Self {
156 context: context.into(),
157 flake_text: flake_text.into(),
158 show_diff: false,
159 cache_config: CacheConfig::default(),
160 screen: Screen::List(ListScreen::multi(
161 inputs.clone(),
162 "Select inputs to remove",
163 false,
164 )),
165 data: WorkflowData::Remove {
166 selected_inputs: Vec::new(),
167 all_inputs: inputs,
168 },
169 }
170 }
171
172 pub fn change_uri(
176 context: impl Into<String>,
177 flake_text: impl Into<String>,
178 id: impl Into<String>,
179 current_uri: Option<&str>,
180 show_diff: bool,
181 cache_config: CacheConfig,
182 ) -> Self {
183 let id_string = id.into();
184 let completions = uri_completion_items(Some(&id_string), &cache_config);
185 Self {
186 context: context.into(),
187 flake_text: flake_text.into(),
188 show_diff,
189 cache_config,
190 screen: Screen::Input(InputScreen {
191 state: InputState::with_completions(current_uri, completions),
192 prompt: format!("for {}", id_string),
193 label: Some("URI".into()),
194 }),
195 data: WorkflowData::Change {
196 selected_input: Some(id_string),
197 uri: None,
198 input_uris: std::collections::HashMap::new(),
199 all_inputs: Vec::new(),
200 },
201 }
202 }
203
204 pub fn select_one(
206 context: impl Into<String>,
207 prompt: impl Into<String>,
208 items: Vec<String>,
209 initial_diff: bool,
210 ) -> Self {
211 Self {
212 context: context.into(),
213 flake_text: String::new(),
214 show_diff: initial_diff,
215 cache_config: CacheConfig::default(),
216 screen: Screen::List(ListScreen::single(items, prompt, initial_diff)),
217 data: WorkflowData::SelectOne {
218 selected_input: None,
219 },
220 }
221 }
222
223 pub fn select_many(
225 context: impl Into<String>,
226 prompt: impl Into<String>,
227 items: Vec<String>,
228 initial_diff: bool,
229 ) -> Self {
230 Self {
231 context: context.into(),
232 flake_text: String::new(),
233 show_diff: initial_diff,
234 cache_config: CacheConfig::default(),
235 screen: Screen::List(ListScreen::multi(items, prompt, initial_diff)),
236 data: WorkflowData::SelectMany {
237 selected_inputs: Vec::new(),
238 },
239 }
240 }
241
242 pub fn confirm(context: impl Into<String>, diff: impl Into<String>) -> Self {
244 Self {
245 context: context.into(),
246 flake_text: String::new(),
247 show_diff: true,
248 cache_config: CacheConfig::default(),
249 screen: Screen::Confirm(ConfirmScreen { diff: diff.into() }),
250 data: WorkflowData::ConfirmOnly { action: None },
251 }
252 }
253
254 pub fn follow(
260 context: impl Into<String>,
261 flake_text: impl Into<String>,
262 nested_inputs: Vec<NestedInput>,
263 top_level_inputs: Vec<String>,
264 ) -> Self {
265 let display_items: Vec<String> = nested_inputs
267 .iter()
268 .map(|i| i.to_display_string())
269 .collect();
270 Self {
271 context: context.into(),
272 flake_text: flake_text.into(),
273 show_diff: false,
274 cache_config: CacheConfig::default(),
275 screen: Screen::List(ListScreen::single(
276 display_items,
277 "Select input to add follows",
278 false,
279 )),
280 data: WorkflowData::Follow {
281 phase: FollowPhase::SelectInput,
282 selected_input: None,
283 selected_target: None,
284 nested_inputs,
285 top_level_inputs,
286 },
287 }
288 }
289
290 pub fn follow_target(
294 context: impl Into<String>,
295 flake_text: impl Into<String>,
296 input: impl Into<String>,
297 top_level_inputs: Vec<String>,
298 ) -> Self {
299 let input = input.into();
300 Self {
301 context: context.into(),
302 flake_text: flake_text.into(),
303 show_diff: false,
304 cache_config: CacheConfig::default(),
305 screen: Screen::List(ListScreen::single(
306 top_level_inputs.clone(),
307 format!("Select target for {input}"),
308 false,
309 )),
310 data: WorkflowData::Follow {
311 phase: FollowPhase::SelectTarget,
312 selected_input: Some(input),
313 selected_target: None,
314 nested_inputs: Vec::<NestedInput>::new(),
315 top_level_inputs,
316 },
317 }
318 }
319
320 pub fn from_command(
333 command: &Command,
334 flake_text: impl Into<String>,
335 inputs: Vec<(String, String)>,
336 diff: bool,
337 cache_config: CacheConfig,
338 ) -> Option<Self> {
339 let flake_text = flake_text.into();
340 let input_ids: Vec<String> = inputs.iter().map(|(id, _)| id.clone()).collect();
341
342 match command {
343 Command::Add { id, uri, .. } => {
345 if id.is_some() && uri.is_some() {
346 None } else {
348 let prefill = id.as_deref();
350 Some(Self::add("Add", flake_text, prefill, cache_config).with_diff(diff))
351 }
352 }
353
354 Command::Remove { id } => {
356 if id.is_some() {
357 None
358 } else {
359 Some(Self::remove("Remove", flake_text, input_ids).with_diff(diff))
360 }
361 }
362
363 Command::Change { id, uri, .. } => {
365 if id.is_some() && uri.is_some() {
366 None } else if let Some(id) = id {
368 let current_uri = inputs
370 .iter()
371 .find(|(i, _)| i == id)
372 .map(|(_, u)| u.as_str());
373 Some(Self::change_uri(
374 "Change",
375 flake_text,
376 id,
377 current_uri,
378 diff,
379 cache_config,
380 ))
381 } else {
382 Some(Self::change("Change", flake_text, inputs, cache_config).with_diff(diff))
384 }
385 }
386
387 Command::Pin { id, .. } => {
389 if id.is_some() {
390 None
391 } else {
392 Some(Self::select_one(
393 "Pin",
394 "Select input to pin",
395 input_ids,
396 diff,
397 ))
398 }
399 }
400
401 Command::Unpin { id } => {
403 if id.is_some() {
404 None
405 } else {
406 Some(Self::select_one(
407 "Unpin",
408 "Select input to unpin",
409 input_ids,
410 diff,
411 ))
412 }
413 }
414
415 Command::Update { id, .. } => {
417 if id.is_some() {
418 None
419 } else {
420 Some(Self::select_many(
421 "Update",
422 "Space select, U all, ^D diff",
423 input_ids,
424 diff,
425 ))
426 }
427 }
428
429 Command::List { .. }
431 | Command::Completion { .. }
432 | Command::Follow { .. }
433 | Command::AddFollow { .. }
434 | Command::Config { .. } => None,
435 }
436 }
437
438 pub fn show_diff(&self) -> bool {
439 self.show_diff
440 }
441
442 pub fn screen(&self) -> &Screen {
443 &self.screen
444 }
445
446 pub fn context(&self) -> &str {
447 &self.context
448 }
449
450 pub fn pending_change(&self) -> Change {
453 self.build_change()
454 }
455
456 pub fn pending_diff(&self) -> String {
462 let change = self.build_preview_change();
463 self.compute_diff(&change)
464 }
465
466 fn build_preview_change(&self) -> Change {
469 match &self.screen {
470 Screen::Input(screen) => {
472 let current_text = screen.state.text();
473 if current_text.is_empty() {
474 return Change::None;
475 }
476 match &self.data {
477 WorkflowData::Add { phase, uri, .. } => match phase {
478 AddPhase::Uri => Change::Add {
479 id: None,
480 uri: Some(current_text.to_string()),
481 flake: true,
482 },
483 AddPhase::Id => Change::Add {
484 id: Some(current_text.to_string()),
485 uri: uri.clone(),
486 flake: true,
487 },
488 },
489 WorkflowData::Change { selected_input, .. } => Change::Change {
490 id: selected_input.clone(),
491 uri: Some(current_text.to_string()),
492 ref_or_rev: None,
493 },
494 _ => self.build_change(),
495 }
496 }
497 Screen::List(screen) => {
499 let selected_items: Vec<String> = screen
500 .state
501 .selected_indices()
502 .iter()
503 .filter_map(|&i| screen.items.get(i).cloned())
504 .collect();
505
506 if !selected_items.is_empty() {
507 return match &self.data {
508 WorkflowData::Remove { .. } => Change::Remove {
509 ids: selected_items.into_iter().map(|s| s.into()).collect(),
510 },
511 WorkflowData::Follow {
512 phase,
513 selected_input,
514 ..
515 } => {
516 if *phase == FollowPhase::SelectTarget {
519 if let Some(input) = selected_input {
520 let target =
521 selected_items.into_iter().next().unwrap_or_default();
522 Change::Follows {
523 input: input.clone().into(),
524 target,
525 }
526 } else {
527 Change::None
528 }
529 } else {
530 Change::None
532 }
533 }
534 _ => self.build_change(),
535 };
536 }
537 if matches!(
538 &self.data,
539 WorkflowData::Follow { .. } | WorkflowData::Change { .. }
540 ) {
541 return Change::None;
542 }
543 self.build_change()
544 }
545 Screen::Confirm(_) => self.build_change(),
546 }
547 }
548
549 pub fn with_diff(mut self, show_diff: bool) -> Self {
551 self.show_diff = show_diff;
552 if let Screen::List(ref mut screen) = self.screen {
554 screen.state =
555 ListState::new(screen.items.len(), screen.state.multi_select(), show_diff);
556 }
557 self
558 }
559
560 pub fn update(&mut self, key: KeyEvent) -> UpdateResult {
561 let screen = self.screen.clone();
562 match screen {
563 Screen::Input(s) => self.update_input(s, key),
564 Screen::List(s) => self.update_list(s, key),
565 Screen::Confirm(_) => self.update_confirm(key),
566 }
567 }
568
569 fn update_input(&mut self, mut screen: InputScreen, key: KeyEvent) -> UpdateResult {
570 let action = InputAction::from_key(key);
571 match action {
572 InputAction::ToggleDiff => {
573 self.show_diff = !self.show_diff;
574 UpdateResult::Continue
575 }
576 _ => {
577 if let Some(result) = screen.state.handle(action) {
578 match result {
579 InputResult::Submit(text) => self.handle_input_submit(text),
580 InputResult::Cancel => {
581 if let WorkflowData::Add { phase, uri, .. } = &mut self.data
583 && *phase == AddPhase::Id
584 {
585 *phase = AddPhase::Uri;
586 self.screen = Screen::Input(InputScreen {
587 state: InputState::with_completions(
588 uri.as_deref(),
589 uri_completion_items(None, &self.cache_config),
590 ),
591 prompt: "Enter flake URI".into(),
592 label: None,
593 });
594 return UpdateResult::Continue;
595 }
596 if let WorkflowData::Change { all_inputs, .. } = &self.data
598 && !all_inputs.is_empty()
599 {
600 self.screen = Screen::List(ListScreen::single(
601 all_inputs.clone(),
602 "Select input to change",
603 self.show_diff,
604 ));
605 return UpdateResult::Continue;
606 }
607 UpdateResult::Cancelled
608 }
609 }
610 } else {
611 if let Screen::Input(s) = &mut self.screen {
612 s.state = screen.state;
613 }
614 UpdateResult::Continue
615 }
616 }
617 }
618 }
619
620 fn update_list(&mut self, mut screen: ListScreen, key: KeyEvent) -> UpdateResult {
621 let action = ListAction::from_key(key);
622 if let Some(result) = screen.state.handle(action) {
623 match result {
624 ListResult::Select(indices, show_diff) => {
625 self.show_diff = show_diff;
626 let items: Vec<String> =
627 indices.iter().map(|&i| screen.items[i].clone()).collect();
628 self.handle_list_submit(indices, items)
629 }
630 ListResult::Cancel => {
631 if let WorkflowData::Follow {
633 phase,
634 nested_inputs,
635 ..
636 } = &mut self.data
637 && *phase == FollowPhase::SelectTarget
638 && !nested_inputs.is_empty()
639 {
640 *phase = FollowPhase::SelectInput;
641 let display_items: Vec<String> = nested_inputs
642 .iter()
643 .map(|i| i.to_display_string())
644 .collect();
645 self.screen = Screen::List(ListScreen::single(
646 display_items,
647 "Select input to add follows",
648 self.show_diff,
649 ));
650 return UpdateResult::Continue;
651 }
652 UpdateResult::Cancelled
653 }
654 }
655 } else {
656 if let Screen::List(s) = &mut self.screen {
657 s.state = screen.state;
658 }
659 UpdateResult::Continue
660 }
661 }
662
663 fn update_confirm(&mut self, key: KeyEvent) -> UpdateResult {
664 let action = ConfirmAction::from_key(key);
665 match action {
666 ConfirmAction::Apply => {
667 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
668 *action = Some(ConfirmResultAction::Apply);
669 }
670 UpdateResult::Done
671 }
672 ConfirmAction::Back => {
673 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
675 *action = Some(ConfirmResultAction::Back);
676 UpdateResult::Done
677 } else {
678 self.go_back();
679 UpdateResult::Continue
680 }
681 }
682 ConfirmAction::Exit => {
683 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
684 *action = Some(ConfirmResultAction::Exit);
685 }
686 UpdateResult::Cancelled
687 }
688 ConfirmAction::None => UpdateResult::Continue,
689 }
690 }
691
692 fn handle_input_submit(&mut self, text: String) -> UpdateResult {
693 match &mut self.data {
694 WorkflowData::Add { phase, uri, id } => match phase {
695 AddPhase::Uri => {
696 let (inferred_id, normalized_uri) = Self::parse_uri_and_infer_id(&text);
697 *uri = Some(normalized_uri);
698 *phase = AddPhase::Id;
699 self.screen = Screen::Input(InputScreen {
700 state: InputState::new(inferred_id.as_deref()),
701 prompt: format!("for {}", text),
702 label: Some("ID".into()),
703 });
704 UpdateResult::Continue
705 }
706 AddPhase::Id => {
707 *id = Some(text);
708 self.transition_to_confirm()
709 }
710 },
711 WorkflowData::Change { uri, .. } => {
712 *uri = Some(text);
713 self.transition_to_confirm()
714 }
715 WorkflowData::Remove { .. }
717 | WorkflowData::SelectOne { .. }
718 | WorkflowData::SelectMany { .. }
719 | WorkflowData::ConfirmOnly { .. }
720 | WorkflowData::Follow { .. } => UpdateResult::Continue,
721 }
722 }
723
724 fn handle_list_submit(&mut self, indices: Vec<usize>, items: Vec<String>) -> UpdateResult {
725 match &mut self.data {
726 WorkflowData::Change {
727 selected_input,
728 input_uris,
729 ..
730 } => {
731 let item = items.into_iter().next().unwrap_or_default();
733 let current_uri = input_uris.get(&item).map(|s| s.as_str());
734 *selected_input = Some(item.clone());
735 self.screen = Screen::Input(InputScreen {
736 state: InputState::with_completions(
737 current_uri,
738 uri_completion_items(Some(&item), &self.cache_config),
739 ),
740 prompt: "Enter new URI".into(),
741 label: Some(item),
742 });
743 UpdateResult::Continue
744 }
745 WorkflowData::SelectOne { selected_input } => {
746 *selected_input = items.into_iter().next();
748 UpdateResult::Done
749 }
750 WorkflowData::Remove {
751 selected_inputs, ..
752 } => {
753 *selected_inputs = items;
754 self.transition_to_confirm()
755 }
756 WorkflowData::SelectMany { selected_inputs } => {
757 *selected_inputs = items;
758 UpdateResult::Done
759 }
760 WorkflowData::Follow {
761 phase,
762 selected_input,
763 selected_target,
764 nested_inputs,
765 top_level_inputs,
766 } => {
767 match phase {
768 FollowPhase::SelectInput => {
769 let index = indices.first().copied().unwrap_or(0);
771 let path = nested_inputs
772 .get(index)
773 .map(|i| i.path.clone())
774 .unwrap_or_default();
775 *selected_input = Some(path.clone());
776 *phase = FollowPhase::SelectTarget;
777 self.screen = Screen::List(ListScreen::single(
778 top_level_inputs.clone(),
779 format!("Select target for {path}"),
780 self.show_diff,
781 ));
782 UpdateResult::Continue
783 }
784 FollowPhase::SelectTarget => {
785 let item = items.into_iter().next().unwrap_or_default();
786 *selected_target = Some(item);
787 self.transition_to_confirm()
788 }
789 }
790 }
791 _ => UpdateResult::Continue,
792 }
793 }
794
795 fn transition_to_confirm(&mut self) -> UpdateResult {
796 if !self.show_diff {
797 return UpdateResult::Done;
798 }
799
800 let change = self.build_change();
801 let diff_str = self.compute_diff(&change);
802 self.screen = Screen::Confirm(ConfirmScreen { diff: diff_str });
803 UpdateResult::Continue
804 }
805
806 fn go_back(&mut self) {
807 match &mut self.data {
808 WorkflowData::Add { phase, id, uri } => {
809 *phase = AddPhase::Id;
810 self.screen = Screen::Input(InputScreen {
811 state: InputState::new(id.as_deref()),
812 prompt: format!("for {}", uri.as_deref().unwrap_or("")),
813 label: Some("ID".into()),
814 });
815 }
816 WorkflowData::Change {
817 selected_input,
818 uri,
819 ..
820 } => {
821 self.screen = Screen::Input(InputScreen {
822 state: InputState::with_completions(
823 uri.as_deref(),
824 uri_completion_items(selected_input.as_deref(), &self.cache_config),
825 ),
826 prompt: "Enter new URI".into(),
827 label: selected_input.clone(),
828 });
829 }
830 WorkflowData::Remove { all_inputs, .. } => {
831 self.screen = Screen::List(ListScreen::multi(
832 all_inputs.clone(),
833 "Select inputs to remove",
834 self.show_diff,
835 ));
836 }
837 WorkflowData::Follow {
838 phase,
839 nested_inputs,
840 top_level_inputs,
841 ..
842 } => {
843 if *phase == FollowPhase::SelectTarget {
846 self.screen = Screen::List(ListScreen::single(
847 top_level_inputs.clone(),
848 "Select target to follow",
849 self.show_diff,
850 ));
851 } else if !nested_inputs.is_empty() {
852 let display_items: Vec<String> = nested_inputs
854 .iter()
855 .map(|i| i.to_display_string())
856 .collect();
857 self.screen = Screen::List(ListScreen::single(
858 display_items,
859 "Select input to add follows",
860 self.show_diff,
861 ));
862 }
863 }
864 WorkflowData::SelectOne { .. }
866 | WorkflowData::SelectMany { .. }
867 | WorkflowData::ConfirmOnly { .. } => {}
868 }
869 }
870
871 fn build_change(&self) -> Change {
872 self.data.build_change()
873 }
874
875 fn compute_diff(&self, change: &Change) -> String {
876 super::workflow::compute_diff(&self.flake_text, change)
877 }
878
879 fn parse_uri_and_infer_id(uri: &str) -> (Option<String>, String) {
880 super::workflow::parse_uri_and_infer_id(uri)
881 }
882
883 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
884 match &self.screen {
885 Screen::Input(screen) => {
886 let input = Input::new(
887 &screen.state,
888 &screen.prompt,
889 &self.context,
890 screen.label.as_deref(),
891 self.show_diff,
892 );
893 Some(input.cursor_position(area))
894 }
895 _ => None,
896 }
897 }
898
899 pub fn terminal_height(&self) -> u16 {
900 match &self.screen {
901 Screen::Input(screen) => {
902 let input = Input::new(
903 &screen.state,
904 &screen.prompt,
905 &self.context,
906 screen.label.as_deref(),
907 self.show_diff,
908 );
909 input.required_height()
910 }
911 Screen::List(s) => super::helpers::list_height(s.items.len(), MAX_LIST_HEIGHT),
912 Screen::Confirm(s) => super::helpers::diff_height(s.diff.lines().count()),
913 }
914 }
915
916 pub fn extract_result(self) -> Option<AppResult> {
917 match self.data {
918 WorkflowData::Add { .. }
919 | WorkflowData::Change { .. }
920 | WorkflowData::Remove { .. }
921 | WorkflowData::Follow { .. } => {
922 let change = self.build_change();
923 if matches!(change, Change::None) {
924 None
925 } else {
926 Some(AppResult::Change(change))
927 }
928 }
929 WorkflowData::SelectOne { selected_input } => selected_input.map(|item| {
930 AppResult::SingleSelect(SingleSelectResult {
931 item,
932 show_diff: self.show_diff,
933 })
934 }),
935 WorkflowData::SelectMany { selected_inputs } => {
936 if selected_inputs.is_empty() {
937 None
938 } else {
939 Some(AppResult::MultiSelect(MultiSelectResultData {
940 items: selected_inputs,
941 show_diff: self.show_diff,
942 }))
943 }
944 }
945 WorkflowData::ConfirmOnly { action } => action.map(AppResult::Confirm),
946 }
947 }
948}