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            // These commands handle their own interactivity or don't need TUI
430            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    /// Get the Change that would be applied based on current workflow state.
451    /// Useful for testing to verify what modification the TUI would produce.
452    pub fn pending_change(&self) -> Change {
453        self.build_change()
454    }
455
456    /// Compute the diff string for the current change against the flake text.
457    /// Returns the unified diff showing what would change.
458    ///
459    /// This looks at the current screen state (including list selections)
460    /// to compute a live preview of what would happen.
461    pub fn pending_diff(&self) -> String {
462        let change = self.build_preview_change();
463        self.compute_diff(&change)
464    }
465
466    /// Build a Change based on current screen state for live preview.
467    /// Unlike build_change(), this looks at current screen input/selections.
468    fn build_preview_change(&self) -> Change {
469        match &self.screen {
470            // For Input screens, use current input text
471            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            // For List screens, use current selections
498            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                            // During SelectInput phase, we can't preview yet
517                            // During SelectTarget phase, use selected_input + current selection
518                            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                                // SelectInput phase - no preview possible
531                                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    /// Set the initial diff mode
550    pub fn with_diff(mut self, show_diff: bool) -> Self {
551        self.show_diff = show_diff;
552        // Also update ListState if we're on a list screen
553        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                            // In Add workflow, Escape from ID input goes back to URI input
582                            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                            // In Change workflow, Escape from URI input goes back to list
597                            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                    // For Follow workflow, Escape from SelectTarget goes back to SelectInput
632                    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                // For ConfirmOnly workflow, Back is a result, not a navigation
674                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            // These workflows don't use input screens
716            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                // Single-select: take first item
732                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                // Single-select: take first item
747                *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                        // Use index to look up the path from nested_inputs
770                        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                // go_back is called from confirm screen, so we need to go back to
844                // the target selection list (SelectTarget phase)
845                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                    // SelectInput phase - go back to input selection
853                    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            // Standalone workflows don't have a "back" concept - they're single screen
865            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}