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