Skip to main content

wt/tui/
event.rs

1//! Pure event handling for the TUI (spec §10). [`App::handle_event`] maps a
2//! terminal event to a state mutation and an [`Effect`] for the runtime to
3//! execute (switch, create, remove, refresh, …). No terminal I/O happens here,
4//! which is what makes the whole interaction testable.
5
6use std::path::PathBuf;
7
8use crossterm::event::{
9    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
10};
11
12use crate::keys::{KeyAction, KeyChord};
13use crate::tui::app::{App, ComposeField, CreateState, CreateStep, MIN_HEIGHT, Mode, Pane};
14
15/// The minimum/maximum list-pane width when resizing.
16const MIN_SIDEBAR: u16 = 10;
17const MAX_SIDEBAR: u16 = 100;
18/// Rows above the first list row (border) for mouse hit-testing.
19const LIST_TOP: u16 = 1;
20
21/// The user's answer to the stale-base confirm modal (issue #56): update the
22/// base branch (fast-forward it) before creating, or proceed off it as-is.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CreateDecision {
25    /// Fast-forward the base to its upstream, then create off the updated base.
26    Update,
27    /// Create off the (stale) base as-is.
28    Proceed,
29}
30
31/// An action for the runtime to perform after a state transition.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Effect {
34    /// Nothing to do.
35    None,
36    /// Switch to the given path (print it, exit).
37    Switch(PathBuf),
38    /// Quit without switching.
39    Quit,
40    /// The terminal is too small; exit with a message (spec §10).
41    TooSmall,
42    /// Create a worktree for `branch` based on `base`. `decision` is `None` for
43    /// the initial attempt — the runtime then pre-flights the base for staleness
44    /// (issue #56) and may bounce to the confirm modal — or a concrete choice when
45    /// re-issued from that modal (update the base first, or proceed as-is).
46    Create {
47        /// The new branch name.
48        branch: String,
49        /// The base ref (or `None` for the default).
50        base: Option<String>,
51        /// The stale-base decision: `None` to pre-flight, else the chosen action.
52        decision: Option<CreateDecision>,
53    },
54    /// Remove the worktree at the given index (confirmed; force semantics).
55    Remove(usize),
56    /// Delete the local branch `branch` of a worktree-less branch row (confirmed;
57    /// issue #53). `force` uses `git branch -D` to delete an unmerged branch.
58    DeleteBranch {
59        /// The branch to delete.
60        branch: String,
61        /// Whether to force-delete an unmerged branch (`-D` vs `-d`).
62        force: bool,
63    },
64    /// Create a worktree for an existing worktree-less branch and switch into it
65    /// (confirmed). The runtime materializes the branch via the `new` path and
66    /// records the new worktree as the chosen path (issue #47).
67    MaterializeBranch {
68        /// The branch to create a worktree for.
69        branch: String,
70    },
71    /// Open the PR picker — the runtime fetches PRs.
72    FetchPrs,
73    /// Check out the PR with the given number.
74    CheckoutPr(u64),
75    /// Check out `branch` in the worktree at `worktree_index` (in place).
76    CheckoutBranch {
77        /// Index into `App::worktrees` of the target worktree.
78        worktree_index: usize,
79        /// The branch to check out.
80        branch: String,
81    },
82    /// Sync (pull then push) the worktree at `worktree_index` in place (issue #63).
83    Sync {
84        /// Index into `App::worktrees` of the target worktree.
85        worktree_index: usize,
86    },
87    /// Initialize the submodules in `dir` recursively (confirmed; issue #50).
88    InitSubmodules {
89        /// The worktree directory whose submodules to initialize.
90        dir: PathBuf,
91        /// How many uninitialized submodules were detected (for the status text).
92        count: usize,
93    },
94    /// Open the given path in the editor.
95    OpenEditor(PathBuf),
96    /// Force a full async refresh.
97    Refresh,
98    /// Draft the PR title/body with the code agent (seeds the compose form).
99    DraftPrAi,
100    /// Submit the composed PR (push + create/update).
101    SubmitPr {
102        /// The PR title.
103        title: String,
104        /// The PR body.
105        body: String,
106        /// Whether to open as a draft (create only).
107        draft: bool,
108    },
109}
110
111impl CreateState {
112    /// The field currently being edited.
113    fn field_mut(&mut self) -> &mut String {
114        match self.step {
115            CreateStep::Branch => &mut self.branch,
116            CreateStep::Base => &mut self.base,
117        }
118    }
119
120    /// The text of the field currently being edited.
121    fn field_value(&self) -> &str {
122        match self.step {
123            CreateStep::Branch => &self.branch,
124            CreateStep::Base => &self.base,
125        }
126    }
127
128    /// Re-filters the options dropdown against the active field and shows it.
129    fn refresh_options(&mut self) {
130        let query = self.field_value().to_owned();
131        self.options.refilter(&query);
132        self.options.open();
133    }
134}
135
136/// Extends the create-prompt base ref to the longest common prefix of the local
137/// branches that start with it (a no-op when nothing matches or there is no
138/// progress to make). Best-effort completion: an empty candidate list — e.g.
139/// when branch enumeration failed — simply does nothing.
140fn complete_base_ref(state: &mut CreateState, branches: &[String]) {
141    let matches: Vec<&str> = branches
142        .iter()
143        .map(String::as_str)
144        .filter(|b| b.starts_with(&state.base))
145        .collect();
146    if let Some(common) = longest_common_prefix(&matches)
147        && common.len() > state.base.len()
148    {
149        state.base = common;
150    }
151}
152
153/// The longest common prefix shared by all `items`, or `None` when `items` is
154/// empty. A single item yields the whole item.
155fn longest_common_prefix(items: &[&str]) -> Option<String> {
156    let (first, rest) = items.split_first()?;
157    let mut prefix: &str = first;
158    for item in rest {
159        // Trim `prefix` to the shared leading chars with `item`, ending on a
160        // `prefix` char boundary so the slice is always valid UTF-8.
161        let shared = prefix
162            .char_indices()
163            .zip(item.chars())
164            .take_while(|&((_, a), b)| a == b)
165            .map(|((i, a), _)| i + a.len_utf8())
166            .last()
167            .unwrap_or(0);
168        prefix = &prefix[..shared];
169        if prefix.is_empty() {
170            break;
171        }
172    }
173    Some(prefix.to_string())
174}
175
176/// The next PR-compose field in Tab order (title → body → model → effort → wrap).
177fn compose_next_field(field: ComposeField) -> ComposeField {
178    match field {
179        ComposeField::Title => ComposeField::Body,
180        ComposeField::Body => ComposeField::Model,
181        ComposeField::Model => ComposeField::Effort,
182        ComposeField::Effort => ComposeField::Title,
183    }
184}
185
186/// The previous PR-compose field in Tab order (the inverse of
187/// [`compose_next_field`], for Shift-Tab).
188fn compose_prev_field(field: ComposeField) -> ComposeField {
189    match field {
190        ComposeField::Title => ComposeField::Effort,
191        ComposeField::Effort => ComposeField::Model,
192        ComposeField::Model => ComposeField::Body,
193        ComposeField::Body => ComposeField::Title,
194    }
195}
196
197impl App {
198    /// Handles a terminal event, returning the [`Effect`] to perform.
199    pub fn handle_event(&mut self, event: Event) -> Effect {
200        match event {
201            Event::Resize(cols, rows) => self.on_resize(cols, rows),
202            Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key),
203            Event::Mouse(mouse) if self.mouse => self.on_mouse(mouse),
204            _ => Effect::None,
205        }
206    }
207
208    /// Handles a resize, exiting if the terminal is too short.
209    fn on_resize(&mut self, cols: u16, rows: u16) -> Effect {
210        self.size = (cols, rows);
211        if rows < MIN_HEIGHT {
212            Effect::TooSmall
213        } else {
214            Effect::None
215        }
216    }
217
218    /// Dispatches a key event by mode.
219    fn on_key(&mut self, key: KeyEvent) -> Effect {
220        match &self.mode {
221            Mode::List => self.key_list(key),
222            Mode::Filter => self.key_filter(key),
223            Mode::Create(_) => self.key_create(key),
224            Mode::PrPicker(_) => self.key_pr(key),
225            Mode::PrCompose(_) => self.key_compose(key),
226            Mode::Checkout(_) => self.key_checkout_picker(key),
227            Mode::ConfirmRemove(_) => self.key_confirm(key),
228            Mode::ConfirmCreate(_) => self.key_confirm_create(key),
229            Mode::ConfirmDeleteBranch { .. } => self.key_confirm_delete_branch(key),
230            Mode::ConfirmStaleBase(_) => self.key_confirm_stale_base(key),
231            Mode::ConfirmInitSubmodules(_) => self.key_confirm_init_submodules(key),
232            Mode::Help => {
233                self.mode = Mode::List;
234                Effect::None
235            }
236        }
237    }
238
239    /// List-mode key handling (via the configurable keymap).
240    fn key_list(&mut self, key: KeyEvent) -> Effect {
241        let Some(action) = self.keymap.action_for(KeyChord::from_event(key)) else {
242            return Effect::None;
243        };
244        let page = (self.size.1 as isize - 3).max(1);
245        match action {
246            KeyAction::NavigateUp => self.nav_or_scroll(-1),
247            KeyAction::NavigateDown => self.nav_or_scroll(1),
248            KeyAction::PageUp => self.nav_or_scroll(-page),
249            KeyAction::PageDown => self.nav_or_scroll(page),
250            KeyAction::GoToTop => self.select_edge(false),
251            KeyAction::GoToBottom => self.select_edge(true),
252            KeyAction::FocusNextPane | KeyAction::FocusPrevPane => self.toggle_focus(),
253            KeyAction::Switch => {
254                if let Some(&index) = self.visible.get(self.selected) {
255                    let wt = &self.worktrees[index];
256                    if wt.has_worktree {
257                        let path = wt.path.clone();
258                        self.chosen = Some(path.clone());
259                        return Effect::Switch(path);
260                    }
261                    // A worktree-less branch row: confirm before creating a
262                    // worktree and switching into it (issue #47).
263                    self.mode = Mode::ConfirmCreate(index);
264                }
265            }
266            KeyAction::Filter => self.mode = Mode::Filter,
267            KeyAction::ClearFilter => self.clear_filter(),
268            KeyAction::New => {
269                // Seed the branch-options dropdown with existing local + remote
270                // branches; it opens once the user types or reaches the base field.
271                let options = crate::tui::OptionList::new(self.branches.clone());
272                // Default the base to the upstream default branch (e.g.
273                // origin/main) so new worktrees fork off the up-to-date remote
274                // tip (issue #70); empty when there is no confident default.
275                self.mode = Mode::Create(CreateState {
276                    base: self.default_base.clone().unwrap_or_default(),
277                    options,
278                    ..Default::default()
279                });
280            }
281            KeyAction::Remove => {
282                // A real worktree is removed; a worktree-less branch row has no
283                // worktree to remove, so Remove deletes its local branch instead
284                // (issue #53).
285                if let Some(&index) = self.visible.get(self.selected) {
286                    self.mode = if self.worktrees[index].has_worktree {
287                        Mode::ConfirmRemove(index)
288                    } else {
289                        Mode::ConfirmDeleteBranch {
290                            index,
291                            force: false,
292                        }
293                    };
294                }
295            }
296            KeyAction::PrCheckout => {
297                self.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
298                    loading: true,
299                    ..Default::default()
300                });
301                return Effect::FetchPrs;
302            }
303            KeyAction::Checkout => {
304                // Seed a branch picker for the selected worktree (its index into
305                // `worktrees`, matching the `Remove` pattern). Checking out a
306                // branch needs a worktree to check it out *in*, so it is a no-op
307                // on a branch row (issue #47).
308                if let Some(&index) = self.visible.get(self.selected)
309                    && self.worktrees[index].has_worktree
310                {
311                    // Open the dropdown immediately so the local + remote branch
312                    // list is browsable with ↑/↓ from the start — checkout is a
313                    // pick-an-existing-branch action, not free text entry (#32).
314                    let mut options = crate::tui::OptionList::new(self.branches.clone());
315                    options.open();
316                    self.mode = Mode::Checkout(crate::tui::app::CheckoutState {
317                        worktree_index: index,
318                        options,
319                        ..Default::default()
320                    });
321                }
322            }
323            KeyAction::Sync => {
324                // Sync works on a real worktree (fetch + ff/push in place) and on a
325                // worktree-less branch row (fetch + move the ref / push from the
326                // repo root); the runtime picks the path by `has_worktree`
327                // (issue #47/#63).
328                if let Some(&index) = self.visible.get(self.selected) {
329                    return Effect::Sync {
330                        worktree_index: index,
331                    };
332                }
333            }
334            KeyAction::OpenEditor => {
335                // A branch row has only a virtual path; nothing to open (issue #47).
336                if let Some(wt) = self.selected_worktree()
337                    && wt.has_worktree
338                {
339                    return Effect::OpenEditor(wt.path.clone());
340                }
341            }
342            KeyAction::Refresh => return Effect::Refresh,
343            KeyAction::SortCycle => self.cycle_sort(),
344            KeyAction::SortReverse => self.reverse_sort(),
345            KeyAction::Help => self.mode = Mode::Help,
346            KeyAction::Quit => {
347                self.quit = true;
348                return Effect::Quit;
349            }
350            KeyAction::ToggleSidebar => self.show_sidebar = !self.show_sidebar,
351            KeyAction::ResizeSidebarGrow => {
352                self.sidebar_width = (self.sidebar_width + 1).min(MAX_SIDEBAR);
353            }
354            KeyAction::ResizeSidebarShrink => {
355                self.sidebar_width = self.sidebar_width.saturating_sub(1).max(MIN_SIDEBAR);
356            }
357        }
358        Effect::None
359    }
360
361    /// Filter-mode key handling.
362    fn key_filter(&mut self, key: KeyEvent) -> Effect {
363        match key.code {
364            KeyCode::Char(c) => self.filter_push(c),
365            KeyCode::Backspace => self.filter_pop(),
366            KeyCode::Enter => self.mode = Mode::List, // keep the filter active
367            KeyCode::Esc => {
368                self.clear_filter();
369                self.mode = Mode::List;
370            }
371            KeyCode::Up => self.move_selection(-1),
372            KeyCode::Down => self.move_selection(1),
373            _ => {}
374        }
375        Effect::None
376    }
377
378    /// Create-mode key handling. The active field offers an inline dropdown of
379    /// existing branches (issue #25): typing filters it, `↑/↓` move into it, and
380    /// `Enter` accepts the highlight when engaged — otherwise `Enter` advances /
381    /// submits the typed text. `Esc` closes an open dropdown before the modal.
382    fn key_create(&mut self, key: KeyEvent) -> Effect {
383        let Mode::Create(state) = &mut self.mode else {
384            return Effect::None;
385        };
386        match key.code {
387            KeyCode::Char(c) => {
388                state.field_mut().push(c);
389                state.error = None;
390                state.refresh_options();
391            }
392            KeyCode::Backspace => {
393                state.field_mut().pop();
394                state.refresh_options();
395            }
396            KeyCode::Up => state.options.up(),
397            KeyCode::Down => state.options.down(),
398            KeyCode::Tab => {
399                if state.step == CreateStep::Base {
400                    complete_base_ref(state, &self.branches);
401                    state.refresh_options();
402                }
403            }
404            KeyCode::Esc => {
405                if state.options.is_open() {
406                    state.options.close();
407                } else {
408                    self.mode = Mode::List;
409                }
410            }
411            KeyCode::Enter => {
412                // Accept the highlighted suggestion only once the user has moved
413                // into the list; otherwise fall through to advance/submit.
414                if let Some(selected) = state.options.selected().map(str::to_owned) {
415                    *state.field_mut() = selected;
416                    state.options.close();
417                } else {
418                    match state.step {
419                        CreateStep::Branch => {
420                            let branch = state.branch.trim();
421                            if branch.is_empty() {
422                                state.error = Some("branch name is required".into());
423                            } else if let Err(msg) = crate::git::validate_branch_name(branch) {
424                                state.error = Some(msg);
425                            } else {
426                                state.step = CreateStep::Base;
427                                // Reveal fork candidates as soon as the base field opens.
428                                state.refresh_options();
429                            }
430                        }
431                        CreateStep::Base => {
432                            let branch = state.branch.clone();
433                            let base = (!state.base.trim().is_empty()).then(|| state.base.clone());
434                            // No decision yet — the runtime pre-flights the base
435                            // for staleness before creating (issue #56).
436                            return Effect::Create {
437                                branch,
438                                base,
439                                decision: None,
440                            };
441                        }
442                    }
443                }
444            }
445            _ => {}
446        }
447        Effect::None
448    }
449
450    /// PR-picker key handling.
451    fn key_pr(&mut self, key: KeyEvent) -> Effect {
452        let Mode::PrPicker(state) = &mut self.mode else {
453            return Effect::None;
454        };
455        match key.code {
456            KeyCode::Up => state.selected = state.selected.saturating_sub(1),
457            KeyCode::Down => {
458                state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
459            }
460            KeyCode::Enter => {
461                if let Some(pr) = state.prs.get(state.selected) {
462                    return Effect::CheckoutPr(pr.number);
463                }
464            }
465            KeyCode::Esc => self.mode = Mode::List,
466            _ => {}
467        }
468        Effect::None
469    }
470
471    /// PR-compose key handling. Fixed keys (overlay convention): typing edits the
472    /// active text field, Tab cycles the fields (title → body → model → effort),
473    /// `↑/↓` pick from the model/effort options dropdown (issue #25), Enter
474    /// advances (or inserts a newline in the body), Ctrl-S submits, Ctrl-D toggles
475    /// draft, Ctrl-A auto-fills with the agent, Ctrl-M/Ctrl-E quick-cycle the
476    /// model/effort, Esc cancels.
477    fn key_compose(&mut self, key: KeyEvent) -> Effect {
478        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
479        let Mode::PrCompose(state) = &mut self.mode else {
480            return Effect::None;
481        };
482        // Ignore input while a submit/draft is in flight.
483        if state.submitting {
484            return Effect::None;
485        }
486        match key.code {
487            KeyCode::Char('s') if ctrl => {
488                if state.title.trim().is_empty() {
489                    state.error = Some("a PR title is required".into());
490                } else {
491                    state.submitting = true;
492                    return Effect::SubmitPr {
493                        title: state.title.clone(),
494                        body: state.body.clone(),
495                        draft: state.draft,
496                    };
497                }
498            }
499            // Ctrl-A: auto-fill title/body with the agent (current model/effort).
500            KeyCode::Char('a') if ctrl => {
501                state.submitting = true;
502                state.error = None;
503                return Effect::DraftPrAi;
504            }
505            // Ctrl-M / Ctrl-E: quick-cycle the model / effort used for the next fill.
506            KeyCode::Char('m') if ctrl => state.model = state.model.next(),
507            KeyCode::Char('e') if ctrl => state.effort = state.effort.next(),
508            KeyCode::Char('d') if ctrl => state.draft = !state.draft,
509            // Plain character input edits the text fields; option fields ignore it.
510            KeyCode::Char(c) if !ctrl => {
511                match state.field {
512                    ComposeField::Title => state.title.push(c),
513                    ComposeField::Body => state.body.push(c),
514                    ComposeField::Model | ComposeField::Effort => {}
515                }
516                state.error = None;
517            }
518            KeyCode::Backspace => {
519                match state.field {
520                    ComposeField::Title => state.title.pop(),
521                    ComposeField::Body => state.body.pop(),
522                    ComposeField::Model | ComposeField::Effort => None,
523                };
524                state.error = None;
525            }
526            // On an option field the arrows move the selection like the picker.
527            KeyCode::Up => match state.field {
528                ComposeField::Model => state.model = state.model.prev(),
529                ComposeField::Effort => state.effort = state.effort.prev(),
530                _ => {}
531            },
532            KeyCode::Down => match state.field {
533                ComposeField::Model => state.model = state.model.next(),
534                ComposeField::Effort => state.effort = state.effort.next(),
535                _ => {}
536            },
537            KeyCode::Tab => state.field = compose_next_field(state.field),
538            KeyCode::BackTab => state.field = compose_prev_field(state.field),
539            KeyCode::Enter => match state.field {
540                ComposeField::Title => state.field = ComposeField::Body,
541                ComposeField::Body => state.body.push('\n'),
542                // On the option fields, Enter confirms and moves on.
543                ComposeField::Model => state.field = ComposeField::Effort,
544                ComposeField::Effort => state.field = ComposeField::Title,
545            },
546            KeyCode::Esc => self.mode = Mode::List,
547            _ => {}
548        }
549        Effect::None
550    }
551
552    /// Checkout branch-picker key handling. A single type-ahead field over the
553    /// known branches (issue #32): typing filters the dropdown, `↑/↓` move into
554    /// it, and `Enter` checks out the highlighted suggestion (when engaged) or the
555    /// typed text. A first `Esc` closes an open dropdown; a second cancels.
556    fn key_checkout_picker(&mut self, key: KeyEvent) -> Effect {
557        let Mode::Checkout(state) = &mut self.mode else {
558            return Effect::None;
559        };
560        // Ignore input while a checkout is in flight.
561        if state.submitting {
562            return Effect::None;
563        }
564        match key.code {
565            KeyCode::Char(c) => {
566                state.query.push(c);
567                state.error = None;
568                state.options.refilter(&state.query);
569                state.options.open();
570            }
571            KeyCode::Backspace => {
572                state.query.pop();
573                state.error = None;
574                state.options.refilter(&state.query);
575                state.options.open();
576            }
577            KeyCode::Up => state.options.up(),
578            KeyCode::Down => state.options.down(),
579            KeyCode::Esc => {
580                if state.options.is_open() {
581                    state.options.close();
582                } else {
583                    self.mode = Mode::List;
584                }
585            }
586            KeyCode::Enter => {
587                // Accept the highlighted suggestion once engaged; else the typed
588                // text (so a branch the list does not contain still works).
589                let branch = state
590                    .options
591                    .selected()
592                    .map(str::to_owned)
593                    .unwrap_or_else(|| state.query.trim().to_string());
594                if branch.is_empty() {
595                    state.error = Some("branch name is required".into());
596                } else {
597                    let worktree_index = state.worktree_index;
598                    return Effect::CheckoutBranch {
599                        worktree_index,
600                        branch,
601                    };
602                }
603            }
604            _ => {}
605        }
606        Effect::None
607    }
608
609    /// Confirm-remove key handling.
610    fn key_confirm(&mut self, key: KeyEvent) -> Effect {
611        let Mode::ConfirmRemove(index) = self.mode else {
612            return Effect::None;
613        };
614        if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
615            self.mode = Mode::List;
616            Effect::Remove(index)
617        } else {
618            self.mode = Mode::List;
619            Effect::None
620        }
621    }
622
623    /// Confirm-create key handling: `y`/`Y` materializes a worktree for the
624    /// branch row and switches into it; any other key cancels (issue #47).
625    fn key_confirm_create(&mut self, key: KeyEvent) -> Effect {
626        let Mode::ConfirmCreate(index) = self.mode else {
627            return Effect::None;
628        };
629        self.mode = Mode::List;
630        if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
631            && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
632        {
633            return Effect::MaterializeBranch { branch };
634        }
635        Effect::None
636    }
637
638    /// Confirm-delete-branch key handling (issue #53): `y`/`Y` deletes the branch
639    /// row's local branch; any other key cancels. The `force` flag (set on the
640    /// unmerged re-prompt) is carried into the effect so the runtime uses
641    /// `git branch -D`.
642    fn key_confirm_delete_branch(&mut self, key: KeyEvent) -> Effect {
643        let Mode::ConfirmDeleteBranch { index, force } = self.mode else {
644            return Effect::None;
645        };
646        self.mode = Mode::List;
647        if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
648            && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
649        {
650            return Effect::DeleteBranch { branch, force };
651        }
652        Effect::None
653    }
654
655    /// Confirm-stale-base key handling (issue #56): `u`/`U` updates the base
656    /// (fast-forward) then creates; `p`/`P` proceeds off the stale base; any other
657    /// key cancels. Re-issues the create with the chosen decision.
658    fn key_confirm_stale_base(&mut self, key: KeyEvent) -> Effect {
659        let Mode::ConfirmStaleBase(state) = &self.mode else {
660            return Effect::None;
661        };
662        let branch = state.branch.clone();
663        let base = state.base.clone();
664        self.mode = Mode::List;
665        let decision = match key.code {
666            KeyCode::Char('u') | KeyCode::Char('U') => CreateDecision::Update,
667            KeyCode::Char('p') | KeyCode::Char('P') => CreateDecision::Proceed,
668            _ => return Effect::None,
669        };
670        Effect::Create {
671            branch,
672            base,
673            decision: Some(decision),
674        }
675    }
676
677    /// Confirm-init-submodules key handling (issue #50): `Enter`/`y`/`Y` — the
678    /// default — initializes the new worktree's submodules recursively; `n`/`N`/`Esc`
679    /// dismisses and leaves them uninitialized; any other key is ignored.
680    fn key_confirm_init_submodules(&mut self, key: KeyEvent) -> Effect {
681        let Mode::ConfirmInitSubmodules(state) = &self.mode else {
682            return Effect::None;
683        };
684        match key.code {
685            KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
686                let dir = state.dir.clone();
687                let count = state.count;
688                self.mode = Mode::List;
689                Effect::InitSubmodules { dir, count }
690            }
691            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
692                self.mode = Mode::List;
693                Effect::None
694            }
695            _ => Effect::None,
696        }
697    }
698
699    /// Mouse handling: row click selects, wheel scrolls, detail click focuses.
700    fn on_mouse(&mut self, mouse: MouseEvent) -> Effect {
701        // A modal overlay owns all input (issue #70): the background list/detail
702        // must not react to clicks, and the wheel scrolls the modal's own list
703        // rather than the hidden list behind it. List/Filter render inline (no
704        // overlay), so they keep the normal mouse behaviour below.
705        if !matches!(self.mode, Mode::List | Mode::Filter) {
706            match mouse.kind {
707                MouseEventKind::ScrollUp => self.modal_scroll(-1),
708                MouseEventKind::ScrollDown => self.modal_scroll(1),
709                _ => {}
710            }
711            return Effect::None;
712        }
713        match mouse.kind {
714            MouseEventKind::Down(MouseButton::Left) => {
715                // The bottom row is the status/help bar; clicks there select nothing.
716                let status_row = self.size.1.saturating_sub(1);
717                if mouse.row >= status_row {
718                    return Effect::None;
719                }
720                if self.show_sidebar && mouse.column < self.sidebar_width {
721                    // Only content rows select; the top border/title row (row 0)
722                    // does not.
723                    if mouse.row >= LIST_TOP {
724                        self.select_row((mouse.row - LIST_TOP) as usize);
725                    }
726                    self.focus = Pane::List;
727                } else {
728                    self.focus = Pane::Detail;
729                }
730            }
731            MouseEventKind::ScrollUp => self.nav_or_scroll(-1),
732            MouseEventKind::ScrollDown => self.nav_or_scroll(1),
733            _ => {}
734        }
735        Effect::None
736    }
737
738    /// Routes one wheel step to the open modal's own list (issue #70): the
739    /// create/checkout pickers move their options dropdown (a no-op while it is
740    /// closed), the PR picker moves its selection. Other overlays have nothing
741    /// to scroll. `delta` is negative for up, positive for down.
742    fn modal_scroll(&mut self, delta: isize) {
743        let up = delta < 0;
744        match &mut self.mode {
745            Mode::Create(state) => {
746                if up {
747                    state.options.up();
748                } else {
749                    state.options.down();
750                }
751            }
752            Mode::Checkout(state) => {
753                if up {
754                    state.options.up();
755                } else {
756                    state.options.down();
757                }
758            }
759            Mode::PrPicker(state) => {
760                if up {
761                    state.selected = state.selected.saturating_sub(1);
762                } else {
763                    state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
764                }
765            }
766            _ => {}
767        }
768    }
769
770    /// Routes a vertical movement to the detail-pane scroll when that pane has
771    /// focus, else to the list selection (spec §10).
772    fn nav_or_scroll(&mut self, delta: isize) {
773        if self.focus == Pane::Detail {
774            self.scroll_detail(delta);
775        } else {
776            self.move_selection(delta);
777        }
778    }
779
780    /// Toggles pane focus.
781    fn toggle_focus(&mut self) {
782        self.focus = match self.focus {
783            Pane::List => Pane::Detail,
784            Pane::Detail => Pane::List,
785        };
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::tui::app::testutil::app;
793    use crossterm::event::{KeyModifiers, MouseButton};
794
795    fn press(code: KeyCode) -> Event {
796        Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
797    }
798
799    fn ctrl(c: char) -> Event {
800        Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
801    }
802
803    #[test]
804    fn navigation_keys() {
805        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
806        a.selected = 0;
807        assert_eq!(a.handle_event(press(KeyCode::Char('j'))), Effect::None);
808        assert_eq!(a.selected, 1);
809        a.handle_event(press(KeyCode::Char('k')));
810        assert_eq!(a.selected, 0);
811        a.handle_event(press(KeyCode::Char('G')));
812        assert_eq!(a.selected, 2);
813        a.handle_event(press(KeyCode::Char('g')));
814        assert_eq!(a.selected, 0);
815        a.handle_event(ctrl('d')); // page down
816        assert!(a.selected >= 1 || a.visible.len() == 1);
817    }
818
819    #[test]
820    fn enter_switches_to_selected() {
821        let mut a = app(&[("main", true), ("feat", false)]);
822        a.selected = 1;
823        let effect = a.handle_event(press(KeyCode::Enter));
824        assert_eq!(effect, Effect::Switch(std::path::PathBuf::from("/r/feat")));
825        assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
826    }
827
828    #[test]
829    fn enter_on_branch_row_opens_confirm_create() {
830        use crate::tui::app::testutil::branch_row;
831        let mut a = app(&[("main", true)]);
832        a.worktrees.push(branch_row("topic"));
833        a.apply_filter(String::new()); // recompute `visible` to include the row
834        a.selected = a.visible.len() - 1; // select the branch row
835        let effect = a.handle_event(press(KeyCode::Enter));
836        assert_eq!(effect, Effect::None);
837        assert!(matches!(a.mode, Mode::ConfirmCreate(_)));
838        assert!(a.chosen.is_none()); // no switch yet — only after confirming
839    }
840
841    #[test]
842    fn confirm_create_y_materializes_other_cancels() {
843        use crate::tui::app::testutil::branch_row;
844        let mut a = app(&[("main", true)]);
845        a.worktrees.push(branch_row("topic"));
846        let idx = a
847            .worktrees
848            .iter()
849            .position(|w| w.branch.as_deref() == Some("topic"))
850            .unwrap();
851        a.mode = Mode::ConfirmCreate(idx);
852        let effect = a.handle_event(press(KeyCode::Char('y')));
853        assert_eq!(
854            effect,
855            Effect::MaterializeBranch {
856                branch: "topic".into()
857            }
858        );
859        assert_eq!(a.mode, Mode::List);
860        // Any non-`y` key cancels.
861        a.mode = Mode::ConfirmCreate(idx);
862        let effect = a.handle_event(press(KeyCode::Char('n')));
863        assert_eq!(effect, Effect::None);
864        assert_eq!(a.mode, Mode::List);
865    }
866
867    #[test]
868    fn checkout_and_open_editor_are_noops_on_branch_rows() {
869        // Checkout / open-editor both need a worktree; on a branch row they do
870        // nothing (issue #47). Remove instead deletes the branch — see below.
871        use crate::tui::app::testutil::branch_row;
872        let mut a = app(&[("main", true)]);
873        a.worktrees.push(branch_row("topic"));
874        a.apply_filter(String::new());
875        a.selected = a.visible.len() - 1; // the branch row
876        assert_eq!(a.handle_event(press(KeyCode::Char('c'))), Effect::None);
877        assert_eq!(a.mode, Mode::List);
878        assert_eq!(a.handle_event(press(KeyCode::Char('o'))), Effect::None);
879        assert_eq!(a.mode, Mode::List);
880    }
881
882    #[test]
883    fn sync_acts_on_worktree_rows_and_branch_rows() {
884        use crate::tui::app::testutil::branch_row;
885        let mut a = app(&[("main", true), ("feat", false)]);
886        // On a real worktree row, `y` yields a Sync effect for that row's index.
887        a.selected = 1;
888        assert_eq!(
889            a.handle_event(press(KeyCode::Char('y'))),
890            Effect::Sync { worktree_index: 1 }
891        );
892        // On a worktree-less branch row, `y` now also yields a Sync effect for that
893        // row's index; the runtime syncs it by branch name (issue #47/#63).
894        a.worktrees.push(branch_row("topic"));
895        a.apply_filter(String::new());
896        let idx = a
897            .worktrees
898            .iter()
899            .position(|w| w.branch.as_deref() == Some("topic"))
900            .unwrap();
901        a.selected = a.visible.iter().position(|&i| i == idx).unwrap();
902        assert_eq!(
903            a.handle_event(press(KeyCode::Char('y'))),
904            Effect::Sync {
905                worktree_index: idx
906            }
907        );
908        assert_eq!(a.mode, Mode::List);
909    }
910
911    #[test]
912    fn remove_on_branch_row_confirms_then_deletes_branch() {
913        // Remove on a worktree-less branch row deletes its local branch (issue
914        // #53): first a confirm dialog, then `y` yields a DeleteBranch effect.
915        use crate::tui::app::testutil::branch_row;
916        let mut a = app(&[("main", true)]);
917        a.worktrees.push(branch_row("topic"));
918        a.apply_filter(String::new());
919        a.selected = a.visible.len() - 1; // the branch row
920        assert_eq!(a.handle_event(press(KeyCode::Char('d'))), Effect::None);
921        assert!(matches!(
922            a.mode,
923            Mode::ConfirmDeleteBranch { force: false, .. }
924        ));
925        let effect = a.handle_event(press(KeyCode::Char('y')));
926        assert_eq!(
927            effect,
928            Effect::DeleteBranch {
929                branch: "topic".into(),
930                force: false,
931            }
932        );
933        assert_eq!(a.mode, Mode::List);
934        // A non-`y` key cancels back to the list.
935        a.mode = Mode::ConfirmDeleteBranch {
936            index: a.visible[a.selected],
937            force: true,
938        };
939        assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
940        assert_eq!(a.mode, Mode::List);
941    }
942
943    #[test]
944    fn confirm_stale_base_keys_reissue_create_or_cancel() {
945        use crate::tui::app::StaleBaseState;
946        let state = StaleBaseState {
947            branch: "feature".into(),
948            base: Some("main".into()),
949            behind: 2,
950            upstream_display: "origin/main".into(),
951            can_fast_forward: true,
952        };
953        let mut a = app(&[("main", true)]);
954        // `u` updates the base then creates.
955        a.mode = Mode::ConfirmStaleBase(state.clone());
956        assert_eq!(
957            a.handle_event(press(KeyCode::Char('u'))),
958            Effect::Create {
959                branch: "feature".into(),
960                base: Some("main".into()),
961                decision: Some(CreateDecision::Update),
962            }
963        );
964        assert_eq!(a.mode, Mode::List);
965        // `p` proceeds off the stale base.
966        a.mode = Mode::ConfirmStaleBase(state.clone());
967        assert_eq!(
968            a.handle_event(press(KeyCode::Char('p'))),
969            Effect::Create {
970                branch: "feature".into(),
971                base: Some("main".into()),
972                decision: Some(CreateDecision::Proceed),
973            }
974        );
975        // Any other key (Esc) cancels.
976        a.mode = Mode::ConfirmStaleBase(state);
977        assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
978        assert_eq!(a.mode, Mode::List);
979    }
980
981    #[test]
982    fn confirm_init_submodules_keys_init_or_skip() {
983        use crate::tui::app::InitSubmodulesState;
984        let state = InitSubmodulesState {
985            dir: PathBuf::from("/wt/feature"),
986            branch: "feature".into(),
987            count: 2,
988        };
989        let mut a = app(&[("main", true)]);
990        // Enter (the default) initializes.
991        a.mode = Mode::ConfirmInitSubmodules(state.clone());
992        assert_eq!(
993            a.handle_event(press(KeyCode::Enter)),
994            Effect::InitSubmodules {
995                dir: PathBuf::from("/wt/feature"),
996                count: 2,
997            }
998        );
999        assert_eq!(a.mode, Mode::List);
1000        // `y` also initializes.
1001        a.mode = Mode::ConfirmInitSubmodules(state.clone());
1002        assert_eq!(
1003            a.handle_event(press(KeyCode::Char('y'))),
1004            Effect::InitSubmodules {
1005                dir: PathBuf::from("/wt/feature"),
1006                count: 2,
1007            }
1008        );
1009        // `n` skips back to the list.
1010        a.mode = Mode::ConfirmInitSubmodules(state.clone());
1011        assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
1012        assert_eq!(a.mode, Mode::List);
1013        // Esc skips too.
1014        a.mode = Mode::ConfirmInitSubmodules(state.clone());
1015        assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1016        assert_eq!(a.mode, Mode::List);
1017        // An unrelated key is ignored and leaves the modal open.
1018        a.mode = Mode::ConfirmInitSubmodules(state);
1019        assert_eq!(a.handle_event(press(KeyCode::Char('x'))), Effect::None);
1020        assert!(matches!(a.mode, Mode::ConfirmInitSubmodules(_)));
1021    }
1022
1023    #[test]
1024    fn quit_returns_quit() {
1025        let mut a = app(&[("a", true)]);
1026        assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1027        assert!(a.quit);
1028    }
1029
1030    #[test]
1031    fn filter_mode_typing_and_escape() {
1032        let mut a = app(&[("alpha", true), ("beta", false)]);
1033        a.handle_event(press(KeyCode::Char('/')));
1034        assert_eq!(a.mode, Mode::Filter);
1035        a.handle_event(press(KeyCode::Char('a')));
1036        a.handle_event(press(KeyCode::Char('l')));
1037        assert_eq!(a.filter, "al");
1038        assert_eq!(a.visible.len(), 1); // only alpha
1039        a.handle_event(press(KeyCode::Enter)); // confirm
1040        assert_eq!(a.mode, Mode::List);
1041        assert_eq!(a.filter, "al"); // stays active
1042        a.handle_event(press(KeyCode::Char('/')));
1043        a.handle_event(press(KeyCode::Esc)); // clears
1044        assert_eq!(a.mode, Mode::List);
1045        assert_eq!(a.filter, "");
1046    }
1047
1048    #[test]
1049    fn create_mode_flow() {
1050        let mut a = app(&[("a", true)]);
1051        a.handle_event(press(KeyCode::Char('n')));
1052        assert!(matches!(a.mode, Mode::Create(_)));
1053        // Empty branch -> error, stays on branch step.
1054        a.handle_event(press(KeyCode::Enter));
1055        if let Mode::Create(s) = &a.mode {
1056            assert!(s.error.is_some());
1057        } else {
1058            panic!("expected create mode");
1059        }
1060        // Type a branch, advance to base.
1061        for c in "feature/x".chars() {
1062            a.handle_event(press(KeyCode::Char(c)));
1063        }
1064        a.handle_event(press(KeyCode::Enter));
1065        if let Mode::Create(s) = &a.mode {
1066            assert_eq!(s.step, CreateStep::Base);
1067            assert_eq!(s.branch, "feature/x");
1068        }
1069        // Submit with empty base -> Create effect with base None.
1070        let effect = a.handle_event(press(KeyCode::Enter));
1071        assert_eq!(
1072            effect,
1073            Effect::Create {
1074                branch: "feature/x".into(),
1075                base: None,
1076                decision: None,
1077            }
1078        );
1079    }
1080
1081    #[test]
1082    fn create_mode_prefills_default_base() {
1083        // Opening the create prompt seeds the base with the upstream default
1084        // branch so the new worktree forks off the remote tip (issue #70).
1085        let mut a = app(&[("main", true)]);
1086        a.branches = vec!["main".into(), "origin/main".into()];
1087        a.default_base = Some("origin/main".into());
1088        a.handle_event(press(KeyCode::Char('n')));
1089        if let Mode::Create(s) = &a.mode {
1090            assert_eq!(s.base, "origin/main");
1091            assert_eq!(s.step, CreateStep::Branch); // still starts on the branch
1092        } else {
1093            panic!("expected create mode");
1094        }
1095    }
1096
1097    #[test]
1098    fn create_mode_base_empty_without_default() {
1099        // No detected default: the base starts empty and submitting leaves it
1100        // `None` so the CLI's own resolution applies (unchanged behaviour).
1101        let mut a = app(&[("main", true)]);
1102        assert!(a.default_base.is_none());
1103        a.handle_event(press(KeyCode::Char('n')));
1104        for c in "feature/x".chars() {
1105            a.handle_event(press(KeyCode::Char(c)));
1106        }
1107        a.handle_event(press(KeyCode::Enter)); // advance to base
1108        if let Mode::Create(s) = &a.mode {
1109            assert_eq!(s.base, "");
1110        } else {
1111            panic!("expected create mode");
1112        }
1113        assert_eq!(
1114            a.handle_event(press(KeyCode::Enter)),
1115            Effect::Create {
1116                branch: "feature/x".into(),
1117                base: None,
1118                decision: None,
1119            }
1120        );
1121    }
1122
1123    #[test]
1124    fn create_mode_rejects_invalid_branch_name() {
1125        let mut a = app(&[("a", true)]);
1126        a.handle_event(press(KeyCode::Char('n')));
1127        for c in "feat..x".chars() {
1128            a.handle_event(press(KeyCode::Char(c)));
1129        }
1130        // Invalid ref name -> inline error, stays on the branch step.
1131        a.handle_event(press(KeyCode::Enter));
1132        if let Mode::Create(s) = &a.mode {
1133            assert_eq!(s.step, CreateStep::Branch);
1134            assert!(s.error.as_deref().unwrap().contains("invalid branch name"));
1135        } else {
1136            panic!("expected create mode");
1137        }
1138        // Typing clears the error (existing char arm).
1139        a.handle_event(press(KeyCode::Char('y')));
1140        if let Mode::Create(s) = &a.mode {
1141            assert!(s.error.is_none());
1142        }
1143        // A legal name then advances to the base step.
1144        if let Mode::Create(s) = &mut a.mode {
1145            s.branch = "feature/x".into();
1146        }
1147        a.handle_event(press(KeyCode::Enter));
1148        if let Mode::Create(s) = &a.mode {
1149            assert_eq!(s.step, CreateStep::Base);
1150        } else {
1151            panic!("expected create mode");
1152        }
1153    }
1154
1155    #[test]
1156    fn create_mode_tab_completes_base_ref() {
1157        let mut a = app(&[("a", true)]);
1158        a.branches = vec!["feature/alpha".into(), "feature/beta".into(), "main".into()];
1159        a.handle_event(press(KeyCode::Char('n')));
1160        for c in "topic".chars() {
1161            a.handle_event(press(KeyCode::Char(c)));
1162        }
1163        a.handle_event(press(KeyCode::Enter)); // advance to base step
1164        // Ambiguous prefix extends to the longest common prefix.
1165        for c in "feat".chars() {
1166            a.handle_event(press(KeyCode::Char(c)));
1167        }
1168        a.handle_event(press(KeyCode::Tab));
1169        if let Mode::Create(s) = &a.mode {
1170            assert_eq!(s.base, "feature/");
1171        } else {
1172            panic!("expected create mode");
1173        }
1174        // Disambiguate, then Tab completes the unique branch fully.
1175        a.handle_event(press(KeyCode::Char('a')));
1176        a.handle_event(press(KeyCode::Tab));
1177        if let Mode::Create(s) = &a.mode {
1178            assert_eq!(s.base, "feature/alpha");
1179        }
1180    }
1181
1182    #[test]
1183    fn create_mode_tab_noop_without_candidates() {
1184        let mut a = app(&[("a", true)]);
1185        a.handle_event(press(KeyCode::Char('n')));
1186        for c in "feature/x".chars() {
1187            a.handle_event(press(KeyCode::Char(c)));
1188        }
1189        a.handle_event(press(KeyCode::Enter)); // base step
1190        for c in "xyz".chars() {
1191            a.handle_event(press(KeyCode::Char(c)));
1192        }
1193        a.handle_event(press(KeyCode::Tab)); // no branches -> no change
1194        if let Mode::Create(s) = &a.mode {
1195            assert_eq!(s.base, "xyz");
1196        }
1197        // Tab on the branch step is a no-op (completion is base-only).
1198        let mut b = app(&[("a", true)]);
1199        b.branches = vec!["main".into()];
1200        b.handle_event(press(KeyCode::Char('n')));
1201        b.handle_event(press(KeyCode::Tab));
1202        if let Mode::Create(s) = &b.mode {
1203            assert!(s.branch.is_empty());
1204        }
1205    }
1206
1207    #[test]
1208    fn longest_common_prefix_cases() {
1209        assert_eq!(longest_common_prefix(&[]), None);
1210        assert_eq!(longest_common_prefix(&["solo"]).as_deref(), Some("solo"));
1211        assert_eq!(
1212            longest_common_prefix(&["feature/a", "feature/b"]).as_deref(),
1213            Some("feature/")
1214        );
1215        assert_eq!(longest_common_prefix(&["abc", "xyz"]).as_deref(), Some(""));
1216    }
1217
1218    #[test]
1219    fn create_mode_escape_cancels() {
1220        let mut a = app(&[("a", true)]);
1221        a.handle_event(press(KeyCode::Char('n')));
1222        a.handle_event(press(KeyCode::Esc));
1223        assert_eq!(a.mode, Mode::List);
1224    }
1225
1226    #[test]
1227    fn create_mode_dropdown_filters_navigates_and_accepts() {
1228        let mut a = app(&[("a", true)]);
1229        a.branches = vec!["main".into(), "origin/main".into(), "origin/dev".into()];
1230        a.handle_event(press(KeyCode::Char('n')));
1231        // Type a new branch name (no existing branch contains "feature/login"),
1232        // so the dropdown has no matches and Enter advances to the base field.
1233        for c in "feature/login".chars() {
1234            a.handle_event(press(KeyCode::Char(c)));
1235        }
1236        a.handle_event(press(KeyCode::Enter));
1237        if let Mode::Create(s) = &a.mode {
1238            assert_eq!(s.step, CreateStep::Base);
1239            // The base field opens its dropdown to all fork candidates.
1240            assert!(s.options.is_open());
1241        } else {
1242            panic!("expected create mode");
1243        }
1244        // Filter the base candidates to the two "origin/" branches.
1245        for c in "origin".chars() {
1246            a.handle_event(press(KeyCode::Char(c)));
1247        }
1248        // Engage the list and accept the highlighted suggestion. Matches follow
1249        // the seeded order [origin/main, origin/dev], so one Down lands on dev.
1250        a.handle_event(press(KeyCode::Down));
1251        a.handle_event(press(KeyCode::Enter));
1252        if let Mode::Create(s) = &a.mode {
1253            assert_eq!(s.base, "origin/dev");
1254            assert!(!s.options.is_open()); // closed after accepting
1255        }
1256        // A final Enter (dropdown closed) submits the create.
1257        let effect = a.handle_event(press(KeyCode::Enter));
1258        assert_eq!(
1259            effect,
1260            Effect::Create {
1261                branch: "feature/login".into(),
1262                base: Some("origin/dev".into()),
1263                decision: None,
1264            }
1265        );
1266    }
1267
1268    #[test]
1269    fn create_mode_escape_closes_dropdown_before_modal() {
1270        let mut a = app(&[("a", true)]);
1271        a.branches = vec!["main".into()];
1272        a.handle_event(press(KeyCode::Char('n')));
1273        a.handle_event(press(KeyCode::Char('m'))); // opens the dropdown (matches "main")
1274        if let Mode::Create(s) = &a.mode {
1275            assert!(s.options.is_open());
1276        }
1277        a.handle_event(press(KeyCode::Esc)); // first Esc closes the dropdown
1278        if let Mode::Create(s) = &a.mode {
1279            assert!(!s.options.is_open());
1280        } else {
1281            panic!("expected create mode (still open)");
1282        }
1283        a.handle_event(press(KeyCode::Esc)); // second Esc cancels the modal
1284        assert_eq!(a.mode, Mode::List);
1285    }
1286
1287    #[test]
1288    fn confirm_remove_y_removes() {
1289        let mut a = app(&[("main", true), ("feat", false)]);
1290        a.selected = 1;
1291        a.handle_event(press(KeyCode::Char('d')));
1292        assert!(matches!(a.mode, Mode::ConfirmRemove(_)));
1293        let effect = a.handle_event(press(KeyCode::Char('y')));
1294        // index 1 = feat (default sort keeps order here).
1295        assert!(matches!(effect, Effect::Remove(_)));
1296        assert_eq!(a.mode, Mode::List);
1297    }
1298
1299    #[test]
1300    fn confirm_remove_other_key_cancels() {
1301        let mut a = app(&[("main", true), ("feat", false)]);
1302        a.selected = 1;
1303        a.handle_event(press(KeyCode::Char('d')));
1304        let effect = a.handle_event(press(KeyCode::Char('n')));
1305        assert_eq!(effect, Effect::None);
1306        assert_eq!(a.mode, Mode::List);
1307    }
1308
1309    #[test]
1310    fn pr_picker_opens_and_fetches() {
1311        let mut a = app(&[("a", true)]);
1312        let effect = a.handle_event(press(KeyCode::Char('p')));
1313        assert_eq!(effect, Effect::FetchPrs);
1314        assert!(matches!(a.mode, Mode::PrPicker(_)));
1315        // Populate PRs and check out one.
1316        if let Mode::PrPicker(s) = &mut a.mode {
1317            s.loading = false;
1318            s.prs = vec![
1319                crate::tui::app::PrItem {
1320                    number: 7,
1321                    title: "x".into(),
1322                    author: "a".into(),
1323                    state: "open".into(),
1324                    created_at: String::new(),
1325                },
1326                crate::tui::app::PrItem {
1327                    number: 9,
1328                    title: "y".into(),
1329                    author: "b".into(),
1330                    state: "open".into(),
1331                    created_at: String::new(),
1332                },
1333            ];
1334        }
1335        a.handle_event(press(KeyCode::Down));
1336        let effect = a.handle_event(press(KeyCode::Enter));
1337        assert_eq!(effect, Effect::CheckoutPr(9));
1338    }
1339
1340    #[test]
1341    fn checkout_key_opens_picker_for_selected_worktree() {
1342        let mut a = app(&[("main", true), ("feature/x", false)]);
1343        a.branches = vec!["main".into(), "feature/x".into()];
1344        a.selected = 1; // the feature/x row
1345        a.handle_event(press(KeyCode::Char('c')));
1346        if let Mode::Checkout(s) = &a.mode {
1347            // The target is the selected row's index into `worktrees`.
1348            assert_eq!(s.worktree_index, a.visible[1]);
1349            assert_eq!(s.options.match_count(), 2);
1350            // The branch list is open immediately so ↑/↓ browse it without typing.
1351            assert!(s.options.is_open());
1352        } else {
1353            panic!("expected checkout mode");
1354        }
1355    }
1356
1357    #[test]
1358    fn checkout_picker_arrows_select_a_branch_without_typing() {
1359        // The dropdown opens on entry, so ↓ then Enter checks out the highlighted
1360        // branch with no type-ahead — picking a local or remote branch directly.
1361        let mut a = app(&[("main", true)]);
1362        a.branches = vec!["main".into(), "origin/feature/x".into()];
1363        a.handle_event(press(KeyCode::Char('c')));
1364        a.handle_event(press(KeyCode::Down)); // engage the list, move off `main`
1365        let effect = a.handle_event(press(KeyCode::Enter));
1366        assert_eq!(
1367            effect,
1368            Effect::CheckoutBranch {
1369                worktree_index: 0,
1370                branch: "origin/feature/x".into(),
1371            }
1372        );
1373    }
1374
1375    #[test]
1376    fn checkout_picker_submits_typed_branch() {
1377        let mut a = app(&[("main", true)]);
1378        a.branches = vec!["main".into(), "feature/x".into()];
1379        a.handle_event(press(KeyCode::Char('c')));
1380        for ch in "feature/x".chars() {
1381            a.handle_event(press(KeyCode::Char(ch)));
1382        }
1383        // Enter without engaging the list submits the typed text.
1384        let effect = a.handle_event(press(KeyCode::Enter));
1385        assert_eq!(
1386            effect,
1387            Effect::CheckoutBranch {
1388                worktree_index: 0,
1389                branch: "feature/x".into(),
1390            }
1391        );
1392    }
1393
1394    #[test]
1395    fn checkout_picker_submits_highlighted_suggestion() {
1396        let mut a = app(&[("main", true)]);
1397        a.branches = vec!["main".into(), "feature/x".into(), "feature/y".into()];
1398        a.handle_event(press(KeyCode::Char('c')));
1399        for ch in "feature".chars() {
1400            a.handle_event(press(KeyCode::Char(ch)));
1401        }
1402        // Matches follow seeded order [feature/x, feature/y]; one Down lands on y.
1403        a.handle_event(press(KeyCode::Down));
1404        let effect = a.handle_event(press(KeyCode::Enter));
1405        assert_eq!(
1406            effect,
1407            Effect::CheckoutBranch {
1408                worktree_index: 0,
1409                branch: "feature/y".into(),
1410            }
1411        );
1412    }
1413
1414    #[test]
1415    fn checkout_picker_empty_query_errors() {
1416        let mut a = app(&[("main", true)]);
1417        a.handle_event(press(KeyCode::Char('c')));
1418        let effect = a.handle_event(press(KeyCode::Enter));
1419        assert_eq!(effect, Effect::None);
1420        if let Mode::Checkout(s) = &a.mode {
1421            assert!(s.error.is_some());
1422        } else {
1423            panic!("expected checkout mode (still open)");
1424        }
1425    }
1426
1427    #[test]
1428    fn checkout_picker_escape_closes_dropdown_then_cancels() {
1429        let mut a = app(&[("main", true)]);
1430        a.branches = vec!["main".into()];
1431        a.handle_event(press(KeyCode::Char('c')));
1432        a.handle_event(press(KeyCode::Char('m'))); // dropdown open on entry; filters to "main"
1433        if let Mode::Checkout(s) = &a.mode {
1434            assert!(s.options.is_open());
1435        }
1436        a.handle_event(press(KeyCode::Esc)); // first Esc closes the dropdown
1437        if let Mode::Checkout(s) = &a.mode {
1438            assert!(!s.options.is_open());
1439        } else {
1440            panic!("expected checkout mode (still open)");
1441        }
1442        a.handle_event(press(KeyCode::Esc)); // second Esc cancels the modal
1443        assert_eq!(a.mode, Mode::List);
1444    }
1445
1446    #[test]
1447    fn compose_typing_field_switch_and_newline() {
1448        use crate::tui::app::PrComposeState;
1449        let mut a = app(&[("a", true)]);
1450        a.mode = Mode::PrCompose(PrComposeState::default());
1451        a.handle_event(press(KeyCode::Char('h')));
1452        a.handle_event(press(KeyCode::Char('i')));
1453        if let Mode::PrCompose(s) = &a.mode {
1454            assert_eq!(s.title, "hi");
1455            assert_eq!(s.field, ComposeField::Title);
1456        } else {
1457            panic!("expected compose mode");
1458        }
1459        // Enter in the title advances to the body.
1460        a.handle_event(press(KeyCode::Enter));
1461        if let Mode::PrCompose(s) = &a.mode {
1462            assert_eq!(s.field, ComposeField::Body);
1463        }
1464        // Typing + Enter in the body inserts a newline.
1465        a.handle_event(press(KeyCode::Char('x')));
1466        a.handle_event(press(KeyCode::Enter));
1467        a.handle_event(press(KeyCode::Char('y')));
1468        if let Mode::PrCompose(s) = &a.mode {
1469            assert_eq!(s.body, "x\ny");
1470        }
1471        // Shift-Tab steps back from the body to the title; Backspace pops it.
1472        a.handle_event(press(KeyCode::BackTab));
1473        a.handle_event(press(KeyCode::Backspace));
1474        if let Mode::PrCompose(s) = &a.mode {
1475            assert_eq!(s.field, ComposeField::Title);
1476            assert_eq!(s.title, "h");
1477        }
1478    }
1479
1480    #[test]
1481    fn compose_tab_cycles_all_four_fields() {
1482        use crate::tui::app::PrComposeState;
1483        let mut a = app(&[("a", true)]);
1484        a.mode = Mode::PrCompose(PrComposeState::default());
1485        let field = |a: &App| {
1486            if let Mode::PrCompose(s) = &a.mode {
1487                s.field
1488            } else {
1489                panic!("expected compose mode")
1490            }
1491        };
1492        assert_eq!(field(&a), ComposeField::Title);
1493        a.handle_event(press(KeyCode::Tab));
1494        assert_eq!(field(&a), ComposeField::Body);
1495        a.handle_event(press(KeyCode::Tab));
1496        assert_eq!(field(&a), ComposeField::Model);
1497        a.handle_event(press(KeyCode::Tab));
1498        assert_eq!(field(&a), ComposeField::Effort);
1499        a.handle_event(press(KeyCode::Tab));
1500        assert_eq!(field(&a), ComposeField::Title); // wraps
1501    }
1502
1503    #[test]
1504    fn compose_model_effort_fields_pick_with_arrows() {
1505        use crate::agent::{AgentModel, Effort};
1506        use crate::tui::app::PrComposeState;
1507        let mut a = app(&[("a", true)]);
1508        a.mode = Mode::PrCompose(PrComposeState::default());
1509        // Tab to the model field (title → body → model).
1510        a.handle_event(press(KeyCode::Tab));
1511        a.handle_event(press(KeyCode::Tab));
1512        // Down advances like next(); Up reverses like prev() (defaults: Sonnet).
1513        a.handle_event(press(KeyCode::Down));
1514        a.handle_event(press(KeyCode::Up));
1515        // Typing on an option field is ignored (no stray character lands).
1516        a.handle_event(press(KeyCode::Char('z')));
1517        if let Mode::PrCompose(s) = &a.mode {
1518            assert_eq!(s.field, ComposeField::Model);
1519            assert_eq!(s.model, AgentModel::Sonnet);
1520            assert_eq!(s.title, "");
1521        } else {
1522            panic!("expected compose mode");
1523        }
1524        // Tab to effort; Down advances it like next() (default: Medium).
1525        a.handle_event(press(KeyCode::Tab));
1526        a.handle_event(press(KeyCode::Down));
1527        if let Mode::PrCompose(s) = &a.mode {
1528            assert_eq!(s.field, ComposeField::Effort);
1529            assert_eq!(s.effort, Effort::Medium.next());
1530        }
1531    }
1532
1533    #[test]
1534    fn compose_ctrl_s_requires_title_and_is_not_typed() {
1535        use crate::tui::app::PrComposeState;
1536        let mut a = app(&[("a", true)]);
1537        a.mode = Mode::PrCompose(PrComposeState::default());
1538        let effect = a.handle_event(ctrl('s'));
1539        assert_eq!(effect, Effect::None);
1540        if let Mode::PrCompose(s) = &a.mode {
1541            assert!(s.error.is_some());
1542            // Ctrl-S must not be inserted as a literal 's'.
1543            assert_eq!(s.title, "");
1544        } else {
1545            panic!("expected compose mode");
1546        }
1547    }
1548
1549    #[test]
1550    fn compose_ctrl_s_submits_when_title_present() {
1551        use crate::tui::app::PrComposeState;
1552        let mut a = app(&[("a", true)]);
1553        a.mode = Mode::PrCompose(PrComposeState {
1554            title: "T".into(),
1555            body: "B".into(),
1556            ..Default::default()
1557        });
1558        let effect = a.handle_event(ctrl('s'));
1559        assert_eq!(
1560            effect,
1561            Effect::SubmitPr {
1562                title: "T".into(),
1563                body: "B".into(),
1564                draft: false
1565            }
1566        );
1567        if let Mode::PrCompose(s) = &a.mode {
1568            assert!(s.submitting);
1569        }
1570    }
1571
1572    #[test]
1573    fn compose_ctrl_d_toggles_draft_and_esc_cancels() {
1574        use crate::tui::app::PrComposeState;
1575        let mut a = app(&[("a", true)]);
1576        a.mode = Mode::PrCompose(PrComposeState::default());
1577        a.handle_event(ctrl('d'));
1578        if let Mode::PrCompose(s) = &a.mode {
1579            assert!(s.draft);
1580        }
1581        a.handle_event(press(KeyCode::Esc));
1582        assert_eq!(a.mode, Mode::List);
1583    }
1584
1585    #[test]
1586    fn compose_ctrl_a_triggers_ai_fill() {
1587        use crate::tui::app::PrComposeState;
1588        let mut a = app(&[("a", true)]);
1589        a.mode = Mode::PrCompose(PrComposeState::default());
1590        let effect = a.handle_event(ctrl('a'));
1591        assert_eq!(effect, Effect::DraftPrAi);
1592        if let Mode::PrCompose(s) = &a.mode {
1593            // The form enters the "working" state; Ctrl-A is not typed as 'a'.
1594            assert!(s.submitting);
1595            assert_eq!(s.title, "");
1596        } else {
1597            panic!("expected compose mode");
1598        }
1599    }
1600
1601    #[test]
1602    fn compose_ctrl_m_and_e_cycle_model_and_effort() {
1603        use crate::agent::{AgentModel, Effort};
1604        use crate::tui::app::PrComposeState;
1605        let mut a = app(&[("a", true)]);
1606        a.mode = Mode::PrCompose(PrComposeState::default());
1607        // Defaults are Sonnet / Medium; cycling advances and never types a char.
1608        a.handle_event(ctrl('m'));
1609        a.handle_event(ctrl('e'));
1610        if let Mode::PrCompose(s) = &a.mode {
1611            assert_eq!(s.model, AgentModel::Sonnet.next());
1612            assert_eq!(s.effort, Effort::Medium.next());
1613            assert_eq!(s.title, "");
1614        } else {
1615            panic!("expected compose mode");
1616        }
1617    }
1618
1619    #[test]
1620    fn help_dismisses_on_any_key() {
1621        let mut a = app(&[("a", true)]);
1622        a.handle_event(press(KeyCode::Char('?')));
1623        assert_eq!(a.mode, Mode::Help);
1624        a.handle_event(press(KeyCode::Char('x')));
1625        assert_eq!(a.mode, Mode::List);
1626    }
1627
1628    #[test]
1629    fn sort_and_sidebar_keys() {
1630        let mut a = app(&[("a", true)]);
1631        a.handle_event(press(KeyCode::Char('s')));
1632        assert_eq!(a.sort.key, crate::model::SortKey::Dirty);
1633        a.handle_event(press(KeyCode::Char('S')));
1634        assert!(a.sort.descending);
1635        let w0 = a.sidebar_width;
1636        a.handle_event(press(KeyCode::Char('+')));
1637        assert_eq!(a.sidebar_width, w0 + 1);
1638        a.handle_event(press(KeyCode::Char('-')));
1639        assert_eq!(a.sidebar_width, w0);
1640        a.handle_event(press(KeyCode::Char('\\')));
1641        assert!(!a.show_sidebar);
1642    }
1643
1644    #[test]
1645    fn resize_too_small_exits() {
1646        let mut a = app(&[("a", true)]);
1647        assert_eq!(a.handle_event(Event::Resize(100, 4)), Effect::TooSmall);
1648        assert_eq!(a.handle_event(Event::Resize(100, 20)), Effect::None);
1649        assert_eq!(a.size, (100, 20));
1650    }
1651
1652    #[test]
1653    fn open_editor_and_refresh() {
1654        let mut a = app(&[("a", true)]);
1655        assert_eq!(
1656            a.handle_event(press(KeyCode::Char('o'))),
1657            Effect::OpenEditor(std::path::PathBuf::from("/r/a"))
1658        );
1659        assert_eq!(a.handle_event(press(KeyCode::Char('r'))), Effect::Refresh);
1660    }
1661
1662    #[test]
1663    fn mouse_click_selects_and_wheel_scrolls() {
1664        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1665        // Click row 2 (1-based with border offset) within sidebar.
1666        let click = Event::Mouse(MouseEvent {
1667            kind: MouseEventKind::Down(MouseButton::Left),
1668            column: 5,
1669            row: 3,
1670            modifiers: KeyModifiers::empty(),
1671        });
1672        a.handle_event(click);
1673        assert_eq!(a.selected, 2);
1674        a.handle_event(Event::Mouse(MouseEvent {
1675            kind: MouseEventKind::ScrollUp,
1676            column: 5,
1677            row: 3,
1678            modifiers: KeyModifiers::empty(),
1679        }));
1680        assert_eq!(a.selected, 1);
1681    }
1682
1683    #[test]
1684    fn mouse_ignored_when_disabled() {
1685        let mut a = app(&[("a", true), ("b", false)]);
1686        a.mouse = false;
1687        a.selected = 0;
1688        a.handle_event(Event::Mouse(MouseEvent {
1689            kind: MouseEventKind::ScrollDown,
1690            column: 5,
1691            row: 3,
1692            modifiers: KeyModifiers::empty(),
1693        }));
1694        assert_eq!(a.selected, 0);
1695    }
1696
1697    #[test]
1698    fn mouse_in_modal_does_not_touch_background() {
1699        // While a modal overlay is open the background list must not react to the
1700        // mouse (issue #70): neither a click nor a wheel scroll changes it.
1701        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1702        a.selected = 1;
1703        a.mode = Mode::Create(CreateState::default());
1704        let click = Event::Mouse(MouseEvent {
1705            kind: MouseEventKind::Down(MouseButton::Left),
1706            column: 5,
1707            row: 3,
1708            modifiers: KeyModifiers::empty(),
1709        });
1710        assert_eq!(a.handle_event(click), Effect::None);
1711        assert_eq!(a.selected, 1);
1712        assert!(matches!(a.mode, Mode::Create(_)));
1713        a.handle_event(Event::Mouse(MouseEvent {
1714            kind: MouseEventKind::ScrollDown,
1715            column: 5,
1716            row: 3,
1717            modifiers: KeyModifiers::empty(),
1718        }));
1719        assert_eq!(a.selected, 1); // background selection untouched
1720    }
1721
1722    #[test]
1723    fn mouse_scroll_moves_create_dropdown() {
1724        // The wheel drives the modal's own options dropdown instead of the
1725        // hidden background list (issue #70).
1726        let mut a = app(&[("a", true)]);
1727        let mut options = crate::tui::OptionList::new(vec![
1728            "main".into(),
1729            "origin/main".into(),
1730            "origin/dev".into(),
1731        ]);
1732        options.open();
1733        a.mode = Mode::Create(CreateState {
1734            options,
1735            ..Default::default()
1736        });
1737        let wheel = |kind| {
1738            Event::Mouse(MouseEvent {
1739                kind,
1740                column: 5,
1741                row: 5,
1742                modifiers: KeyModifiers::empty(),
1743            })
1744        };
1745        a.handle_event(wheel(MouseEventKind::ScrollDown));
1746        if let Mode::Create(s) = &a.mode {
1747            // Engaged the list and moved one row down.
1748            assert_eq!(s.options.selected(), Some("origin/main"));
1749        } else {
1750            panic!("expected create mode");
1751        }
1752        a.handle_event(wheel(MouseEventKind::ScrollUp));
1753        if let Mode::Create(s) = &a.mode {
1754            assert_eq!(s.options.selected(), Some("main"));
1755        }
1756    }
1757
1758    #[test]
1759    fn mouse_scroll_moves_pr_picker_selection() {
1760        use crate::tui::app::{PrItem, PrPickerState};
1761        let pr = |number| PrItem {
1762            number,
1763            title: "t".into(),
1764            author: "a".into(),
1765            state: "open".into(),
1766            created_at: String::new(),
1767        };
1768        let mut a = app(&[("a", true)]);
1769        a.mode = Mode::PrPicker(PrPickerState {
1770            loading: false,
1771            prs: vec![pr(1), pr(2)],
1772            ..Default::default()
1773        });
1774        let wheel = |kind| {
1775            Event::Mouse(MouseEvent {
1776                kind,
1777                column: 5,
1778                row: 5,
1779                modifiers: KeyModifiers::empty(),
1780            })
1781        };
1782        a.handle_event(wheel(MouseEventKind::ScrollDown));
1783        if let Mode::PrPicker(s) = &a.mode {
1784            assert_eq!(s.selected, 1);
1785        } else {
1786            panic!("expected pr picker");
1787        }
1788        // Clamps at the last row, then scrolls back up.
1789        a.handle_event(wheel(MouseEventKind::ScrollDown));
1790        if let Mode::PrPicker(s) = &a.mode {
1791            assert_eq!(s.selected, 1);
1792        }
1793        a.handle_event(wheel(MouseEventKind::ScrollUp));
1794        if let Mode::PrPicker(s) = &a.mode {
1795            assert_eq!(s.selected, 0);
1796        }
1797    }
1798
1799    #[test]
1800    fn tab_toggles_focus() {
1801        let mut a = app(&[("a", true)]);
1802        assert_eq!(a.focus, Pane::List);
1803        a.handle_event(press(KeyCode::Tab));
1804        assert_eq!(a.focus, Pane::Detail);
1805    }
1806
1807    #[test]
1808    fn navigation_scrolls_detail_when_focused() {
1809        let mut a = app(&[("a", true), ("b", false)]);
1810        a.worktrees[0].recent_commits = vec![crate::model::Commit {
1811            hash: "h".into(),
1812            subject: "s".into(),
1813            author: "x".into(),
1814            timestamp: "2024-01-15T10:30:00Z".into(),
1815        }];
1816        a.handle_event(press(KeyCode::Tab)); // focus the detail pane
1817        a.handle_event(press(KeyCode::Char('j'))); // scrolls detail, not list
1818        assert_eq!(a.detail_scroll, 1);
1819        assert_eq!(a.selected, 0);
1820        a.handle_event(press(KeyCode::Char('k')));
1821        assert_eq!(a.detail_scroll, 0);
1822        // Back on the list, navigation moves the selection and resets scroll.
1823        a.handle_event(press(KeyCode::Tab));
1824        a.detail_scroll = 3;
1825        a.handle_event(press(KeyCode::Char('j')));
1826        assert_eq!(a.selected, 1);
1827        assert_eq!(a.detail_scroll, 0);
1828    }
1829
1830    #[test]
1831    fn mouse_click_on_status_bar_and_title_row_select_nothing() {
1832        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1833        a.size = (100, 30);
1834        a.selected = 1;
1835        // Click the bottom status bar row (row 29): no selection change.
1836        let click = |row: u16| {
1837            Event::Mouse(MouseEvent {
1838                kind: MouseEventKind::Down(MouseButton::Left),
1839                column: 5,
1840                row,
1841                modifiers: KeyModifiers::empty(),
1842            })
1843        };
1844        a.handle_event(click(29));
1845        assert_eq!(a.selected, 1);
1846        // Click the list title/border row (row 0): no selection change.
1847        a.handle_event(click(0));
1848        assert_eq!(a.selected, 1);
1849    }
1850}