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