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 { .. } | Command::Completion { .. } | Command::Follow { .. } => None,
432 }
433 }
434
435 pub fn show_diff(&self) -> bool {
436 self.show_diff
437 }
438
439 pub fn screen(&self) -> &Screen {
440 &self.screen
441 }
442
443 pub fn context(&self) -> &str {
444 &self.context
445 }
446
447 pub fn pending_change(&self) -> Change {
450 self.build_change()
451 }
452
453 pub fn pending_diff(&self) -> String {
459 let change = self.build_preview_change();
460 self.compute_diff(&change)
461 }
462
463 fn build_preview_change(&self) -> Change {
466 match &self.screen {
467 Screen::Input(screen) => {
469 let current_text = screen.state.text();
470 if current_text.is_empty() {
471 return self.build_change();
472 }
473 match &self.data {
474 WorkflowData::Add { phase, uri, .. } => match phase {
475 AddPhase::Uri => Change::Add {
476 id: None,
477 uri: Some(current_text.to_string()),
478 flake: true,
479 },
480 AddPhase::Id => Change::Add {
481 id: Some(current_text.to_string()),
482 uri: uri.clone(),
483 flake: true,
484 },
485 },
486 WorkflowData::Change { selected_input, .. } => Change::Change {
487 id: selected_input.clone(),
488 uri: Some(current_text.to_string()),
489 ref_or_rev: None,
490 },
491 _ => self.build_change(),
492 }
493 }
494 Screen::List(screen) => {
496 let selected_items: Vec<String> = screen
497 .state
498 .selected_indices()
499 .iter()
500 .filter_map(|&i| screen.items.get(i).cloned())
501 .collect();
502
503 if !selected_items.is_empty() {
504 return match &self.data {
505 WorkflowData::Remove { .. } => Change::Remove {
506 ids: selected_items.into_iter().map(|s| s.into()).collect(),
507 },
508 WorkflowData::Follow {
509 phase,
510 selected_input,
511 ..
512 } => {
513 if *phase == FollowPhase::SelectTarget {
516 if let Some(input) = selected_input {
517 let target =
518 selected_items.into_iter().next().unwrap_or_default();
519 Change::Follows {
520 input: input.clone().into(),
521 target,
522 }
523 } else {
524 Change::None
525 }
526 } else {
527 Change::None
529 }
530 }
531 _ => self.build_change(),
532 };
533 }
534 if let WorkflowData::Follow { .. } = &self.data {
536 return Change::None;
537 }
538 self.build_change()
539 }
540 Screen::Confirm(_) => self.build_change(),
541 }
542 }
543
544 pub fn with_diff(mut self, show_diff: bool) -> Self {
546 self.show_diff = show_diff;
547 if let Screen::List(ref mut screen) = self.screen {
549 screen.state =
550 ListState::new(screen.items.len(), screen.state.multi_select(), show_diff);
551 }
552 self
553 }
554
555 pub fn update(&mut self, key: KeyEvent) -> UpdateResult {
556 let screen = self.screen.clone();
557 match screen {
558 Screen::Input(s) => self.update_input(s, key),
559 Screen::List(s) => self.update_list(s, key),
560 Screen::Confirm(_) => self.update_confirm(key),
561 }
562 }
563
564 fn update_input(&mut self, mut screen: InputScreen, key: KeyEvent) -> UpdateResult {
565 let action = InputAction::from_key(key);
566 match action {
567 InputAction::ToggleDiff => {
568 self.show_diff = !self.show_diff;
569 UpdateResult::Continue
570 }
571 _ => {
572 if let Some(result) = screen.state.handle(action) {
573 match result {
574 InputResult::Submit(text) => self.handle_input_submit(text),
575 InputResult::Cancel => {
576 if let WorkflowData::Add { phase, uri, .. } = &mut self.data
578 && *phase == AddPhase::Id
579 {
580 *phase = AddPhase::Uri;
581 self.screen = Screen::Input(InputScreen {
582 state: InputState::with_completions(
583 uri.as_deref(),
584 uri_completion_items(None, &self.cache_config),
585 ),
586 prompt: "Enter flake URI".into(),
587 label: None,
588 });
589 return UpdateResult::Continue;
590 }
591 if let WorkflowData::Change { all_inputs, .. } = &self.data
593 && !all_inputs.is_empty()
594 {
595 self.screen = Screen::List(ListScreen::single(
596 all_inputs.clone(),
597 "Select input to change",
598 self.show_diff,
599 ));
600 return UpdateResult::Continue;
601 }
602 UpdateResult::Cancelled
603 }
604 }
605 } else {
606 if let Screen::Input(s) = &mut self.screen {
607 s.state = screen.state;
608 }
609 UpdateResult::Continue
610 }
611 }
612 }
613 }
614
615 fn update_list(&mut self, mut screen: ListScreen, key: KeyEvent) -> UpdateResult {
616 let action = ListAction::from_key(key);
617 if let Some(result) = screen.state.handle(action) {
618 match result {
619 ListResult::Select(indices, show_diff) => {
620 self.show_diff = show_diff;
621 let items: Vec<String> =
622 indices.iter().map(|&i| screen.items[i].clone()).collect();
623 self.handle_list_submit(indices, items)
624 }
625 ListResult::Cancel => {
626 if let WorkflowData::Follow {
628 phase,
629 nested_inputs,
630 ..
631 } = &mut self.data
632 && *phase == FollowPhase::SelectTarget
633 && !nested_inputs.is_empty()
634 {
635 *phase = FollowPhase::SelectInput;
636 let display_items: Vec<String> = nested_inputs
637 .iter()
638 .map(|i| i.to_display_string())
639 .collect();
640 self.screen = Screen::List(ListScreen::single(
641 display_items,
642 "Select input to add follows",
643 self.show_diff,
644 ));
645 return UpdateResult::Continue;
646 }
647 UpdateResult::Cancelled
648 }
649 }
650 } else {
651 if let Screen::List(s) = &mut self.screen {
652 s.state = screen.state;
653 }
654 UpdateResult::Continue
655 }
656 }
657
658 fn update_confirm(&mut self, key: KeyEvent) -> UpdateResult {
659 let action = ConfirmAction::from_key(key);
660 match action {
661 ConfirmAction::Apply => {
662 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
663 *action = Some(ConfirmResultAction::Apply);
664 }
665 UpdateResult::Done
666 }
667 ConfirmAction::Back => {
668 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
670 *action = Some(ConfirmResultAction::Back);
671 UpdateResult::Done
672 } else {
673 self.go_back();
674 UpdateResult::Continue
675 }
676 }
677 ConfirmAction::Exit => {
678 if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
679 *action = Some(ConfirmResultAction::Exit);
680 }
681 UpdateResult::Cancelled
682 }
683 ConfirmAction::None => UpdateResult::Continue,
684 }
685 }
686
687 fn handle_input_submit(&mut self, text: String) -> UpdateResult {
688 match &mut self.data {
689 WorkflowData::Add { phase, uri, id } => match phase {
690 AddPhase::Uri => {
691 let (inferred_id, normalized_uri) = Self::parse_uri_and_infer_id(&text);
692 *uri = Some(normalized_uri);
693 *phase = AddPhase::Id;
694 self.screen = Screen::Input(InputScreen {
695 state: InputState::new(inferred_id.as_deref()),
696 prompt: format!("for {}", text),
697 label: Some("ID".into()),
698 });
699 UpdateResult::Continue
700 }
701 AddPhase::Id => {
702 *id = Some(text);
703 self.transition_to_confirm()
704 }
705 },
706 WorkflowData::Change { uri, .. } => {
707 *uri = Some(text);
708 self.transition_to_confirm()
709 }
710 WorkflowData::Remove { .. }
712 | WorkflowData::SelectOne { .. }
713 | WorkflowData::SelectMany { .. }
714 | WorkflowData::ConfirmOnly { .. }
715 | WorkflowData::Follow { .. } => UpdateResult::Continue,
716 }
717 }
718
719 fn handle_list_submit(&mut self, indices: Vec<usize>, items: Vec<String>) -> UpdateResult {
720 match &mut self.data {
721 WorkflowData::Change {
722 selected_input,
723 input_uris,
724 ..
725 } => {
726 let item = items.into_iter().next().unwrap_or_default();
728 let current_uri = input_uris.get(&item).map(|s| s.as_str());
729 *selected_input = Some(item.clone());
730 self.screen = Screen::Input(InputScreen {
731 state: InputState::with_completions(
732 current_uri,
733 uri_completion_items(Some(&item), &self.cache_config),
734 ),
735 prompt: "Enter new URI".into(),
736 label: Some(item),
737 });
738 UpdateResult::Continue
739 }
740 WorkflowData::SelectOne { selected_input } => {
741 *selected_input = items.into_iter().next();
743 UpdateResult::Done
744 }
745 WorkflowData::Remove {
746 selected_inputs, ..
747 } => {
748 *selected_inputs = items;
749 self.transition_to_confirm()
750 }
751 WorkflowData::SelectMany { selected_inputs } => {
752 *selected_inputs = items;
753 UpdateResult::Done
754 }
755 WorkflowData::Follow {
756 phase,
757 selected_input,
758 selected_target,
759 nested_inputs,
760 top_level_inputs,
761 } => {
762 match phase {
763 FollowPhase::SelectInput => {
764 let index = indices.first().copied().unwrap_or(0);
766 let path = nested_inputs
767 .get(index)
768 .map(|i| i.path.clone())
769 .unwrap_or_default();
770 *selected_input = Some(path.clone());
771 *phase = FollowPhase::SelectTarget;
772 self.screen = Screen::List(ListScreen::single(
773 top_level_inputs.clone(),
774 format!("Select target for {path}"),
775 self.show_diff,
776 ));
777 UpdateResult::Continue
778 }
779 FollowPhase::SelectTarget => {
780 let item = items.into_iter().next().unwrap_or_default();
781 *selected_target = Some(item);
782 self.transition_to_confirm()
783 }
784 }
785 }
786 _ => UpdateResult::Continue,
787 }
788 }
789
790 fn transition_to_confirm(&mut self) -> UpdateResult {
791 if !self.show_diff {
792 return UpdateResult::Done;
793 }
794
795 let change = self.build_change();
796 let diff_str = self.compute_diff(&change);
797 self.screen = Screen::Confirm(ConfirmScreen { diff: diff_str });
798 UpdateResult::Continue
799 }
800
801 fn go_back(&mut self) {
802 match &mut self.data {
803 WorkflowData::Add { phase, id, uri } => {
804 *phase = AddPhase::Id;
805 self.screen = Screen::Input(InputScreen {
806 state: InputState::new(id.as_deref()),
807 prompt: format!("for {}", uri.as_deref().unwrap_or("")),
808 label: Some("ID".into()),
809 });
810 }
811 WorkflowData::Change {
812 selected_input,
813 uri,
814 ..
815 } => {
816 self.screen = Screen::Input(InputScreen {
817 state: InputState::with_completions(
818 uri.as_deref(),
819 uri_completion_items(selected_input.as_deref(), &self.cache_config),
820 ),
821 prompt: "Enter new URI".into(),
822 label: selected_input.clone(),
823 });
824 }
825 WorkflowData::Remove { all_inputs, .. } => {
826 self.screen = Screen::List(ListScreen::multi(
827 all_inputs.clone(),
828 "Select inputs to remove",
829 self.show_diff,
830 ));
831 }
832 WorkflowData::Follow {
833 phase,
834 nested_inputs,
835 top_level_inputs,
836 ..
837 } => {
838 if *phase == FollowPhase::SelectTarget {
841 self.screen = Screen::List(ListScreen::single(
842 top_level_inputs.clone(),
843 "Select target to follow",
844 self.show_diff,
845 ));
846 } else if !nested_inputs.is_empty() {
847 let display_items: Vec<String> = nested_inputs
849 .iter()
850 .map(|i| i.to_display_string())
851 .collect();
852 self.screen = Screen::List(ListScreen::single(
853 display_items,
854 "Select input to add follows",
855 self.show_diff,
856 ));
857 }
858 }
859 WorkflowData::SelectOne { .. }
861 | WorkflowData::SelectMany { .. }
862 | WorkflowData::ConfirmOnly { .. } => {}
863 }
864 }
865
866 fn build_change(&self) -> Change {
867 self.data.build_change()
868 }
869
870 fn compute_diff(&self, change: &Change) -> String {
871 super::workflow::compute_diff(&self.flake_text, change)
872 }
873
874 fn parse_uri_and_infer_id(uri: &str) -> (Option<String>, String) {
875 super::workflow::parse_uri_and_infer_id(uri)
876 }
877
878 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
879 match &self.screen {
880 Screen::Input(screen) => {
881 let input = Input::new(
882 &screen.state,
883 &screen.prompt,
884 &self.context,
885 screen.label.as_deref(),
886 self.show_diff,
887 );
888 Some(input.cursor_position(area))
889 }
890 _ => None,
891 }
892 }
893
894 pub fn terminal_height(&self) -> u16 {
895 match &self.screen {
896 Screen::Input(screen) => {
897 let input = Input::new(
898 &screen.state,
899 &screen.prompt,
900 &self.context,
901 screen.label.as_deref(),
902 self.show_diff,
903 );
904 input.required_height()
905 }
906 Screen::List(s) => super::helpers::list_height(s.items.len(), MAX_LIST_HEIGHT),
907 Screen::Confirm(s) => super::helpers::diff_height(s.diff.lines().count()),
908 }
909 }
910
911 pub fn extract_result(self) -> Option<AppResult> {
912 match self.data {
913 WorkflowData::Add { .. }
914 | WorkflowData::Change { .. }
915 | WorkflowData::Remove { .. }
916 | WorkflowData::Follow { .. } => {
917 let change = self.build_change();
918 if matches!(change, Change::None) {
919 None
920 } else {
921 Some(AppResult::Change(change))
922 }
923 }
924 WorkflowData::SelectOne { selected_input } => selected_input.map(|item| {
925 AppResult::SingleSelect(SingleSelectResult {
926 item,
927 show_diff: self.show_diff,
928 })
929 }),
930 WorkflowData::SelectMany { selected_inputs } => {
931 if selected_inputs.is_empty() {
932 None
933 } else {
934 Some(AppResult::MultiSelect(MultiSelectResultData {
935 items: selected_inputs,
936 show_diff: self.show_diff,
937 }))
938 }
939 }
940 WorkflowData::ConfirmOnly { action } => action.map(AppResult::Confirm),
941 }
942 }
943}