Skip to main content

ctx_tui/
tui.rs

1//! Interactive manager for contexts and repos, lazygit-style.
2//!
3//! One thread owns all state: the event loop below receives key input,
4//! timer ticks, and worker results over a channel and mutates the app in
5//! response. Anything subprocess-heavy (creates, archives, status probes)
6//! runs on worker threads that only ever report back as events.
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10use std::sync::mpsc::{Receiver, Sender};
11use std::time::{Duration, Instant};
12
13use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use ratatui::layout::{Constraint, Layout, Margin, Rect};
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{
18    Block, BorderType, Cell as TableCell, Clear, Paragraph, Row, Table, TableState, Wrap,
19};
20use tui_input::Input;
21use tui_input::backend::crossterm::EventHandler;
22
23use crate::config::Config;
24use crate::contexts::{self, Context};
25use crate::errors::Result as CtxResult;
26use crate::git::new_command;
27use crate::multiplexer::Multiplexer;
28use crate::{forge, repos, status};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Request {
32    Open {
33        name: String,
34    },
35    New {
36        repo: String,
37        name: String,
38        base: Option<String>,
39    },
40}
41
42const SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
43
44const STATUS_POLL_SECONDS: f64 = 2.0;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Panel {
48    Contexts,
49    Repos,
50    Archived,
51}
52
53impl Panel {
54    const ALL: [Panel; 3] = [Panel::Contexts, Panel::Repos, Panel::Archived];
55
56    fn title(self) -> &'static str {
57        match self {
58            Panel::Contexts => "[1] Contexts",
59            Panel::Repos => "[2] Repos",
60            Panel::Archived => "[3] Archived",
61        }
62    }
63
64    fn name(self) -> &'static str {
65        match self {
66            Panel::Contexts => "contexts",
67            Panel::Repos => "repos",
68            Panel::Archived => "archived",
69        }
70    }
71
72    fn cycle(self, step: isize) -> Panel {
73        let index = Panel::ALL
74            .iter()
75            .position(|p| *p == self)
76            .expect("panel listed") as isize;
77        let next = (index + step).rem_euclid(Panel::ALL.len() as isize);
78        Panel::ALL[next as usize]
79    }
80}
81
82/// One table cell: text plus the status vocabulary's style word, if any.
83#[derive(Debug, Clone, PartialEq, Eq, Default)]
84pub struct CellValue {
85    text: String,
86    style: Option<&'static str>,
87}
88
89impl CellValue {
90    fn plain(text: impl Into<String>) -> CellValue {
91        CellValue {
92            text: text.into(),
93            style: None,
94        }
95    }
96
97    fn styled(text: impl Into<String>, style: Option<&'static str>) -> CellValue {
98        CellValue {
99            text: text.into(),
100            style,
101        }
102    }
103}
104
105#[derive(Debug, Clone)]
106struct TableRow {
107    key: String,
108    cells: Vec<CellValue>,
109}
110
111/// A panel's table: keyed rows plus a cursor, ratatui-agnostic for tests.
112struct PanelTable {
113    headers: Vec<String>,
114    rows: Vec<TableRow>,
115    cursor: usize,
116    view: TableState,
117    // Visible rows at the last render, for page-wise movement.
118    page: usize,
119}
120
121impl PanelTable {
122    fn new(headers: Vec<String>) -> PanelTable {
123        PanelTable {
124            headers,
125            rows: Vec::new(),
126            cursor: 0,
127            view: TableState::default(),
128            page: 10,
129        }
130    }
131
132    fn clear(&mut self) {
133        self.rows.clear();
134        self.cursor = 0;
135    }
136
137    fn add_row(&mut self, key: impl Into<String>, cells: Vec<CellValue>) {
138        self.rows.push(TableRow {
139            key: key.into(),
140            cells,
141        });
142    }
143
144    fn row_count(&self) -> usize {
145        self.rows.len()
146    }
147
148    fn move_cursor(&mut self, row: isize) {
149        if self.rows.is_empty() {
150            self.cursor = 0;
151            return;
152        }
153        self.cursor = row.clamp(0, self.rows.len() as isize - 1) as usize;
154    }
155
156    fn selected_key(&self) -> Option<&str> {
157        self.rows.get(self.cursor).map(|row| row.key.as_str())
158    }
159
160    fn update_cell(&mut self, key: &str, column: usize, cell: CellValue) {
161        // The row may have been deleted or archived since the fetch started.
162        if let Some(row) = self.rows.iter_mut().find(|row| row.key == key)
163            && let Some(slot) = row.cells.get_mut(column)
164        {
165            *slot = cell;
166        }
167    }
168
169    fn cursor_to_key(&mut self, key: &str) {
170        if let Some(index) = self.rows.iter().position(|row| row.key == key) {
171            self.cursor = index;
172        }
173    }
174}
175
176enum PromptKind {
177    NewName { repo: String },
178    NewBase { repo: String, name: String },
179    AddRepo,
180}
181
182enum ConfirmKind {
183    DeleteLive(Context),
184    DeleteArchived(Context),
185    RemoveRepo(String),
186    EmptyArchive,
187}
188
189enum Modal {
190    Prompt {
191        title: String,
192        placeholder: &'static str,
193        input: Input,
194        // A pre-filled value is replaced by the first keystroke, like a
195        // selected-on-focus input.
196        replace_on_type: bool,
197        kind: PromptKind,
198    },
199    Alert {
200        message: String,
201    },
202    Help {
203        panel: Panel,
204    },
205    Confirm {
206        message: String,
207        confirm_label: &'static str,
208        selected: usize,
209        kind: ConfirmKind,
210    },
211}
212
213struct Filter {
214    target: Panel,
215    query: Input,
216    rows: Vec<TableRow>,
217}
218
219/// A worker's report back to the event loop.
220#[derive(Debug, Default)]
221pub struct WorkerDone {
222    reload: bool,
223    finish_busy: bool,
224    alert: Option<String>,
225    exit: bool,
226    /// The worker's last report; decrements the outstanding-worker count.
227    finished: bool,
228}
229
230pub enum Event {
231    Key(KeyEvent),
232    Mouse(ratatui::crossterm::event::MouseEvent),
233    Tick,
234    Cell {
235        key: String,
236        column: usize,
237        cell: CellValue,
238    },
239    ColumnDone(usize),
240    Worker(WorkerDone),
241    Redraw,
242}
243
244pub struct CtxTui {
245    cfg: Config,
246    mux: Arc<dyn Multiplexer>,
247    exit_on_open: bool,
248    tx: Sender<Event>,
249    rx: Receiver<Event>,
250    panel: Panel,
251    contexts: PanelTable,
252    repos: PanelTable,
253    archived: PanelTable,
254    busy: HashSet<Panel>,
255    spinner_frame: usize,
256    fetching: HashSet<usize>,
257    poll_at: Vec<Instant>,
258    filter: Option<Filter>,
259    modal: Option<Modal>,
260    // Alerts that arrived while a prompt or confirm was open; shown once
261    // the popup closes (Textual stacked screens instead).
262    pending_alerts: Vec<String>,
263    workers: usize,
264    outcome: Option<Request>,
265    quit: bool,
266    // Panel rectangles from the last render, for mouse hit-testing.
267    areas: HashMap<Panel, Rect>,
268    button_areas: Vec<Rect>,
269}
270
271fn spawn_worker<F: FnOnce() + Send + 'static>(f: F) {
272    #[cfg(test)]
273    let f = crate::testutil::propagate_env(f);
274    std::thread::spawn(f);
275}
276
277// Scoped fetch threads carry the calling test's env stubs along.
278#[cfg(test)]
279use crate::testutil::propagate_env as carry_env;
280#[cfg(not(test))]
281fn carry_env<R, F: FnOnce() -> R + Send>(f: F) -> F {
282    f
283}
284
285impl CtxTui {
286    pub fn new(cfg: Config, mux: Arc<dyn Multiplexer>, exit_on_open: bool) -> CtxTui {
287        let (tx, rx) = std::sync::mpsc::channel();
288        let status_names: Vec<String> = cfg.status.iter().map(|s| s.name.to_uppercase()).collect();
289        let mut contexts_headers = vec![
290            "NAME".to_string(),
291            "REPO".to_string(),
292            "BRANCH".to_string(),
293            "STATUS".to_string(),
294        ];
295        contexts_headers.extend(status_names);
296        let now = Instant::now();
297        let intervals = 1 + cfg.status.len();
298        CtxTui {
299            cfg,
300            mux,
301            exit_on_open,
302            tx,
303            rx,
304            panel: Panel::Contexts,
305            contexts: PanelTable::new(contexts_headers),
306            repos: PanelTable::new(vec!["NAME".to_string(), "URL".to_string()]),
307            archived: PanelTable::new(vec![
308                "NAME".to_string(),
309                "REPO".to_string(),
310                "BRANCH".to_string(),
311            ]),
312            busy: HashSet::new(),
313            spinner_frame: 0,
314            fetching: HashSet::new(),
315            poll_at: vec![now; intervals],
316            filter: None,
317            modal: None,
318            pending_alerts: Vec::new(),
319            workers: 0,
320            outcome: None,
321            quit: false,
322            areas: HashMap::new(),
323            button_areas: Vec::new(),
324        }
325    }
326
327    /// Each status column's poll cadence: its provider's refresh interval.
328    ///
329    /// The STATUS column and columns without an interval ride the base tick;
330    /// nothing polls faster than it.
331    fn poll_intervals(&self) -> Vec<Duration> {
332        let mut intervals = vec![Duration::from_secs_f64(STATUS_POLL_SECONDS)];
333        intervals.extend(self.cfg.status.iter().map(|col| {
334            Duration::from_secs_f64(status::refresh_interval(col).max(STATUS_POLL_SECONDS))
335        }));
336        intervals
337    }
338
339    /// Startup work: populate the panels and sweep interrupted deletions.
340    pub fn mount(&mut self) {
341        self.reload();
342        let cfg = self.cfg.clone();
343        let tx = self.tx.clone();
344        self.workers += 1;
345        spawn_worker(move || {
346            // Finish any context deletions a previous run left half-done.
347            contexts::sweep_deleting(&cfg);
348            let _ = tx.send(Event::Worker(WorkerDone {
349                finished: true,
350                ..WorkerDone::default()
351            }));
352        });
353        let intervals = self.poll_intervals();
354        let now = Instant::now();
355        self.poll_at = intervals.iter().map(|interval| now + *interval).collect();
356    }
357
358    /// Repaint the panels from what is cheap to read; statuses fill in after.
359    ///
360    /// A status provider may take seconds per context (the GitHub built-ins
361    /// shell out to `gh`), which is more than the panels can wait for and far
362    /// more than the interface can stop responding for.
363    fn reload(&mut self) {
364        if let Some(filter) = self.filter.take() {
365            // A reload repopulates every panel, so the snapshot is stale.
366            self.panel = filter.target;
367        }
368        let blanks = 1 + self.cfg.status.len();
369        self.contexts.clear();
370        let mut ctxs = contexts::list_contexts(&self.cfg);
371        // Pin the attached context on top: recency is keyed on git activity,
372        // so a busy background session often outranks the one being viewed.
373        let current = ctxs.iter().position(|c| self.mux.is_current(c));
374        if let Some(index) = current {
375            let ctx = ctxs.remove(index);
376            ctxs.insert(0, ctx);
377        }
378        for (index, ctx) in ctxs.iter().enumerate() {
379            let name_style = (current.is_some() && index == 0).then_some("bold bright_green");
380            let mut cells = vec![
381                CellValue::styled(&ctx.name, name_style),
382                CellValue::plain(&ctx.repo),
383                CellValue::plain(contexts::current_branch(ctx)),
384            ];
385            cells.extend(std::iter::repeat_with(CellValue::default).take(blanks));
386            self.contexts.add_row(&ctx.name, cells);
387        }
388        // Land the cursor on the most recent other context: the common reason
389        // to open the TUI is switching away, not reopening the same session.
390        if current.is_some() && self.contexts.row_count() > 1 {
391            self.contexts.move_cursor(1);
392        }
393        self.repos.clear();
394        let default = repos::default_repo(&self.cfg);
395        let mut names = repos::repo_names(&self.cfg);
396        names.sort_by_key(|name| (Some(name) != default.as_ref(), name.clone()));
397        for name in names {
398            let label = if Some(&name) == default.as_ref() {
399                format!("{name} *")
400            } else {
401                name.clone()
402            };
403            let url = repos::repo_url(&self.cfg, &name).unwrap_or_default();
404            self.repos
405                .add_row(&name, vec![CellValue::plain(label), CellValue::plain(url)]);
406        }
407        self.archived.clear();
408        for ctx in contexts::list_archived(&self.cfg) {
409            self.archived.add_row(
410                &ctx.name,
411                vec![
412                    CellValue::plain(&ctx.name),
413                    CellValue::plain(&ctx.repo),
414                    CellValue::plain(contexts::current_branch(&ctx)),
415                ],
416            );
417        }
418        self.refresh_statuses();
419    }
420
421    fn refresh_statuses(&mut self) {
422        for index in 0..=self.cfg.status.len() {
423            self.refresh_column(index);
424        }
425    }
426
427    /// Fetch one column's cells concurrently, painting each as it lands.
428    fn refresh_column(&mut self, index: usize) {
429        if self.fetching.contains(&index) {
430            return;
431        }
432        self.fetching.insert(index);
433        let cfg = self.cfg.clone();
434        let tx = self.tx.clone();
435        spawn_worker(move || {
436            // ColumnDone must go out even if a fetch thread panics (the
437            // scope re-panics on join), or the column stays marked
438            // in-flight and never refreshes again.
439            struct Done {
440                tx: Sender<Event>,
441                index: usize,
442            }
443            impl Drop for Done {
444                fn drop(&mut self) {
445                    let _ = self.tx.send(Event::ColumnDone(self.index));
446                }
447            }
448            let _done = Done {
449                tx: tx.clone(),
450                index,
451            };
452            let ctxs = contexts::list_contexts(&cfg);
453            std::thread::scope(|scope| {
454                for ctx in &ctxs {
455                    let tx = tx.clone();
456                    let cfg = &cfg;
457                    scope.spawn(carry_env(move || {
458                        let cell = fetch_cell(cfg, ctx, index);
459                        let _ = tx.send(Event::Cell {
460                            key: ctx.name.clone(),
461                            column: index,
462                            cell,
463                        });
464                    }));
465                }
466            });
467        });
468    }
469
470    /// Keep one status column live without a full (cursor-resetting) reload.
471    fn poll_column(&mut self, index: usize) {
472        if self.busy.is_empty() {
473            self.refresh_column(index);
474        }
475    }
476
477    fn start_busy(&mut self, panel: Panel) {
478        self.busy.insert(panel);
479    }
480
481    fn finish_busy(&mut self) {
482        self.busy.clear();
483    }
484
485    fn table(&self, panel: Panel) -> &PanelTable {
486        match panel {
487            Panel::Contexts => &self.contexts,
488            Panel::Repos => &self.repos,
489            Panel::Archived => &self.archived,
490        }
491    }
492
493    fn table_mut(&mut self, panel: Panel) -> &mut PanelTable {
494        match panel {
495            Panel::Contexts => &mut self.contexts,
496            Panel::Repos => &mut self.repos,
497            Panel::Archived => &mut self.archived,
498        }
499    }
500
501    /// Centrally disable mutating actions while a worker runs or a popup is open.
502    fn allow_mutation(&self) -> bool {
503        self.busy.is_empty() && self.modal.is_none()
504    }
505
506    /// Resolve the cursor's context from disk; reloads and yields None if stale.
507    fn selected_context(&mut self) -> Option<Context> {
508        let key = self.contexts.selected_key()?.to_string();
509        match contexts::find_context(&self.cfg, &key) {
510            Ok(ctx) => Some(ctx),
511            Err(_) => {
512                // The row went stale, e.g. the context was deleted externally.
513                self.reload();
514                None
515            }
516        }
517    }
518
519    /// Resolve the archived panel's cursor from disk; reloads and yields None if stale.
520    fn selected_archived(&mut self) -> Option<Context> {
521        let key = self.archived.selected_key()?.to_string();
522        match contexts::find_archived(&self.cfg, &key) {
523            Ok(ctx) => Some(ctx),
524            Err(_) => {
525                self.reload();
526                None
527            }
528        }
529    }
530
531    fn alert(&mut self, message: impl Into<String>) {
532        if self.modal.is_some() {
533            self.pending_alerts.push(message.into());
534            return;
535        }
536        self.modal = Some(Modal::Alert {
537            message: message.into(),
538        });
539    }
540
541    /// Show the next queued alert once the current popup is gone.
542    fn show_pending_alert(&mut self) {
543        if self.modal.is_none() && !self.pending_alerts.is_empty() {
544            let message = self.pending_alerts.remove(0);
545            self.modal = Some(Modal::Alert { message });
546        }
547    }
548
549    // ------------------------------------------------------------------
550    // Event handling
551
552    pub fn handle(&mut self, event: Event) {
553        match event {
554            Event::Key(key) => self.handle_key(key),
555            Event::Mouse(mouse) => self.handle_mouse(mouse),
556            Event::Tick => self.handle_tick(),
557            Event::Cell { key, column, cell } => {
558                self.contexts.update_cell(&key, 3 + column, cell);
559            }
560            Event::ColumnDone(index) => {
561                self.fetching.remove(&index);
562            }
563            Event::Worker(done) => self.handle_worker(done),
564            Event::Redraw => {}
565        }
566    }
567
568    fn handle_worker(&mut self, done: WorkerDone) {
569        if done.finished {
570            self.workers = self.workers.saturating_sub(1);
571        }
572        if done.reload {
573            self.reload();
574        }
575        if done.finish_busy {
576            self.finish_busy();
577        }
578        if let Some(message) = done.alert {
579            self.alert(message);
580        }
581        if done.exit {
582            self.quit = true;
583        }
584    }
585
586    fn handle_tick(&mut self) {
587        if !self.busy.is_empty() {
588            self.spinner_frame += 1;
589        }
590        // Background polling only runs with status columns configured,
591        // like the original; a bare listing refreshes on demand.
592        if self.cfg.status.is_empty() {
593            return;
594        }
595        let intervals = self.poll_intervals();
596        let now = Instant::now();
597        for (index, interval) in intervals.iter().enumerate() {
598            if now >= self.poll_at[index] {
599                self.poll_at[index] = now + *interval;
600                self.poll_column(index);
601            }
602        }
603    }
604
605    fn handle_key(&mut self, key: KeyEvent) {
606        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
607            self.quit = true;
608            return;
609        }
610        if self.modal.is_some() {
611            self.handle_modal_key(key);
612            return;
613        }
614        if self.filter.is_some() {
615            self.handle_filter_key(key);
616            return;
617        }
618        match key.code {
619            KeyCode::Char('1') => self.panel = Panel::Contexts,
620            KeyCode::Char('2') => self.panel = Panel::Repos,
621            KeyCode::Char('3') => self.panel = Panel::Archived,
622            KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
623                self.panel = self.panel.cycle(-1)
624            }
625            KeyCode::Tab => self.panel = self.panel.cycle(1),
626            KeyCode::Char('l') | KeyCode::Right => self.panel = self.panel.cycle(1),
627            KeyCode::Char('j') | KeyCode::Down => {
628                let table = self.table_mut(self.panel);
629                table.move_cursor(table.cursor as isize + 1);
630            }
631            KeyCode::Char('k') | KeyCode::Up => {
632                let table = self.table_mut(self.panel);
633                table.move_cursor(table.cursor as isize - 1);
634            }
635            KeyCode::Char('g') | KeyCode::Home => self.table_mut(self.panel).move_cursor(0),
636            KeyCode::Char('G') | KeyCode::End => {
637                let table = self.table_mut(self.panel);
638                table.move_cursor(table.row_count() as isize - 1);
639            }
640            KeyCode::PageDown => {
641                let table = self.table_mut(self.panel);
642                table.move_cursor(table.cursor as isize + table.page.max(1) as isize);
643            }
644            KeyCode::PageUp => {
645                let table = self.table_mut(self.panel);
646                table.move_cursor(table.cursor as isize - table.page.max(1) as isize);
647            }
648            KeyCode::Char('q') => self.quit = true,
649            KeyCode::Char('r') => self.reload(),
650            KeyCode::Char('?') => {
651                self.modal = Some(Modal::Help { panel: self.panel });
652            }
653            KeyCode::Char('/') => self.action_filter(),
654            KeyCode::Char('n') if self.allow_mutation() => self.action_new(),
655            KeyCode::Char('N') if self.allow_mutation() => self.action_new_from_base(),
656            KeyCode::Enter => self.action_select(),
657            _ => self.handle_panel_key(key),
658        }
659    }
660
661    /// Enter on a panel row: the panel's primary action, mutation-gated.
662    fn action_select(&mut self) {
663        if !self.allow_mutation() {
664            return;
665        }
666        match self.panel {
667            Panel::Contexts => self.action_open(),
668            Panel::Repos => self.action_new(),
669            Panel::Archived => self.open_archived(),
670        }
671    }
672
673    fn handle_panel_key(&mut self, key: KeyEvent) {
674        let gated = self.allow_mutation();
675        match (self.panel, key.code) {
676            (Panel::Contexts, KeyCode::Char(' ')) if gated => self.action_open(),
677            (Panel::Contexts, KeyCode::Char('o')) => self.action_open_pr(),
678            (Panel::Contexts, KeyCode::Char('d')) if gated => self.action_archive(),
679            (Panel::Contexts, KeyCode::Char('D')) if gated => self.action_delete(),
680            (Panel::Repos, KeyCode::Char('a')) if gated => self.action_add_repo(),
681            (Panel::Repos, KeyCode::Char('s')) if gated => self.action_set_default_repo(),
682            (Panel::Repos, KeyCode::Char('d')) if gated => self.action_delete(),
683            (Panel::Archived, KeyCode::Char('u')) if gated => self.action_unarchive(),
684            (Panel::Archived, KeyCode::Char('d') | KeyCode::Char('D')) if gated => {
685                self.action_delete()
686            }
687            (Panel::Archived, KeyCode::Char('e')) if gated => self.action_empty_archive(),
688            _ => {}
689        }
690    }
691
692    fn handle_modal_key(&mut self, key: KeyEvent) {
693        let Some(modal) = self.modal.take() else {
694            return;
695        };
696        match modal {
697            // q and r stay live under popups, like the app-level bindings
698            // that kept firing beneath Textual's modal screens.
699            Modal::Alert { message } => match key.code {
700                KeyCode::Esc | KeyCode::Enter => {}
701                KeyCode::Char('q') => self.quit = true,
702                KeyCode::Char('r') => {
703                    self.reload();
704                    self.modal = Some(Modal::Alert { message });
705                }
706                _ => self.modal = Some(Modal::Alert { message }),
707            },
708            Modal::Help { panel } => match key.code {
709                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => {}
710                KeyCode::Char('q') => self.quit = true,
711                KeyCode::Char('r') => {
712                    self.reload();
713                    self.modal = Some(Modal::Help { panel });
714                }
715                _ => self.modal = Some(Modal::Help { panel }),
716            },
717            Modal::Prompt {
718                title,
719                placeholder,
720                mut input,
721                mut replace_on_type,
722                kind,
723            } => match key.code {
724                KeyCode::Esc => {}
725                KeyCode::Enter => {
726                    let value = input.value().trim().to_string();
727                    if value.is_empty() {
728                        self.modal = Some(Modal::Prompt {
729                            title,
730                            placeholder,
731                            input,
732                            replace_on_type,
733                            kind,
734                        });
735                    } else {
736                        self.submit_prompt(kind, value);
737                    }
738                }
739                code => {
740                    // The pre-fill acts selected: plain typing replaces it,
741                    // backspace/delete removes it whole, anything else (a
742                    // chord, a cursor move) just deselects.
743                    let mut consumed = false;
744                    if replace_on_type {
745                        replace_on_type = false;
746                        let plain = !key.modifiers.intersects(
747                            KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
748                        );
749                        match code {
750                            KeyCode::Char(_) if plain => input = Input::default(),
751                            KeyCode::Backspace | KeyCode::Delete => {
752                                input = Input::default();
753                                consumed = true;
754                            }
755                            _ => {}
756                        }
757                    }
758                    if !consumed {
759                        input.handle_event(&ratatui::crossterm::event::Event::Key(key));
760                    }
761                    self.modal = Some(Modal::Prompt {
762                        title,
763                        placeholder,
764                        input,
765                        replace_on_type,
766                        kind,
767                    });
768                }
769            },
770            Modal::Confirm {
771                message,
772                confirm_label,
773                mut selected,
774                kind,
775            } => match key.code {
776                KeyCode::Esc => {}
777                KeyCode::Char('q') => self.quit = true,
778                KeyCode::Char('r') => {
779                    self.reload();
780                    self.modal = Some(Modal::Confirm {
781                        message,
782                        confirm_label,
783                        selected,
784                        kind,
785                    });
786                }
787                KeyCode::Enter => {
788                    if selected == 0 {
789                        self.confirm(kind);
790                    }
791                }
792                KeyCode::Char('j' | 'l') | KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
793                    selected = (selected + 1) % 2;
794                    self.modal = Some(Modal::Confirm {
795                        message,
796                        confirm_label,
797                        selected,
798                        kind,
799                    });
800                }
801                KeyCode::Char('k' | 'h') | KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
802                    selected = (selected + 1) % 2;
803                    self.modal = Some(Modal::Confirm {
804                        message,
805                        confirm_label,
806                        selected,
807                        kind,
808                    });
809                }
810                _ => {
811                    self.modal = Some(Modal::Confirm {
812                        message,
813                        confirm_label,
814                        selected,
815                        kind,
816                    });
817                }
818            },
819        }
820        self.show_pending_alert();
821    }
822
823    fn handle_filter_key(&mut self, key: KeyEvent) {
824        match key.code {
825            KeyCode::Esc => self.dismiss_filter(),
826            KeyCode::Down => self.filter_cursor(1),
827            KeyCode::Up => self.filter_cursor(-1),
828            KeyCode::Enter => self.submit_filter(),
829            _ => {
830                if let Some(filter) = &mut self.filter {
831                    filter
832                        .query
833                        .handle_event(&ratatui::crossterm::event::Event::Key(key));
834                    self.apply_filter();
835                }
836            }
837        }
838    }
839
840    fn handle_mouse(&mut self, mouse: ratatui::crossterm::event::MouseEvent) {
841        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
842
843        match mouse.kind {
844            MouseEventKind::Down(MouseButton::Left) => {
845                if self.modal.is_some() {
846                    self.click_modal(mouse.column, mouse.row);
847                    return;
848                }
849                for panel in Panel::ALL {
850                    if let Some(area) = self.areas.get(&panel)
851                        && area.contains((mouse.column, mouse.row).into())
852                    {
853                        self.panel = panel;
854                        // Rows start below the border and header; clicks on
855                        // blank space past the last row only focus the panel.
856                        let first_row = area.y + 2;
857                        if mouse.row >= first_row {
858                            let index =
859                                (mouse.row - first_row) as usize + self.table(panel).view.offset();
860                            if index < self.table(panel).row_count() {
861                                self.table_mut(panel).move_cursor(index as isize);
862                            }
863                        }
864                    }
865                }
866            }
867            MouseEventKind::ScrollDown if self.modal.is_none() && self.filter.is_none() => {
868                let table = self.table_mut(self.panel);
869                table.move_cursor(table.cursor as isize + 1);
870            }
871            MouseEventKind::ScrollUp if self.modal.is_none() && self.filter.is_none() => {
872                let table = self.table_mut(self.panel);
873                table.move_cursor(table.cursor as isize - 1);
874            }
875            _ => {}
876        }
877    }
878
879    fn click_modal(&mut self, column: u16, row: u16) {
880        let hit = self
881            .button_areas
882            .iter()
883            .position(|area| area.contains((column, row).into()));
884        if let Some(Modal::Confirm { .. }) = &self.modal
885            && let Some(index) = hit
886        {
887            let Some(Modal::Confirm { kind, .. }) = self.modal.take() else {
888                return;
889            };
890            if index == 0 {
891                self.confirm(kind);
892            }
893            self.show_pending_alert();
894        }
895    }
896
897    // ------------------------------------------------------------------
898    // Actions
899
900    fn action_open(&mut self) {
901        let Some(ctx) = self.selected_context() else {
902            return;
903        };
904        if !self.mux.can_open_in_place() {
905            self.outcome = Some(Request::Open { name: ctx.name });
906            self.quit = true;
907            return;
908        }
909        if let Err(err) = self.mux.open(&ctx, None) {
910            self.alert(err.to_string());
911            return;
912        }
913        if self.exit_on_open {
914            self.quit = true;
915        }
916    }
917
918    /// The target repo: the hovered repo on the repos panel, else the default.
919    ///
920    /// With no default set, fall back to the hovered row's repo.
921    fn repo_for_new(&mut self) -> Option<String> {
922        if self.panel == Panel::Repos {
923            return self.repos.selected_key().map(str::to_string);
924        }
925        if let Some(default) = repos::default_repo(&self.cfg) {
926            return Some(default);
927        }
928        let hovered = match self.panel {
929            Panel::Contexts => self.selected_context(),
930            _ => self.selected_archived(),
931        };
932        if let Some(ctx) = hovered {
933            return Some(ctx.repo);
934        }
935        self.repos.selected_key().map(str::to_string)
936    }
937
938    fn name_prompt(&mut self, repo: String) {
939        let value = contexts::random_name(&self.cfg).unwrap_or_default();
940        self.modal = Some(Modal::Prompt {
941            title: format!("New context for {repo}"),
942            placeholder: "name",
943            input: Input::new(value),
944            replace_on_type: true,
945            kind: PromptKind::NewName { repo },
946        });
947    }
948
949    fn action_new(&mut self) {
950        match self.repo_for_new() {
951            None => self.alert("no repos registered; press a to add one"),
952            Some(repo) => self.name_prompt(repo),
953        }
954    }
955
956    fn action_new_from_base(&mut self) {
957        // The base prompt follows once the name is submitted.
958        match self.repo_for_new() {
959            None => self.alert("no repos registered; press a to add one"),
960            Some(repo) => {
961                self.name_prompt(repo);
962                if let Some(Modal::Prompt { kind, .. }) = &mut self.modal {
963                    let PromptKind::NewName { repo } = std::mem::replace(kind, PromptKind::AddRepo)
964                    else {
965                        return;
966                    };
967                    *kind = PromptKind::NewBase {
968                        repo,
969                        name: String::new(),
970                    };
971                }
972            }
973        }
974    }
975
976    fn submit_prompt(&mut self, kind: PromptKind, value: String) {
977        match kind {
978            PromptKind::NewName { repo } => self.create(repo, value, None),
979            PromptKind::NewBase { repo, name } if name.is_empty() => {
980                // The first prompt of the from-base flow gathered the name;
981                // now ask for the base branch.
982                self.modal = Some(Modal::Prompt {
983                    title: format!("Base branch for {value}"),
984                    placeholder: "branch",
985                    input: Input::default(),
986                    replace_on_type: false,
987                    kind: PromptKind::NewBase { repo, name: value },
988                });
989            }
990            PromptKind::NewBase { repo, name } => self.create(repo, name, Some(value)),
991            PromptKind::AddRepo => {
992                self.start_busy(Panel::Repos);
993                let cfg = self.cfg.clone();
994                let tx = self.tx.clone();
995                self.workers += 1;
996                spawn_worker(move || {
997                    let alert = repos::add_repo(&cfg, &value, None)
998                        .err()
999                        .map(|err| err.to_string());
1000                    let _ = tx.send(Event::Worker(WorkerDone {
1001                        reload: true,
1002                        finish_busy: true,
1003                        alert,
1004                        finished: true,
1005                        ..WorkerDone::default()
1006                    }));
1007                });
1008            }
1009        }
1010    }
1011
1012    /// Create in the background if we can stay running, else exit to the CLI.
1013    fn create(&mut self, repo: String, name: String, base: Option<String>) {
1014        if !self.mux.can_open_in_place() {
1015            self.outcome = Some(Request::New { repo, name, base });
1016            self.quit = true;
1017            return;
1018        }
1019        self.start_busy(Panel::Contexts);
1020        let cfg = self.cfg.clone();
1021        let mux = self.mux.clone();
1022        let tx = self.tx.clone();
1023        let exit_on_open = self.exit_on_open;
1024        self.workers += 1;
1025        spawn_worker(move || {
1026            let ctx = match contexts::create_context(&cfg, &repo, &name, base.as_deref()) {
1027                Err(err) => {
1028                    let _ = tx.send(Event::Worker(WorkerDone {
1029                        finish_busy: true,
1030                        alert: Some(err.to_string()),
1031                        finished: true,
1032                        ..WorkerDone::default()
1033                    }));
1034                    return;
1035                }
1036                Ok(ctx) => ctx,
1037            };
1038            let _ = tx.send(Event::Worker(WorkerDone {
1039                reload: true,
1040                finish_busy: true,
1041                ..WorkerDone::default()
1042            }));
1043            let opened = mux.open(&ctx, Some(&HashMap::new()));
1044            let _ = tx.send(Event::Worker(WorkerDone {
1045                alert: opened.as_ref().err().map(|err| err.to_string()),
1046                exit: opened.is_ok() && exit_on_open,
1047                finished: true,
1048                ..WorkerDone::default()
1049            }));
1050        });
1051    }
1052
1053    /// Toggle the selected repo as the default for new contexts.
1054    fn action_set_default_repo(&mut self) {
1055        let Some(name) = self.repos.selected_key().map(str::to_string) else {
1056            return;
1057        };
1058        let current = repos::default_repo(&self.cfg);
1059        let target = if current.as_deref() == Some(name.as_str()) {
1060            None
1061        } else {
1062            Some(name.as_str())
1063        };
1064        if let Err(err) = repos::set_default_repo(&self.cfg, target) {
1065            self.alert(err.to_string());
1066            return;
1067        }
1068        self.reload();
1069    }
1070
1071    fn action_add_repo(&mut self) {
1072        self.modal = Some(Modal::Prompt {
1073            title: "Add repo".to_string(),
1074            placeholder: "clone URL",
1075            input: Input::default(),
1076            replace_on_type: false,
1077            kind: PromptKind::AddRepo,
1078        });
1079    }
1080
1081    fn action_unarchive(&mut self) {
1082        let Some(ctx) = self.selected_archived() else {
1083            return;
1084        };
1085        self.start_busy(Panel::Archived);
1086        self.unarchive_worker(ctx, false);
1087    }
1088
1089    /// Enter on an archived context: unarchive it and open its session.
1090    fn open_archived(&mut self) {
1091        let Some(ctx) = self.selected_archived() else {
1092            return;
1093        };
1094        if !self.mux.can_open_in_place() {
1095            if let Err(err) = contexts::unarchive_context(&self.cfg, &ctx) {
1096                self.alert(err.to_string());
1097                return;
1098            }
1099            self.outcome = Some(Request::Open { name: ctx.name });
1100            self.quit = true;
1101            return;
1102        }
1103        self.start_busy(Panel::Archived);
1104        self.unarchive_worker(ctx, true);
1105    }
1106
1107    fn unarchive_worker(&mut self, ctx: Context, open_after: bool) {
1108        let cfg = self.cfg.clone();
1109        let mux = self.mux.clone();
1110        let tx = self.tx.clone();
1111        let exit_on_open = self.exit_on_open;
1112        self.workers += 1;
1113        spawn_worker(move || {
1114            let restored = match contexts::unarchive_context(&cfg, &ctx) {
1115                Err(err) => {
1116                    let _ = tx.send(Event::Worker(WorkerDone {
1117                        finish_busy: true,
1118                        alert: Some(err.to_string()),
1119                        finished: true,
1120                        ..WorkerDone::default()
1121                    }));
1122                    return;
1123                }
1124                Ok(restored) => restored,
1125            };
1126            let _ = tx.send(Event::Worker(WorkerDone {
1127                reload: true,
1128                finish_busy: true,
1129                ..WorkerDone::default()
1130            }));
1131            if !open_after {
1132                let _ = tx.send(Event::Worker(WorkerDone {
1133                    finished: true,
1134                    ..WorkerDone::default()
1135                }));
1136                return;
1137            }
1138            let opened = mux.open(&restored, None);
1139            let _ = tx.send(Event::Worker(WorkerDone {
1140                alert: opened.as_ref().err().map(|err| err.to_string()),
1141                exit: opened.is_ok() && exit_on_open,
1142                finished: true,
1143                ..WorkerDone::default()
1144            }));
1145        });
1146    }
1147
1148    fn action_empty_archive(&mut self) {
1149        let archived = contexts::list_archived(&self.cfg);
1150        if archived.is_empty() {
1151            return;
1152        }
1153        self.modal = Some(Modal::Confirm {
1154            message: format!(
1155                "Permanently delete all {} archived context(s)?",
1156                archived.len()
1157            ),
1158            confirm_label: "Empty",
1159            selected: 0,
1160            kind: ConfirmKind::EmptyArchive,
1161        });
1162    }
1163
1164    fn action_delete(&mut self) {
1165        match self.panel {
1166            Panel::Repos => self.delete_repo_prompt(),
1167            Panel::Archived => {
1168                if let Some(ctx) = self.selected_archived() {
1169                    self.confirm_delete(ctx, false);
1170                }
1171            }
1172            Panel::Contexts => {
1173                if let Some(ctx) = self.selected_context() {
1174                    self.confirm_delete(ctx, true);
1175                }
1176            }
1177        }
1178    }
1179
1180    fn confirm_delete(&mut self, ctx: Context, live: bool) {
1181        let mut problems = Vec::new();
1182        if contexts::is_dirty(&ctx).unwrap_or(false) {
1183            problems.push("uncommitted changes");
1184        }
1185        if !contexts::unpushed_commits(&ctx)
1186            .unwrap_or_default()
1187            .is_empty()
1188        {
1189            problems.push("unpushed commits");
1190        }
1191        let (message, label) = if problems.is_empty() {
1192            (format!("Permanently delete {}?", ctx.qualified()), "Delete")
1193        } else {
1194            (
1195                format!(
1196                    "{} has {}. Permanently delete anyway?",
1197                    ctx.qualified(),
1198                    problems.join(" and ")
1199                ),
1200                "Force delete",
1201            )
1202        };
1203        self.modal = Some(Modal::Confirm {
1204            message,
1205            confirm_label: label,
1206            selected: 0,
1207            kind: if live {
1208                ConfirmKind::DeleteLive(ctx)
1209            } else {
1210                ConfirmKind::DeleteArchived(ctx)
1211            },
1212        });
1213    }
1214
1215    fn delete_repo_prompt(&mut self) {
1216        let Some(name) = self.repos.selected_key().map(str::to_string) else {
1217            return;
1218        };
1219        self.modal = Some(Modal::Confirm {
1220            message: format!("Remove repo '{name}'? Its contexts are left alone."),
1221            confirm_label: "Remove",
1222            selected: 0,
1223            kind: ConfirmKind::RemoveRepo(name),
1224        });
1225    }
1226
1227    fn confirm(&mut self, kind: ConfirmKind) {
1228        match kind {
1229            ConfirmKind::DeleteLive(ctx) => {
1230                self.start_busy(Panel::Contexts);
1231                self.teardown_worker(ctx, Teardown::Delete);
1232            }
1233            ConfirmKind::DeleteArchived(ctx) => {
1234                self.start_busy(Panel::Archived);
1235                let tx = self.tx.clone();
1236                self.workers += 1;
1237                spawn_worker(move || {
1238                    let alert = contexts::remove_context(&ctx)
1239                        .err()
1240                        .map(|err| err.to_string());
1241                    let _ = tx.send(Event::Worker(WorkerDone {
1242                        reload: true,
1243                        finish_busy: true,
1244                        alert,
1245                        finished: true,
1246                        ..WorkerDone::default()
1247                    }));
1248                });
1249            }
1250            ConfirmKind::RemoveRepo(name) => {
1251                self.start_busy(Panel::Repos);
1252                let cfg = self.cfg.clone();
1253                let tx = self.tx.clone();
1254                self.workers += 1;
1255                spawn_worker(move || {
1256                    let alert = repos::remove_repo(&cfg, &name)
1257                        .err()
1258                        .map(|err| err.to_string());
1259                    let _ = tx.send(Event::Worker(WorkerDone {
1260                        reload: true,
1261                        finish_busy: true,
1262                        alert,
1263                        finished: true,
1264                        ..WorkerDone::default()
1265                    }));
1266                });
1267            }
1268            ConfirmKind::EmptyArchive => {
1269                self.start_busy(Panel::Archived);
1270                let cfg = self.cfg.clone();
1271                let tx = self.tx.clone();
1272                self.workers += 1;
1273                spawn_worker(move || {
1274                    let alert = contexts::empty_archive(&cfg)
1275                        .err()
1276                        .map(|err| err.to_string());
1277                    let _ = tx.send(Event::Worker(WorkerDone {
1278                        reload: true,
1279                        finish_busy: true,
1280                        alert,
1281                        finished: true,
1282                        ..WorkerDone::default()
1283                    }));
1284                });
1285            }
1286        }
1287    }
1288
1289    /// Archive the selected context straight away; it is cheap to undo.
1290    fn action_archive(&mut self) {
1291        let Some(ctx) = self.selected_context() else {
1292            return;
1293        };
1294        self.start_busy(Panel::Contexts);
1295        self.teardown_worker(ctx, Teardown::Archive);
1296    }
1297
1298    fn teardown_worker(&mut self, ctx: Context, mode: Teardown) {
1299        let cfg = self.cfg.clone();
1300        let mux = self.mux.clone();
1301        let tx = self.tx.clone();
1302        self.workers += 1;
1303        spawn_worker(move || {
1304            let alert = teardown(&cfg, mux.as_ref(), &ctx, mode)
1305                .err()
1306                .map(|err| err.to_string());
1307            let _ = tx.send(Event::Worker(WorkerDone {
1308                reload: true,
1309                finish_busy: true,
1310                alert,
1311                finished: true,
1312                ..WorkerDone::default()
1313            }));
1314        });
1315    }
1316
1317    fn action_open_pr(&mut self) {
1318        if self.panel != Panel::Contexts {
1319            return;
1320        }
1321        let Some(ctx) = self.selected_context() else {
1322            return;
1323        };
1324        let tx = self.tx.clone();
1325        self.workers += 1;
1326        spawn_worker(move || {
1327            let alert = open_pr(&ctx).err();
1328            let _ = tx.send(Event::Worker(WorkerDone {
1329                alert,
1330                finished: true,
1331                ..WorkerDone::default()
1332            }));
1333        });
1334    }
1335
1336    // ------------------------------------------------------------------
1337    // Filter
1338
1339    fn action_filter(&mut self) {
1340        if self.filter.is_some() {
1341            return;
1342        }
1343        let target = self.panel;
1344        let rows = self.table(target).rows.clone();
1345        self.filter = Some(Filter {
1346            target,
1347            query: Input::default(),
1348            rows,
1349        });
1350    }
1351
1352    fn drop_filter(&mut self) -> Option<Filter> {
1353        self.filter.take()
1354    }
1355
1356    /// Restore the full panel, keeping the cursor on the filtered selection.
1357    fn dismiss_filter(&mut self) {
1358        let Some(filter) = self.drop_filter() else {
1359            return;
1360        };
1361        let selected = self.table(filter.target).selected_key().map(str::to_string);
1362        let table = self.table_mut(filter.target);
1363        table.rows = filter.rows;
1364        if let Some(key) = selected {
1365            table.cursor_to_key(&key);
1366        }
1367        self.panel = filter.target;
1368    }
1369
1370    fn filter_cursor(&mut self, step: isize) {
1371        if let Some(filter) = &self.filter {
1372            let target = filter.target;
1373            let table = self.table_mut(target);
1374            table.move_cursor(table.cursor as isize + step);
1375        }
1376    }
1377
1378    fn apply_filter(&mut self) {
1379        let Some(filter) = &self.filter else {
1380            return;
1381        };
1382        let target = filter.target;
1383        let with_repo = target != Panel::Repos;
1384        let query = filter.query.value().to_string();
1385        let matching: Vec<TableRow> = filter
1386            .rows
1387            .iter()
1388            .filter(|row| {
1389                let haystack = if with_repo {
1390                    format!(
1391                        "{}/{}",
1392                        row.cells.get(1).map(|c| c.text.as_str()).unwrap_or(""),
1393                        row.key
1394                    )
1395                } else {
1396                    row.key.clone()
1397                };
1398                fuzzy_match(&query, &haystack)
1399            })
1400            .cloned()
1401            .collect();
1402        let table = self.table_mut(target);
1403        table.rows = matching;
1404        // Each keystroke re-selects the top match, like the original's
1405        // clear-and-refill (whose clear reset the cursor).
1406        table.move_cursor(0);
1407    }
1408
1409    fn submit_filter(&mut self) {
1410        let Some(filter) = &self.filter else {
1411            return;
1412        };
1413        let target = filter.target;
1414        if self.table(target).selected_key().is_none() {
1415            return;
1416        }
1417        self.dismiss_filter();
1418        self.panel = target;
1419        self.action_select();
1420    }
1421
1422    // ------------------------------------------------------------------
1423    // Run loop
1424
1425    pub fn run(mut self) -> std::io::Result<Option<Request>> {
1426        use ratatui::crossterm::event::{
1427            DisableMouseCapture, EnableMouseCapture, Event as CtEvent, KeyEventKind,
1428        };
1429        use ratatui::crossterm::execute;
1430
1431        let mut terminal = ratatui::init();
1432        let _ = execute!(std::io::stdout(), EnableMouseCapture);
1433        self.mount();
1434
1435        // Input thread: raw crossterm events onto the app channel.
1436        let tx = self.tx.clone();
1437        std::thread::spawn(move || {
1438            while let Ok(event) = ratatui::crossterm::event::read() {
1439                let forwarded = match event {
1440                    CtEvent::Key(key) if key.kind != KeyEventKind::Release => Event::Key(key),
1441                    CtEvent::Mouse(mouse) => Event::Mouse(mouse),
1442                    CtEvent::Resize(_, _) => Event::Redraw,
1443                    _ => continue,
1444                };
1445                if tx.send(forwarded).is_err() {
1446                    break;
1447                }
1448            }
1449        });
1450        // Tick thread: the spinner beat and the status poll clock.
1451        let tx = self.tx.clone();
1452        std::thread::spawn(move || {
1453            loop {
1454                std::thread::sleep(Duration::from_millis(100));
1455                if tx.send(Event::Tick).is_err() {
1456                    break;
1457                }
1458            }
1459        });
1460
1461        let result = loop {
1462            terminal.draw(|frame| {
1463                let area = frame.area();
1464                let buffer = frame.buffer_mut();
1465                self.render(area, buffer);
1466            })?;
1467            let Ok(event) = self.rx.recv() else {
1468                break None;
1469            };
1470            self.handle(event);
1471            while let Ok(event) = self.rx.try_recv() {
1472                self.handle(event);
1473            }
1474            if self.quit {
1475                break self.outcome.take();
1476            }
1477        };
1478
1479        let _ = execute!(std::io::stdout(), DisableMouseCapture);
1480        ratatui::restore();
1481        // Quitting must cancel an in-flight create, not wait out its fetch.
1482        crate::git::kill_inflight();
1483        Ok(result)
1484    }
1485
1486    // ------------------------------------------------------------------
1487    // Rendering
1488
1489    pub fn render(&mut self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1490        let filter_height = u16::from(self.filter.is_some());
1491        let [contexts_area, bottom_area, filter_area, footer_area] = Layout::vertical([
1492            Constraint::Fill(7),
1493            Constraint::Fill(3),
1494            Constraint::Length(filter_height),
1495            Constraint::Length(1),
1496        ])
1497        .areas(area);
1498        let [repos_area, archived_area] =
1499            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(bottom_area);
1500        self.areas = HashMap::from([
1501            (Panel::Contexts, contexts_area),
1502            (Panel::Repos, repos_area),
1503            (Panel::Archived, archived_area),
1504        ]);
1505        self.render_panel(Panel::Contexts, contexts_area, buffer);
1506        self.render_panel(Panel::Repos, repos_area, buffer);
1507        self.render_panel(Panel::Archived, archived_area, buffer);
1508        if self.filter.is_some() {
1509            self.render_filter(filter_area, buffer);
1510        }
1511        self.render_footer(footer_area, buffer);
1512        self.render_modal(area, buffer);
1513    }
1514
1515    fn render_panel(&mut self, panel: Panel, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1516        let theme = self.cfg.theme.clone();
1517        let busy = self.busy.contains(&panel);
1518        let focused = self.panel == panel && self.filter.is_none() && self.modal.is_none();
1519        let border = if focused {
1520            theme_color(&theme.border_active)
1521        } else {
1522            theme_color(&theme.border_inactive)
1523        };
1524        let mut title = panel.title().to_string();
1525        if busy {
1526            let frame = SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()];
1527            title = format!("{title} {frame}");
1528        }
1529        let block = Block::bordered()
1530            .border_type(BorderType::Rounded)
1531            .border_style(Style::default().fg(border))
1532            .title(title);
1533        let inner = block.inner(area);
1534        block.render_widget(area, buffer);
1535
1536        let table = self.table_mut(panel);
1537        // One header line inside the borders; the rest is a page of rows.
1538        table.page = inner.height.saturating_sub(1).max(1) as usize;
1539        let columns = table.headers.len();
1540        let mut widths = vec![0usize; columns];
1541        for (index, header) in table.headers.iter().enumerate() {
1542            widths[index] = header.chars().count();
1543        }
1544        for row in &table.rows {
1545            for (index, cell) in row.cells.iter().enumerate() {
1546                widths[index] = widths[index].max(cell.text.chars().count());
1547            }
1548        }
1549        let foreground = theme_color(&theme.foreground);
1550        let header = Row::new(
1551            table
1552                .headers
1553                .iter()
1554                .map(|header| TableCell::from(header.as_str())),
1555        )
1556        .style(Style::default().fg(foreground).bold());
1557        let mut base = Style::default().fg(foreground);
1558        if busy {
1559            base = base.add_modifier(Modifier::DIM);
1560        }
1561        let rows: Vec<Row> = table
1562            .rows
1563            .iter()
1564            .map(|row| {
1565                Row::new(row.cells.iter().map(|cell| {
1566                    let mut style = base;
1567                    if let Some(name) = cell.style {
1568                        style = style.patch(status_style(name));
1569                    }
1570                    TableCell::from(cell.text.clone()).style(style)
1571                }))
1572            })
1573            .collect();
1574        // lazygit-style selection: only the focused panel shows its cursor;
1575        // bright-bold status colours keep their contrast on it.
1576        let highlight = if focused {
1577            Style::default()
1578                .bg(theme_color(&theme.selection))
1579                .add_modifier(Modifier::BOLD)
1580        } else {
1581            Style::default()
1582        };
1583        table
1584            .view
1585            .select((!table.rows.is_empty()).then_some(table.cursor));
1586        let widget = Table::new(
1587            rows,
1588            widths
1589                .iter()
1590                .map(|width| Constraint::Length(*width as u16))
1591                .collect::<Vec<_>>(),
1592        )
1593        .header(header)
1594        .column_spacing(2)
1595        .row_highlight_style(highlight);
1596        ratatui::widgets::StatefulWidget::render(widget, inner, buffer, &mut table.view);
1597    }
1598
1599    fn render_filter(&self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1600        let Some(filter) = &self.filter else {
1601            return;
1602        };
1603        let value = filter.query.value();
1604        let line = if value.is_empty() {
1605            Line::from(Span::styled(" filter", Style::default().dim()))
1606        } else {
1607            Line::from(format!(" {value}"))
1608        };
1609        Paragraph::new(line).render_widget(area, buffer);
1610    }
1611
1612    fn render_footer(&self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1613        let bindings: &[(&str, &str)] = match self.panel {
1614            Panel::Contexts => &[
1615                ("space", "Open"),
1616                ("o", "Open PR"),
1617                ("d", "Archive"),
1618                ("D", "Delete"),
1619                ("n", "New context"),
1620                ("/", "Filter"),
1621                ("r", "Refresh"),
1622                ("q", "Quit"),
1623                ("?", "Help"),
1624            ],
1625            Panel::Repos => &[
1626                ("a", "Add repo"),
1627                ("s", "Set default"),
1628                ("d", "Remove repo"),
1629                ("n", "New context"),
1630                ("/", "Filter"),
1631                ("r", "Refresh"),
1632                ("q", "Quit"),
1633                ("?", "Help"),
1634            ],
1635            Panel::Archived => &[
1636                ("u", "Unarchive"),
1637                ("d", "Delete"),
1638                ("e", "Empty"),
1639                ("n", "New context"),
1640                ("/", "Filter"),
1641                ("r", "Refresh"),
1642                ("q", "Quit"),
1643                ("?", "Help"),
1644            ],
1645        };
1646        let mut spans = Vec::new();
1647        for (key, label) in bindings {
1648            if !spans.is_empty() {
1649                spans.push(Span::raw("  "));
1650            }
1651            spans.push(Span::styled(*key, Style::default().bold()));
1652            spans.push(Span::raw(" "));
1653            spans.push(Span::styled(*label, Style::default().dim()));
1654        }
1655        Paragraph::new(Line::from(spans)).render_widget(area, buffer);
1656    }
1657
1658    fn render_modal(&mut self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1659        self.button_areas.clear();
1660        let Some(modal) = &self.modal else {
1661            return;
1662        };
1663        // The help dialog sizes to its widest binding line; the rest stay
1664        // at the classic dialog width.
1665        let width = match modal {
1666            Modal::Help { panel } => {
1667                let bindings = panel_keybindings(*panel);
1668                let pad = bindings
1669                    .iter()
1670                    .map(|(key, _)| key.chars().count())
1671                    .max()
1672                    .unwrap_or(0)
1673                    + 3;
1674                let widest = bindings
1675                    .iter()
1676                    .map(|(_, desc)| pad + desc.chars().count())
1677                    .max()
1678                    .unwrap_or(0) as u16;
1679                widest + 6
1680            }
1681            _ => 60,
1682        };
1683        let width = width.min(area.width.saturating_sub(4)).max(20);
1684        let inner_width = width.saturating_sub(6) as usize;
1685        // Title, body, whether an input field follows, confirm buttons.
1686        type ModalParts<'a> = (
1687            Option<String>,
1688            Vec<Line<'a>>,
1689            bool,
1690            Option<(&'a str, usize)>,
1691        );
1692        let (title, body_lines, has_input, buttons): ModalParts = match modal {
1693            Modal::Prompt { title, .. } => (Some(title.clone()), Vec::new(), true, None),
1694            Modal::Alert { message } => {
1695                // Errors quote whatever failed (git argv, paths); the text
1696                // renders verbatim, never as markup.
1697                (None, wrapped_lines(message, inner_width), false, None)
1698            }
1699            Modal::Help { panel } => {
1700                let bindings = panel_keybindings(*panel);
1701                // Pad keys to the longest so the descriptions align.
1702                let pad = bindings
1703                    .iter()
1704                    .map(|(key, _)| key.chars().count())
1705                    .max()
1706                    .unwrap_or(0)
1707                    + 3;
1708                let mut lines = vec![Line::from(format!("Keybindings ({})", panel.name()))];
1709                lines.push(Line::default());
1710                for (key, desc) in bindings {
1711                    let fill = " ".repeat(pad - key.chars().count());
1712                    lines.push(Line::from(format!("{key}{fill}{desc}")));
1713                }
1714                (None, lines, false, None)
1715            }
1716            Modal::Confirm {
1717                message,
1718                confirm_label,
1719                selected,
1720                ..
1721            } => (
1722                None,
1723                wrapped_lines(message, inner_width),
1724                false,
1725                Some((confirm_label, *selected)),
1726            ),
1727        };
1728        let mut height = body_lines.len() as u16 + 4;
1729        if title.is_some() {
1730            height += 2;
1731        }
1732        if has_input {
1733            height += 3;
1734        }
1735        if buttons.is_some() {
1736            height += 2;
1737        }
1738        let height = height.min(area.height);
1739        let popup = Rect {
1740            x: area.x + (area.width.saturating_sub(width)) / 2,
1741            y: area.y + (area.height.saturating_sub(height)) / 2,
1742            width,
1743            height,
1744        };
1745        Clear.render_widget(popup, buffer);
1746        let block = Block::bordered().border_type(BorderType::Rounded);
1747        let inner = block.inner(popup);
1748        block.render_widget(popup, buffer);
1749        let inner = inner.inner(Margin::new(2, 1));
1750        let mut y = inner.y;
1751        if let Some(title) = title {
1752            Paragraph::new(title).render_widget(
1753                Rect {
1754                    height: 1,
1755                    y,
1756                    ..inner
1757                },
1758                buffer,
1759            );
1760            y += 2;
1761        }
1762        if !body_lines.is_empty() {
1763            let height = body_lines.len() as u16;
1764            Paragraph::new(body_lines)
1765                .wrap(Wrap { trim: false })
1766                .render_widget(Rect { height, y, ..inner }, buffer);
1767            y += height + 1;
1768        }
1769        if has_input
1770            && let Some(Modal::Prompt {
1771                input, placeholder, ..
1772            }) = &self.modal
1773        {
1774            let field = Rect {
1775                height: 3,
1776                y,
1777                ..inner
1778            };
1779            let border_active = theme_color(&self.cfg.theme.border_active);
1780            let block = Block::bordered()
1781                .border_type(BorderType::Rounded)
1782                .border_style(Style::default().fg(border_active));
1783            let text_area = block.inner(field);
1784            block.render_widget(field, buffer);
1785            let value = input.value();
1786            let line = if value.is_empty() {
1787                Line::from(Span::styled(*placeholder, Style::default().dim()))
1788            } else {
1789                Line::from(value.to_string())
1790            };
1791            Paragraph::new(line).render_widget(text_area, buffer);
1792            // A visible cursor: invert the cell the input writes next.
1793            let cursor_x = text_area.x + (input.visual_cursor() as u16).min(text_area.width - 1);
1794            if let Some(cell) = buffer.cell_mut((cursor_x, text_area.y)) {
1795                cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
1796            }
1797        }
1798        if let Some((label, selected)) = buttons {
1799            let confirm = format!("[ {label} ]");
1800            let cancel = "[ Cancel ]".to_string();
1801            let total = (confirm.chars().count() + 2 + cancel.chars().count()) as u16;
1802            let start = inner.x + inner.width.saturating_sub(total);
1803            let confirm_area = Rect {
1804                x: start,
1805                y,
1806                width: confirm.chars().count() as u16,
1807                height: 1,
1808            };
1809            let cancel_area = Rect {
1810                x: start + confirm_area.width + 2,
1811                y,
1812                width: cancel.chars().count() as u16,
1813                height: 1,
1814            };
1815            let selected_style = Style::default().add_modifier(Modifier::REVERSED);
1816            Paragraph::new(Span::styled(
1817                confirm,
1818                if selected == 0 {
1819                    selected_style.fg(Color::LightRed)
1820                } else {
1821                    Style::default().fg(Color::LightRed)
1822                },
1823            ))
1824            .render_widget(confirm_area, buffer);
1825            Paragraph::new(Span::styled(
1826                cancel,
1827                if selected == 1 {
1828                    selected_style
1829                } else {
1830                    Style::default()
1831                },
1832            ))
1833            .render_widget(cancel_area, buffer);
1834            self.button_areas = vec![confirm_area, cancel_area];
1835        }
1836    }
1837}
1838
1839enum Teardown {
1840    Archive,
1841    Delete,
1842}
1843
1844fn teardown(cfg: &Config, mux: &dyn Multiplexer, ctx: &Context, mode: Teardown) -> CtxResult<()> {
1845    if mux.exists(ctx) && mux.is_current(ctx) {
1846        // Killing our own session takes the TUI (and the client) down with
1847        // it, so land the client elsewhere first.
1848        switch_away(cfg, mux, ctx);
1849    }
1850    // Kill last: killing our own session ends the TUI, so nothing after the
1851    // kill is guaranteed to run. Kill even when the removal fails half-way;
1852    // the startup sweep finishes the removal.
1853    let removed = match mode {
1854        Teardown::Archive => contexts::archive_context(cfg, ctx).map(|_| ()),
1855        Teardown::Delete => contexts::remove_context(ctx),
1856    };
1857    if mux.exists(ctx) {
1858        mux.kill(ctx)?;
1859    }
1860    removed
1861}
1862
1863/// Re-point the client at the most recent other running session.
1864fn switch_away(cfg: &Config, mux: &dyn Multiplexer, ctx: &Context) {
1865    for other in contexts::list_contexts(cfg) {
1866        if other.name == ctx.name || !mux.exists(&other) {
1867            continue;
1868        }
1869        if mux.open(&other, None).is_ok() {
1870            return;
1871        }
1872    }
1873}
1874
1875fn open_pr(ctx: &Context) -> Result<(), String> {
1876    let remote = new_command("git")
1877        .args(["remote", "get-url", "origin"])
1878        .current_dir(&ctx.path)
1879        .output()
1880        .map_err(|err| err.to_string())?;
1881    let command = forge::pr_view_command(String::from_utf8_lossy(&remote.stdout).trim());
1882    let result = new_command(&command[0])
1883        .args(&command[1..])
1884        .current_dir(&ctx.path)
1885        .output()
1886        .map_err(|err| err.to_string())?;
1887    if !result.status.success() {
1888        let stderr = String::from_utf8_lossy(&result.stderr);
1889        let detail = stderr.trim();
1890        return Err(if detail.is_empty() {
1891            "could not open the PR".to_string()
1892        } else {
1893            detail.to_string()
1894        });
1895    }
1896    Ok(())
1897}
1898
1899fn fetch_cell(cfg: &Config, ctx: &Context, index: usize) -> CellValue {
1900    if index == 0 {
1901        // Colour a status cell if its value is a well-known status word.
1902        let state = status::git_state(ctx);
1903        let style = status::STATUS_STYLES
1904            .iter()
1905            .find(|(word, _)| *word == state)
1906            .map(|(_, style)| *style);
1907        return CellValue::styled(state, style);
1908    }
1909    let column = &cfg.status[index - 1];
1910    match status::column_status(ctx, column) {
1911        Some(cell) if !cell.is_empty() => {
1912            let display = status::cell_icon(column, &cell, cfg.nerd_font);
1913            CellValue::styled(display, status::cell_style(&cell))
1914        }
1915        _ => CellValue::default(),
1916    }
1917}
1918
1919/// True when the query's characters appear in order within the name.
1920fn fuzzy_match(query: &str, name: &str) -> bool {
1921    let name: Vec<char> = name.to_lowercase().chars().collect();
1922    let mut position = 0;
1923    for ch in query.to_lowercase().chars() {
1924        match name[position..].iter().position(|c| *c == ch) {
1925            Some(offset) => position += offset + 1,
1926            None => return false,
1927        }
1928    }
1929    true
1930}
1931
1932fn wrapped_lines(message: &str, width: usize) -> Vec<Line<'static>> {
1933    use unicode_width::UnicodeWidthChar;
1934
1935    // Wrap by display width, not character count: a double-width character
1936    // would otherwise overflow the popup and clip the message tail.
1937    let width = width.max(10);
1938    let mut lines = Vec::new();
1939    for raw in message.lines() {
1940        if raw.is_empty() {
1941            lines.push(Line::default());
1942            continue;
1943        }
1944        let mut current = String::new();
1945        let mut used = 0;
1946        for ch in raw.chars() {
1947            let ch_width = ch.width().unwrap_or(0);
1948            if used + ch_width > width && !current.is_empty() {
1949                lines.push(Line::from(std::mem::take(&mut current)));
1950                used = 0;
1951            }
1952            current.push(ch);
1953            used += ch_width;
1954        }
1955        if !current.is_empty() {
1956            lines.push(Line::from(current));
1957        }
1958    }
1959    lines
1960}
1961
1962fn panel_keybindings(panel: Panel) -> Vec<(&'static str, &'static str)> {
1963    let panel_bindings: &[(&str, &str)] = match panel {
1964        Panel::Contexts => &[
1965            ("enter / space", "open context"),
1966            ("o", "open the PR in the browser"),
1967            ("n", "new context"),
1968            ("N", "new context from a base branch"),
1969            ("d", "archive context"),
1970            ("D", "permanently delete context"),
1971        ],
1972        Panel::Repos => &[
1973            ("enter / n", "new context"),
1974            ("N", "new context from a base branch"),
1975            ("a", "add repo"),
1976            ("s", "set / clear default repo"),
1977            ("d", "remove repo"),
1978        ],
1979        Panel::Archived => &[
1980            ("enter", "unarchive and open context"),
1981            ("u", "unarchive context"),
1982            ("n", "new context"),
1983            ("N", "new context from a base branch"),
1984            ("d / D", "permanently delete context"),
1985            ("e", "empty the archive"),
1986        ],
1987    };
1988    let common: &[(&str, &str)] = &[
1989        ("j / k / ↓ / ↑", "move within panel"),
1990        ("g / G", "jump to top / bottom"),
1991        ("h / l / ← / → / tab / shift-tab", "switch panel"),
1992        ("1 / 2 / 3", "jump to panel"),
1993        ("/", "fuzzy filter by repo and name"),
1994        ("r", "refresh"),
1995        ("?", "this help"),
1996        ("q / ctrl+c", "quit"),
1997    ];
1998    panel_bindings.iter().chain(common).copied().collect()
1999}
2000
2001/// Map a theme colour (ansi name or hex) onto a terminal colour.
2002fn theme_color(name: &str) -> Color {
2003    if let Some(hex) = name.strip_prefix('#')
2004        && hex.len() == 6
2005        && let Ok(value) = u32::from_str_radix(hex, 16)
2006    {
2007        return Color::Rgb((value >> 16) as u8, (value >> 8) as u8, value as u8);
2008    }
2009    match name {
2010        "ansi_default" => Color::Reset,
2011        "ansi_black" => Color::Black,
2012        "ansi_red" => Color::Red,
2013        "ansi_green" => Color::Green,
2014        "ansi_yellow" => Color::Yellow,
2015        "ansi_blue" => Color::Blue,
2016        "ansi_magenta" => Color::Magenta,
2017        "ansi_cyan" => Color::Cyan,
2018        "ansi_white" => Color::White,
2019        _ => Color::Reset,
2020    }
2021}
2022
2023/// A status vocabulary style ("bold bright_green") as a terminal style.
2024fn status_style(name: &str) -> Style {
2025    let mut style = Style::default();
2026    for word in name.split_whitespace() {
2027        style = match word {
2028            "bold" => style.add_modifier(Modifier::BOLD),
2029            "bright_green" => style.fg(Color::LightGreen),
2030            "bright_cyan" => style.fg(Color::LightCyan),
2031            "bright_yellow" => style.fg(Color::LightYellow),
2032            "bright_red" => style.fg(Color::LightRed),
2033            "bright_magenta" => style.fg(Color::LightMagenta),
2034            "bright_black" => style.fg(Color::DarkGray),
2035            _ => style,
2036        };
2037    }
2038    style
2039}
2040
2041/// Widget rendering without a Frame, so tests can draw into a plain buffer.
2042trait RenderWidget {
2043    fn render_widget(self, area: Rect, buffer: &mut ratatui::buffer::Buffer);
2044}
2045
2046impl<W: ratatui::widgets::Widget> RenderWidget for W {
2047    fn render_widget(self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
2048        self.render(area, buffer);
2049    }
2050}
2051
2052#[cfg(test)]
2053mod tests {
2054    use std::sync::Mutex;
2055
2056    use ratatui::buffer::Buffer;
2057
2058    use super::*;
2059    use crate::config::StatusColumn;
2060    use crate::multiplexer::MultiplexerError;
2061    use crate::testutil::{TestEnv, test_env};
2062
2063    #[derive(Default)]
2064    struct MuxState {
2065        calls: Vec<(String, String)>,
2066        path_present_at_kill: Option<bool>,
2067    }
2068
2069    /// Test double: canned exists()/is_current() answers, recorded calls.
2070    struct TestMux {
2071        exists: bool,
2072        current: Option<String>,
2073        state: Mutex<MuxState>,
2074    }
2075
2076    impl TestMux {
2077        fn stub() -> Arc<TestMux> {
2078            Arc::new(TestMux {
2079                exists: false,
2080                current: None,
2081                state: Mutex::new(MuxState::default()),
2082            })
2083        }
2084
2085        fn recording(current: Option<&str>) -> Arc<TestMux> {
2086            Arc::new(TestMux {
2087                exists: true,
2088                current: current.map(str::to_string),
2089                state: Mutex::new(MuxState::default()),
2090            })
2091        }
2092
2093        fn calls(&self) -> Vec<(String, String)> {
2094            self.state.lock().unwrap().calls.clone()
2095        }
2096    }
2097
2098    impl Multiplexer for TestMux {
2099        fn can_open_in_place(&self) -> bool {
2100            true
2101        }
2102
2103        fn exists(&self, _ctx: &Context) -> bool {
2104            self.exists
2105        }
2106
2107        fn is_current(&self, ctx: &Context) -> bool {
2108            self.current.as_deref() == Some(ctx.name.as_str())
2109        }
2110
2111        fn create(
2112            &self,
2113            _ctx: &Context,
2114            _values: Option<&HashMap<String, String>>,
2115        ) -> Result<(), MultiplexerError> {
2116            Ok(())
2117        }
2118
2119        fn open(
2120            &self,
2121            ctx: &Context,
2122            _values: Option<&HashMap<String, String>>,
2123        ) -> Result<(), MultiplexerError> {
2124            self.state
2125                .lock()
2126                .unwrap()
2127                .calls
2128                .push(("open".to_string(), ctx.name.clone()));
2129            Ok(())
2130        }
2131
2132        fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
2133            let mut state = self.state.lock().unwrap();
2134            state.path_present_at_kill = Some(ctx.path.exists());
2135            state.calls.push(("kill".to_string(), ctx.name.clone()));
2136            Ok(())
2137        }
2138    }
2139
2140    fn registered() -> (TestEnv, std::path::PathBuf) {
2141        let env = test_env();
2142        let origin = env.origin();
2143        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
2144        (env, origin)
2145    }
2146
2147    fn create(env: &TestEnv, repo: &str, name: &str) -> Context {
2148        contexts::create_context(&env.cfg, repo, name, None).unwrap()
2149    }
2150
2151    fn slow_status_cfg(env: &TestEnv) -> Config {
2152        let mut cfg = env.cfg.clone();
2153        cfg.status = vec![StatusColumn {
2154            name: "slow".to_string(),
2155            command: Some("sleep 0.5; echo hi".to_string()),
2156            builtin: None,
2157            interval: None,
2158        }];
2159        cfg
2160    }
2161
2162    impl CtxTui {
2163        fn key(&mut self, code: KeyCode) {
2164            self.handle(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
2165        }
2166
2167        fn keys(&mut self, codes: &[KeyCode]) {
2168            for code in codes {
2169                self.key(*code);
2170            }
2171        }
2172
2173        fn idle(&self) -> bool {
2174            self.workers == 0 && self.fetching.is_empty()
2175        }
2176
2177        /// Pump worker events until the predicate holds or the deadline passes.
2178        fn drain_until(&mut self, mut pred: impl FnMut(&CtxTui) -> bool) -> bool {
2179            let deadline = Instant::now() + Duration::from_secs(8);
2180            loop {
2181                if pred(self) {
2182                    return true;
2183                }
2184                if Instant::now() > deadline {
2185                    return false;
2186                }
2187                if let Ok(event) = self.rx.recv_timeout(Duration::from_millis(50)) {
2188                    self.handle(event);
2189                }
2190            }
2191        }
2192
2193        fn drain_idle(&mut self) {
2194            assert!(self.drain_until(CtxTui::idle), "workers never finished");
2195        }
2196
2197        fn slow_cells(&self) -> Vec<String> {
2198            self.contexts
2199                .rows
2200                .iter()
2201                .map(|row| row.cells[4].text.clone())
2202                .collect()
2203        }
2204    }
2205
2206    fn app(cfg: &Config, mux: Arc<TestMux>) -> CtxTui {
2207        let mut app = CtxTui::new(cfg.clone(), mux, false);
2208        app.mount();
2209        app
2210    }
2211
2212    fn buffer_text(buffer: &Buffer) -> String {
2213        let mut text = String::new();
2214        for y in 0..buffer.area.height {
2215            for x in 0..buffer.area.width {
2216                text.push_str(buffer[(x, y)].symbol());
2217            }
2218            text.push('\n');
2219        }
2220        text
2221    }
2222
2223    fn render(app: &mut CtxTui) -> String {
2224        let area = Rect::new(0, 0, 100, 30);
2225        let mut buffer = Buffer::empty(area);
2226        app.render(area, &mut buffer);
2227        buffer_text(&buffer)
2228    }
2229
2230    #[test]
2231    fn panels_are_populated_before_the_statuses_are() {
2232        // Rows must be there to act on straight away, slow providers or not.
2233        let (env, _origin) = registered();
2234        let cfg = slow_status_cfg(&env);
2235        for name in ["one", "two"] {
2236            contexts::create_context(&cfg, "origin", name, None).unwrap();
2237        }
2238
2239        let mut app = app(&cfg, TestMux::stub());
2240
2241        assert_eq!(app.contexts.row_count(), 2);
2242        assert_eq!(app.repos.row_count(), 1);
2243        assert_eq!(app.slow_cells(), ["", ""]);
2244
2245        assert!(
2246            app.drain_until(|app| app.slow_cells() == ["hi", "hi"]),
2247            "statuses never filled in"
2248        );
2249    }
2250
2251    #[test]
2252    fn arrow_keys_navigate_like_the_vim_keys() {
2253        let (env, _origin) = registered();
2254        for name in ["one", "two"] {
2255            create(&env, "origin", name);
2256        }
2257        let mut app = app(&env.cfg, TestMux::stub());
2258
2259        app.key(KeyCode::Down);
2260        assert_eq!(app.contexts.cursor, 1);
2261        app.key(KeyCode::Up);
2262        assert_eq!(app.contexts.cursor, 0);
2263
2264        app.key(KeyCode::Right);
2265        assert_eq!(app.panel, Panel::Repos);
2266        app.key(KeyCode::Right);
2267        assert_eq!(app.panel, Panel::Archived);
2268        app.key(KeyCode::Left);
2269        assert_eq!(app.panel, Panel::Repos);
2270
2271        app.key(KeyCode::Char('d'));
2272        let selected = |app: &CtxTui| match &app.modal {
2273            Some(Modal::Confirm { selected, .. }) => *selected,
2274            _ => panic!("expected a confirm dialog"),
2275        };
2276        let first = selected(&app);
2277        app.key(KeyCode::Right);
2278        assert_eq!(selected(&app), first + 1);
2279        app.key(KeyCode::Up);
2280        assert_eq!(selected(&app), first);
2281    }
2282
2283    #[test]
2284    fn tab_cycles_panels_like_textual_focus() {
2285        let (env, _origin) = registered();
2286        create(&env, "origin", "one");
2287        let mut app = app(&env.cfg, TestMux::stub());
2288
2289        app.key(KeyCode::Tab);
2290        assert_eq!(app.panel, Panel::Repos);
2291        app.key(KeyCode::Tab);
2292        assert_eq!(app.panel, Panel::Archived);
2293        app.key(KeyCode::Tab);
2294        assert_eq!(app.panel, Panel::Contexts);
2295        app.key(KeyCode::BackTab);
2296        assert_eq!(app.panel, Panel::Archived);
2297
2298        // Inside a confirm dialog, tab moves between the buttons instead.
2299        app.panel = Panel::Contexts;
2300        app.key(KeyCode::Char('D'));
2301        let selected = |app: &CtxTui| match &app.modal {
2302            Some(Modal::Confirm { selected, .. }) => *selected,
2303            _ => panic!("expected a confirm dialog"),
2304        };
2305        assert_eq!(selected(&app), 0);
2306        app.key(KeyCode::Tab);
2307        assert_eq!(selected(&app), 1);
2308        app.key(KeyCode::Tab);
2309        assert_eq!(selected(&app), 0, "button focus must wrap around");
2310    }
2311
2312    #[test]
2313    fn alerts_show_bracketed_error_text_verbatim() {
2314        // Errors often quote a git command; its brackets must render as text.
2315        let (env, _origin) = registered();
2316        let mut app = app(&env.cfg, TestMux::stub());
2317        let message = "Command '[git, -c, http.lowSpeedLimit=1000, fetch, origin]' failed";
2318
2319        app.alert(message);
2320        let text = render(&mut app);
2321
2322        assert!(text.contains("'[git, -c,"), "alert text missing: {text}");
2323    }
2324
2325    #[test]
2326    fn archiving_another_context_does_not_switch() {
2327        let (env, _origin) = registered();
2328        for name in ["one", "two"] {
2329            create(&env, "origin", name);
2330        }
2331        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2332        let mux = TestMux::recording(Some("two"));
2333        let mut app = app(&env.cfg, mux.clone());
2334
2335        app.teardown_worker(ctx, Teardown::Archive);
2336        app.drain_idle();
2337
2338        assert_eq!(mux.calls(), [("kill".to_string(), "one".to_string())]);
2339        assert!(contexts::find_context(&env.cfg, "one").is_err());
2340    }
2341
2342    #[test]
2343    fn theme_colours_reach_the_terminal_styles() {
2344        assert_eq!(theme_color("#2d3f76"), Color::Rgb(0x2d, 0x3f, 0x76));
2345        assert_eq!(theme_color("ansi_default"), Color::Reset);
2346        assert_eq!(theme_color("ansi_blue"), Color::Blue);
2347    }
2348
2349    #[test]
2350    fn current_context_is_pinned_and_cursor_starts_below_it() {
2351        let (env, _origin) = registered();
2352        for name in ["one", "two"] {
2353            create(&env, "origin", name);
2354        }
2355
2356        let app = app(&env.cfg, TestMux::recording(Some("one")));
2357
2358        assert_eq!(
2359            app.contexts.rows[0].key, "one",
2360            "the attached context must be the top row"
2361        );
2362        assert_eq!(
2363            app.contexts.cursor, 1,
2364            "the cursor must start on the next context"
2365        );
2366    }
2367
2368    #[test]
2369    fn cursor_starts_on_top_without_a_current_context() {
2370        let (env, _origin) = registered();
2371        for name in ["one", "two"] {
2372            create(&env, "origin", name);
2373        }
2374
2375        let app = app(&env.cfg, TestMux::stub());
2376
2377        assert_eq!(app.contexts.cursor, 0);
2378    }
2379
2380    #[test]
2381    fn new_prompt_prefills_a_generated_name() {
2382        let (env, _origin) = registered();
2383        let mut app = app(&env.cfg, TestMux::stub());
2384
2385        app.key(KeyCode::Char('n'));
2386        let name = match &app.modal {
2387            Some(Modal::Prompt { input, .. }) => input.value().to_string(),
2388            _ => panic!("expected the name prompt"),
2389        };
2390        assert!(
2391            !name.is_empty(),
2392            "the prompt must pre-fill a generated name"
2393        );
2394        app.key(KeyCode::Enter);
2395        app.drain_idle();
2396
2397        assert!(contexts::find_context(&env.cfg, &name).is_ok());
2398    }
2399
2400    #[test]
2401    fn typing_replaces_the_prefilled_name() {
2402        let (env, _origin) = registered();
2403        let mut app = app(&env.cfg, TestMux::stub());
2404
2405        app.key(KeyCode::Char('n'));
2406        app.key(KeyCode::Char('x'));
2407
2408        match &app.modal {
2409            Some(Modal::Prompt { input, .. }) => assert_eq!(input.value(), "x"),
2410            _ => panic!("expected the name prompt"),
2411        }
2412    }
2413
2414    #[test]
2415    fn new_context_uses_the_default_repo_off_the_repos_panel() {
2416        let (env, _origin) = registered();
2417        let other = env.make_origin("other", false);
2418        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2419        create(&env, "origin", "one");
2420        repos::set_default_repo(&env.cfg, Some("other")).unwrap();
2421        let mut app = app(&env.cfg, TestMux::stub());
2422
2423        assert_eq!(
2424            app.repo_for_new().as_deref(),
2425            Some("other"),
2426            "contexts panel must use the default"
2427        );
2428        app.panel = Panel::Repos;
2429        app.key(KeyCode::Char('j'));
2430        assert_eq!(
2431            app.repo_for_new().as_deref(),
2432            Some("origin"),
2433            "repos panel must use the hovered repo"
2434        );
2435    }
2436
2437    #[test]
2438    fn default_repo_sorts_first() {
2439        let (env, _origin) = registered();
2440        let other = env.make_origin("aaa", false);
2441        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2442        repos::set_default_repo(&env.cfg, Some("origin")).unwrap();
2443
2444        let app = app(&env.cfg, TestMux::stub());
2445
2446        assert_eq!(
2447            app.repos.selected_key(),
2448            Some("origin"),
2449            "default must be the top row"
2450        );
2451    }
2452
2453    #[test]
2454    fn s_toggles_the_default_repo() {
2455        let (env, _origin) = registered();
2456        let mut app = app(&env.cfg, TestMux::stub());
2457
2458        app.panel = Panel::Repos;
2459        app.key(KeyCode::Char('s'));
2460        assert_eq!(repos::default_repo(&env.cfg).as_deref(), Some("origin"));
2461        app.key(KeyCode::Char('s'));
2462        assert_eq!(repos::default_repo(&env.cfg), None);
2463    }
2464
2465    #[test]
2466    fn o_opens_the_pr_in_the_browser() {
2467        let (env, _origin) = registered();
2468        let ctx = create(&env, "origin", "one");
2469        let log = env.root().join("gh-args");
2470        let _gh = env.fake_cli("gh", &format!("echo \"$@\" > {}", log.display()));
2471        let mut app = app(&env.cfg, TestMux::stub());
2472
2473        app.key(KeyCode::Char('o'));
2474        app.drain_idle();
2475
2476        assert_eq!(
2477            std::fs::read_to_string(&log).unwrap().trim(),
2478            "pr view --web"
2479        );
2480        assert_eq!(
2481            contexts::find_context(&env.cfg, "one").unwrap().path,
2482            ctx.path
2483        );
2484    }
2485
2486    #[test]
2487    fn o_uses_the_forge_from_the_remote() {
2488        let (env, _origin) = registered();
2489        let ctx = create(&env, "origin", "one");
2490        crate::testutil::git(
2491            &[
2492                "remote",
2493                "set-url",
2494                "origin",
2495                "git@gitlab.com:jane/tool.git",
2496            ],
2497            &ctx.path,
2498        );
2499        let log = env.root().join("glab-args");
2500        let _glab = env.fake_cli("glab", &format!("echo \"$@\" > {}", log.display()));
2501        let mut app = app(&env.cfg, TestMux::stub());
2502
2503        app.key(KeyCode::Char('o'));
2504        app.drain_idle();
2505
2506        assert_eq!(
2507            std::fs::read_to_string(&log).unwrap().trim(),
2508            "mr view --web"
2509        );
2510    }
2511
2512    #[test]
2513    fn archive_key_archives_without_a_prompt() {
2514        let (env, _origin) = registered();
2515        create(&env, "origin", "one");
2516        let mut app = app(&env.cfg, TestMux::stub());
2517
2518        app.key(KeyCode::Char('d'));
2519        app.drain_idle();
2520
2521        assert!(contexts::find_archived(&env.cfg, "one").is_ok());
2522    }
2523
2524    #[test]
2525    fn delete_key_asks_for_confirmation() {
2526        let (env, _origin) = registered();
2527        let ctx = create(&env, "origin", "one");
2528        contexts::archive_context(&env.cfg, &ctx).unwrap();
2529        let mut app = app(&env.cfg, TestMux::stub());
2530
2531        app.panel = Panel::Archived;
2532        app.key(KeyCode::Char('d'));
2533        assert!(matches!(app.modal, Some(Modal::Confirm { .. })));
2534        app.key(KeyCode::Esc);
2535        app.drain_idle();
2536
2537        assert!(contexts::find_archived(&env.cfg, "one").is_ok());
2538    }
2539
2540    #[test]
2541    fn shift_delete_key_on_contexts_asks_for_confirmation() {
2542        let (env, _origin) = registered();
2543        create(&env, "origin", "one");
2544        let mut app = app(&env.cfg, TestMux::stub());
2545
2546        app.key(KeyCode::Char('D'));
2547        assert!(matches!(app.modal, Some(Modal::Confirm { .. })));
2548        app.key(KeyCode::Esc);
2549        app.drain_idle();
2550
2551        assert!(contexts::find_context(&env.cfg, "one").is_ok());
2552    }
2553
2554    #[test]
2555    fn confirming_delete_removes_the_checkout() {
2556        let (env, _origin) = registered();
2557        let ctx = create(&env, "origin", "one");
2558        let mut app = app(&env.cfg, TestMux::stub());
2559
2560        app.key(KeyCode::Char('D'));
2561        app.key(KeyCode::Enter);
2562        app.drain_idle();
2563
2564        assert!(!ctx.path.exists());
2565        assert!(contexts::find_context(&env.cfg, "one").is_err());
2566    }
2567
2568    #[test]
2569    fn startup_sweeps_interrupted_deletions() {
2570        let (env, _origin) = registered();
2571        let ctx = create(&env, "origin", "one");
2572        let leftover = ctx.path.with_file_name("one.deleting");
2573        std::fs::rename(&ctx.path, &leftover).unwrap();
2574
2575        let mut app = app(&env.cfg, TestMux::stub());
2576        app.drain_idle();
2577
2578        assert!(!leftover.exists());
2579    }
2580
2581    #[test]
2582    fn add_repo_key_is_local_to_the_repos_panel() {
2583        // `a` opens the add-repo prompt only while the repos panel is focused.
2584        let (env, _origin) = registered();
2585        let mut app = app(&env.cfg, TestMux::stub());
2586
2587        app.key(KeyCode::Char('a'));
2588        assert!(app.modal.is_none(), "a must be inert off the repos panel");
2589
2590        app.panel = Panel::Repos;
2591        app.key(KeyCode::Char('a'));
2592        assert!(matches!(app.modal, Some(Modal::Prompt { .. })));
2593    }
2594
2595    #[test]
2596    fn archiving_the_current_context_switches_away_then_kills() {
2597        let (env, _origin) = registered();
2598        for name in ["one", "two"] {
2599            create(&env, "origin", name);
2600        }
2601        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2602        let mux = TestMux::recording(Some("one"));
2603        let mut app = app(&env.cfg, mux.clone());
2604
2605        app.teardown_worker(ctx, Teardown::Archive);
2606        app.drain_idle();
2607
2608        assert_eq!(
2609            mux.calls(),
2610            [
2611                ("open".to_string(), "two".to_string()),
2612                ("kill".to_string(), "one".to_string()),
2613            ]
2614        );
2615        // Killing our own session ends the process, so the move must have
2616        // landed by the time the kill happens.
2617        assert_eq!(
2618            mux.state.lock().unwrap().path_present_at_kill,
2619            Some(false),
2620            "the move must come before the kill"
2621        );
2622        assert!(contexts::find_archived(&env.cfg, "one").is_ok());
2623    }
2624
2625    #[test]
2626    fn archiving_kills_the_session_even_when_the_move_fails() {
2627        let (env, _origin) = registered();
2628        let ctx = create(&env, "origin", "one");
2629        // An occupied archive path fails the move before anything happens.
2630        std::fs::create_dir_all(env.cfg.archive_dir.join("origin").join("one")).unwrap();
2631        let mux = TestMux::recording(None);
2632        let mut app = app(&env.cfg, mux.clone());
2633
2634        app.teardown_worker(ctx.clone(), Teardown::Archive);
2635        app.drain_idle();
2636
2637        assert_eq!(mux.calls(), [("kill".to_string(), "one".to_string())]);
2638        assert!(ctx.path.exists());
2639    }
2640
2641    #[test]
2642    fn archiving_the_current_context_leaves_no_stale_busy_state() {
2643        // A TUI in a tmux popup outlives its session's kill; it must repaint.
2644        let (env, _origin) = registered();
2645        for name in ["one", "two"] {
2646            create(&env, "origin", name);
2647        }
2648        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2649        let mut app = app(&env.cfg, TestMux::recording(Some("one")));
2650
2651        app.start_busy(Panel::Contexts);
2652        app.teardown_worker(ctx, Teardown::Archive);
2653        app.drain_idle();
2654
2655        assert!(
2656            app.busy.is_empty(),
2657            "the panel stayed dimmed after the archive"
2658        );
2659        assert_eq!(app.contexts.row_count(), 1);
2660    }
2661
2662    #[test]
2663    fn slash_filters_and_enter_opens_the_match() {
2664        let (env, _origin) = registered();
2665        for name in ["alpha", "beta"] {
2666            create(&env, "origin", name);
2667        }
2668        let mux = TestMux::recording(None);
2669        let mut app = app(&env.cfg, mux.clone());
2670
2671        app.keys(&[KeyCode::Char('/'), KeyCode::Char('b'), KeyCode::Char('t')]);
2672        assert_eq!(
2673            app.contexts.row_count(),
2674            1,
2675            "only the fuzzy match may remain"
2676        );
2677        app.key(KeyCode::Enter);
2678
2679        assert!(
2680            mux.calls()
2681                .contains(&("open".to_string(), "beta".to_string()))
2682        );
2683        assert_eq!(
2684            app.contexts.row_count(),
2685            2,
2686            "the filter must clear after opening"
2687        );
2688    }
2689
2690    #[test]
2691    fn escape_clears_the_filter() {
2692        let (env, _origin) = registered();
2693        for name in ["alpha", "beta"] {
2694            create(&env, "origin", name);
2695        }
2696        let mut app = app(&env.cfg, TestMux::stub());
2697
2698        app.keys(&[KeyCode::Char('/'), KeyCode::Char('b')]);
2699        assert_eq!(app.contexts.row_count(), 1);
2700        app.key(KeyCode::Esc);
2701        assert_eq!(app.contexts.row_count(), 2);
2702        assert_eq!(app.panel, Panel::Contexts);
2703    }
2704
2705    #[test]
2706    fn enter_with_no_matches_keeps_filtering() {
2707        let (env, _origin) = registered();
2708        create(&env, "origin", "alpha");
2709        let mux = TestMux::recording(None);
2710        let mut app = app(&env.cfg, mux.clone());
2711
2712        app.keys(&[KeyCode::Char('/'), KeyCode::Char('z')]);
2713        assert_eq!(app.contexts.row_count(), 0);
2714        app.key(KeyCode::Enter);
2715
2716        assert!(mux.calls().is_empty());
2717        assert_eq!(app.contexts.row_count(), 0, "the filter must stay active");
2718        assert!(app.filter.is_some());
2719    }
2720
2721    #[test]
2722    fn filter_matches_the_repo_too() {
2723        let (env, _origin) = registered();
2724        let other = env.make_origin("other", false);
2725        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2726        create(&env, "origin", "alpha");
2727        create(&env, "other", "beta");
2728        let mut app = app(&env.cfg, TestMux::stub());
2729
2730        app.keys(&[
2731            KeyCode::Char('/'),
2732            KeyCode::Char('o'),
2733            KeyCode::Char('t'),
2734            KeyCode::Char('h'),
2735        ]);
2736
2737        assert_eq!(app.contexts.row_count(), 1);
2738        assert_eq!(app.contexts.selected_key(), Some("beta"));
2739    }
2740
2741    #[test]
2742    fn filter_is_panel_scoped() {
2743        let (env, _origin) = registered();
2744        let other = env.make_origin("other", false);
2745        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2746        create(&env, "origin", "alpha");
2747        let mut app = app(&env.cfg, TestMux::stub());
2748
2749        app.panel = Panel::Repos;
2750        app.keys(&[KeyCode::Char('/'), KeyCode::Char('x')]);
2751        assert_eq!(app.repos.row_count(), 0);
2752        assert_eq!(
2753            app.contexts.row_count(),
2754            1,
2755            "other panels must keep their rows"
2756        );
2757        app.key(KeyCode::Esc);
2758        assert_eq!(app.repos.row_count(), 2);
2759        assert_eq!(app.panel, Panel::Repos);
2760    }
2761
2762    #[test]
2763    fn the_ui_stays_responsive_while_statuses_fetch() {
2764        // A slow status provider must not stall the event loop.
2765        let (env, _origin) = registered();
2766        let cfg = slow_status_cfg(&env);
2767        for name in ["one", "two"] {
2768            contexts::create_context(&cfg, "origin", name, None).unwrap();
2769        }
2770        let mut app = app(&cfg, TestMux::stub());
2771
2772        // The fetch is in flight; input must land immediately regardless.
2773        let start = Instant::now();
2774        app.key(KeyCode::Down);
2775        assert_eq!(app.contexts.cursor, 1);
2776        assert!(
2777            start.elapsed() < Duration::from_millis(200),
2778            "input handling stalled behind the status fetch"
2779        );
2780        assert!(
2781            app.drain_until(|app| app.slow_cells() == ["hi", "hi"]),
2782            "statuses never arrived"
2783        );
2784    }
2785
2786    #[test]
2787    fn typing_in_the_filter_reselects_the_top_match() {
2788        let (env, _origin) = registered();
2789        for name in ["match-one", "match-two", "other"] {
2790            create(&env, "origin", name);
2791        }
2792        let mux = TestMux::recording(None);
2793        let mut app = app(&env.cfg, mux.clone());
2794        app.key(KeyCode::Down);
2795        assert_eq!(
2796            app.contexts.cursor, 1,
2797            "precondition: cursor off the top row"
2798        );
2799
2800        app.keys(&[KeyCode::Char('/'), KeyCode::Char('m'), KeyCode::Char('a')]);
2801
2802        assert_eq!(app.contexts.row_count(), 2);
2803        assert_eq!(
2804            app.contexts.cursor, 0,
2805            "each keystroke must reselect the top match"
2806        );
2807        let top = app.contexts.selected_key().unwrap().to_string();
2808        app.key(KeyCode::Enter);
2809        assert!(mux.calls().contains(&("open".to_string(), top)));
2810    }
2811
2812    #[test]
2813    fn alerts_wait_for_an_open_prompt() {
2814        let (env, _origin) = registered();
2815        let mut app = app(&env.cfg, TestMux::stub());
2816        app.key(KeyCode::Char('n'));
2817        assert!(matches!(app.modal, Some(Modal::Prompt { .. })));
2818
2819        app.handle(Event::Worker(WorkerDone {
2820            alert: Some("boom".to_string()),
2821            finished: true,
2822            ..WorkerDone::default()
2823        }));
2824
2825        assert!(
2826            matches!(app.modal, Some(Modal::Prompt { .. })),
2827            "a worker alert must not clobber the prompt"
2828        );
2829        app.key(KeyCode::Esc);
2830        match &app.modal {
2831            Some(Modal::Alert { message }) => assert_eq!(message, "boom"),
2832            other => panic!("expected the queued alert, got {:?}", other.is_some()),
2833        }
2834    }
2835
2836    #[test]
2837    fn backspace_clears_the_prefilled_name_whole() {
2838        let (env, _origin) = registered();
2839        let mut app = app(&env.cfg, TestMux::stub());
2840
2841        app.key(KeyCode::Char('n'));
2842        app.key(KeyCode::Backspace);
2843
2844        match &app.modal {
2845            Some(Modal::Prompt { input, .. }) => {
2846                assert_eq!(
2847                    input.value(),
2848                    "",
2849                    "backspace must delete the selected pre-fill"
2850                )
2851            }
2852            _ => panic!("expected the name prompt"),
2853        }
2854    }
2855
2856    #[test]
2857    fn control_chords_do_not_wipe_the_prefilled_name() {
2858        let (env, _origin) = registered();
2859        let mut app = app(&env.cfg, TestMux::stub());
2860
2861        app.key(KeyCode::Char('n'));
2862        let before = match &app.modal {
2863            Some(Modal::Prompt { input, .. }) => input.value().to_string(),
2864            _ => panic!("expected the name prompt"),
2865        };
2866        app.handle(Event::Key(KeyEvent::new(
2867            KeyCode::Char('a'),
2868            KeyModifiers::CONTROL,
2869        )));
2870
2871        match &app.modal {
2872            Some(Modal::Prompt { input, .. }) => assert_eq!(input.value(), before),
2873            _ => panic!("expected the name prompt"),
2874        }
2875    }
2876
2877    #[test]
2878    fn polling_only_runs_with_status_columns() {
2879        let (env, _origin) = registered();
2880        create(&env, "origin", "one");
2881
2882        // Without status columns nothing polls in the background.
2883        let mut app = app(&env.cfg, TestMux::stub());
2884        app.drain_idle();
2885        app.poll_at = vec![Instant::now() - Duration::from_secs(60)];
2886        app.handle(Event::Tick);
2887        assert!(app.fetching.is_empty(), "a bare listing must not poll");
2888
2889        // With one, the elapsed deadline refreshes its column.
2890        let cfg = slow_status_cfg(&env);
2891        let mut app = crate::tui::CtxTui::new(cfg, TestMux::stub(), false);
2892        app.mount();
2893        app.drain_idle();
2894        app.poll_at = vec![Instant::now() - Duration::from_secs(60); 2];
2895        app.handle(Event::Tick);
2896        assert!(!app.fetching.is_empty(), "an elapsed deadline must poll");
2897        app.drain_idle();
2898    }
2899
2900    #[test]
2901    fn q_and_r_stay_live_under_popups() {
2902        let (env, _origin) = registered();
2903        create(&env, "origin", "one");
2904        let mut app = app(&env.cfg, TestMux::stub());
2905
2906        app.key(KeyCode::Char('?'));
2907        app.key(KeyCode::Char('r'));
2908        assert!(
2909            matches!(app.modal, Some(Modal::Help { .. })),
2910            "r must keep the popup"
2911        );
2912
2913        app.key(KeyCode::Char('q'));
2914        assert!(app.quit, "q must quit under a popup");
2915    }
2916
2917    #[test]
2918    fn page_keys_move_by_a_page() {
2919        let (env, _origin) = registered();
2920        for name in ["one", "two", "three"] {
2921            create(&env, "origin", name);
2922        }
2923        let mut app = app(&env.cfg, TestMux::stub());
2924        app.contexts.page = 2;
2925
2926        app.key(KeyCode::PageDown);
2927        assert_eq!(app.contexts.cursor, 2);
2928        app.key(KeyCode::PageUp);
2929        assert_eq!(app.contexts.cursor, 0);
2930        app.key(KeyCode::End);
2931        assert_eq!(app.contexts.cursor, 2);
2932        app.key(KeyCode::Home);
2933        assert_eq!(app.contexts.cursor, 0);
2934    }
2935
2936    #[test]
2937    fn clicks_below_the_rows_leave_the_selection_alone() {
2938        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
2939
2940        let (env, _origin) = registered();
2941        for name in ["one", "two"] {
2942            create(&env, "origin", name);
2943        }
2944        let mut app = app(&env.cfg, TestMux::stub());
2945        render(&mut app); // record the panel areas for hit-testing
2946
2947        let area = app.areas[&Panel::Contexts];
2948        let blank_row = area.y + 2 + app.contexts.row_count() as u16 + 3;
2949        app.handle(Event::Mouse(MouseEvent {
2950            kind: MouseEventKind::Down(MouseButton::Left),
2951            column: area.x + 2,
2952            row: blank_row,
2953            modifiers: KeyModifiers::NONE,
2954        }));
2955
2956        assert_eq!(
2957            app.panel,
2958            Panel::Contexts,
2959            "the click still focuses the panel"
2960        );
2961        assert_eq!(
2962            app.contexts.cursor, 0,
2963            "blank space must not move the cursor"
2964        );
2965    }
2966
2967    #[test]
2968    fn wrapped_lines_wrap_by_display_width() {
2969        let wide = "あ".repeat(20); // each character is two columns wide
2970
2971        let lines = wrapped_lines(&wide, 10);
2972
2973        assert_eq!(lines.len(), 4, "twenty double-width chars at width 10");
2974    }
2975
2976    #[test]
2977    fn footer_and_titles_render() {
2978        let (env, _origin) = registered();
2979        create(&env, "origin", "one");
2980        let mut app = app(&env.cfg, TestMux::stub());
2981
2982        let text = render(&mut app);
2983
2984        assert!(text.contains("[1] Contexts"));
2985        assert!(text.contains("[2] Repos"));
2986        assert!(text.contains("[3] Archived"));
2987        assert!(text.contains("NAME"));
2988        assert!(text.contains("one"));
2989        assert!(text.contains("Open PR"));
2990    }
2991
2992    #[test]
2993    fn help_screen_lists_the_panel_bindings() {
2994        let (env, _origin) = registered();
2995        let mut app = app(&env.cfg, TestMux::stub());
2996
2997        app.key(KeyCode::Char('?'));
2998        let text = render(&mut app);
2999
3000        assert!(text.contains("Keybindings (contexts)"));
3001        assert!(text.contains("open the PR in the browser"));
3002        app.key(KeyCode::Esc);
3003        assert!(app.modal.is_none());
3004    }
3005}