Skip to main content

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