flake_edit/tui/
app.rs

1//! Unified TUI application model
2//!
3//! This module provides a unified state machine for the TUI that:
4//! - Manages the complete workflow (Add/Change/Remove)
5//! - Handles screen transitions internally
6//! - Maintains global state (show_diff) across screens
7//! - Is fully testable with pure update() and render() functions
8
9use 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
23// Re-export workflow types that are part of the public API
24pub 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/// The current screen being displayed
39#[derive(Debug, Clone)]
40pub enum Screen {
41    Input(InputScreen),
42    List(ListScreen),
43    Confirm(ConfirmScreen),
44}
45
46/// Input screen state
47#[derive(Debug, Clone)]
48pub struct InputScreen {
49    pub state: InputState,
50    pub prompt: String,
51    pub label: Option<String>,
52}
53
54/// List screen state (unified single and multi-select)
55#[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/// Confirm screen state
83#[derive(Debug, Clone)]
84pub struct ConfirmScreen {
85    pub diff: String,
86}
87
88impl App {
89    /// Create a new App for the Add workflow
90    ///
91    /// The workflow asks for URI first, then ID (with inferred ID as default).
92    /// Optionally provide a prefill_uri if the user provided a URI on the command line.
93    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    /// Create a new App for the Change workflow
119    ///
120    /// Takes a list of (id, current_uri) pairs. The current_uri is used to
121    /// prefill the input field when an item is selected.
122    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    /// Create a new App for the Remove workflow
150    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    /// Create a new App for the Change workflow when ID is already known
173    ///
174    /// This is a single-screen variant that just asks for the new URI.
175    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    /// Create a standalone single-select App (for Pin/Unpin workflows)
205    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    /// Create a standalone multi-select App (for Update workflow)
224    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    /// Create a standalone confirmation App with pre-computed diff
243    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    /// Create a new App for the Follow workflow
255    ///
256    /// Shows a list of nested inputs to select from, then a list of targets.
257    /// `nested_inputs` contains the nested input paths with their existing follows info
258    /// `top_level_inputs` are the available targets like "nixpkgs", "flake-utils"
259    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        // Convert to display strings for the UI
266        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    /// Create a new App for the Follow workflow when input is already selected
291    ///
292    /// Shows only the target selection list.
293    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    /// Create an App from a CLI Command.
321    ///
322    /// This is the main entry point for creating TUI apps from parsed CLI arguments.
323    /// Returns `None` if the command doesn't need interactive TUI (e.g., all args provided,
324    /// or it's a non-interactive command like List).
325    ///
326    /// # Arguments
327    /// * `command` - The parsed CLI command
328    /// * `flake_text` - The content of the flake.nix file
329    /// * `inputs` - List of (id, uri) pairs representing current inputs
330    /// * `diff` - Whether diff mode is enabled
331    /// * `cache_config` - Cache configuration for completions
332    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            // Add: interactive if no id+uri provided
344            Command::Add { id, uri, .. } => {
345                if id.is_some() && uri.is_some() {
346                    None // Non-interactive: all args provided
347                } else {
348                    // prefill_uri is the first positional arg (id field) when uri is None
349                    let prefill = id.as_deref();
350                    Some(Self::add("Add", flake_text, prefill, cache_config).with_diff(diff))
351                }
352            }
353
354            // Remove: interactive if no id provided
355            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            // Change: multiple interactive modes
364            Command::Change { id, uri, .. } => {
365                if id.is_some() && uri.is_some() {
366                    None // Non-interactive: all args provided
367                } else if let Some(id) = id {
368                    // ID provided but no URI: show URI input for that specific input
369                    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                    // No args: show input selection list
383                    Some(Self::change("Change", flake_text, inputs, cache_config).with_diff(diff))
384                }
385            }
386
387            // Pin: interactive if no id provided
388            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            // Unpin: interactive if no id provided
402            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            // Update: interactive if no id provided
416            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            // List, Completion, and Follow don't need TUI
430            // Follow always requires both input and target arguments
431            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    /// Get the Change that would be applied based on current workflow state.
448    /// Useful for testing to verify what modification the TUI would produce.
449    pub fn pending_change(&self) -> Change {
450        self.build_change()
451    }
452
453    /// Compute the diff string for the current change against the flake text.
454    /// Returns the unified diff showing what would change.
455    ///
456    /// This looks at the current screen state (including list selections)
457    /// to compute a live preview of what would happen.
458    pub fn pending_diff(&self) -> String {
459        let change = self.build_preview_change();
460        self.compute_diff(&change)
461    }
462
463    /// Build a Change based on current screen state for live preview.
464    /// Unlike build_change(), this looks at current screen input/selections.
465    fn build_preview_change(&self) -> Change {
466        match &self.screen {
467            // For Input screens, use current input text
468            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            // For List screens, use current selections
495            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                            // During SelectInput phase, we can't preview yet
514                            // During SelectTarget phase, use selected_input + current selection
515                            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                                // SelectInput phase - no preview possible
528                                Change::None
529                            }
530                        }
531                        _ => self.build_change(),
532                    };
533                }
534                // For Follow workflow, return None when nothing selected yet
535                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    /// Set the initial diff mode
545    pub fn with_diff(mut self, show_diff: bool) -> Self {
546        self.show_diff = show_diff;
547        // Also update ListState if we're on a list screen
548        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                            // In Add workflow, Escape from ID input goes back to URI input
577                            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                            // In Change workflow, Escape from URI input goes back to list
592                            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                    // For Follow workflow, Escape from SelectTarget goes back to SelectInput
627                    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                // For ConfirmOnly workflow, Back is a result, not a navigation
669                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            // These workflows don't use input screens
711            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                // Single-select: take first item
727                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                // Single-select: take first item
742                *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                        // Use index to look up the path from nested_inputs
765                        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                // go_back is called from confirm screen, so we need to go back to
839                // the target selection list (SelectTarget phase)
840                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                    // SelectInput phase - go back to input selection
848                    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            // Standalone workflows don't have a "back" concept - they're single screen
860            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}