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