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