1use 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#[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
111struct PanelTable {
113 headers: Vec<String>,
114 rows: Vec<TableRow>,
115 cursor: usize,
116 view: TableState,
117 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 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 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#[derive(Debug, Default)]
227pub struct WorkerDone {
228 reload: bool,
229 finish_busy: bool,
230 alert: Option<String>,
231 exit: bool,
232 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 pending_alerts: Vec<String>,
269 workers: usize,
270 outcome: Option<Request>,
271 quit: bool,
272 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#[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 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 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 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 fn reload(&mut self) {
370 if let Some(filter) = self.filter.take() {
371 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 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 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 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 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 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 fn allow_mutation(&self) -> bool {
509 self.busy.is_empty() && self.modal.is_none()
510 }
511
512 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 self.reload();
520 None
521 }
522 }
523 }
524
525 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 crate::git::kill_inflight();
1548 Ok(result)
1549 }
1550
1551 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 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 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 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 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 (None, wrapped_lines(message, inner_width), false, None)
1766 }
1767 Modal::Help { panel } => {
1768 let bindings = panel_keybindings(*panel);
1769 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 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 switch_away(cfg, mux, ctx);
1917 }
1918 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
1931fn 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 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
1987fn 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 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
2069fn 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
2091fn 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
2102fn 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
2120trait 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); 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); 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}