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 prompt: String,
59}
60
61impl ListScreen {
62    pub fn single(items: Vec<String>, prompt: impl Into<String>, show_diff: bool) -> Self {
63        Self {
64            state: ListState::new(items, false, show_diff),
65            prompt: prompt.into(),
66        }
67    }
68
69    pub fn multi(items: Vec<String>, prompt: impl Into<String>, show_diff: bool) -> Self {
70        Self {
71            state: ListState::new(items, true, show_diff),
72            prompt: prompt.into(),
73        }
74    }
75}
76
77/// Confirm screen state
78#[derive(Debug, Clone)]
79pub struct ConfirmScreen {
80    pub diff: String,
81}
82
83impl App {
84    /// Create a new App for the Add workflow
85    ///
86    /// The workflow asks for URI first, then ID (with inferred ID as default).
87    /// Optionally provide a prefill_uri if the user provided a URI on the command line.
88    pub fn add(
89        context: impl Into<String>,
90        flake_text: impl Into<String>,
91        prefill_uri: Option<&str>,
92        cache_config: CacheConfig,
93    ) -> Self {
94        let completions = uri_completion_items(None, &cache_config);
95        Self {
96            context: context.into(),
97            flake_text: flake_text.into(),
98            show_diff: false,
99            cache_config,
100            screen: Screen::Input(InputScreen {
101                state: InputState::with_completions(prefill_uri, completions),
102                prompt: "Enter flake URI".into(),
103                label: None,
104            }),
105            data: WorkflowData::Add {
106                step: AddStep::Uri,
107                uri: None,
108                id: None,
109            },
110        }
111    }
112
113    /// Create a new App for the Change workflow
114    ///
115    /// Takes a list of (id, current_uri) pairs. The current_uri is used to
116    /// prefill the input field when an item is selected.
117    pub fn change(
118        context: impl Into<String>,
119        flake_text: impl Into<String>,
120        inputs: Vec<(String, String)>,
121        cache_config: CacheConfig,
122    ) -> Self {
123        let input_ids: Vec<String> = inputs.iter().map(|(id, _)| id.clone()).collect();
124        let input_uris: std::collections::HashMap<String, String> = inputs.into_iter().collect();
125        Self {
126            context: context.into(),
127            flake_text: flake_text.into(),
128            show_diff: false,
129            cache_config,
130            screen: Screen::List(ListScreen::single(
131                input_ids.clone(),
132                "Select input to change",
133                false,
134            )),
135            data: WorkflowData::Change {
136                selected_input: None,
137                uri: None,
138                input_uris,
139                all_inputs: input_ids,
140            },
141        }
142    }
143
144    /// Create a new App for the Remove workflow
145    pub fn remove(
146        context: impl Into<String>,
147        flake_text: impl Into<String>,
148        inputs: Vec<String>,
149    ) -> Self {
150        Self {
151            context: context.into(),
152            flake_text: flake_text.into(),
153            show_diff: false,
154            cache_config: CacheConfig::default(),
155            screen: Screen::List(ListScreen::multi(
156                inputs.clone(),
157                "Select inputs to remove",
158                false,
159            )),
160            data: WorkflowData::Remove {
161                selected_inputs: Vec::new(),
162                all_inputs: inputs,
163            },
164        }
165    }
166
167    /// Create a new App for the Change workflow when ID is already known
168    ///
169    /// This is a single-screen variant that just asks for the new URI.
170    pub fn change_uri(
171        context: impl Into<String>,
172        flake_text: impl Into<String>,
173        id: impl Into<String>,
174        current_uri: Option<&str>,
175        show_diff: bool,
176        cache_config: CacheConfig,
177    ) -> Self {
178        let id_string = id.into();
179        let completions = uri_completion_items(Some(&id_string), &cache_config);
180        Self {
181            context: context.into(),
182            flake_text: flake_text.into(),
183            show_diff,
184            cache_config,
185            screen: Screen::Input(InputScreen {
186                state: InputState::with_completions(current_uri, completions),
187                prompt: format!("for {}", id_string),
188                label: Some("URI".into()),
189            }),
190            data: WorkflowData::Change {
191                selected_input: Some(id_string),
192                uri: None,
193                input_uris: std::collections::HashMap::new(),
194                all_inputs: Vec::new(),
195            },
196        }
197    }
198
199    /// Create a standalone single-select App (for Pin/Unpin workflows)
200    pub fn select_one(
201        context: impl Into<String>,
202        prompt: impl Into<String>,
203        items: Vec<String>,
204        initial_diff: bool,
205    ) -> Self {
206        Self {
207            context: context.into(),
208            flake_text: String::new(),
209            show_diff: initial_diff,
210            cache_config: CacheConfig::default(),
211            screen: Screen::List(ListScreen::single(items, prompt, initial_diff)),
212            data: WorkflowData::SelectOne {
213                selected_input: None,
214            },
215        }
216    }
217
218    /// Create a standalone multi-select App (for Update workflow)
219    pub fn select_many(
220        context: impl Into<String>,
221        prompt: impl Into<String>,
222        items: Vec<String>,
223        initial_diff: bool,
224    ) -> Self {
225        Self {
226            context: context.into(),
227            flake_text: String::new(),
228            show_diff: initial_diff,
229            cache_config: CacheConfig::default(),
230            screen: Screen::List(ListScreen::multi(items, prompt, initial_diff)),
231            data: WorkflowData::SelectMany {
232                selected_inputs: Vec::new(),
233            },
234        }
235    }
236
237    /// Create a standalone confirmation App with pre-computed diff
238    pub fn confirm(context: impl Into<String>, diff: impl Into<String>) -> Self {
239        Self {
240            context: context.into(),
241            flake_text: String::new(),
242            show_diff: true,
243            cache_config: CacheConfig::default(),
244            screen: Screen::Confirm(ConfirmScreen { diff: diff.into() }),
245            data: WorkflowData::ConfirmOnly { action: None },
246        }
247    }
248
249    /// Create a new App for the Follow workflow
250    ///
251    /// Shows a list of nested inputs to select from, then a list of targets.
252    /// `nested_inputs` contains the nested input paths with their existing follows info
253    /// `top_level_inputs` are the available targets like "nixpkgs", "flake-utils"
254    pub fn follow(
255        context: impl Into<String>,
256        flake_text: impl Into<String>,
257        nested_inputs: Vec<NestedInput>,
258        top_level_inputs: Vec<String>,
259    ) -> Self {
260        // Convert to display strings for the UI
261        let display_items: Vec<String> = nested_inputs
262            .iter()
263            .map(|i| i.to_display_string())
264            .collect();
265        Self {
266            context: context.into(),
267            flake_text: flake_text.into(),
268            show_diff: false,
269            cache_config: CacheConfig::default(),
270            screen: Screen::List(ListScreen::single(
271                display_items,
272                "Select input to add follows",
273                false,
274            )),
275            data: WorkflowData::Follow {
276                step: FollowStep::SelectInput,
277                selected_input: None,
278                selected_target: None,
279                nested_inputs,
280                top_level_inputs,
281            },
282        }
283    }
284
285    /// Create a new App for the Follow workflow when input is already selected
286    ///
287    /// Shows only the target selection list.
288    pub fn follow_target(
289        context: impl Into<String>,
290        flake_text: impl Into<String>,
291        input: impl Into<String>,
292        top_level_inputs: Vec<String>,
293    ) -> Self {
294        let input = input.into();
295        Self {
296            context: context.into(),
297            flake_text: flake_text.into(),
298            show_diff: false,
299            cache_config: CacheConfig::default(),
300            screen: Screen::List(ListScreen::single(
301                top_level_inputs.clone(),
302                format!("Select target for {input}"),
303                false,
304            )),
305            data: WorkflowData::Follow {
306                step: FollowStep::SelectTarget,
307                selected_input: Some(input),
308                selected_target: None,
309                nested_inputs: Vec::<NestedInput>::new(),
310                top_level_inputs,
311            },
312        }
313    }
314
315    /// Create an App from a CLI Command.
316    ///
317    /// This is the main entry point for creating TUI apps from parsed CLI arguments.
318    /// Returns `None` if the command doesn't need interactive TUI (e.g., all args provided,
319    /// or it's a non-interactive command like List).
320    ///
321    /// # Arguments
322    /// * `command` - The parsed CLI command
323    /// * `flake_text` - The content of the flake.nix file
324    /// * `inputs` - List of (id, uri) pairs representing current inputs
325    /// * `diff` - Whether diff mode is enabled
326    /// * `cache_config` - Cache configuration for completions
327    pub fn from_command(
328        command: &Command,
329        flake_text: impl Into<String>,
330        inputs: Vec<(String, String)>,
331        diff: bool,
332        cache_config: CacheConfig,
333    ) -> Option<Self> {
334        let flake_text = flake_text.into();
335        let input_ids: Vec<String> = inputs.iter().map(|(id, _)| id.clone()).collect();
336
337        match command {
338            // Add: interactive if no id+uri provided
339            Command::Add { id, uri, .. } => {
340                if id.is_some() && uri.is_some() {
341                    None // Non-interactive: all args provided
342                } else {
343                    // prefill_uri is the first positional arg (id field) when uri is None
344                    let prefill = id.as_deref();
345                    Some(Self::add("Add", flake_text, prefill, cache_config).with_diff(diff))
346                }
347            }
348
349            // Remove: interactive if no id provided
350            Command::Remove { id } => {
351                if id.is_some() {
352                    None
353                } else {
354                    Some(Self::remove("Remove", flake_text, input_ids).with_diff(diff))
355                }
356            }
357
358            // Change: multiple interactive modes
359            Command::Change { id, uri, .. } => {
360                if id.is_some() && uri.is_some() {
361                    None // Non-interactive: all args provided
362                } else if let Some(id) = id {
363                    // ID provided but no URI: show URI input for that specific input
364                    let current_uri = inputs
365                        .iter()
366                        .find(|(i, _)| i == id)
367                        .map(|(_, u)| u.as_str());
368                    Some(Self::change_uri(
369                        "Change",
370                        flake_text,
371                        id,
372                        current_uri,
373                        diff,
374                        cache_config,
375                    ))
376                } else {
377                    // No args: show input selection list
378                    Some(Self::change("Change", flake_text, inputs, cache_config).with_diff(diff))
379                }
380            }
381
382            // Pin: interactive if no id provided
383            Command::Pin { id, .. } => {
384                if id.is_some() {
385                    None
386                } else {
387                    Some(Self::select_one(
388                        "Pin",
389                        "Select input to pin",
390                        input_ids,
391                        diff,
392                    ))
393                }
394            }
395
396            // Unpin: interactive if no id provided
397            Command::Unpin { id } => {
398                if id.is_some() {
399                    None
400                } else {
401                    Some(Self::select_one(
402                        "Unpin",
403                        "Select input to unpin",
404                        input_ids,
405                        diff,
406                    ))
407                }
408            }
409
410            // Update: interactive if no id provided
411            Command::Update { id, .. } => {
412                if id.is_some() {
413                    None
414                } else {
415                    Some(Self::select_many(
416                        "Update",
417                        "Space select, U all, ^D diff",
418                        input_ids,
419                        diff,
420                    ))
421                }
422            }
423
424            // These commands handle their own interactivity or don't need TUI
425            Command::List { .. }
426            | Command::Follow { .. }
427            | Command::AddFollow { .. }
428            | Command::Toggle { .. }
429            | Command::Config { .. } => None,
430        }
431    }
432
433    pub fn show_diff(&self) -> bool {
434        self.show_diff
435    }
436
437    pub fn screen(&self) -> &Screen {
438        &self.screen
439    }
440
441    pub fn context(&self) -> &str {
442        &self.context
443    }
444
445    /// Compute the diff string for the current change against the flake text.
446    /// Returns the unified diff showing what would change.
447    ///
448    /// This looks at the current screen state (including list selections)
449    /// to compute a live preview of what would happen.
450    pub fn pending_diff(&self) -> String {
451        let change = self.build_preview_change();
452        self.compute_diff(&change)
453    }
454
455    /// Build a Change based on current screen state for live preview.
456    /// Unlike build_change(), this looks at current screen input/selections.
457    fn build_preview_change(&self) -> Change {
458        match &self.screen {
459            // For Input screens, use current input text
460            Screen::Input(screen) => {
461                let current_text = screen.state.text();
462                if current_text.is_empty() {
463                    return Change::None;
464                }
465                match &self.data {
466                    WorkflowData::Add { step, uri, .. } => match step {
467                        AddStep::Uri => Change::Add {
468                            id: None,
469                            uri: Some(current_text.to_string()),
470                            flake: true,
471                        },
472                        AddStep::Id => Change::Add {
473                            id: crate::change::ChangeId::parse(current_text).ok(),
474                            uri: uri.clone(),
475                            flake: true,
476                        },
477                    },
478                    WorkflowData::Change { selected_input, .. } => Change::Change {
479                        id: selected_input
480                            .as_deref()
481                            .and_then(|s| crate::change::ChangeId::parse(s).ok()),
482                        uri: Some(current_text.to_string()),
483                    },
484                    _ => self.build_change(),
485                }
486            }
487            // For List screens, use current selections
488            Screen::List(screen) => {
489                let selected_items: Vec<String> = screen
490                    .state
491                    .selected_indices()
492                    .iter()
493                    .filter_map(|&i| screen.state.items().get(i).cloned())
494                    .collect();
495
496                if !selected_items.is_empty() {
497                    return match &self.data {
498                        WorkflowData::Remove { .. } => Change::Remove {
499                            ids: selected_items
500                                .into_iter()
501                                .filter_map(|s| crate::change::ChangeId::parse(&s).ok())
502                                .collect(),
503                        },
504                        WorkflowData::Follow {
505                            step,
506                            selected_input,
507                            ..
508                        } => {
509                            // During SelectInput step, we can't preview yet
510                            // During SelectTarget step, use selected_input + current selection
511                            if *step == FollowStep::SelectTarget {
512                                if let Some(input) = selected_input {
513                                    let target_str: String =
514                                        selected_items.into_iter().next().unwrap_or_default();
515                                    let parsed = crate::change::ChangeId::parse(input)
516                                        .ok()
517                                        .zip(crate::follows::AttrPath::parse(&target_str).ok());
518                                    if let Some((change_id, target_path)) = parsed {
519                                        Change::Follows {
520                                            input: change_id,
521                                            target: target_path,
522                                        }
523                                    } else {
524                                        Change::None
525                                    }
526                                } else {
527                                    Change::None
528                                }
529                            } else {
530                                // SelectInput step - 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.set_show_diff(show_diff);
555        }
556        self
557    }
558
559    pub fn update(&mut self, key: KeyEvent) -> UpdateResult {
560        let screen = self.screen.clone();
561        match screen {
562            Screen::Input(s) => self.update_input(s, key),
563            Screen::List(s) => self.update_list(s, key),
564            Screen::Confirm(_) => self.update_confirm(key),
565        }
566    }
567
568    fn update_input(&mut self, mut screen: InputScreen, key: KeyEvent) -> UpdateResult {
569        let action = InputAction::from_key(key);
570        match action {
571            InputAction::ToggleDiff => {
572                self.show_diff = !self.show_diff;
573                UpdateResult::Continue
574            }
575            _ => {
576                if let Some(result) = screen.state.handle(action) {
577                    match result {
578                        InputResult::Submit(text) => self.handle_input_submit(text),
579                        InputResult::Cancel => {
580                            // In Add workflow, Escape from ID input goes back to URI input
581                            if let WorkflowData::Add { step, uri, .. } = &mut self.data
582                                && *step == AddStep::Id
583                            {
584                                *step = AddStep::Uri;
585                                self.screen = Screen::Input(InputScreen {
586                                    state: InputState::with_completions(
587                                        uri.as_deref(),
588                                        uri_completion_items(None, &self.cache_config),
589                                    ),
590                                    prompt: "Enter flake URI".into(),
591                                    label: None,
592                                });
593                                return UpdateResult::Continue;
594                            }
595                            // In Change workflow, Escape from URI input goes back to list
596                            if let WorkflowData::Change { all_inputs, .. } = &self.data
597                                && !all_inputs.is_empty()
598                            {
599                                self.screen = Screen::List(ListScreen::single(
600                                    all_inputs.clone(),
601                                    "Select input to change",
602                                    self.show_diff,
603                                ));
604                                return UpdateResult::Continue;
605                            }
606                            UpdateResult::Cancelled
607                        }
608                    }
609                } else {
610                    if let Screen::Input(s) = &mut self.screen {
611                        s.state = screen.state;
612                    }
613                    UpdateResult::Continue
614                }
615            }
616        }
617    }
618
619    fn update_list(&mut self, mut screen: ListScreen, key: KeyEvent) -> UpdateResult {
620        let action = ListAction::from_key(key, screen.state.search_active());
621        if let Some(result) = screen.state.handle(action) {
622            match result {
623                ListResult::Select(indices, show_diff) => {
624                    self.show_diff = show_diff;
625                    let items: Vec<String> = indices
626                        .iter()
627                        .map(|&i| screen.state.items()[i].clone())
628                        .collect();
629                    self.handle_list_submit(indices, items)
630                }
631                ListResult::Cancel => {
632                    // For Follow workflow, Escape from SelectTarget goes back to SelectInput
633                    if let WorkflowData::Follow {
634                        step,
635                        nested_inputs,
636                        ..
637                    } = &mut self.data
638                        && *step == FollowStep::SelectTarget
639                        && !nested_inputs.is_empty()
640                    {
641                        *step = FollowStep::SelectInput;
642                        let display_items: Vec<String> = nested_inputs
643                            .iter()
644                            .map(|i| i.to_display_string())
645                            .collect();
646                        self.screen = Screen::List(ListScreen::single(
647                            display_items,
648                            "Select input to add follows",
649                            self.show_diff,
650                        ));
651                        return UpdateResult::Continue;
652                    }
653                    UpdateResult::Cancelled
654                }
655            }
656        } else {
657            if let Screen::List(s) = &mut self.screen {
658                s.state = screen.state;
659            }
660            UpdateResult::Continue
661        }
662    }
663
664    fn update_confirm(&mut self, key: KeyEvent) -> UpdateResult {
665        let action = ConfirmAction::from_key(key);
666        match action {
667            ConfirmAction::Apply => {
668                if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
669                    *action = Some(ConfirmResultAction::Apply);
670                }
671                UpdateResult::Done
672            }
673            ConfirmAction::Back => {
674                // For ConfirmOnly workflow, Back is a result, not a navigation
675                if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
676                    *action = Some(ConfirmResultAction::Back);
677                    UpdateResult::Done
678                } else {
679                    self.go_back();
680                    UpdateResult::Continue
681                }
682            }
683            ConfirmAction::Exit => {
684                if let WorkflowData::ConfirmOnly { action, .. } = &mut self.data {
685                    *action = Some(ConfirmResultAction::Exit);
686                }
687                UpdateResult::Cancelled
688            }
689            ConfirmAction::None => UpdateResult::Continue,
690        }
691    }
692
693    fn handle_input_submit(&mut self, text: String) -> UpdateResult {
694        match &mut self.data {
695            WorkflowData::Add { step, uri, id } => match step {
696                AddStep::Uri => {
697                    let (inferred_id, normalized_uri) = Self::parse_uri_and_infer_id(&text);
698                    *uri = Some(normalized_uri);
699                    *step = AddStep::Id;
700                    self.screen = Screen::Input(InputScreen {
701                        state: InputState::new(inferred_id.as_deref()),
702                        prompt: format!("for {}", text),
703                        label: Some("ID".into()),
704                    });
705                    UpdateResult::Continue
706                }
707                AddStep::Id => {
708                    *id = Some(text);
709                    self.transition_to_confirm()
710                }
711            },
712            WorkflowData::Change { uri, .. } => {
713                *uri = Some(text);
714                self.transition_to_confirm()
715            }
716            // These workflows don't use input screens
717            WorkflowData::Remove { .. }
718            | WorkflowData::SelectOne { .. }
719            | WorkflowData::SelectMany { .. }
720            | WorkflowData::ConfirmOnly { .. }
721            | WorkflowData::Follow { .. } => UpdateResult::Continue,
722        }
723    }
724
725    fn handle_list_submit(&mut self, indices: Vec<usize>, items: Vec<String>) -> UpdateResult {
726        match &mut self.data {
727            WorkflowData::Change {
728                selected_input,
729                input_uris,
730                ..
731            } => {
732                // Single-select: take first item
733                let item = items.into_iter().next().unwrap_or_default();
734                let current_uri = input_uris.get(&item).map(|s| s.as_str());
735                *selected_input = Some(item.clone());
736                self.screen = Screen::Input(InputScreen {
737                    state: InputState::with_completions(
738                        current_uri,
739                        uri_completion_items(Some(&item), &self.cache_config),
740                    ),
741                    prompt: "Enter new URI".into(),
742                    label: Some(item),
743                });
744                UpdateResult::Continue
745            }
746            WorkflowData::SelectOne { selected_input } => {
747                // Single-select: take first item
748                *selected_input = items.into_iter().next();
749                UpdateResult::Done
750            }
751            WorkflowData::Remove {
752                selected_inputs, ..
753            } => {
754                *selected_inputs = items;
755                self.transition_to_confirm()
756            }
757            WorkflowData::SelectMany { selected_inputs } => {
758                *selected_inputs = items;
759                UpdateResult::Done
760            }
761            WorkflowData::Follow {
762                step,
763                selected_input,
764                selected_target,
765                nested_inputs,
766                top_level_inputs,
767            } => {
768                match step {
769                    FollowStep::SelectInput => {
770                        // Use index to look up the path from nested_inputs
771                        let index = indices.first().copied().unwrap_or(0);
772                        let path = nested_inputs
773                            .get(index)
774                            .map(|i| i.path.to_string())
775                            .unwrap_or_default();
776                        *selected_input = Some(path.clone());
777                        *step = FollowStep::SelectTarget;
778                        self.screen = Screen::List(ListScreen::single(
779                            top_level_inputs.clone(),
780                            format!("Select target for {path}"),
781                            self.show_diff,
782                        ));
783                        UpdateResult::Continue
784                    }
785                    FollowStep::SelectTarget => {
786                        let item = items.into_iter().next().unwrap_or_default();
787                        *selected_target = Some(item);
788                        self.transition_to_confirm()
789                    }
790                }
791            }
792            _ => UpdateResult::Continue,
793        }
794    }
795
796    fn transition_to_confirm(&mut self) -> UpdateResult {
797        if !self.show_diff {
798            return UpdateResult::Done;
799        }
800
801        let change = self.build_change();
802        let diff_str = self.compute_diff(&change);
803        self.screen = Screen::Confirm(ConfirmScreen { diff: diff_str });
804        UpdateResult::Continue
805    }
806
807    fn go_back(&mut self) {
808        match &mut self.data {
809            WorkflowData::Add { step, id, uri } => {
810                *step = AddStep::Id;
811                self.screen = Screen::Input(InputScreen {
812                    state: InputState::new(id.as_deref()),
813                    prompt: format!("for {}", uri.as_deref().unwrap_or("")),
814                    label: Some("ID".into()),
815                });
816            }
817            WorkflowData::Change {
818                selected_input,
819                uri,
820                ..
821            } => {
822                self.screen = Screen::Input(InputScreen {
823                    state: InputState::with_completions(
824                        uri.as_deref(),
825                        uri_completion_items(selected_input.as_deref(), &self.cache_config),
826                    ),
827                    prompt: "Enter new URI".into(),
828                    label: selected_input.clone(),
829                });
830            }
831            WorkflowData::Remove { all_inputs, .. } => {
832                self.screen = Screen::List(ListScreen::multi(
833                    all_inputs.clone(),
834                    "Select inputs to remove",
835                    self.show_diff,
836                ));
837            }
838            WorkflowData::Follow {
839                step,
840                nested_inputs,
841                top_level_inputs,
842                ..
843            } => {
844                // go_back is called from confirm screen, so we need to go back to
845                // the target selection list (SelectTarget step)
846                if *step == FollowStep::SelectTarget {
847                    self.screen = Screen::List(ListScreen::single(
848                        top_level_inputs.clone(),
849                        "Select target to follow",
850                        self.show_diff,
851                    ));
852                } else if !nested_inputs.is_empty() {
853                    // SelectInput step - go back to input selection
854                    let display_items: Vec<String> = nested_inputs
855                        .iter()
856                        .map(|i| i.to_display_string())
857                        .collect();
858                    self.screen = Screen::List(ListScreen::single(
859                        display_items,
860                        "Select input to add follows",
861                        self.show_diff,
862                    ));
863                }
864            }
865            // Standalone workflows don't have a "back" concept - they're single screen
866            WorkflowData::SelectOne { .. }
867            | WorkflowData::SelectMany { .. }
868            | WorkflowData::ConfirmOnly { .. } => {}
869        }
870    }
871
872    fn build_change(&self) -> Change {
873        self.data.build_change()
874    }
875
876    fn compute_diff(&self, change: &Change) -> String {
877        super::workflow::compute_diff(&self.flake_text, change)
878    }
879
880    fn parse_uri_and_infer_id(uri: &str) -> (Option<String>, String) {
881        super::workflow::parse_uri_and_infer_id(uri)
882    }
883
884    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
885        match &self.screen {
886            Screen::Input(screen) => {
887                let input = Input::new(
888                    &screen.state,
889                    &screen.prompt,
890                    &self.context,
891                    screen.label.as_deref(),
892                    self.show_diff,
893                );
894                Some(input.cursor_position(area))
895            }
896            _ => None,
897        }
898    }
899
900    pub fn terminal_height(&self) -> u16 {
901        match &self.screen {
902            Screen::Input(screen) => {
903                let input = Input::new(
904                    &screen.state,
905                    &screen.prompt,
906                    &self.context,
907                    screen.label.as_deref(),
908                    self.show_diff,
909                );
910                input.required_height()
911            }
912            Screen::List(s) => super::helpers::list_height(s.state.len(), MAX_LIST_HEIGHT),
913            Screen::Confirm(s) => super::helpers::diff_height(s.diff.lines().count()),
914        }
915    }
916
917    pub fn extract_result(self) -> Option<AppResult> {
918        match self.data {
919            WorkflowData::Add { .. }
920            | WorkflowData::Change { .. }
921            | WorkflowData::Remove { .. }
922            | WorkflowData::Follow { .. } => {
923                let change = self.build_change();
924                if matches!(change, Change::None) {
925                    None
926                } else {
927                    Some(AppResult::Change(change))
928                }
929            }
930            WorkflowData::SelectOne { selected_input } => selected_input.map(|item| {
931                AppResult::SingleSelect(SingleSelectResult {
932                    item,
933                    show_diff: self.show_diff,
934                })
935            }),
936            WorkflowData::SelectMany { selected_inputs } => {
937                if selected_inputs.is_empty() {
938                    None
939                } else {
940                    Some(AppResult::MultiSelect(MultiSelectResultData {
941                        items: selected_inputs,
942                        show_diff: self.show_diff,
943                    }))
944                }
945            }
946            WorkflowData::ConfirmOnly { action } => action.map(AppResult::Confirm),
947        }
948    }
949}