Skip to main content

wt/tui/
event.rs

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