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 mut block = Block::bordered()
1530            .border_type(BorderType::Rounded)
1531            .border_style(Style::default().fg(border))
1532            .title(title);
1533        if panel == Panel::Contexts {
1534            block = block.title_top(version_line().right_aligned());
1535        }
1536        let inner = block.inner(area);
1537        block.render_widget(area, buffer);
1538
1539        let table = self.table_mut(panel);
1540        // One header line inside the borders; the rest is a page of rows.
1541        table.page = inner.height.saturating_sub(1).max(1) as usize;
1542        let columns = table.headers.len();
1543        let mut widths = vec![0usize; columns];
1544        for (index, header) in table.headers.iter().enumerate() {
1545            widths[index] = header.chars().count();
1546        }
1547        for row in &table.rows {
1548            for (index, cell) in row.cells.iter().enumerate() {
1549                widths[index] = widths[index].max(cell.text.chars().count());
1550            }
1551        }
1552        let foreground = theme_color(&theme.foreground);
1553        let header = Row::new(
1554            table
1555                .headers
1556                .iter()
1557                .map(|header| TableCell::from(header.as_str())),
1558        )
1559        .style(Style::default().fg(foreground).bold());
1560        let mut base = Style::default().fg(foreground);
1561        if busy {
1562            base = base.add_modifier(Modifier::DIM);
1563        }
1564        let rows: Vec<Row> = table
1565            .rows
1566            .iter()
1567            .map(|row| {
1568                Row::new(row.cells.iter().map(|cell| {
1569                    let mut style = base;
1570                    if let Some(name) = cell.style {
1571                        style = style.patch(status_style(name));
1572                    }
1573                    TableCell::from(cell.text.clone()).style(style)
1574                }))
1575            })
1576            .collect();
1577        // lazygit-style selection: only the focused panel shows its cursor;
1578        // bright-bold status colours keep their contrast on it.
1579        let highlight = if focused {
1580            Style::default()
1581                .bg(theme_color(&theme.selection))
1582                .add_modifier(Modifier::BOLD)
1583        } else {
1584            Style::default()
1585        };
1586        table
1587            .view
1588            .select((!table.rows.is_empty()).then_some(table.cursor));
1589        let widget = Table::new(
1590            rows,
1591            widths
1592                .iter()
1593                .map(|width| Constraint::Length(*width as u16))
1594                .collect::<Vec<_>>(),
1595        )
1596        .header(header)
1597        .column_spacing(2)
1598        .row_highlight_style(highlight);
1599        ratatui::widgets::StatefulWidget::render(widget, inner, buffer, &mut table.view);
1600    }
1601
1602    fn render_filter(&self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1603        let Some(filter) = &self.filter else {
1604            return;
1605        };
1606        let value = filter.query.value();
1607        let line = if value.is_empty() {
1608            Line::from(Span::styled(" filter", Style::default().dim()))
1609        } else {
1610            Line::from(format!(" {value}"))
1611        };
1612        Paragraph::new(line).render_widget(area, buffer);
1613    }
1614
1615    fn render_footer(&self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1616        let bindings: &[(&str, &str)] = match self.panel {
1617            Panel::Contexts => &[
1618                ("space", "Open"),
1619                ("o", "Open PR"),
1620                ("d", "Archive"),
1621                ("D", "Delete"),
1622                ("n", "New context"),
1623                ("/", "Filter"),
1624                ("r", "Refresh"),
1625                ("q", "Quit"),
1626                ("?", "Help"),
1627            ],
1628            Panel::Repos => &[
1629                ("a", "Add repo"),
1630                ("s", "Set default"),
1631                ("d", "Remove repo"),
1632                ("n", "New context"),
1633                ("/", "Filter"),
1634                ("r", "Refresh"),
1635                ("q", "Quit"),
1636                ("?", "Help"),
1637            ],
1638            Panel::Archived => &[
1639                ("u", "Unarchive"),
1640                ("d", "Delete"),
1641                ("e", "Empty"),
1642                ("n", "New context"),
1643                ("/", "Filter"),
1644                ("r", "Refresh"),
1645                ("q", "Quit"),
1646                ("?", "Help"),
1647            ],
1648        };
1649        let mut spans = Vec::new();
1650        for (key, label) in bindings {
1651            if !spans.is_empty() {
1652                spans.push(Span::raw("  "));
1653            }
1654            spans.push(Span::styled(*key, Style::default().bold()));
1655            spans.push(Span::raw(" "));
1656            spans.push(Span::styled(*label, Style::default().dim()));
1657        }
1658        Paragraph::new(Line::from(spans)).render_widget(area, buffer);
1659    }
1660
1661    fn render_modal(&mut self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
1662        self.button_areas.clear();
1663        let Some(modal) = &self.modal else {
1664            return;
1665        };
1666        // The help dialog sizes to its widest binding line; the rest stay
1667        // at the classic dialog width.
1668        let width = match modal {
1669            Modal::Help { panel } => {
1670                let bindings = panel_keybindings(*panel);
1671                let pad = bindings
1672                    .iter()
1673                    .map(|(key, _)| key.chars().count())
1674                    .max()
1675                    .unwrap_or(0)
1676                    + 3;
1677                let widest = bindings
1678                    .iter()
1679                    .map(|(_, desc)| pad + desc.chars().count())
1680                    .max()
1681                    .unwrap_or(0) as u16;
1682                widest + 6
1683            }
1684            _ => 60,
1685        };
1686        let width = width.min(area.width.saturating_sub(4)).max(20);
1687        let inner_width = width.saturating_sub(6) as usize;
1688        // Title, body, whether an input field follows, confirm buttons.
1689        type ModalParts<'a> = (
1690            Option<String>,
1691            Vec<Line<'a>>,
1692            bool,
1693            Option<(&'a str, usize)>,
1694        );
1695        let (title, body_lines, has_input, buttons): ModalParts = match modal {
1696            Modal::Prompt { title, .. } => (Some(title.clone()), Vec::new(), true, None),
1697            Modal::Alert { message } => {
1698                // Errors quote whatever failed (git argv, paths); the text
1699                // renders verbatim, never as markup.
1700                (None, wrapped_lines(message, inner_width), false, None)
1701            }
1702            Modal::Help { panel } => {
1703                let bindings = panel_keybindings(*panel);
1704                // Pad keys to the longest so the descriptions align.
1705                let pad = bindings
1706                    .iter()
1707                    .map(|(key, _)| key.chars().count())
1708                    .max()
1709                    .unwrap_or(0)
1710                    + 3;
1711                let mut lines = vec![Line::from(format!("Keybindings ({})", panel.name()))];
1712                lines.push(Line::default());
1713                for (key, desc) in bindings {
1714                    let fill = " ".repeat(pad - key.chars().count());
1715                    lines.push(Line::from(format!("{key}{fill}{desc}")));
1716                }
1717                (None, lines, false, None)
1718            }
1719            Modal::Confirm {
1720                message,
1721                confirm_label,
1722                selected,
1723                ..
1724            } => (
1725                None,
1726                wrapped_lines(message, inner_width),
1727                false,
1728                Some((confirm_label, *selected)),
1729            ),
1730        };
1731        let mut height = body_lines.len() as u16 + 4;
1732        if title.is_some() {
1733            height += 2;
1734        }
1735        if has_input {
1736            height += 3;
1737        }
1738        if buttons.is_some() {
1739            height += 2;
1740        }
1741        let height = height.min(area.height);
1742        let popup = Rect {
1743            x: area.x + (area.width.saturating_sub(width)) / 2,
1744            y: area.y + (area.height.saturating_sub(height)) / 2,
1745            width,
1746            height,
1747        };
1748        Clear.render_widget(popup, buffer);
1749        let block = Block::bordered().border_type(BorderType::Rounded);
1750        let inner = block.inner(popup);
1751        block.render_widget(popup, buffer);
1752        let inner = inner.inner(Margin::new(2, 1));
1753        let mut y = inner.y;
1754        if let Some(title) = title {
1755            Paragraph::new(title).render_widget(
1756                Rect {
1757                    height: 1,
1758                    y,
1759                    ..inner
1760                },
1761                buffer,
1762            );
1763            y += 2;
1764        }
1765        if !body_lines.is_empty() {
1766            let height = body_lines.len() as u16;
1767            Paragraph::new(body_lines)
1768                .wrap(Wrap { trim: false })
1769                .render_widget(Rect { height, y, ..inner }, buffer);
1770            y += height + 1;
1771        }
1772        if has_input
1773            && let Some(Modal::Prompt {
1774                input, placeholder, ..
1775            }) = &self.modal
1776        {
1777            let field = Rect {
1778                height: 3,
1779                y,
1780                ..inner
1781            };
1782            let border_active = theme_color(&self.cfg.theme.border_active);
1783            let block = Block::bordered()
1784                .border_type(BorderType::Rounded)
1785                .border_style(Style::default().fg(border_active));
1786            let text_area = block.inner(field);
1787            block.render_widget(field, buffer);
1788            let value = input.value();
1789            let line = if value.is_empty() {
1790                Line::from(Span::styled(*placeholder, Style::default().dim()))
1791            } else {
1792                Line::from(value.to_string())
1793            };
1794            Paragraph::new(line).render_widget(text_area, buffer);
1795            // A visible cursor: invert the cell the input writes next.
1796            let cursor_x = text_area.x + (input.visual_cursor() as u16).min(text_area.width - 1);
1797            if let Some(cell) = buffer.cell_mut((cursor_x, text_area.y)) {
1798                cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
1799            }
1800        }
1801        if let Some((label, selected)) = buttons {
1802            let confirm = format!("[ {label} ]");
1803            let cancel = "[ Cancel ]".to_string();
1804            let total = (confirm.chars().count() + 2 + cancel.chars().count()) as u16;
1805            let start = inner.x + inner.width.saturating_sub(total);
1806            let confirm_area = Rect {
1807                x: start,
1808                y,
1809                width: confirm.chars().count() as u16,
1810                height: 1,
1811            };
1812            let cancel_area = Rect {
1813                x: start + confirm_area.width + 2,
1814                y,
1815                width: cancel.chars().count() as u16,
1816                height: 1,
1817            };
1818            let selected_style = Style::default().add_modifier(Modifier::REVERSED);
1819            Paragraph::new(Span::styled(
1820                confirm,
1821                if selected == 0 {
1822                    selected_style.fg(Color::LightRed)
1823                } else {
1824                    Style::default().fg(Color::LightRed)
1825                },
1826            ))
1827            .render_widget(confirm_area, buffer);
1828            Paragraph::new(Span::styled(
1829                cancel,
1830                if selected == 1 {
1831                    selected_style
1832                } else {
1833                    Style::default()
1834                },
1835            ))
1836            .render_widget(cancel_area, buffer);
1837            self.button_areas = vec![confirm_area, cancel_area];
1838        }
1839    }
1840}
1841
1842enum Teardown {
1843    Archive,
1844    Delete,
1845}
1846
1847fn teardown(cfg: &Config, mux: &dyn Multiplexer, ctx: &Context, mode: Teardown) -> CtxResult<()> {
1848    if mux.exists(ctx) && mux.is_current(ctx) {
1849        // Killing our own session takes the TUI (and the client) down with
1850        // it, so land the client elsewhere first.
1851        switch_away(cfg, mux, ctx);
1852    }
1853    // Kill last: killing our own session ends the TUI, so nothing after the
1854    // kill is guaranteed to run. Kill even when the removal fails half-way;
1855    // the startup sweep finishes the removal.
1856    let removed = match mode {
1857        Teardown::Archive => contexts::archive_context(cfg, ctx).map(|_| ()),
1858        Teardown::Delete => contexts::remove_context(ctx),
1859    };
1860    if mux.exists(ctx) {
1861        mux.kill(ctx)?;
1862    }
1863    removed
1864}
1865
1866/// Re-point the client at the most recent other running session.
1867fn switch_away(cfg: &Config, mux: &dyn Multiplexer, ctx: &Context) {
1868    for other in contexts::list_contexts(cfg) {
1869        if other.name == ctx.name || !mux.exists(&other) {
1870            continue;
1871        }
1872        if mux.open(&other, None).is_ok() {
1873            return;
1874        }
1875    }
1876}
1877
1878fn open_pr(ctx: &Context) -> Result<(), String> {
1879    let remote = new_command("git")
1880        .args(["remote", "get-url", "origin"])
1881        .current_dir(&ctx.path)
1882        .output()
1883        .map_err(|err| err.to_string())?;
1884    let command = forge::pr_view_command(String::from_utf8_lossy(&remote.stdout).trim());
1885    let result = new_command(&command[0])
1886        .args(&command[1..])
1887        .current_dir(&ctx.path)
1888        .output()
1889        .map_err(|err| err.to_string())?;
1890    if !result.status.success() {
1891        let stderr = String::from_utf8_lossy(&result.stderr);
1892        let detail = stderr.trim();
1893        return Err(if detail.is_empty() {
1894            "could not open the PR".to_string()
1895        } else {
1896            detail.to_string()
1897        });
1898    }
1899    Ok(())
1900}
1901
1902fn fetch_cell(cfg: &Config, ctx: &Context, index: usize) -> CellValue {
1903    if index == 0 {
1904        // Colour a status cell if its value is a well-known status word.
1905        let state = status::git_state(ctx);
1906        let style = status::STATUS_STYLES
1907            .iter()
1908            .find(|(word, _)| *word == state)
1909            .map(|(_, style)| *style);
1910        return CellValue::styled(state, style);
1911    }
1912    let column = &cfg.status[index - 1];
1913    match status::column_status(ctx, column) {
1914        Some(cell) if !cell.is_empty() => {
1915            let display = status::cell_icon(column, &cell, cfg.nerd_font);
1916            CellValue::styled(display, status::cell_style(&cell))
1917        }
1918        _ => CellValue::default(),
1919    }
1920}
1921
1922/// True when the query's characters appear in order within the name.
1923fn fuzzy_match(query: &str, name: &str) -> bool {
1924    let name: Vec<char> = name.to_lowercase().chars().collect();
1925    let mut position = 0;
1926    for ch in query.to_lowercase().chars() {
1927        match name[position..].iter().position(|c| *c == ch) {
1928            Some(offset) => position += offset + 1,
1929            None => return false,
1930        }
1931    }
1932    true
1933}
1934
1935fn wrapped_lines(message: &str, width: usize) -> Vec<Line<'static>> {
1936    use unicode_width::UnicodeWidthChar;
1937
1938    // Wrap by display width, not character count: a double-width character
1939    // would otherwise overflow the popup and clip the message tail.
1940    let width = width.max(10);
1941    let mut lines = Vec::new();
1942    for raw in message.lines() {
1943        if raw.is_empty() {
1944            lines.push(Line::default());
1945            continue;
1946        }
1947        let mut current = String::new();
1948        let mut used = 0;
1949        for ch in raw.chars() {
1950            let ch_width = ch.width().unwrap_or(0);
1951            if used + ch_width > width && !current.is_empty() {
1952                lines.push(Line::from(std::mem::take(&mut current)));
1953                used = 0;
1954            }
1955            current.push(ch);
1956            used += ch_width;
1957        }
1958        if !current.is_empty() {
1959            lines.push(Line::from(current));
1960        }
1961    }
1962    lines
1963}
1964
1965fn panel_keybindings(panel: Panel) -> Vec<(&'static str, &'static str)> {
1966    let panel_bindings: &[(&str, &str)] = match panel {
1967        Panel::Contexts => &[
1968            ("enter / space", "open context"),
1969            ("o", "open the PR in the browser"),
1970            ("n", "new context"),
1971            ("N", "new context from a base branch"),
1972            ("d", "archive context"),
1973            ("D", "permanently delete context"),
1974        ],
1975        Panel::Repos => &[
1976            ("enter / n", "new context"),
1977            ("N", "new context from a base branch"),
1978            ("a", "add repo"),
1979            ("s", "set / clear default repo"),
1980            ("d", "remove repo"),
1981        ],
1982        Panel::Archived => &[
1983            ("enter", "unarchive and open context"),
1984            ("u", "unarchive context"),
1985            ("n", "new context"),
1986            ("N", "new context from a base branch"),
1987            ("d / D", "permanently delete context"),
1988            ("e", "empty the archive"),
1989        ],
1990    };
1991    let common: &[(&str, &str)] = &[
1992        ("j / k / ↓ / ↑", "move within panel"),
1993        ("g / G", "jump to top / bottom"),
1994        ("h / l / ← / → / tab / shift-tab", "switch panel"),
1995        ("1 / 2 / 3", "jump to panel"),
1996        ("/", "fuzzy filter by repo and name"),
1997        ("r", "refresh"),
1998        ("?", "this help"),
1999        ("q / ctrl+c", "quit"),
2000    ];
2001    panel_bindings.iter().chain(common).copied().collect()
2002}
2003
2004/// Map a theme colour (ansi name or hex) onto a terminal colour.
2005fn theme_color(name: &str) -> Color {
2006    if let Some(hex) = name.strip_prefix('#')
2007        && hex.len() == 6
2008        && let Ok(value) = u32::from_str_radix(hex, 16)
2009    {
2010        return Color::Rgb((value >> 16) as u8, (value >> 8) as u8, value as u8);
2011    }
2012    match name {
2013        "ansi_default" => Color::Reset,
2014        "ansi_black" => Color::Black,
2015        "ansi_red" => Color::Red,
2016        "ansi_green" => Color::Green,
2017        "ansi_yellow" => Color::Yellow,
2018        "ansi_blue" => Color::Blue,
2019        "ansi_magenta" => Color::Magenta,
2020        "ansi_cyan" => Color::Cyan,
2021        "ansi_white" => Color::White,
2022        _ => Color::Reset,
2023    }
2024}
2025
2026/// This build's version, for the Contexts panel's top border.
2027fn version_line() -> Line<'static> {
2028    Line::from(vec![
2029        Span::styled(
2030            concat!("v", env!("CARGO_PKG_VERSION")),
2031            Style::default().dim(),
2032        ),
2033        Span::raw(" "),
2034    ])
2035}
2036
2037/// A status vocabulary style ("bold bright_green") as a terminal style.
2038fn status_style(name: &str) -> Style {
2039    let mut style = Style::default();
2040    for word in name.split_whitespace() {
2041        style = match word {
2042            "bold" => style.add_modifier(Modifier::BOLD),
2043            "bright_green" => style.fg(Color::LightGreen),
2044            "bright_cyan" => style.fg(Color::LightCyan),
2045            "bright_yellow" => style.fg(Color::LightYellow),
2046            "bright_red" => style.fg(Color::LightRed),
2047            "bright_magenta" => style.fg(Color::LightMagenta),
2048            "bright_black" => style.fg(Color::DarkGray),
2049            _ => style,
2050        };
2051    }
2052    style
2053}
2054
2055/// Widget rendering without a Frame, so tests can draw into a plain buffer.
2056trait RenderWidget {
2057    fn render_widget(self, area: Rect, buffer: &mut ratatui::buffer::Buffer);
2058}
2059
2060impl<W: ratatui::widgets::Widget> RenderWidget for W {
2061    fn render_widget(self, area: Rect, buffer: &mut ratatui::buffer::Buffer) {
2062        self.render(area, buffer);
2063    }
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068    use std::sync::Mutex;
2069
2070    use ratatui::buffer::Buffer;
2071
2072    use super::*;
2073    use crate::config::StatusColumn;
2074    use crate::multiplexer::MultiplexerError;
2075    use crate::testutil::{TestEnv, test_env};
2076
2077    #[derive(Default)]
2078    struct MuxState {
2079        calls: Vec<(String, String)>,
2080        path_present_at_kill: Option<bool>,
2081    }
2082
2083    /// Test double: canned exists()/is_current() answers, recorded calls.
2084    struct TestMux {
2085        exists: bool,
2086        current: Option<String>,
2087        state: Mutex<MuxState>,
2088    }
2089
2090    impl TestMux {
2091        fn stub() -> Arc<TestMux> {
2092            Arc::new(TestMux {
2093                exists: false,
2094                current: None,
2095                state: Mutex::new(MuxState::default()),
2096            })
2097        }
2098
2099        fn recording(current: Option<&str>) -> Arc<TestMux> {
2100            Arc::new(TestMux {
2101                exists: true,
2102                current: current.map(str::to_string),
2103                state: Mutex::new(MuxState::default()),
2104            })
2105        }
2106
2107        fn calls(&self) -> Vec<(String, String)> {
2108            self.state.lock().unwrap().calls.clone()
2109        }
2110    }
2111
2112    impl Multiplexer for TestMux {
2113        fn can_open_in_place(&self) -> bool {
2114            true
2115        }
2116
2117        fn exists(&self, _ctx: &Context) -> bool {
2118            self.exists
2119        }
2120
2121        fn is_current(&self, ctx: &Context) -> bool {
2122            self.current.as_deref() == Some(ctx.name.as_str())
2123        }
2124
2125        fn create(
2126            &self,
2127            _ctx: &Context,
2128            _values: Option<&HashMap<String, String>>,
2129        ) -> Result<(), MultiplexerError> {
2130            Ok(())
2131        }
2132
2133        fn open(
2134            &self,
2135            ctx: &Context,
2136            _values: Option<&HashMap<String, String>>,
2137        ) -> Result<(), MultiplexerError> {
2138            self.state
2139                .lock()
2140                .unwrap()
2141                .calls
2142                .push(("open".to_string(), ctx.name.clone()));
2143            Ok(())
2144        }
2145
2146        fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
2147            let mut state = self.state.lock().unwrap();
2148            state.path_present_at_kill = Some(ctx.path.exists());
2149            state.calls.push(("kill".to_string(), ctx.name.clone()));
2150            Ok(())
2151        }
2152    }
2153
2154    fn registered() -> (TestEnv, std::path::PathBuf) {
2155        let env = test_env();
2156        let origin = env.origin();
2157        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
2158        (env, origin)
2159    }
2160
2161    fn create(env: &TestEnv, repo: &str, name: &str) -> Context {
2162        contexts::create_context(&env.cfg, repo, name, None).unwrap()
2163    }
2164
2165    fn slow_status_cfg(env: &TestEnv) -> Config {
2166        let mut cfg = env.cfg.clone();
2167        cfg.status = vec![StatusColumn {
2168            name: "slow".to_string(),
2169            command: Some("sleep 0.5; echo hi".to_string()),
2170            builtin: None,
2171            interval: None,
2172        }];
2173        cfg
2174    }
2175
2176    impl CtxTui {
2177        fn key(&mut self, code: KeyCode) {
2178            self.handle(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
2179        }
2180
2181        fn keys(&mut self, codes: &[KeyCode]) {
2182            for code in codes {
2183                self.key(*code);
2184            }
2185        }
2186
2187        fn idle(&self) -> bool {
2188            self.workers == 0 && self.fetching.is_empty()
2189        }
2190
2191        /// Pump worker events until the predicate holds or the deadline passes.
2192        fn drain_until(&mut self, mut pred: impl FnMut(&CtxTui) -> bool) -> bool {
2193            let deadline = Instant::now() + Duration::from_secs(8);
2194            loop {
2195                if pred(self) {
2196                    return true;
2197                }
2198                if Instant::now() > deadline {
2199                    return false;
2200                }
2201                if let Ok(event) = self.rx.recv_timeout(Duration::from_millis(50)) {
2202                    self.handle(event);
2203                }
2204            }
2205        }
2206
2207        fn drain_idle(&mut self) {
2208            assert!(self.drain_until(CtxTui::idle), "workers never finished");
2209        }
2210
2211        fn slow_cells(&self) -> Vec<String> {
2212            self.contexts
2213                .rows
2214                .iter()
2215                .map(|row| row.cells[4].text.clone())
2216                .collect()
2217        }
2218    }
2219
2220    fn app(cfg: &Config, mux: Arc<TestMux>) -> CtxTui {
2221        let mut app = CtxTui::new(cfg.clone(), mux, false);
2222        app.mount();
2223        app
2224    }
2225
2226    fn buffer_text(buffer: &Buffer) -> String {
2227        let mut text = String::new();
2228        for y in 0..buffer.area.height {
2229            for x in 0..buffer.area.width {
2230                text.push_str(buffer[(x, y)].symbol());
2231            }
2232            text.push('\n');
2233        }
2234        text
2235    }
2236
2237    fn render(app: &mut CtxTui) -> String {
2238        let area = Rect::new(0, 0, 100, 30);
2239        let mut buffer = Buffer::empty(area);
2240        app.render(area, &mut buffer);
2241        buffer_text(&buffer)
2242    }
2243
2244    #[test]
2245    fn panels_are_populated_before_the_statuses_are() {
2246        // Rows must be there to act on straight away, slow providers or not.
2247        let (env, _origin) = registered();
2248        let cfg = slow_status_cfg(&env);
2249        for name in ["one", "two"] {
2250            contexts::create_context(&cfg, "origin", name, None).unwrap();
2251        }
2252
2253        let mut app = app(&cfg, TestMux::stub());
2254
2255        assert_eq!(app.contexts.row_count(), 2);
2256        assert_eq!(app.repos.row_count(), 1);
2257        assert_eq!(app.slow_cells(), ["", ""]);
2258
2259        assert!(
2260            app.drain_until(|app| app.slow_cells() == ["hi", "hi"]),
2261            "statuses never filled in"
2262        );
2263    }
2264
2265    #[test]
2266    fn arrow_keys_navigate_like_the_vim_keys() {
2267        let (env, _origin) = registered();
2268        for name in ["one", "two"] {
2269            create(&env, "origin", name);
2270        }
2271        let mut app = app(&env.cfg, TestMux::stub());
2272
2273        app.key(KeyCode::Down);
2274        assert_eq!(app.contexts.cursor, 1);
2275        app.key(KeyCode::Up);
2276        assert_eq!(app.contexts.cursor, 0);
2277
2278        app.key(KeyCode::Right);
2279        assert_eq!(app.panel, Panel::Repos);
2280        app.key(KeyCode::Right);
2281        assert_eq!(app.panel, Panel::Archived);
2282        app.key(KeyCode::Left);
2283        assert_eq!(app.panel, Panel::Repos);
2284
2285        app.key(KeyCode::Char('d'));
2286        let selected = |app: &CtxTui| match &app.modal {
2287            Some(Modal::Confirm { selected, .. }) => *selected,
2288            _ => panic!("expected a confirm dialog"),
2289        };
2290        let first = selected(&app);
2291        app.key(KeyCode::Right);
2292        assert_eq!(selected(&app), first + 1);
2293        app.key(KeyCode::Up);
2294        assert_eq!(selected(&app), first);
2295    }
2296
2297    #[test]
2298    fn tab_cycles_panels_like_textual_focus() {
2299        let (env, _origin) = registered();
2300        create(&env, "origin", "one");
2301        let mut app = app(&env.cfg, TestMux::stub());
2302
2303        app.key(KeyCode::Tab);
2304        assert_eq!(app.panel, Panel::Repos);
2305        app.key(KeyCode::Tab);
2306        assert_eq!(app.panel, Panel::Archived);
2307        app.key(KeyCode::Tab);
2308        assert_eq!(app.panel, Panel::Contexts);
2309        app.key(KeyCode::BackTab);
2310        assert_eq!(app.panel, Panel::Archived);
2311
2312        // Inside a confirm dialog, tab moves between the buttons instead.
2313        app.panel = Panel::Contexts;
2314        app.key(KeyCode::Char('D'));
2315        let selected = |app: &CtxTui| match &app.modal {
2316            Some(Modal::Confirm { selected, .. }) => *selected,
2317            _ => panic!("expected a confirm dialog"),
2318        };
2319        assert_eq!(selected(&app), 0);
2320        app.key(KeyCode::Tab);
2321        assert_eq!(selected(&app), 1);
2322        app.key(KeyCode::Tab);
2323        assert_eq!(selected(&app), 0, "button focus must wrap around");
2324    }
2325
2326    #[test]
2327    fn alerts_show_bracketed_error_text_verbatim() {
2328        // Errors often quote a git command; its brackets must render as text.
2329        let (env, _origin) = registered();
2330        let mut app = app(&env.cfg, TestMux::stub());
2331        let message = "Command '[git, -c, http.lowSpeedLimit=1000, fetch, origin]' failed";
2332
2333        app.alert(message);
2334        let text = render(&mut app);
2335
2336        assert!(text.contains("'[git, -c,"), "alert text missing: {text}");
2337    }
2338
2339    #[test]
2340    fn archiving_another_context_does_not_switch() {
2341        let (env, _origin) = registered();
2342        for name in ["one", "two"] {
2343            create(&env, "origin", name);
2344        }
2345        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2346        let mux = TestMux::recording(Some("two"));
2347        let mut app = app(&env.cfg, mux.clone());
2348
2349        app.teardown_worker(ctx, Teardown::Archive);
2350        app.drain_idle();
2351
2352        assert_eq!(mux.calls(), [("kill".to_string(), "one".to_string())]);
2353        assert!(contexts::find_context(&env.cfg, "one").is_err());
2354    }
2355
2356    #[test]
2357    fn theme_colours_reach_the_terminal_styles() {
2358        assert_eq!(theme_color("#2d3f76"), Color::Rgb(0x2d, 0x3f, 0x76));
2359        assert_eq!(theme_color("ansi_default"), Color::Reset);
2360        assert_eq!(theme_color("ansi_blue"), Color::Blue);
2361    }
2362
2363    #[test]
2364    fn current_context_is_pinned_and_cursor_starts_below_it() {
2365        let (env, _origin) = registered();
2366        for name in ["one", "two"] {
2367            create(&env, "origin", name);
2368        }
2369
2370        let app = app(&env.cfg, TestMux::recording(Some("one")));
2371
2372        assert_eq!(
2373            app.contexts.rows[0].key, "one",
2374            "the attached context must be the top row"
2375        );
2376        assert_eq!(
2377            app.contexts.cursor, 1,
2378            "the cursor must start on the next context"
2379        );
2380    }
2381
2382    #[test]
2383    fn cursor_starts_on_top_without_a_current_context() {
2384        let (env, _origin) = registered();
2385        for name in ["one", "two"] {
2386            create(&env, "origin", name);
2387        }
2388
2389        let app = app(&env.cfg, TestMux::stub());
2390
2391        assert_eq!(app.contexts.cursor, 0);
2392    }
2393
2394    #[test]
2395    fn new_prompt_prefills_a_generated_name() {
2396        let (env, _origin) = registered();
2397        let mut app = app(&env.cfg, TestMux::stub());
2398
2399        app.key(KeyCode::Char('n'));
2400        let name = match &app.modal {
2401            Some(Modal::Prompt { input, .. }) => input.value().to_string(),
2402            _ => panic!("expected the name prompt"),
2403        };
2404        assert!(
2405            !name.is_empty(),
2406            "the prompt must pre-fill a generated name"
2407        );
2408        app.key(KeyCode::Enter);
2409        app.drain_idle();
2410
2411        assert!(contexts::find_context(&env.cfg, &name).is_ok());
2412    }
2413
2414    #[test]
2415    fn typing_replaces_the_prefilled_name() {
2416        let (env, _origin) = registered();
2417        let mut app = app(&env.cfg, TestMux::stub());
2418
2419        app.key(KeyCode::Char('n'));
2420        app.key(KeyCode::Char('x'));
2421
2422        match &app.modal {
2423            Some(Modal::Prompt { input, .. }) => assert_eq!(input.value(), "x"),
2424            _ => panic!("expected the name prompt"),
2425        }
2426    }
2427
2428    #[test]
2429    fn new_context_uses_the_default_repo_off_the_repos_panel() {
2430        let (env, _origin) = registered();
2431        let other = env.make_origin("other", false);
2432        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2433        create(&env, "origin", "one");
2434        repos::set_default_repo(&env.cfg, Some("other")).unwrap();
2435        let mut app = app(&env.cfg, TestMux::stub());
2436
2437        assert_eq!(
2438            app.repo_for_new().as_deref(),
2439            Some("other"),
2440            "contexts panel must use the default"
2441        );
2442        app.panel = Panel::Repos;
2443        app.key(KeyCode::Char('j'));
2444        assert_eq!(
2445            app.repo_for_new().as_deref(),
2446            Some("origin"),
2447            "repos panel must use the hovered repo"
2448        );
2449    }
2450
2451    #[test]
2452    fn default_repo_sorts_first() {
2453        let (env, _origin) = registered();
2454        let other = env.make_origin("aaa", false);
2455        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2456        repos::set_default_repo(&env.cfg, Some("origin")).unwrap();
2457
2458        let app = app(&env.cfg, TestMux::stub());
2459
2460        assert_eq!(
2461            app.repos.selected_key(),
2462            Some("origin"),
2463            "default must be the top row"
2464        );
2465    }
2466
2467    #[test]
2468    fn s_toggles_the_default_repo() {
2469        let (env, _origin) = registered();
2470        let mut app = app(&env.cfg, TestMux::stub());
2471
2472        app.panel = Panel::Repos;
2473        app.key(KeyCode::Char('s'));
2474        assert_eq!(repos::default_repo(&env.cfg).as_deref(), Some("origin"));
2475        app.key(KeyCode::Char('s'));
2476        assert_eq!(repos::default_repo(&env.cfg), None);
2477    }
2478
2479    #[test]
2480    fn o_opens_the_pr_in_the_browser() {
2481        let (env, _origin) = registered();
2482        let ctx = create(&env, "origin", "one");
2483        let log = env.root().join("gh-args");
2484        let _gh = env.fake_cli("gh", &format!("echo \"$@\" > {}", log.display()));
2485        let mut app = app(&env.cfg, TestMux::stub());
2486
2487        app.key(KeyCode::Char('o'));
2488        app.drain_idle();
2489
2490        assert_eq!(
2491            std::fs::read_to_string(&log).unwrap().trim(),
2492            "pr view --web"
2493        );
2494        assert_eq!(
2495            contexts::find_context(&env.cfg, "one").unwrap().path,
2496            ctx.path
2497        );
2498    }
2499
2500    #[test]
2501    fn o_uses_the_forge_from_the_remote() {
2502        let (env, _origin) = registered();
2503        let ctx = create(&env, "origin", "one");
2504        crate::testutil::git(
2505            &[
2506                "remote",
2507                "set-url",
2508                "origin",
2509                "git@gitlab.com:jane/tool.git",
2510            ],
2511            &ctx.path,
2512        );
2513        let log = env.root().join("glab-args");
2514        let _glab = env.fake_cli("glab", &format!("echo \"$@\" > {}", log.display()));
2515        let mut app = app(&env.cfg, TestMux::stub());
2516
2517        app.key(KeyCode::Char('o'));
2518        app.drain_idle();
2519
2520        assert_eq!(
2521            std::fs::read_to_string(&log).unwrap().trim(),
2522            "mr view --web"
2523        );
2524    }
2525
2526    #[test]
2527    fn archive_key_archives_without_a_prompt() {
2528        let (env, _origin) = registered();
2529        create(&env, "origin", "one");
2530        let mut app = app(&env.cfg, TestMux::stub());
2531
2532        app.key(KeyCode::Char('d'));
2533        app.drain_idle();
2534
2535        assert!(contexts::find_archived(&env.cfg, "one").is_ok());
2536    }
2537
2538    #[test]
2539    fn delete_key_asks_for_confirmation() {
2540        let (env, _origin) = registered();
2541        let ctx = create(&env, "origin", "one");
2542        contexts::archive_context(&env.cfg, &ctx).unwrap();
2543        let mut app = app(&env.cfg, TestMux::stub());
2544
2545        app.panel = Panel::Archived;
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_archived(&env.cfg, "one").is_ok());
2552    }
2553
2554    #[test]
2555    fn shift_delete_key_on_contexts_asks_for_confirmation() {
2556        let (env, _origin) = registered();
2557        create(&env, "origin", "one");
2558        let mut app = app(&env.cfg, TestMux::stub());
2559
2560        app.key(KeyCode::Char('D'));
2561        assert!(matches!(app.modal, Some(Modal::Confirm { .. })));
2562        app.key(KeyCode::Esc);
2563        app.drain_idle();
2564
2565        assert!(contexts::find_context(&env.cfg, "one").is_ok());
2566    }
2567
2568    #[test]
2569    fn confirming_delete_removes_the_checkout() {
2570        let (env, _origin) = registered();
2571        let ctx = create(&env, "origin", "one");
2572        let mut app = app(&env.cfg, TestMux::stub());
2573
2574        app.key(KeyCode::Char('D'));
2575        app.key(KeyCode::Enter);
2576        app.drain_idle();
2577
2578        assert!(!ctx.path.exists());
2579        assert!(contexts::find_context(&env.cfg, "one").is_err());
2580    }
2581
2582    #[test]
2583    fn startup_sweeps_interrupted_deletions() {
2584        let (env, _origin) = registered();
2585        let ctx = create(&env, "origin", "one");
2586        let leftover = ctx.path.with_file_name("one.deleting");
2587        std::fs::rename(&ctx.path, &leftover).unwrap();
2588
2589        let mut app = app(&env.cfg, TestMux::stub());
2590        app.drain_idle();
2591
2592        assert!(!leftover.exists());
2593    }
2594
2595    #[test]
2596    fn add_repo_key_is_local_to_the_repos_panel() {
2597        // `a` opens the add-repo prompt only while the repos panel is focused.
2598        let (env, _origin) = registered();
2599        let mut app = app(&env.cfg, TestMux::stub());
2600
2601        app.key(KeyCode::Char('a'));
2602        assert!(app.modal.is_none(), "a must be inert off the repos panel");
2603
2604        app.panel = Panel::Repos;
2605        app.key(KeyCode::Char('a'));
2606        assert!(matches!(app.modal, Some(Modal::Prompt { .. })));
2607    }
2608
2609    #[test]
2610    fn archiving_the_current_context_switches_away_then_kills() {
2611        let (env, _origin) = registered();
2612        for name in ["one", "two"] {
2613            create(&env, "origin", name);
2614        }
2615        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2616        let mux = TestMux::recording(Some("one"));
2617        let mut app = app(&env.cfg, mux.clone());
2618
2619        app.teardown_worker(ctx, Teardown::Archive);
2620        app.drain_idle();
2621
2622        assert_eq!(
2623            mux.calls(),
2624            [
2625                ("open".to_string(), "two".to_string()),
2626                ("kill".to_string(), "one".to_string()),
2627            ]
2628        );
2629        // Killing our own session ends the process, so the move must have
2630        // landed by the time the kill happens.
2631        assert_eq!(
2632            mux.state.lock().unwrap().path_present_at_kill,
2633            Some(false),
2634            "the move must come before the kill"
2635        );
2636        assert!(contexts::find_archived(&env.cfg, "one").is_ok());
2637    }
2638
2639    #[test]
2640    fn archiving_kills_the_session_even_when_the_move_fails() {
2641        let (env, _origin) = registered();
2642        let ctx = create(&env, "origin", "one");
2643        // An occupied archive path fails the move before anything happens.
2644        std::fs::create_dir_all(env.cfg.archive_dir.join("origin").join("one")).unwrap();
2645        let mux = TestMux::recording(None);
2646        let mut app = app(&env.cfg, mux.clone());
2647
2648        app.teardown_worker(ctx.clone(), Teardown::Archive);
2649        app.drain_idle();
2650
2651        assert_eq!(mux.calls(), [("kill".to_string(), "one".to_string())]);
2652        assert!(ctx.path.exists());
2653    }
2654
2655    #[test]
2656    fn archiving_the_current_context_leaves_no_stale_busy_state() {
2657        // A TUI in a tmux popup outlives its session's kill; it must repaint.
2658        let (env, _origin) = registered();
2659        for name in ["one", "two"] {
2660            create(&env, "origin", name);
2661        }
2662        let ctx = contexts::find_context(&env.cfg, "one").unwrap();
2663        let mut app = app(&env.cfg, TestMux::recording(Some("one")));
2664
2665        app.start_busy(Panel::Contexts);
2666        app.teardown_worker(ctx, Teardown::Archive);
2667        app.drain_idle();
2668
2669        assert!(
2670            app.busy.is_empty(),
2671            "the panel stayed dimmed after the archive"
2672        );
2673        assert_eq!(app.contexts.row_count(), 1);
2674    }
2675
2676    #[test]
2677    fn slash_filters_and_enter_opens_the_match() {
2678        let (env, _origin) = registered();
2679        for name in ["alpha", "beta"] {
2680            create(&env, "origin", name);
2681        }
2682        let mux = TestMux::recording(None);
2683        let mut app = app(&env.cfg, mux.clone());
2684
2685        app.keys(&[KeyCode::Char('/'), KeyCode::Char('b'), KeyCode::Char('t')]);
2686        assert_eq!(
2687            app.contexts.row_count(),
2688            1,
2689            "only the fuzzy match may remain"
2690        );
2691        app.key(KeyCode::Enter);
2692
2693        assert!(
2694            mux.calls()
2695                .contains(&("open".to_string(), "beta".to_string()))
2696        );
2697        assert_eq!(
2698            app.contexts.row_count(),
2699            2,
2700            "the filter must clear after opening"
2701        );
2702    }
2703
2704    #[test]
2705    fn escape_clears_the_filter() {
2706        let (env, _origin) = registered();
2707        for name in ["alpha", "beta"] {
2708            create(&env, "origin", name);
2709        }
2710        let mut app = app(&env.cfg, TestMux::stub());
2711
2712        app.keys(&[KeyCode::Char('/'), KeyCode::Char('b')]);
2713        assert_eq!(app.contexts.row_count(), 1);
2714        app.key(KeyCode::Esc);
2715        assert_eq!(app.contexts.row_count(), 2);
2716        assert_eq!(app.panel, Panel::Contexts);
2717    }
2718
2719    #[test]
2720    fn enter_with_no_matches_keeps_filtering() {
2721        let (env, _origin) = registered();
2722        create(&env, "origin", "alpha");
2723        let mux = TestMux::recording(None);
2724        let mut app = app(&env.cfg, mux.clone());
2725
2726        app.keys(&[KeyCode::Char('/'), KeyCode::Char('z')]);
2727        assert_eq!(app.contexts.row_count(), 0);
2728        app.key(KeyCode::Enter);
2729
2730        assert!(mux.calls().is_empty());
2731        assert_eq!(app.contexts.row_count(), 0, "the filter must stay active");
2732        assert!(app.filter.is_some());
2733    }
2734
2735    #[test]
2736    fn filter_matches_the_repo_too() {
2737        let (env, _origin) = registered();
2738        let other = env.make_origin("other", false);
2739        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2740        create(&env, "origin", "alpha");
2741        create(&env, "other", "beta");
2742        let mut app = app(&env.cfg, TestMux::stub());
2743
2744        app.keys(&[
2745            KeyCode::Char('/'),
2746            KeyCode::Char('o'),
2747            KeyCode::Char('t'),
2748            KeyCode::Char('h'),
2749        ]);
2750
2751        assert_eq!(app.contexts.row_count(), 1);
2752        assert_eq!(app.contexts.selected_key(), Some("beta"));
2753    }
2754
2755    #[test]
2756    fn filter_is_panel_scoped() {
2757        let (env, _origin) = registered();
2758        let other = env.make_origin("other", false);
2759        repos::add_repo(&env.cfg, &other.to_string_lossy(), None).unwrap();
2760        create(&env, "origin", "alpha");
2761        let mut app = app(&env.cfg, TestMux::stub());
2762
2763        app.panel = Panel::Repos;
2764        app.keys(&[KeyCode::Char('/'), KeyCode::Char('x')]);
2765        assert_eq!(app.repos.row_count(), 0);
2766        assert_eq!(
2767            app.contexts.row_count(),
2768            1,
2769            "other panels must keep their rows"
2770        );
2771        app.key(KeyCode::Esc);
2772        assert_eq!(app.repos.row_count(), 2);
2773        assert_eq!(app.panel, Panel::Repos);
2774    }
2775
2776    #[test]
2777    fn the_ui_stays_responsive_while_statuses_fetch() {
2778        // A slow status provider must not stall the event loop.
2779        let (env, _origin) = registered();
2780        let cfg = slow_status_cfg(&env);
2781        for name in ["one", "two"] {
2782            contexts::create_context(&cfg, "origin", name, None).unwrap();
2783        }
2784        let mut app = app(&cfg, TestMux::stub());
2785
2786        // The fetch is in flight; input must land immediately regardless.
2787        let start = Instant::now();
2788        app.key(KeyCode::Down);
2789        assert_eq!(app.contexts.cursor, 1);
2790        assert!(
2791            start.elapsed() < Duration::from_millis(200),
2792            "input handling stalled behind the status fetch"
2793        );
2794        assert!(
2795            app.drain_until(|app| app.slow_cells() == ["hi", "hi"]),
2796            "statuses never arrived"
2797        );
2798    }
2799
2800    #[test]
2801    fn typing_in_the_filter_reselects_the_top_match() {
2802        let (env, _origin) = registered();
2803        for name in ["match-one", "match-two", "other"] {
2804            create(&env, "origin", name);
2805        }
2806        let mux = TestMux::recording(None);
2807        let mut app = app(&env.cfg, mux.clone());
2808        app.key(KeyCode::Down);
2809        assert_eq!(
2810            app.contexts.cursor, 1,
2811            "precondition: cursor off the top row"
2812        );
2813
2814        app.keys(&[KeyCode::Char('/'), KeyCode::Char('m'), KeyCode::Char('a')]);
2815
2816        assert_eq!(app.contexts.row_count(), 2);
2817        assert_eq!(
2818            app.contexts.cursor, 0,
2819            "each keystroke must reselect the top match"
2820        );
2821        let top = app.contexts.selected_key().unwrap().to_string();
2822        app.key(KeyCode::Enter);
2823        assert!(mux.calls().contains(&("open".to_string(), top)));
2824    }
2825
2826    #[test]
2827    fn alerts_wait_for_an_open_prompt() {
2828        let (env, _origin) = registered();
2829        let mut app = app(&env.cfg, TestMux::stub());
2830        app.key(KeyCode::Char('n'));
2831        assert!(matches!(app.modal, Some(Modal::Prompt { .. })));
2832
2833        app.handle(Event::Worker(WorkerDone {
2834            alert: Some("boom".to_string()),
2835            finished: true,
2836            ..WorkerDone::default()
2837        }));
2838
2839        assert!(
2840            matches!(app.modal, Some(Modal::Prompt { .. })),
2841            "a worker alert must not clobber the prompt"
2842        );
2843        app.key(KeyCode::Esc);
2844        match &app.modal {
2845            Some(Modal::Alert { message }) => assert_eq!(message, "boom"),
2846            other => panic!("expected the queued alert, got {:?}", other.is_some()),
2847        }
2848    }
2849
2850    #[test]
2851    fn backspace_clears_the_prefilled_name_whole() {
2852        let (env, _origin) = registered();
2853        let mut app = app(&env.cfg, TestMux::stub());
2854
2855        app.key(KeyCode::Char('n'));
2856        app.key(KeyCode::Backspace);
2857
2858        match &app.modal {
2859            Some(Modal::Prompt { input, .. }) => {
2860                assert_eq!(
2861                    input.value(),
2862                    "",
2863                    "backspace must delete the selected pre-fill"
2864                )
2865            }
2866            _ => panic!("expected the name prompt"),
2867        }
2868    }
2869
2870    #[test]
2871    fn control_chords_do_not_wipe_the_prefilled_name() {
2872        let (env, _origin) = registered();
2873        let mut app = app(&env.cfg, TestMux::stub());
2874
2875        app.key(KeyCode::Char('n'));
2876        let before = match &app.modal {
2877            Some(Modal::Prompt { input, .. }) => input.value().to_string(),
2878            _ => panic!("expected the name prompt"),
2879        };
2880        app.handle(Event::Key(KeyEvent::new(
2881            KeyCode::Char('a'),
2882            KeyModifiers::CONTROL,
2883        )));
2884
2885        match &app.modal {
2886            Some(Modal::Prompt { input, .. }) => assert_eq!(input.value(), before),
2887            _ => panic!("expected the name prompt"),
2888        }
2889    }
2890
2891    #[test]
2892    fn polling_only_runs_with_status_columns() {
2893        let (env, _origin) = registered();
2894        create(&env, "origin", "one");
2895
2896        // Without status columns nothing polls in the background.
2897        let mut app = app(&env.cfg, TestMux::stub());
2898        app.drain_idle();
2899        app.poll_at = vec![Instant::now() - Duration::from_secs(60)];
2900        app.handle(Event::Tick);
2901        assert!(app.fetching.is_empty(), "a bare listing must not poll");
2902
2903        // With one, the elapsed deadline refreshes its column.
2904        let cfg = slow_status_cfg(&env);
2905        let mut app = crate::tui::CtxTui::new(cfg, TestMux::stub(), false);
2906        app.mount();
2907        app.drain_idle();
2908        app.poll_at = vec![Instant::now() - Duration::from_secs(60); 2];
2909        app.handle(Event::Tick);
2910        assert!(!app.fetching.is_empty(), "an elapsed deadline must poll");
2911        app.drain_idle();
2912    }
2913
2914    #[test]
2915    fn q_and_r_stay_live_under_popups() {
2916        let (env, _origin) = registered();
2917        create(&env, "origin", "one");
2918        let mut app = app(&env.cfg, TestMux::stub());
2919
2920        app.key(KeyCode::Char('?'));
2921        app.key(KeyCode::Char('r'));
2922        assert!(
2923            matches!(app.modal, Some(Modal::Help { .. })),
2924            "r must keep the popup"
2925        );
2926
2927        app.key(KeyCode::Char('q'));
2928        assert!(app.quit, "q must quit under a popup");
2929    }
2930
2931    #[test]
2932    fn page_keys_move_by_a_page() {
2933        let (env, _origin) = registered();
2934        for name in ["one", "two", "three"] {
2935            create(&env, "origin", name);
2936        }
2937        let mut app = app(&env.cfg, TestMux::stub());
2938        app.contexts.page = 2;
2939
2940        app.key(KeyCode::PageDown);
2941        assert_eq!(app.contexts.cursor, 2);
2942        app.key(KeyCode::PageUp);
2943        assert_eq!(app.contexts.cursor, 0);
2944        app.key(KeyCode::End);
2945        assert_eq!(app.contexts.cursor, 2);
2946        app.key(KeyCode::Home);
2947        assert_eq!(app.contexts.cursor, 0);
2948    }
2949
2950    #[test]
2951    fn clicks_below_the_rows_leave_the_selection_alone() {
2952        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
2953
2954        let (env, _origin) = registered();
2955        for name in ["one", "two"] {
2956            create(&env, "origin", name);
2957        }
2958        let mut app = app(&env.cfg, TestMux::stub());
2959        render(&mut app); // record the panel areas for hit-testing
2960
2961        let area = app.areas[&Panel::Contexts];
2962        let blank_row = area.y + 2 + app.contexts.row_count() as u16 + 3;
2963        app.handle(Event::Mouse(MouseEvent {
2964            kind: MouseEventKind::Down(MouseButton::Left),
2965            column: area.x + 2,
2966            row: blank_row,
2967            modifiers: KeyModifiers::NONE,
2968        }));
2969
2970        assert_eq!(
2971            app.panel,
2972            Panel::Contexts,
2973            "the click still focuses the panel"
2974        );
2975        assert_eq!(
2976            app.contexts.cursor, 0,
2977            "blank space must not move the cursor"
2978        );
2979    }
2980
2981    #[test]
2982    fn wrapped_lines_wrap_by_display_width() {
2983        let wide = "あ".repeat(20); // each character is two columns wide
2984
2985        let lines = wrapped_lines(&wide, 10);
2986
2987        assert_eq!(lines.len(), 4, "twenty double-width chars at width 10");
2988    }
2989
2990    #[test]
2991    fn footer_and_titles_render() {
2992        let (env, _origin) = registered();
2993        create(&env, "origin", "one");
2994        let mut app = app(&env.cfg, TestMux::stub());
2995
2996        let text = render(&mut app);
2997
2998        assert!(text.contains("[1] Contexts"));
2999        assert!(text.contains("[2] Repos"));
3000        assert!(text.contains("[3] Archived"));
3001        assert!(text.contains("NAME"));
3002        assert!(text.contains("one"));
3003        assert!(text.contains("Open PR"));
3004        assert!(text.contains(concat!("v", env!("CARGO_PKG_VERSION"))));
3005    }
3006
3007    #[test]
3008    fn help_screen_lists_the_panel_bindings() {
3009        let (env, _origin) = registered();
3010        let mut app = app(&env.cfg, TestMux::stub());
3011
3012        app.key(KeyCode::Char('?'));
3013        let text = render(&mut app);
3014
3015        assert!(text.contains("Keybindings (contexts)"));
3016        assert!(text.contains("open the PR in the browser"));
3017        app.key(KeyCode::Esc);
3018        assert!(app.modal.is_none());
3019    }
3020}