1use std::cell::{Cell, RefCell};
2use std::io;
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use crossterm::event::{
7 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
8 KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
9};
10use crossterm::execute;
11use crossterm::terminal::{
12 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
13};
14use ratatui::Terminal;
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::{Position, Rect};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph};
20
21use crate::assignee::{git_identity, resolve_me};
22use crate::commands::{self, mv};
23use crate::config::{BoardConfiguration, Config};
24use crate::error::{Result, TixError};
25use crate::keybindings::{EffectiveBindings, InputContext};
26use crate::model::{Issue, Priority, Status, today};
27use crate::planning::Planner;
28use crate::search::{self, Corpus, FilterField};
29use crate::storage::{IssueSelector, Store};
30
31use crate::editor;
32use crate::ui::board::{self, BoardState, HitTarget};
33use crate::ui::commandbar;
34use crate::ui::context_menu::{ContextMenu, MenuEvent, MenuItem, MenuItemKind};
35use crate::ui::date_picker::{DatePicker, DatePickerEvent, DatePickerHit};
36use crate::ui::detail::{self, DetailState};
37use crate::ui::dialog::{DialogEvent, RolloverDialog};
38use crate::ui::form::{self, FormState};
39use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem, PickerMode, SelectionModel};
40use crate::ui::planning::{
41 PlanningForm, PlanningKind, PlanningModal, PlanningPrompt, PlanningRow, PlanningSegment,
42};
43use crate::ui::settings::{SettingsModal, SettingsPrompt};
44use crate::ui::tabs::BoardTabs;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Action {
48 None,
49 Quit,
50 OpenEditor(String),
51 OpenAgent(String),
52 SwitchBoard(usize),
54 ConfirmDeleteYes,
57 ConfirmDeleteNo,
58 FilterModalFocus(FilterField),
60 SettingsRow(usize),
62 DatePickerHit(DatePickerHit),
66 HelpClose,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Mode {
72 Board,
73 BulkModal,
76 TriageInbox,
86 RolloverDialog,
87 Detail,
88 Form,
89 ConfirmDelete,
90 MoveIntoDone {
91 target_col: usize,
92 buffer: String,
93 },
94 ConfirmBlockedMove {
100 target_col: usize,
101 warning: String,
102 },
103 TransitionReason {
116 target_col: usize,
117 reason: String,
119 buffer: String,
121 },
122 Help {
123 return_to: Box<Mode>,
124 },
125 ContextMenu {
129 return_to: Box<Mode>,
130 },
131 Filter {
132 buffer: String,
133 },
134 FilterModal,
135 Settings,
136 PlanningManagement,
137 PlanningForm,
138 DatePicker {
140 return_to: Box<Mode>,
141 },
142 Comment {
143 buffer: String,
144 },
145 Asset {
146 buffer: String,
147 },
148 EditLintWarning {
149 filename: String,
150 message: String,
151 },
152 LinkCreate {
161 buffer: String,
162 step: LinkStep,
163 target: Option<u32>,
171 },
172 LinkEdit {
178 index: usize,
179 buffer: String,
180 step: LinkStep,
181 target: Option<u32>,
185 kind: Option<crate::relationships::RelationKind>,
187 },
188 ConfirmRemoveRelationship {
194 index: usize,
195 },
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum LinkStep {
201 Target,
203 Kind,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct HitRegion {
212 pub rect: Rect,
213 pub action: Action,
214}
215
216#[derive(Debug)]
221struct MouseDown {
222 col: usize,
223 card: Option<usize>,
224 at: Instant,
225}
226
227#[derive(Debug)]
230struct Drag {
231 col: usize,
232 card: Option<usize>,
233}
234
235#[derive(Debug)]
240pub struct MouseState {
241 last_down: Option<MouseDown>,
242 drag: Option<Drag>,
243 pub double_click_window: Duration,
244}
245
246impl Default for MouseState {
247 fn default() -> Self {
248 MouseState {
249 last_down: None,
250 drag: None,
251 double_click_window: Duration::from_millis(500),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum FormPickerField {
258 Labels,
259 Assignee,
260 Cycles,
261 Milestones,
262 Reviewer,
263 Parent,
266}
267
268#[derive(Debug, Clone)]
269struct FormPicker {
270 field: FormPickerField,
271 state: DropdownState,
272}
273
274const WHEEL_ROWS: u16 = 5;
280
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
287struct HelpMetrics {
288 visible: u16,
290 max_scroll: u16,
292}
293
294#[allow(clippy::struct_excessive_bools)]
299pub struct App {
300 pub mode: Mode,
301 pub board: BoardState,
302 pub detail: Option<DetailState>,
303 pub form: Option<FormState>,
304 pub confirm_delete_target: Option<String>,
305 pub store: Store,
306 pub status_msg: Option<String>,
307 pub quit: bool,
308 pub bindings: EffectiveBindings,
309 pub active_config: usize,
313 pub tabs: BoardTabs,
315 filter: Option<String>,
316 corpus: Corpus,
317 pub show_archived: bool,
326 help_scroll: u16,
332 help_query: String,
339 help_searching: bool,
340 help_metrics: std::cell::Cell<HelpMetrics>,
345 board_page: std::cell::Cell<usize>,
352 pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
353 pub settings: Option<SettingsModal>,
355 pub planning: Option<PlanningModal>,
357 pub planning_form: Option<PlanningForm>,
359 pub date_picker: Option<DatePicker>,
361 structured: search::StructuredFilter,
364 rollover_dialog: Option<RolloverDialog>,
365 form_picker: Option<FormPicker>,
366 one_shot: bool,
368 frame_hit_regions: RefCell<Vec<HitRegion>>,
373 mouse: MouseState,
375 context_menu: Option<ContextMenu>,
378 pub bulk: Option<crate::ui::bulk::BulkModal>,
381 pub triage: Option<crate::ui::triage::TriageState>,
385 drag_preview_col: Option<usize>,
387 frame_size: Cell<Rect>,
390}
391
392impl App {
393 pub fn note_disclosure(&mut self, disclosure: String) {
406 self.status_msg = Some(match self.status_msg.take() {
419 Some(existing) if !existing.is_empty() => format!("{disclosure} — {existing}"),
420 _ => disclosure,
421 });
422 }
423
424 pub fn new(config: &Config) -> Result<Self> {
425 let store = Store::open(config)?;
426 let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
427 let board = BoardState::build(&store, config.default_config());
428 let corpus = Corpus::build(&store)?;
429 let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
430 let rollover_plan = planner.rollover_plan(&store, &today());
431 let rollover_dialog = if rollover_plan.is_empty() {
432 None
433 } else {
434 Some(RolloverDialog::new(
435 rollover_plan
436 .into_iter()
437 .map(|p| (p.from_cycle, p.to_cycle, p.issues.len()))
438 .collect(),
439 ))
440 };
441 Ok(App {
442 mode: if rollover_dialog.is_some() {
443 Mode::RolloverDialog
444 } else {
445 Mode::Board
446 },
447 board,
448 show_archived: false,
451 help_scroll: 0,
452 help_query: String::new(),
453 help_searching: false,
454 help_metrics: std::cell::Cell::new(HelpMetrics::default()),
455 board_page: std::cell::Cell::new(0),
456 detail: None,
457 form: None,
458 confirm_delete_target: None,
459 store,
460 status_msg: None,
461 quit: false,
462 bindings: config.effective_keybindings()?,
463 active_config: 0,
464 tabs,
465 filter: None,
466 corpus,
467 filter_modal: None,
468 settings: None,
469 planning: None,
470 planning_form: None,
471 date_picker: None,
472 structured: search::StructuredFilter::default(),
473 rollover_dialog,
474 form_picker: None,
475 one_shot: false,
476 frame_hit_regions: RefCell::new(Vec::new()),
477 mouse: MouseState::default(),
478 context_menu: None,
479 bulk: None,
480 triage: None,
481 drag_preview_col: None,
482 frame_size: Cell::new(Rect::new(0, 0, 0, 0)),
483 })
484 }
485
486 fn active_board_config(&self) -> &BoardConfiguration {
488 self.store
489 .config
490 .configs()
491 .nth(self.active_config)
492 .map(|(_, board)| board)
493 .unwrap_or_else(|| self.store.config.default_config())
494 }
495
496 fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
503 let name = self
504 .store
505 .config
506 .configs()
507 .nth(self.active_config)
508 .map(|(name, _)| name)
509 .unwrap_or("default");
510 crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
511 }
512
513 fn category_of_issue(
521 &self,
522 issue: &crate::model::Issue,
523 ) -> Option<(String, Option<crate::workflow::ColorHint>)> {
524 let resolution = self.workflow_resolution();
525 crate::workflow::category_of(issue, &resolution)
526 .map(|c| (c.label().to_string(), c.color_hint()))
527 }
528
529 pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
530 if key.kind != KeyEventKind::Press {
531 return Ok(Action::None);
532 }
533 let context = self.input_context();
534 let key = self
535 .bindings
536 .action(context, key)
537 .map(|action| self.bindings.canonical_event(context, action))
538 .unwrap_or_else(|| {
539 if Self::allows_text_fallback(context) {
540 key
541 } else {
542 KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)
543 }
544 });
545 self.handle_resolved_key(context, key)
546 }
547
548 fn allows_text_fallback(context: InputContext) -> bool {
549 matches!(
550 context,
551 InputContext::Form
552 | InputContext::FormPicker
553 | InputContext::Filter
554 | InputContext::FilterModal
555 | InputContext::HelpSearch
561 | InputContext::PlanningForm
562 | InputContext::Comment
563 | InputContext::Asset
564 | InputContext::MoveIntoDone
565 | InputContext::LinkPrompt
566 | InputContext::SettingsPrompt
567 | InputContext::BulkModal
568 | InputContext::Triage
569 )
570 }
571
572 fn input_context(&self) -> InputContext {
573 match &self.mode {
574 Mode::Board => InputContext::Board,
575 Mode::RolloverDialog => InputContext::Rollover,
576 Mode::Detail => InputContext::Detail,
577 Mode::Form if self.form_picker.is_some() => InputContext::FormPicker,
578 Mode::Form => InputContext::Form,
579 Mode::ConfirmDelete => InputContext::ConfirmDelete,
580 Mode::MoveIntoDone { .. } => InputContext::MoveIntoDone,
581 Mode::TransitionReason { .. } => InputContext::MoveIntoDone,
582 Mode::ConfirmBlockedMove { .. } => InputContext::ConfirmBlockedMove,
583 Mode::Help { .. } if self.help_searching => InputContext::HelpSearch,
584 Mode::Help { .. } => InputContext::Help,
585 Mode::ContextMenu { .. } => InputContext::ContextMenu,
586 Mode::Filter { .. } => InputContext::Filter,
587 Mode::FilterModal => InputContext::FilterModal,
588 Mode::Settings
589 if self
590 .settings
591 .as_ref()
592 .is_some_and(|modal| modal.prompt.is_some()) =>
593 {
594 InputContext::SettingsPrompt
595 }
596 Mode::Settings => InputContext::Settings,
597 Mode::PlanningManagement
598 if self
599 .planning
600 .as_ref()
601 .is_some_and(|modal| modal.prompt.is_some()) =>
602 {
603 InputContext::PlanningConfirm
604 }
605 Mode::PlanningManagement => InputContext::PlanningManagement,
606 Mode::PlanningForm => InputContext::PlanningForm,
607 Mode::DatePicker { .. } => InputContext::DatePicker,
608 Mode::Comment { .. } => InputContext::Comment,
609 Mode::Asset { .. } => InputContext::Asset,
610 Mode::EditLintWarning { .. } => InputContext::EditWarning,
611 Mode::LinkCreate { .. } | Mode::LinkEdit { .. } => InputContext::LinkPrompt,
612 Mode::ConfirmRemoveRelationship { .. } => InputContext::ConfirmDelete,
613 Mode::BulkModal => InputContext::BulkModal,
614 Mode::TriageInbox => InputContext::Triage,
615 }
616 }
617
618 fn open_bulk_modal(&mut self, action: crate::ui::bulk::BatchAction, mark: bool) {
638 if mark {
639 let number = self.board.get_selected_card().map(|c| c.number);
640 let mut modal = self
641 .bulk
642 .take()
643 .unwrap_or_else(|| crate::ui::bulk::BulkModal::new(action));
644 if let Some(number) = number {
645 if let Err(problem) = modal.selection.toggle(number) {
646 modal.note = Some(problem.to_string());
647 }
648 }
649 self.bulk = Some(modal);
650 } else if self.bulk.is_none() {
651 self.bulk = Some(crate::ui::bulk::BulkModal::new(action));
652 }
653 self.mode = Mode::BulkModal;
654 }
655
656 fn open_triage_inbox(&mut self) -> Result<Action> {
664 match crate::ui::triage::TriageState::open(&self.store) {
665 Ok(state) => {
666 self.triage = Some(state);
667 self.mode = Mode::TriageInbox;
668 }
669 Err(e) => self.status_msg = Some(e.to_string()),
670 }
671 Ok(Action::None)
672 }
673
674 fn handle_triage_key(&mut self, key: KeyEvent) -> Result<Action> {
683 use crate::ui::triage::ModalState;
684
685 let Some(mut state) = self.triage.take() else {
686 self.mode = Mode::Board;
687 return Ok(Action::None);
688 };
689
690 let editing = state.modal.as_ref().is_some_and(|m| m.editing);
691 let armed = state.modal.as_ref().is_some_and(|m| m.is_armed());
692 let previewed = matches!(
693 state.modal.as_ref().map(|m| &m.state),
694 Some(ModalState::Preview(_))
695 );
696 let mut closed = false;
697 let mut wrote = false;
698
699 if state.modal.is_some() {
700 match key.code {
701 KeyCode::Esc if editing => {
705 if let Some(modal) = state.modal.as_mut() {
706 modal.abort_edit();
707 }
708 }
709 KeyCode::Esc => {
714 let carried = match state.modal.as_ref().map(|m| &m.state) {
715 Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => {
716 Some(text.lines().next().unwrap_or(text.as_str()).to_string())
717 }
718 _ => None,
719 };
720 state.close_modal();
721 if carried.is_some() {
722 state.note = carried;
723 }
724 }
725 KeyCode::Enter if editing => {
726 if let Some(modal) = state.modal.as_mut() {
727 modal.commit_edit();
728 }
729 }
730 KeyCode::Enter => {
731 if let Some(modal) = state.modal.as_mut() {
732 modal.begin_edit();
733 }
734 }
735 KeyCode::Backspace if editing => {
736 if let Some(modal) = state.modal.as_mut() {
737 modal.pop_char();
738 }
739 }
740 KeyCode::Char(c) if editing => {
741 if let Some(modal) = state.modal.as_mut() {
742 modal.push_char(c);
743 }
744 }
745 KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') if !editing => {
750 if let Some(modal) = state.modal.as_mut() {
751 modal.move_cursor(1);
752 }
753 }
754 KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') if !editing => {
755 if let Some(modal) = state.modal.as_mut() {
756 modal.move_cursor(-1);
757 }
758 }
759 KeyCode::Char('t') if !editing => {
762 if let Some(modal) = state.modal.as_mut() {
763 let wanted = if modal.kind == crate::triage::DecisionKind::Accept {
764 crate::ui::triage::DecisionField::Target
765 } else {
766 crate::ui::triage::DecisionField::DuplicateOf
767 };
768 if let Some(index) = modal.fields.iter().position(|f| *f == wanted) {
769 modal.cursor = index;
770 }
771 }
772 }
773 KeyCode::Char('a') => {
778 if let Some(modal) = state.modal.as_mut() {
779 modal.cycle_kind();
780 }
781 }
782 KeyCode::Char('p') => state.preview(&self.store)?,
783 KeyCode::Char('y') => {
784 if armed {
785 state.confirm(&self.store)?;
786 wrote = state.modal_finished();
787 } else if previewed {
788 state.confirm(&self.store)?;
789 } else {
790 state.note = Some("press `p` to preview first".to_string());
791 }
792 }
793 _ => {}
794 }
795 if wrote {
796 state.refresh(&self.store)?;
800 }
801 } else {
802 match key.code {
803 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => closed = true,
804 KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
805 KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
806 KeyCode::Char(' ') => state.toggle_current(),
807 KeyCode::Char('X') => state.clear_selection(),
808 KeyCode::Char('A') => {
812 state.by_age = !state.by_age;
813 state.refresh(&self.store)?;
814 }
815 KeyCode::Char('a') => {
816 state.open_single(crate::triage::DecisionKind::Accept);
817 }
818 KeyCode::Char('d') => {
819 state.open_single(crate::triage::DecisionKind::Decline);
820 }
821 KeyCode::Char('u') => {
822 state.open_single(crate::triage::DecisionKind::Duplicate);
823 }
824 KeyCode::Char('f') => {
825 state.open_single(crate::triage::DecisionKind::Defer);
826 }
827 KeyCode::Char('p') => {
831 state.open_batch(crate::triage::DecisionKind::Accept);
832 }
833 _ => {}
834 }
835 }
836
837 if closed {
838 let note = state.note.clone();
842 self.triage = None;
843 self.mode = Mode::Board;
844 self.rebuild_board();
845 if note.is_some() {
846 self.status_msg = note;
847 }
848 return Ok(Action::None);
849 }
850
851 self.triage = Some(state);
852 self.mode = Mode::TriageInbox;
853 Ok(Action::None)
854 }
855
856 fn handle_bulk_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
857 use crate::ui::bulk::{BulkFocus, BulkState};
858 let Some(mut modal) = self.bulk.take() else {
859 self.mode = Mode::Board;
860 return Ok(Action::None);
861 };
862 let issues = crate::ui::bulk::visible_issues(&self.board, &self.store)?;
865 let result = if modal.focus == BulkFocus::Edit {
866 match key.code {
869 KeyCode::Esc => {
870 modal.abort_edit();
871 Ok(())
872 }
873 KeyCode::Enter => modal.commit_edit(),
874 KeyCode::Backspace => {
875 modal.draft.pop();
876 Ok(())
877 }
878 KeyCode::Char(c) => {
879 modal.draft.push(c);
880 Ok(())
881 }
882 _ => Ok(()),
883 }
884 } else {
885 match key.code {
886 KeyCode::Esc => {
887 self.bulk = None;
891 self.mode = Mode::Board;
892 self.rebuild_board();
893 return Ok(Action::None);
894 }
895 KeyCode::Tab | KeyCode::Down => {
896 modal.move_cursor(1);
897 Ok(())
898 }
899 KeyCode::BackTab | KeyCode::Up => {
900 modal.move_cursor(-1);
901 Ok(())
902 }
903 KeyCode::Enter => {
904 if modal.row_names().is_empty() {
905 modal.preview(&self.store, &issues)
908 } else {
909 modal.begin_edit();
910 Ok(())
911 }
912 }
913 KeyCode::Char('p') => modal.preview(&self.store, &issues),
914 KeyCode::Char('y') => {
915 if modal.is_armed() {
916 modal.confirm(&self.store)
917 } else {
918 modal.arm();
919 Ok(())
920 }
921 }
922 KeyCode::Char('X') => {
927 modal.selection.clear();
928 modal.mark_stale();
929 Ok(())
930 }
931 KeyCode::Char(' ') | KeyCode::Char('x') => {
932 match self.board.get_selected_card().map(|c| c.number) {
933 None => Ok(()),
934 Some(card) => {
935 let outcome = if key.code == KeyCode::Char(' ') {
936 modal.selection.toggle(card).map(|_| true)
937 } else if modal.selection.remove(card) {
938 Ok(true)
939 } else {
940 Err(crate::ui::bulk::SelectionProblem::AlreadySelected)
941 };
942 match outcome {
943 Ok(_) => {
944 modal.mark_stale();
945 Ok(())
946 }
947 Err(problem) => {
948 modal.note = Some(if key.code == KeyCode::Char('x') {
949 format!("#{card} was not selected")
953 } else {
954 problem.to_string()
955 });
956 Ok(())
957 }
958 }
959 }
960 }
961 }
962 KeyCode::Char('a') => {
963 modal.action = modal.action.next();
967 modal.state = BulkState::Selecting;
968 modal.plan_stale = true;
969 modal.cursor = 0;
970 Ok(())
971 }
972 KeyCode::Char('t') => {
973 if let Some(card) = self.board.get_selected_card() {
977 modal.target = Some(card.number.to_string());
978 modal.mark_stale();
979 }
980 Ok(())
981 }
982 KeyCode::Char('c') => {
983 let names = modal.row_names();
996 match names.iter().position(|row| *row == "clear") {
997 None => {
998 modal.note = Some(format!(
1002 "a {} has no clearable fields — nothing to clear",
1003 modal.action.as_str()
1004 ));
1005 Ok(())
1006 }
1007 Some(clear_row) => {
1008 let name = modal.row_name(modal.cursor);
1009 if name == "clear" {
1010 let current = modal.row_value(clear_row);
1011 if current == "—" {
1012 modal.note = Some(
1017 "no field is named — press c on the field you \
1018 want to empty"
1019 .to_string(),
1020 );
1021 Ok(())
1022 } else {
1023 modal.set_row(clear_row, current)
1024 }
1025 } else {
1026 modal.set_row(clear_row, name.to_string())
1027 }
1028 }
1029 }
1030 }
1031 _ => Ok(()),
1032 }
1033 };
1034 if let Err(e) = &result {
1035 modal.note = Some(e.to_string());
1036 }
1037 let finished = matches!(modal.state, BulkState::Done(..)) && result.is_ok();
1042 self.bulk = Some(modal);
1043 if finished {
1044 if let Some(note) = self.bulk.as_ref().and_then(|m| m.note.clone()) {
1047 self.status_msg = Some(note);
1048 }
1049 self.mode = Mode::Board;
1050 self.rebuild_board();
1051 } else {
1052 self.mode = Mode::BulkModal;
1053 }
1054 let _ = result;
1067 Ok(Action::None)
1068 }
1069
1070 fn handle_resolved_key(&mut self, _context: InputContext, key: KeyEvent) -> Result<Action> {
1071 match self.mode.clone() {
1072 Mode::Board => self.handle_board_key(key),
1073 Mode::RolloverDialog => self.handle_rollover_dialog_key(key),
1074 Mode::Detail => self.handle_detail_key(key),
1075 Mode::Form => self.handle_form_key(key),
1076 Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
1077 Mode::MoveIntoDone { target_col, .. } => {
1078 self.handle_move_into_done_key(key, target_col)
1079 }
1080 Mode::TransitionReason { target_col, .. } => {
1081 self.handle_transition_reason_key(key, target_col)
1082 }
1083 Mode::ConfirmBlockedMove {
1084 target_col,
1085 warning,
1086 } => self.handle_confirm_blocked_move_key(key, target_col, &warning),
1087 Mode::Help { .. } => self.handle_help_key(key),
1088 Mode::ContextMenu { .. } => self.handle_context_menu_key(key),
1089 Mode::Filter { .. } => self.handle_filter_key(key),
1090 Mode::FilterModal => self.handle_filter_modal_key(key),
1091 Mode::BulkModal => self.handle_bulk_modal_key(key),
1092 Mode::TriageInbox => self.handle_triage_key(key),
1093 Mode::Settings => self.handle_settings_key(key),
1094 Mode::PlanningManagement => self.handle_planning_key(key),
1095 Mode::PlanningForm => self.handle_planning_form_key(key),
1096 Mode::DatePicker { .. } => self.handle_date_picker_key(key),
1097 Mode::Comment { .. } => self.handle_comment_key(key),
1098 Mode::Asset { .. } => self.handle_asset_key(key),
1099 Mode::EditLintWarning { .. } => self.handle_edit_lint_warning_key(key),
1100 Mode::LinkCreate { .. } => self.handle_link_prompt_key(key),
1101 Mode::LinkEdit { .. } => self.handle_link_prompt_key(key),
1102 Mode::ConfirmRemoveRelationship { .. } => self.handle_remove_relationship_key(key),
1103 }
1104 }
1105
1106 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1112 let mut action = match self.mode.clone() {
1113 Mode::Board => self.handle_board_mouse(mouse),
1114 Mode::Detail => self.handle_detail_mouse(mouse),
1115 Mode::ConfirmDelete => self.handle_confirm_delete_mouse(mouse),
1116 Mode::Help { .. } => self.handle_help_mouse(mouse),
1117 Mode::ContextMenu { .. } => self.handle_context_menu_mouse(mouse),
1118 Mode::FilterModal => self.handle_filter_modal_mouse(mouse),
1119 Mode::Settings => self.handle_settings_mouse(mouse),
1120 Mode::DatePicker { .. } => self.handle_date_picker_mouse(mouse),
1121 _ => Ok(Action::None),
1122 }?;
1123 if action == Action::None
1124 && matches!(mouse.kind, MouseEventKind::Down(_))
1125 && !matches!(self.mode, Mode::ContextMenu { .. })
1126 {
1127 if let Some(region_action) = self.resolve_region(mouse.column, mouse.row) {
1134 action = region_action;
1135 }
1136 }
1137 Ok(action)
1138 }
1139
1140 pub fn record_region(&self, rect: Rect, action: Action) {
1145 self.frame_hit_regions
1146 .borrow_mut()
1147 .push(HitRegion { rect, action });
1148 }
1149
1150 fn resolve_region(&self, x: u16, y: u16) -> Option<Action> {
1152 self.frame_hit_regions
1153 .borrow()
1154 .iter()
1155 .rev()
1156 .find(|region| region.rect.contains(Position::new(x, y)))
1157 .map(|region| region.action.clone())
1158 }
1159
1160 fn handle_board_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1171 match mouse.kind {
1172 MouseEventKind::Down(MouseButton::Left) => {
1173 self.drag_preview_col = None;
1174 let hit = self.board_hit_at(mouse.column, mouse.row);
1175 self.mouse_down_left(hit)
1176 }
1177 MouseEventKind::Down(MouseButton::Right) => {
1178 self.drag_preview_col = None;
1179 let hit = self.board_hit_at(mouse.column, mouse.row);
1180 self.open_context_menu(hit)
1181 }
1182 MouseEventKind::Up(MouseButton::Left) => {
1183 self.drag_preview_col = None;
1184 self.mouse_up_left(mouse.column, mouse.row)
1185 }
1186 MouseEventKind::Drag(_) => {
1187 self.mouse_drag();
1188 self.update_drag_preview(mouse.column, mouse.row);
1189 Ok(Action::None)
1190 }
1191 MouseEventKind::ScrollUp => {
1192 self.board.move_up();
1193 Ok(Action::None)
1194 }
1195 MouseEventKind::ScrollDown => {
1196 self.board.move_down();
1197 Ok(Action::None)
1198 }
1199 MouseEventKind::Moved => {
1200 self.drag_preview_col = None;
1201 if let Some(tab) = self.tab_hover_at(mouse.column, mouse.row) {
1202 self.tabs.hovered = Some(tab);
1203 self.board.hover = None;
1204 } else {
1205 self.tabs.hovered = None;
1206 self.mouse_hover(mouse.column, mouse.row);
1207 }
1208 Ok(Action::None)
1209 }
1210 _ => Ok(Action::None),
1211 }
1212 }
1213
1214 fn tab_hover_at(&self, x: u16, y: u16) -> Option<usize> {
1217 if !self.tabs.needs_bar() {
1218 return None;
1219 }
1220 let area = self.frame_size.get();
1221 if y < area.y || y >= area.y + TAB_HEIGHT {
1222 return None;
1223 }
1224 let tabs_area = Rect {
1225 y: area.y,
1226 height: TAB_HEIGHT,
1227 ..area
1228 };
1229 (0..self.tabs.len()).find(|&index| {
1230 self.tabs
1231 .tab_rect(index, tabs_area)
1232 .is_some_and(|rect| rect.contains(Position::new(x, y)))
1233 })
1234 }
1235
1236 fn mouse_hover(&mut self, x: u16, y: u16) {
1241 self.board.hover = match self.board_hit_at(x, y) {
1242 Some(HitTarget::Card { col, row }) => Some((col, row)),
1243 _ => None,
1244 };
1245 }
1246
1247 fn update_drag_preview(&mut self, x: u16, y: u16) {
1249 match self.board_hit_at(x, y) {
1250 Some(HitTarget::Card { col, row }) => {
1251 self.drag_preview_col = Some(col);
1252 self.board.hover = Some((col, row));
1253 }
1254 Some(HitTarget::Column { col }) if col < self.board.real_cols() => {
1255 self.drag_preview_col = Some(col);
1256 self.board.hover = None;
1257 }
1258 _ => {
1259 self.drag_preview_col = None;
1260 self.board.hover = None;
1261 }
1262 }
1263 }
1264
1265 fn handle_detail_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1267 match mouse.kind {
1268 MouseEventKind::ScrollUp => {
1269 if let Some(d) = self.detail.as_mut() {
1270 d.scroll_by(-i32::from(WHEEL_ROWS));
1271 }
1272 }
1273 MouseEventKind::ScrollDown => {
1274 if let Some(d) = self.detail.as_mut() {
1275 d.scroll_by(i32::from(WHEEL_ROWS));
1276 }
1277 }
1278 _ => {}
1279 }
1280 Ok(Action::None)
1281 }
1282
1283 fn board_mouse_area(&self, area: Rect) -> Option<Rect> {
1288 let tab_h: u16 = if self.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
1289 let filter_h: u16 = if self.filter.is_some() || self.structured.has_active_criteria() {
1290 FILTER_HEIGHT
1291 } else {
1292 0
1293 };
1294 let hint_h: u16 = if self.one_shot { 0 } else { HINT_HEIGHT };
1295 if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
1296 return None;
1297 }
1298 Some(Rect {
1299 y: area.y + tab_h + HOVER_HEIGHT + filter_h,
1300 height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
1301 ..area
1302 })
1303 }
1304
1305 fn board_hit_at(&self, x: u16, y: u16) -> Option<HitTarget> {
1308 let frame = self.frame_size.get();
1309 let board_area = self.board_mouse_area(frame)?;
1310 self.board.hit_test(
1311 board_area,
1312 board::BoardDisplay::from(self.active_board_config()),
1313 self.status_msg.is_some(),
1314 x,
1315 y,
1316 )
1317 }
1318
1319 fn select_at(&mut self, hit: HitTarget) {
1322 match hit {
1323 HitTarget::Card { col, row } => {
1324 self.board.active_col = col;
1325 self.board.selected = row;
1326 }
1327 HitTarget::Column { col } => {
1328 self.board.active_col = col;
1329 self.board.clamp_selection();
1330 }
1331 }
1332 }
1333
1334 fn mouse_down_left(&mut self, hit: Option<HitTarget>) -> Result<Action> {
1335 let Some(target) = hit else {
1336 return Ok(Action::None);
1337 };
1338 self.select_at(target);
1339 let (col, card) = match target {
1340 HitTarget::Card { col, row } => (col, Some(row)),
1341 HitTarget::Column { col } => (col, None),
1342 };
1343
1344 let double_click = match target {
1345 HitTarget::Card { col, row } => match self.mouse.last_down.as_ref() {
1346 Some(prev) => {
1349 prev.at.elapsed() < self.mouse.double_click_window
1350 && prev.col == col
1351 && prev.card == Some(row)
1352 }
1353 None => false,
1354 },
1355 HitTarget::Column { .. } => false,
1356 };
1357
1358 self.mouse.last_down = Some(MouseDown {
1359 col,
1360 card,
1361 at: Instant::now(),
1362 });
1363
1364 if double_click {
1365 return self.open_detail_for(target);
1366 }
1367 Ok(Action::None)
1368 }
1369
1370 fn open_detail_for(&mut self, hit: HitTarget) -> Result<Action> {
1373 if let HitTarget::Card { col, row } = hit {
1374 if let Some(card) = self.board.column_cards(col).get(row).cloned() {
1375 let issue = self.store.get(IssueSelector::Filename(card.filename))?;
1376 let relationships = self.store.relationships_for(issue.number)?;
1379 let (all, done_column) = self.store.graph_source()?;
1382 let graph = commands::graph_over(&all, &done_column);
1383 let number = issue.number;
1384 let category = self.category_of_issue(&issue);
1387 self.detail = Some(DetailState {
1388 issue,
1389 category,
1390 relationships,
1391 chain: graph.dependency_chain(number),
1392 blocked: commands::blocked_note(&graph.blocked_state(number)),
1393 hierarchy: commands::parent::facts_over(&all, &done_column, number),
1397 scroll_offset: 0,
1398 viewport: std::cell::Cell::new(0),
1399 max_scroll: std::cell::Cell::new(0),
1400 rel_cursor: 0,
1401 });
1402 self.mode = Mode::Detail;
1403 }
1404 }
1405 Ok(Action::None)
1406 }
1407
1408 fn open_context_menu(&mut self, hit: Option<HitTarget>) -> Result<Action> {
1414 let target = match hit {
1415 Some(target @ HitTarget::Card { .. }) => target,
1416 _ => return Ok(Action::None),
1418 };
1419 self.select_at(target);
1420 let mut items = vec![
1421 MenuItem {
1422 kind: MenuItemKind::OpenDetail,
1423 label: "Open detail".to_string(),
1424 },
1425 MenuItem {
1426 kind: MenuItemKind::EditInEditor,
1427 label: "Edit in editor".to_string(),
1428 },
1429 ];
1430 for col in 0..self.board.real_cols() {
1431 if col != self.board.active_col {
1432 let name = self.board.column_name(col);
1433 items.push(MenuItem {
1434 kind: MenuItemKind::MoveTo { column: col },
1435 label: format!("Move to {name}"),
1436 });
1437 }
1438 }
1439 items.push(MenuItem {
1440 kind: MenuItemKind::Delete,
1441 label: "Delete".to_string(),
1442 });
1443 items.push(MenuItem {
1444 kind: MenuItemKind::RefreshBoard,
1445 label: "Refresh board".to_string(),
1446 });
1447 self.context_menu = Some(ContextMenu::new(items));
1448 self.mode = Mode::ContextMenu {
1449 return_to: Box::new(Mode::Board),
1450 };
1451 Ok(Action::None)
1452 }
1453
1454 fn close_context_menu(&mut self) {
1456 if let Mode::ContextMenu { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
1457 self.mode = *return_to;
1458 }
1459 self.context_menu = None;
1460 }
1461
1462 fn handle_context_menu_key(&mut self, key: KeyEvent) -> Result<Action> {
1466 let Some(menu) = self.context_menu.as_mut() else {
1467 self.close_context_menu();
1468 return Ok(Action::None);
1469 };
1470 match menu.handle_key(key.code) {
1471 MenuEvent::None => Ok(Action::None),
1472 MenuEvent::Dismissed => {
1473 self.close_context_menu();
1474 Ok(Action::None)
1475 }
1476 MenuEvent::Selected(index) => {
1477 let kind = menu.item(index).map(|item| item.kind.clone());
1478 let Some(kind) = kind else {
1479 return Ok(Action::None);
1480 };
1481 self.activate_context_item(kind)
1482 }
1483 }
1484 }
1485
1486 fn handle_context_menu_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1491 match mouse.kind {
1492 MouseEventKind::ScrollUp => {
1493 if let Some(menu) = self.context_menu.as_mut() {
1494 let _ = menu.handle_key(KeyCode::Up);
1495 }
1496 Ok(Action::None)
1497 }
1498 MouseEventKind::ScrollDown => {
1499 if let Some(menu) = self.context_menu.as_mut() {
1500 let _ = menu.handle_key(KeyCode::Down);
1501 }
1502 Ok(Action::None)
1503 }
1504 MouseEventKind::Down(_) => {
1505 let Some(menu) = self.context_menu.take() else {
1506 self.close_context_menu();
1507 return Ok(Action::None);
1508 };
1509 let bounds = menu.bounds(self.frame_size.get());
1510 let in_popup = mouse.column >= bounds.x
1511 && mouse.column < bounds.x + bounds.width
1512 && mouse.row >= bounds.y
1513 && mouse.row < bounds.y + bounds.height;
1514 if !in_popup {
1515 self.close_context_menu();
1516 return Ok(Action::None);
1517 }
1518 let row_hit = mouse.row.checked_sub(bounds.y + 1);
1521 let index = match row_hit {
1522 Some(offset) => usize::from(offset),
1523 None => return Ok(Action::None),
1524 };
1525 let kind = menu.item(index).map(|item| item.kind.clone());
1526 self.context_menu = Some(menu);
1527 match kind {
1528 Some(kind) => self.activate_context_item(kind),
1529 None => Ok(Action::None),
1530 }
1531 }
1532 _ => Ok(Action::None),
1533 }
1534 }
1535
1536 fn activate_context_item(&mut self, kind: MenuItemKind) -> Result<Action> {
1541 self.close_context_menu();
1542 match kind {
1543 MenuItemKind::OpenDetail => {
1544 let hit = HitTarget::Card {
1545 col: self.board.active_col,
1546 row: self.board.selected,
1547 };
1548 self.open_detail_for(hit)
1549 }
1550 MenuItemKind::EditInEditor => {
1551 if let Some(card) = self.board.get_selected_card() {
1552 return Ok(Action::OpenEditor(card.filename.clone()));
1553 }
1554 Ok(Action::None)
1555 }
1556 MenuItemKind::MoveTo { column } => self.move_selected_to(column),
1557 MenuItemKind::Delete => {
1558 if let Some(card) = self.board.get_selected_card().cloned() {
1559 self.confirm_delete_target = Some(card.filename);
1560 self.mode = Mode::ConfirmDelete;
1561 }
1562 Ok(Action::None)
1563 }
1564 MenuItemKind::RefreshBoard => {
1565 self.rebuild_board();
1566 self.status_msg = Some("Refreshed".to_string());
1567 Ok(Action::None)
1568 }
1569 }
1570 }
1571
1572 fn mouse_drag(&mut self) {
1577 if self.mouse.drag.is_none() {
1578 if let Some(down) = &self.mouse.last_down {
1579 if down.card.is_some() {
1580 self.mouse.drag = Some(Drag {
1581 col: down.col,
1582 card: down.card,
1583 });
1584 }
1585 }
1586 }
1587 }
1588
1589 fn mouse_up_left(&mut self, x: u16, y: u16) -> Result<Action> {
1595 self.board.hover = None;
1596 if let Some(drag) = self.mouse.drag.take() {
1597 self.mouse.last_down = None;
1598 if drag.card.is_none() {
1599 return Ok(Action::None);
1600 }
1601 let target = match self.board_hit_at(x, y) {
1602 Some(target) => target,
1603 None => return Ok(Action::None),
1604 };
1605 match target {
1606 HitTarget::Card { col, row } if col == drag.col => {
1607 if Some(row) == drag.card {
1608 return Ok(Action::None);
1609 }
1610 return self.reorder_selected_within_column(col, drag.card.unwrap(), row);
1611 }
1612 HitTarget::Column { col } if col == drag.col => {
1613 return self.reorder_selected_within_column(
1614 col,
1615 drag.card.unwrap(),
1616 self.board.column_cards(col).len().saturating_sub(1),
1617 );
1618 }
1619 HitTarget::Card { col, .. } | HitTarget::Column { col }
1620 if col < self.board.real_cols() && col != drag.col =>
1621 {
1622 return self.move_selected_to(col);
1623 }
1624 _ => return Ok(Action::None),
1625 }
1626 }
1627 Ok(Action::None)
1628 }
1629
1630 fn persist_card_order(&mut self) -> bool {
1634 let board_name = self.active_board_name().to_string();
1635 let mut order = Vec::new();
1636 let active_config = self.active_board_config().clone();
1637 order.extend(
1638 self.board
1639 .columns
1640 .iter()
1641 .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
1642 );
1643 order.extend(
1644 self.board
1645 .hidden
1646 .iter()
1647 .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
1648 );
1649 order.extend(board::sparse_card_order(
1650 &active_config,
1651 &self.board.mismatch.cards,
1652 ));
1653 let Some(board_config) = self.store.config.kanban.configurations.get_mut(&board_name)
1654 else {
1655 return false;
1656 };
1657 board_config.card_order = order;
1658 self.persist_config()
1659 }
1660
1661 fn reorder_selected_within_column(
1662 &mut self,
1663 col: usize,
1664 from_row: usize,
1665 to_row: usize,
1666 ) -> Result<Action> {
1667 let (filename, number, column_name) = {
1668 let Some(column) = self.board.columns.get_mut(col) else {
1669 return Ok(Action::None);
1670 };
1671 if from_row >= column.cards.len() || to_row >= column.cards.len() || from_row == to_row
1672 {
1673 return Ok(Action::None);
1674 }
1675 let card = column.cards.remove(from_row);
1676 let filename = card.filename.clone();
1677 let number = card.number;
1678 let insert_at = to_row;
1679 column.cards.insert(insert_at, card);
1680 (filename, number, column.name.clone())
1681 };
1682
1683 if !self.persist_card_order() {
1684 return Ok(Action::None);
1685 }
1686 self.rebuild_board();
1687 self.select_card_by_filename(&filename);
1688 self.status_msg = Some(format!("Reordered #{} in {}", number, column_name));
1689 Ok(Action::None)
1690 }
1691
1692 fn move_selected_within_column(&mut self, delta: isize) -> Result<Action> {
1694 let col = self.board.active_col;
1695 let column_name = if col < self.board.columns.len() {
1696 self.board.columns[col].name.clone()
1697 } else if col == self.board.columns.len() && self.board.show_mismatch {
1698 self.board.mismatch.name.clone()
1699 } else {
1700 return Ok(Action::None);
1701 };
1702 let (filename, number) = {
1703 let cards = if col < self.board.columns.len() {
1704 &mut self.board.columns[col].cards
1705 } else {
1706 &mut self.board.mismatch.cards
1707 };
1708 let len = cards.len();
1709 if len < 2 {
1710 return Ok(Action::None);
1711 }
1712 let from_row = self.board.selected;
1713 if from_row >= len {
1714 return Ok(Action::None);
1715 }
1716 let to_row = if delta < 0 {
1717 from_row.saturating_sub(delta.unsigned_abs())
1718 } else {
1719 usize::min(from_row + delta as usize, len - 1)
1720 };
1721 if to_row == from_row {
1722 return Ok(Action::None);
1723 }
1724 let card = cards.remove(from_row);
1725 let filename = card.filename.clone();
1726 let number = card.number;
1727 let insert_at = to_row;
1728 cards.insert(insert_at, card);
1729 (filename, number)
1730 };
1731 if !self.persist_card_order() {
1732 return Ok(Action::None);
1733 }
1734 self.rebuild_board();
1735 self.select_card_by_filename(&filename);
1736 self.status_msg = Some(format!(
1737 "Moved #{} {} in {}",
1738 number,
1739 if delta < 0 { "up" } else { "down" },
1740 column_name
1741 ));
1742 Ok(Action::None)
1743 }
1744
1745 #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1746 fn handle_confirm_delete_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1747 Ok(Action::None)
1748 }
1749
1750 #[allow(clippy::unnecessary_wraps)]
1751 fn handle_help_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1752 match mouse.kind {
1756 MouseEventKind::ScrollUp => self.scroll_help(-i32::from(WHEEL_ROWS)),
1757 MouseEventKind::ScrollDown => self.scroll_help(i32::from(WHEEL_ROWS)),
1758 _ => {}
1759 }
1760 Ok(Action::None)
1761 }
1762
1763 #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1764 fn handle_filter_modal_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1765 Ok(Action::None)
1766 }
1767
1768 #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1769 fn handle_settings_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1770 Ok(Action::None)
1771 }
1772
1773 #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1774 fn handle_date_picker_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1775 Ok(Action::None)
1776 }
1777
1778 fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
1779 if key
1780 .modifiers
1781 .contains(crossterm::event::KeyModifiers::CONTROL)
1782 {
1783 match key.code {
1784 KeyCode::Up => return self.move_selected_within_column(-1),
1785 KeyCode::Down => return self.move_selected_within_column(1),
1786 _ => {}
1787 }
1788 }
1789 match key.code {
1790 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
1791 self.quit = true;
1792 Ok(Action::Quit)
1793 }
1794 KeyCode::Tab => {
1795 self.tabs.next();
1796 self.active_config = self.tabs.selected;
1797 self.switch_config();
1798 Ok(Action::None)
1799 }
1800 KeyCode::BackTab => {
1801 self.tabs.previous();
1802 self.active_config = self.tabs.selected;
1803 self.switch_config();
1804 Ok(Action::None)
1805 }
1806 KeyCode::Left | KeyCode::Char('h') => {
1807 self.board.move_left();
1808 Ok(Action::None)
1809 }
1810 KeyCode::Right | KeyCode::Char('l') => {
1811 self.board.move_right();
1812 Ok(Action::None)
1813 }
1814 KeyCode::Up | KeyCode::Char('k') => {
1815 self.board.move_up();
1816 Ok(Action::None)
1817 }
1818 KeyCode::Down | KeyCode::Char('j') => {
1819 self.board.move_down();
1820 Ok(Action::None)
1821 }
1822 KeyCode::Enter => {
1823 if let Some(card) = self.board.get_selected_card().cloned() {
1824 let issue = self.store.get(IssueSelector::Filename(card.filename))?;
1825 let relationships = self.store.relationships_for(issue.number)?;
1826 let (all, done_column) = self.store.graph_source()?;
1827 let graph = commands::graph_over(&all, &done_column);
1828 let number = issue.number;
1829 let category = self.category_of_issue(&issue);
1830 self.detail = Some(DetailState {
1831 issue,
1832 category,
1833 relationships,
1834 chain: graph.dependency_chain(number),
1835 blocked: commands::blocked_note(&graph.blocked_state(number)),
1836 hierarchy: commands::parent::facts_over(&all, &done_column, number),
1839 scroll_offset: 0,
1840 viewport: std::cell::Cell::new(0),
1841 max_scroll: std::cell::Cell::new(0),
1842 rel_cursor: 0,
1843 });
1844 self.mode = Mode::Detail;
1845 }
1846 Ok(Action::None)
1847 }
1848 KeyCode::Char('e') => {
1849 if let Some(card) = self.board.get_selected_card() {
1850 Ok(Action::OpenEditor(card.filename.clone()))
1851 } else {
1852 Ok(Action::None)
1853 }
1854 }
1855 KeyCode::Char('A') => {
1856 if let Some(card) = self.board.get_selected_card() {
1857 Ok(Action::OpenAgent(card.filename.clone()))
1858 } else {
1859 Ok(Action::None)
1860 }
1861 }
1862 KeyCode::Char('c') => {
1863 self.toggle_column_visibility();
1864 Ok(Action::None)
1865 }
1866 KeyCode::Char('f') => {
1867 self.open_filter_modal();
1868 Ok(Action::None)
1869 }
1870 KeyCode::Char(' ') => {
1877 self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
1878 Ok(Action::None)
1879 }
1880 KeyCode::Char('x') | KeyCode::Char('X') => {
1881 if self.bulk.is_some() {
1882 let Some(card) = self.board.get_selected_card().map(|c| c.number) else {
1883 return Ok(Action::None);
1884 };
1885 if let Some(modal) = self.bulk.as_mut() {
1886 if !modal.selection.remove(card) {
1887 modal.note = Some(format!("#{card} was not selected"));
1888 }
1889 }
1890 } else {
1891 self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
1892 }
1893 Ok(Action::None)
1894 }
1895 KeyCode::Char('B') => {
1896 let action = self
1897 .bulk
1898 .as_ref()
1899 .map(|m| m.action)
1900 .unwrap_or(crate::ui::bulk::BatchAction::Edit);
1901 self.open_bulk_modal(action, false);
1902 Ok(Action::None)
1903 }
1904 KeyCode::Char('<') => {
1905 let real_cols = self.board.real_cols();
1906 if real_cols > 0 {
1907 let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
1908 {
1909 real_cols - 1
1910 } else {
1911 self.board.active_col - 1
1912 };
1913 self.move_selected_to(target)
1914 } else {
1915 Ok(Action::None)
1916 }
1917 }
1918 KeyCode::Char('>') => {
1919 let real_cols = self.board.real_cols();
1920 if real_cols > 0 {
1921 let target = if self.board.active_col >= real_cols {
1922 0
1923 } else {
1924 (self.board.active_col + 1) % real_cols
1925 };
1926 self.move_selected_to(target)
1927 } else {
1928 Ok(Action::None)
1929 }
1930 }
1931 KeyCode::Char('n') => {
1932 self.open_new_issue_form();
1933 Ok(Action::None)
1934 }
1935 KeyCode::Char('C') => {
1936 if let Some(card) = self.board.get_selected_card().cloned() {
1943 match crate::commands::view::get(&self.store, &card.filename) {
1944 Ok(issue) => {
1945 self.status_msg = Some(format!("Cloning #{}", issue.number));
1946 self.form = Some(FormState::from_issue(&issue));
1947 self.mode = Mode::Form;
1948 }
1949 Err(err) => self.status_msg = Some(format!("Cannot clone: {err}")),
1950 }
1951 } else {
1952 self.status_msg = Some("No issue selected to clone".to_string());
1953 }
1954 Ok(Action::None)
1955 }
1956 KeyCode::Char('a') => {
1961 self.show_archived = !self.show_archived;
1962 self.refresh_corpus()?;
1963 self.status_msg = Some(
1964 if self.show_archived {
1965 "Showing archived issues"
1966 } else {
1967 "Hiding archived issues"
1968 }
1969 .to_string(),
1970 );
1971 Ok(Action::None)
1972 }
1973 KeyCode::PageDown => {
1982 self.move_selection_by_page(1);
1983 Ok(Action::None)
1984 }
1985 KeyCode::PageUp => {
1986 self.move_selection_by_page(-1);
1987 Ok(Action::None)
1988 }
1989 KeyCode::Char('d') => {
1990 if let Some(card) = self.board.get_selected_card().cloned() {
1991 self.confirm_delete_target = Some(card.filename);
1992 self.mode = Mode::ConfirmDelete;
1993 }
1994 Ok(Action::None)
1995 }
1996 KeyCode::Char('r') => {
1997 self.rebuild_board();
1998 self.status_msg = Some("Refreshed".to_string());
1999 Ok(Action::None)
2000 }
2001 KeyCode::Char('s') => {
2002 self.open_settings(None);
2003 Ok(Action::None)
2004 }
2005 KeyCode::Char('t') => self.open_triage_inbox(),
2010 KeyCode::Char('m') | KeyCode::Char('M') => {
2011 self.open_planning_modal();
2012 Ok(Action::None)
2013 }
2014 KeyCode::Char('b') => {
2015 self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
2016 Ok(Action::None)
2017 }
2018 KeyCode::Char('?') => {
2019 self.open_help(Mode::Board);
2020 Ok(Action::None)
2021 }
2022 KeyCode::Char('/') => {
2023 let buffer = self.filter.clone().unwrap_or_default();
2024 self.mode = Mode::Filter { buffer };
2025 Ok(Action::None)
2026 }
2027 _ => Ok(Action::None),
2028 }
2029 }
2030
2031 fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
2046 self.move_selected_to_impl(target_col, None, false)
2047 }
2048
2049 fn move_selected_to_impl(
2062 &mut self,
2063 target_col: usize,
2064 override_reason: Option<String>,
2065 acknowledged: bool,
2066 ) -> Result<Action> {
2067 let card = match self.board.get_selected_card().cloned() {
2068 Some(c) => c,
2069 None => return Ok(Action::None),
2070 };
2071
2072 let target_name = self.board.column_name(target_col).to_string();
2073 let (_, resolution) = crate::workflow::active_resolution(&self.store.config);
2080 let is_done_target = resolution
2081 .resolve(&target_name)
2082 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
2083 let current_col_name = self.board.column_name(self.board.active_col);
2084 let was_done = resolution
2085 .resolve(current_col_name)
2086 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
2087
2088 let issue = self
2097 .store
2098 .get(IssueSelector::Filename(card.filename.clone()))?;
2099
2100 if override_reason.is_none()
2106 && let crate::workflow::TransitionOutcome::Rejected(diagnostic) =
2107 crate::workflow::validate_transition(
2108 &issue,
2109 &target_name,
2110 &resolution,
2111 &self.store.config,
2112 None,
2113 )
2114 {
2115 self.mode = Mode::TransitionReason {
2116 target_col,
2117 reason: diagnostic.to_string(),
2118 buffer: String::new(),
2119 };
2120 return Ok(Action::None);
2121 }
2122
2123 let guard = mv::blocked_move_guard(&self.store.config, &self.store, &issue, &target_name)?;
2124 if guard.needs_acknowledgement() && !acknowledged {
2125 self.mode = Mode::ConfirmBlockedMove {
2126 target_col,
2127 warning: guard.warning().unwrap_or_default(),
2128 };
2129 return Ok(Action::None);
2130 }
2131
2132 if is_done_target && card.commit.is_none() {
2133 self.mode = Mode::MoveIntoDone {
2134 target_col,
2135 buffer: String::new(),
2136 };
2137 return Ok(Action::None);
2138 }
2139
2140 let commit = if was_done { None } else { card.commit.clone() };
2150 mv::apply_guarded(
2151 &self.store.config,
2152 &self.store,
2153 &card.filename,
2154 &target_name,
2155 commit.as_deref(),
2156 override_reason.as_deref(),
2157 acknowledged,
2158 )?;
2159
2160 self.rebuild_board();
2161 self.board.select_column(target_col);
2162 self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
2163 Ok(Action::None)
2164 }
2165
2166 fn handle_transition_reason_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
2177 match key.code {
2178 KeyCode::Enter => {
2179 let reason = match &self.mode {
2180 Mode::TransitionReason { buffer, .. } => buffer.clone(),
2181 _ => String::new(),
2182 };
2183 if reason.trim().is_empty() {
2184 self.status_msg = Some(
2185 "A reason is required: the decision is recorded in the issue's \
2186 history and has to say why"
2187 .to_string(),
2188 );
2189 return Ok(Action::None);
2190 }
2191 self.mode = Mode::Board;
2195 self.move_selected_to_impl(target_col, Some(reason), false)
2196 }
2197 KeyCode::Esc => {
2198 self.mode = Mode::Board;
2199 Ok(Action::None)
2200 }
2201 KeyCode::Char(c) => {
2202 if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
2203 buffer.push(c);
2204 }
2205 Ok(Action::None)
2206 }
2207 KeyCode::Backspace => {
2208 if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
2209 buffer.pop();
2210 }
2211 Ok(Action::None)
2212 }
2213 _ => Ok(Action::None),
2214 }
2215 }
2216
2217 fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
2218 match key.code {
2219 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
2220 self.mode = Mode::Board;
2221 self.detail = None;
2222 Ok(Action::None)
2223 }
2224 KeyCode::Char('e') => {
2225 if let Some(ref d) = self.detail {
2226 let filename = d.issue.filename.clone();
2227 Ok(Action::OpenEditor(filename))
2228 } else {
2229 Ok(Action::None)
2230 }
2231 }
2232 KeyCode::PageUp => {
2233 if let Some(d) = self.detail.as_mut() {
2234 let page = d.page();
2235 d.scroll_by(-i32::from(page));
2236 }
2237 Ok(Action::None)
2238 }
2239 KeyCode::PageDown => {
2240 if let Some(d) = self.detail.as_mut() {
2241 let page = d.page();
2242 d.scroll_by(i32::from(page));
2243 }
2244 Ok(Action::None)
2245 }
2246 KeyCode::Char('?') => {
2247 self.open_help(Mode::Detail);
2248 Ok(Action::None)
2249 }
2250 KeyCode::Char('c') => {
2251 self.mode = Mode::Comment {
2252 buffer: String::new(),
2253 };
2254 Ok(Action::None)
2255 }
2256 KeyCode::Char('a') => {
2257 self.mode = Mode::Asset {
2258 buffer: String::new(),
2259 };
2260 Ok(Action::None)
2261 }
2262 KeyCode::Char('n') => {
2265 self.mode = Mode::LinkCreate {
2266 buffer: String::new(),
2267 step: LinkStep::Target,
2268 target: None,
2269 };
2270 Ok(Action::None)
2271 }
2272 KeyCode::Char('E') => {
2273 match self.selected_relationship_id() {
2277 Some(id) => {
2278 let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
2279 self.mode = Mode::LinkEdit {
2280 index,
2281 buffer: String::new(),
2282 step: LinkStep::Target,
2283 target: None,
2284 kind: None,
2285 };
2286 self.status_msg = Some(format!("Re-kind {id}"));
2287 }
2288 None => {
2289 self.status_msg =
2290 Some("no relationship selected — press j or k first".to_string())
2291 }
2292 }
2293 Ok(Action::None)
2294 }
2295 KeyCode::Char('d') => {
2296 match self.selected_relationship().cloned() {
2297 Some(rel) => {
2298 let line = crate::commands::link::relationship_line(&rel);
2299 let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
2300 self.status_msg = Some(format!("Remove {line}? (y to confirm)"));
2301 self.mode = Mode::ConfirmRemoveRelationship { index };
2302 }
2303 None => {
2304 self.status_msg =
2305 Some("no relationship selected — press j or k first".to_string())
2306 }
2307 }
2308 Ok(Action::None)
2309 }
2310 KeyCode::Char('j') | KeyCode::Down => {
2311 self.move_rel_cursor(1);
2312 Ok(Action::None)
2313 }
2314 KeyCode::Char('k') | KeyCode::Up => {
2315 self.move_rel_cursor(-1);
2316 Ok(Action::None)
2317 }
2318 _ => Ok(Action::None),
2319 }
2320 }
2321
2322 fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
2323 if self.form_picker.is_some() {
2324 return self.handle_form_picker_key(key);
2325 }
2326 let form = match self.form.as_mut() {
2327 Some(f) => f,
2328 None => return Ok(Action::None),
2329 };
2330 match key.code {
2331 KeyCode::Esc => {
2332 self.form = None;
2333 self.mode = Mode::Board;
2334 Ok(Action::None)
2335 }
2336 KeyCode::Enter => {
2337 if form.is_date_focus() && !form.focused_date_is_complete() {
2338 self.open_date_picker(Mode::Form);
2339 return Ok(Action::None);
2340 }
2341 if form.focus.is_parent() && form.parent.trim().is_empty() {
2352 self.open_form_picker();
2353 return Ok(Action::None);
2354 }
2355 if form.title.trim().is_empty() {
2356 self.status_msg = Some("Title is required".to_string());
2357 return Ok(Action::None);
2358 }
2359 let start_date = if form.start_date.trim().is_empty() {
2360 None
2361 } else {
2362 Some(form.start_date.trim().to_string())
2363 };
2364 let due_date = if form.due_date.trim().is_empty() {
2365 None
2366 } else {
2367 Some(form.due_date.trim().to_string())
2368 };
2369 if let Some(start) = start_date.as_deref()
2370 && !crate::model::validate_date(start)
2371 {
2372 self.status_msg = Some("Start date must be YYYY-MM-DD".to_string());
2373 return Ok(Action::None);
2374 }
2375 if let Some(due) = due_date.as_deref()
2376 && !crate::model::validate_date(due)
2377 {
2378 self.status_msg = Some("Due date must be YYYY-MM-DD".to_string());
2379 return Ok(Action::None);
2380 }
2381 if let (Some(start), Some(due)) = (start_date.as_deref(), due_date.as_deref())
2382 && due < start
2383 {
2384 self.status_msg = Some("Due date must be >= start date".to_string());
2385 return Ok(Action::None);
2386 }
2387 let title = form.title.trim().to_string();
2388 let status_str = if form.status.trim().is_empty() {
2389 self.store
2390 .config
2391 .default_config()
2392 .columns
2393 .first()
2394 .cloned()
2395 .unwrap_or_else(|| "todo".to_string())
2396 } else {
2397 form.status.trim().to_string()
2398 };
2399 let priority: Priority = form.priority.parse().unwrap_or_default();
2400 let identity = git_identity();
2401 let assignee = if form.assignee.trim().is_empty() {
2402 None
2403 } else {
2404 resolve_me(form.assignee.trim(), identity.as_ref())
2405 };
2406 let labels: Vec<String> = form
2407 .labels
2408 .split(',')
2409 .map(|s| s.trim().to_string())
2410 .filter(|s| !s.is_empty())
2411 .collect();
2412 let estimate = if form.estimate.trim().is_empty() {
2413 None
2414 } else {
2415 Some(form.estimate.trim().to_string())
2416 };
2417 let cycles = {
2418 let values: Vec<String> = form
2419 .cycles
2420 .split(',')
2421 .map(str::trim)
2422 .filter(|s| !s.is_empty())
2423 .map(str::to_string)
2424 .collect();
2425 (!values.is_empty()).then_some(values)
2426 };
2427 let milestones = {
2428 let values: Vec<String> = form
2429 .milestones
2430 .split(',')
2431 .map(str::trim)
2432 .filter(|s| !s.is_empty())
2433 .map(str::to_string)
2434 .collect();
2435 (!values.is_empty()).then_some(values)
2436 };
2437 let reviewer = if form.reviewer.trim().is_empty() {
2438 None
2439 } else {
2440 Some(form.reviewer.trim().to_string())
2441 };
2442
2443 let parent_number = match form.parent.trim() {
2473 "" => None,
2474 raw => match self.store.resolve_issue(raw).and_then(|target| {
2475 let (index, _) = self.store.hierarchy_index()?;
2476 crate::hierarchy::validate_new_parent(&index, target.number)?;
2477 Ok(target.number)
2478 }) {
2479 Ok(number) => Some(number),
2480 Err(e) => {
2481 self.status_msg = Some(e.to_string());
2482 return Ok(Action::None);
2483 }
2484 },
2485 };
2486
2487 let mut issue = Issue {
2488 number: 0,
2489 title,
2490 status: Status::new(status_str),
2491 labels,
2492 priority,
2493 assignee,
2494 estimate,
2495 commit: None,
2496 gitlab_id: None,
2497 linear_id: None,
2498 mission: None,
2499 task: None,
2500 relationships: vec![],
2501 files: vec![],
2502 cycles,
2503 milestones,
2504 reviewer,
2505 start_date,
2506 due_date,
2507 created: today(),
2508 updated: today(),
2509 filename: String::new(),
2510 body: String::new(),
2511 history: crate::model::History::new(),
2512 has_retired_links_key: false,
2513 parent: parent_number,
2514 extra_frontmatter: std::collections::BTreeMap::new(),
2515 archived: false,
2516 entered_at: None,
2517 source: None,
2518 };
2519 if let Some(number) = parent_number {
2525 issue.history.append_now(
2526 &crate::storage::actor_identity(),
2527 crate::model::HistoryEventKind::ParentSet,
2528 &crate::hierarchy::change_detail(None, Some(number)),
2529 );
2530 }
2531 crate::triage::record_entry_if_inbox(&self.store.config, &mut issue, "new")?;
2540 self.store.create(&mut issue, "", None)?;
2541 let filename = issue.filename.clone();
2546 let basename = filename.rsplit('/').next().unwrap_or(&filename).to_string();
2547 self.status_msg = Some(format!("Created {basename}"));
2548 self.form = None;
2549 self.mode = Mode::Board;
2550 self.refresh_corpus()?;
2551 self.select_card_by_filename(&filename);
2552 Ok(Action::None)
2553 }
2554 KeyCode::Tab | KeyCode::Down => {
2555 form.move_focus_next();
2556 Ok(Action::None)
2557 }
2558 KeyCode::BackTab | KeyCode::Up => {
2559 form.move_focus_prev();
2560 Ok(Action::None)
2561 }
2562 KeyCode::F(2) => {
2563 if form.is_date_focus() {
2564 self.open_date_picker(Mode::Form);
2565 } else {
2566 self.open_form_picker();
2567 }
2568 Ok(Action::None)
2569 }
2570 KeyCode::Char(c) => {
2571 form.insert_char(c);
2572 Ok(Action::None)
2573 }
2574 KeyCode::Backspace => {
2575 form.delete_char();
2576 Ok(Action::None)
2577 }
2578 _ => Ok(Action::None),
2579 }
2580 }
2581
2582 fn open_date_picker(&mut self, return_to: Mode) {
2585 let current = match return_to {
2586 Mode::Form => self.form.as_ref().map(|f| f.focused_value().to_string()),
2587 Mode::PlanningForm => self
2588 .planning_form
2589 .as_ref()
2590 .map(|f| f.focused_value().to_string()),
2591 Mode::FilterModal => self.filter_modal.as_ref().map(|m| m.focused_value()),
2592 _ => None,
2593 };
2594 self.date_picker = Some(DatePicker::new(current.as_deref().unwrap_or("")));
2595 self.mode = Mode::DatePicker {
2596 return_to: Box::new(return_to),
2597 };
2598 }
2599
2600 fn handle_date_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
2601 let Some(picker) = self.date_picker.as_mut() else {
2602 self.mode = Mode::Board;
2603 return Ok(Action::None);
2604 };
2605 match picker.handle_key(key.code) {
2606 DatePickerEvent::None => {}
2607 DatePickerEvent::Cancelled => self.close_date_picker(),
2608 DatePickerEvent::Selected(value) => {
2609 self.apply_date_selection(&value);
2610 self.close_date_picker();
2611 }
2612 DatePickerEvent::Cleared => {
2613 self.apply_date_selection("");
2614 self.close_date_picker();
2615 }
2616 }
2617 Ok(Action::None)
2618 }
2619
2620 fn close_date_picker(&mut self) {
2622 let return_to = match std::mem::replace(&mut self.mode, Mode::Board) {
2623 Mode::DatePicker { return_to } => *return_to,
2624 _ => Mode::Board,
2625 };
2626 self.date_picker = None;
2627 self.mode = return_to;
2628 }
2629
2630 fn apply_date_selection(&mut self, value: &str) {
2633 let return_to = match &self.mode {
2634 Mode::DatePicker { return_to } => (**return_to).clone(),
2635 _ => return,
2636 };
2637 match return_to {
2638 Mode::Form => {
2639 if let Some(form) = self.form.as_mut() {
2640 form.set_focused_date(value);
2641 }
2642 }
2643 Mode::PlanningForm => {
2644 if let Some(form) = self.planning_form.as_mut() {
2645 form.set_focused_date(value);
2646 }
2647 }
2648 Mode::FilterModal => {
2649 if let Some(modal) = self.filter_modal.as_mut() {
2650 modal.set_focused_date(value);
2651 }
2652 self.rebuild_board();
2653 }
2654 _ => {}
2655 }
2656 }
2657
2658 fn handle_form_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
2659 if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) {
2660 self.form_picker = None;
2661 return Ok(Action::None);
2662 }
2663 let event = match self.form_picker.as_mut() {
2664 Some(picker) => picker.state.handle_key(key.code),
2665 None => return Ok(Action::None),
2666 };
2667 match event {
2668 PickerEvent::None => {}
2669 PickerEvent::Cancelled => {
2670 self.form_picker = None;
2671 }
2672 PickerEvent::Selected(ids) => {
2673 self.apply_picker_selection(ids);
2674 }
2675 PickerEvent::Created(value) => {
2676 self.apply_picker_created(value)?;
2677 }
2678 }
2679 Ok(Action::None)
2680 }
2681
2682 fn open_form_picker(&mut self) {
2683 let Some(form) = self.form.as_ref() else {
2684 return;
2685 };
2686 let field = match form.focus {
2687 form::FormFocus::Labels => FormPickerField::Labels,
2688 form::FormFocus::Assignee => FormPickerField::Assignee,
2689 form::FormFocus::Cycles => FormPickerField::Cycles,
2690 form::FormFocus::Milestones => FormPickerField::Milestones,
2691 form::FormFocus::Reviewer => FormPickerField::Reviewer,
2692 form::FormFocus::Parent => FormPickerField::Parent,
2693 _ => return,
2694 };
2695 let options = self.picker_options(field);
2696 let mut state = match field {
2697 FormPickerField::Labels | FormPickerField::Cycles | FormPickerField::Milestones => {
2698 DropdownState::multi(options)
2699 }
2700 FormPickerField::Assignee | FormPickerField::Reviewer | FormPickerField::Parent => {
2701 DropdownState::single(options)
2702 }
2703 };
2704 self.seed_picker_selection(field, &mut state);
2705 self.form_picker = Some(FormPicker { field, state });
2706 }
2707
2708 fn picker_options(&self, field: FormPickerField) -> Vec<PickerItem> {
2709 match field {
2710 FormPickerField::Cycles => {
2711 let root = self.project_root();
2712 let planner = Planner::from_config(self.store.config.clone(), root);
2713 planner
2714 .cycle_names()
2715 .into_iter()
2716 .map(|name| PickerItem {
2717 id: name.clone(),
2718 label: name,
2719 })
2720 .collect()
2721 }
2722 FormPickerField::Milestones => {
2723 let root = self.project_root();
2724 let planner = Planner::from_config(self.store.config.clone(), root);
2725 planner
2726 .milestone_names()
2727 .unwrap_or_default()
2728 .into_iter()
2729 .map(|name| PickerItem {
2730 id: name.clone(),
2731 label: name,
2732 })
2733 .collect()
2734 }
2735 FormPickerField::Labels => {
2736 let mut out = std::collections::BTreeSet::new();
2737 for issue in self.store.list().unwrap_or_default() {
2738 for label in issue.labels {
2739 if !label.trim().is_empty() {
2740 out.insert(label);
2741 }
2742 }
2743 }
2744 out.into_iter()
2745 .map(|label| PickerItem {
2746 id: label.clone(),
2747 label,
2748 })
2749 .collect()
2750 }
2751 FormPickerField::Parent => {
2752 self.store
2764 .list()
2765 .unwrap_or_default()
2766 .into_iter()
2767 .map(|issue| PickerItem {
2768 id: issue.number.to_string(),
2769 label: format!("#{} {}", issue.number, issue.title),
2770 })
2771 .collect()
2772 }
2773 FormPickerField::Assignee | FormPickerField::Reviewer => {
2774 let mut out = std::collections::BTreeSet::new();
2775 for team in self.store.config.teams.values() {
2776 for member in &team.members {
2777 if !member.trim().is_empty() {
2778 out.insert(member.trim().to_string());
2779 }
2780 }
2781 }
2782 if let Some(id) = git_identity() {
2783 out.insert(id.to_string());
2784 }
2785 if let Some(form) = self.form.as_ref() {
2786 let current = match field {
2787 FormPickerField::Assignee => form.assignee.trim(),
2788 FormPickerField::Reviewer => form.reviewer.trim(),
2789 _ => "",
2790 };
2791 if !current.is_empty() {
2792 out.insert(current.to_string());
2793 }
2794 }
2795 out.into_iter()
2796 .map(|member| PickerItem {
2797 id: member.clone(),
2798 label: member,
2799 })
2800 .collect()
2801 }
2802 }
2803 }
2804
2805 fn seed_picker_selection(&self, field: FormPickerField, picker: &mut DropdownState) {
2806 let Some(form) = self.form.as_ref() else {
2807 return;
2808 };
2809 let values: Vec<String> = match field {
2810 FormPickerField::Labels => form
2811 .labels
2812 .split(',')
2813 .map(str::trim)
2814 .filter(|s| !s.is_empty())
2815 .map(str::to_string)
2816 .collect(),
2817 FormPickerField::Cycles => form
2818 .cycles
2819 .split(',')
2820 .map(str::trim)
2821 .filter(|s| !s.is_empty())
2822 .map(str::to_string)
2823 .collect(),
2824 FormPickerField::Milestones => form
2825 .milestones
2826 .split(',')
2827 .map(str::trim)
2828 .filter(|s| !s.is_empty())
2829 .map(str::to_string)
2830 .collect(),
2831 FormPickerField::Assignee => form
2832 .assignee
2833 .trim()
2834 .split(',')
2835 .map(str::trim)
2836 .filter(|s| !s.is_empty())
2837 .map(str::to_string)
2838 .take(1)
2839 .collect(),
2840 FormPickerField::Reviewer => form
2841 .reviewer
2842 .trim()
2843 .split(',')
2844 .map(str::trim)
2845 .filter(|s| !s.is_empty())
2846 .map(str::to_string)
2847 .take(1)
2848 .collect(),
2849 FormPickerField::Parent => (!form.parent.trim().is_empty())
2852 .then(|| form.parent.trim().to_string())
2853 .into_iter()
2854 .collect(),
2855 };
2856 match &mut picker.selection {
2857 SelectionModel::Single(current) => {
2858 *current = values.first().cloned();
2859 }
2860 SelectionModel::Multi(current) => {
2861 current.clear();
2862 current.extend(values);
2863 }
2864 }
2865 }
2866
2867 fn apply_picker_selection(&mut self, values: Vec<String>) {
2868 let Some(form) = self.form.as_mut() else {
2869 return;
2870 };
2871 let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
2872 return;
2873 };
2874 match field {
2875 FormPickerField::Labels => form.labels = values.join(","),
2876 FormPickerField::Assignee => {
2877 form.assignee = values.first().cloned().unwrap_or_default()
2878 }
2879 FormPickerField::Cycles => form.cycles = values.join(","),
2880 FormPickerField::Milestones => form.milestones = values.join(","),
2881 FormPickerField::Reviewer => {
2882 form.reviewer = values.first().cloned().unwrap_or_default()
2883 }
2884 FormPickerField::Parent => form.parent = values.first().cloned().unwrap_or_default(),
2887 }
2888 }
2889
2890 fn apply_picker_created(&mut self, value: String) -> Result<()> {
2891 let value = value.trim().to_string();
2892 if value.is_empty() {
2893 return Ok(());
2894 }
2895 let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
2896 return Ok(());
2897 };
2898 match field {
2899 FormPickerField::Cycles => {
2900 let root = self.project_root();
2901 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
2902 match planner.add_cycle(Some(value.clone())) {
2903 Ok(_) => {
2904 self.reload_config_from(root.as_path())?;
2905 }
2906 Err(e) => {
2907 self.status_msg = Some(e.to_string());
2908 return Ok(());
2909 }
2910 }
2911 }
2912 FormPickerField::Milestones => {
2913 let root = self.project_root();
2914 let planner = Planner::from_config(self.store.config.clone(), root.clone());
2915 match planner.create_milestone_file(&value, None, None) {
2916 Ok(_) => {
2917 self.reload_config_from(root.as_path())?;
2918 }
2919 Err(e) => {
2920 self.status_msg = Some(e.to_string());
2921 return Ok(());
2922 }
2923 }
2924 }
2925 FormPickerField::Assignee | FormPickerField::Reviewer => {
2926 let root = self.project_root();
2927 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
2928 let team = self
2929 .store
2930 .config
2931 .planning
2932 .default_team
2933 .clone()
2934 .unwrap_or_else(|| "default".to_string());
2935 match planner.add_team_member(&team, &value) {
2936 Ok(()) => {
2937 self.reload_config_from(root.as_path())?;
2938 }
2939 Err(e) => {
2940 self.status_msg = Some(e.to_string());
2941 return Ok(());
2942 }
2943 }
2944 }
2945 FormPickerField::Labels | FormPickerField::Parent => {}
2952 }
2953
2954 let next_options = self.picker_options(field);
2955 if let Some(picker) = self.form_picker.as_mut() {
2956 picker.state.options = next_options;
2957 picker.state.filter.clear();
2958 picker.state.cursor = 0;
2959 picker.state.mode = PickerMode::Idle;
2960 picker.state.create_buffer.clear();
2961 match &mut picker.state.selection {
2962 SelectionModel::Single(current) => *current = Some(value.clone()),
2963 SelectionModel::Multi(current) => {
2964 current.insert(value.clone());
2965 }
2966 }
2967 let values = match &picker.state.selection {
2968 SelectionModel::Single(current) => current.iter().cloned().collect(),
2969 SelectionModel::Multi(current) => current.iter().cloned().collect(),
2970 };
2971 self.apply_picker_selection(values);
2972 }
2973 Ok(())
2974 }
2975
2976 fn project_root(&self) -> PathBuf {
2977 Config::project_root(&self.store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
2978 }
2979
2980 fn reload_config_from(&mut self, root: &std::path::Path) -> Result<()> {
2981 let cfg = Config::load_with_warning(&root.join("tix.toml"), |_| {})?;
2982 self.bindings = cfg.effective_keybindings()?;
2983 self.store.config = cfg;
2984 Ok(())
2985 }
2986
2987 fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
2988 match key.code {
2989 KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_delete_yes(),
2990 _ => self.confirm_delete_no(),
2991 }
2992 }
2993
2994 fn confirm_delete_yes(&mut self) -> Result<Action> {
2997 if let Some(filename) = self.confirm_delete_target.take() {
2998 self.store.delete(&filename)?;
2999 self.status_msg = Some(format!("Deleted {filename}"));
3000 }
3001 self.mode = Mode::Board;
3002 self.rebuild_board();
3003 Ok(Action::None)
3004 }
3005
3006 fn confirm_delete_no(&mut self) -> Result<Action> {
3009 self.confirm_delete_target = None;
3010 self.mode = Mode::Board;
3011 Ok(Action::None)
3012 }
3013
3014 fn handle_confirm_blocked_move_key(
3021 &mut self,
3022 key: KeyEvent,
3023 target_col: usize,
3024 warning: &str,
3025 ) -> Result<Action> {
3026 let confirmed = matches!(
3027 key.code,
3028 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
3029 );
3030 if !confirmed {
3031 self.mode = Mode::Board;
3035 self.status_msg = Some(format!("Move cancelled; {warning}"));
3036 return Ok(Action::None);
3037 }
3038
3039 self.mode = Mode::Board;
3049 self.move_selected_to_impl(target_col, None, true)
3050 }
3051
3052 fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
3053 match key.code {
3054 KeyCode::Enter => {
3055 let buffer = match &self.mode {
3056 Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
3057 _ => unreachable!(),
3058 };
3059
3060 let card = match self.board.get_selected_card().cloned() {
3061 Some(c) => c,
3062 None => {
3063 self.mode = Mode::Board;
3064 return Ok(Action::None);
3065 }
3066 };
3067
3068 let target_name = self.board.column_name(target_col).to_string();
3069 let trimmed = buffer.trim();
3070 let moved = mv::apply_guarded(
3083 &self.store.config,
3084 &self.store,
3085 &card.filename,
3086 &target_name,
3087 if trimmed.is_empty() {
3088 None
3089 } else {
3090 Some(trimmed)
3091 },
3092 None,
3093 true,
3094 )?;
3095
3096 self.rebuild_board();
3097 self.board.select_column(target_col);
3098 let ack = if moved.acknowledged {
3102 ", blocked move acknowledged"
3103 } else {
3104 ""
3105 };
3106 self.status_msg = Some(match trimmed {
3107 "" => format!(
3108 "Moved #{} to {target_name} (no commit recorded{ack})",
3109 moved.issue.number
3110 ),
3111 _ => format!(
3112 "Moved #{} to {target_name} (commit: {}{ack})",
3113 moved.issue.number, trimmed
3114 ),
3115 });
3116 self.mode = Mode::Board;
3117 Ok(Action::None)
3118 }
3119 KeyCode::Esc => {
3120 self.mode = Mode::Board;
3121 Ok(Action::None)
3122 }
3123 KeyCode::Char(c) => {
3124 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
3125 buffer.push(c);
3126 }
3127 Ok(Action::None)
3128 }
3129 KeyCode::Backspace => {
3130 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
3131 buffer.pop();
3132 }
3133 Ok(Action::None)
3134 }
3135 _ => Ok(Action::None),
3136 }
3137 }
3138
3139 fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
3140 if self.help_searching {
3146 match key.code {
3147 KeyCode::Enter => self.help_searching = false,
3148 KeyCode::Esc => {
3149 self.help_query.clear();
3150 self.help_searching = false;
3151 }
3152 KeyCode::Backspace => {
3153 self.help_query.pop();
3154 }
3155 KeyCode::Char(c) => self.help_query.push(c),
3156 _ => {}
3157 }
3158 self.help_scroll = 0;
3161 return Ok(Action::None);
3162 }
3163 match key.code {
3164 KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
3165 self.close_help();
3166 }
3167 KeyCode::Char('/') => self.help_searching = true,
3171 KeyCode::Down | KeyCode::Char('j') => self.scroll_help(1),
3178 KeyCode::Up | KeyCode::Char('k') => self.scroll_help(-1),
3179 KeyCode::PageDown => self.scroll_help(i32::from(self.help_page())),
3180 KeyCode::PageUp => self.scroll_help(-i32::from(self.help_page())),
3181 _ => {}
3182 }
3183 Ok(Action::None)
3184 }
3185
3186 fn help_page(&self) -> u16 {
3188 self.help_metrics.get().visible.saturating_sub(1).max(1)
3189 }
3190
3191 fn open_help(&mut self, return_to: Mode) {
3198 self.help_scroll = 0;
3199 self.help_query.clear();
3200 self.help_searching = false;
3201 self.mode = Mode::Help {
3202 return_to: Box::new(return_to),
3203 };
3204 }
3205
3206 fn scroll_help(&mut self, rows: i32) {
3212 let max = i32::from(self.help_metrics.get().max_scroll);
3213 let next = i32::from(self.help_scroll) + rows;
3214 self.help_scroll = next.clamp(0, max) as u16;
3215 }
3216
3217 fn close_help(&mut self) {
3218 if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
3219 self.mode = *return_to;
3220 }
3221 }
3222
3223 fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
3224 match key.code {
3225 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
3226 self.filter = None;
3227 self.board = BoardState::build(&self.store, self.active_board_config());
3228 self.status_msg = None;
3229 self.mode = Mode::Board;
3230 }
3231 KeyCode::Enter => {
3232 self.mode = Mode::Board;
3233 }
3234 KeyCode::Char(c) => {
3235 let mut next = match &self.mode {
3236 Mode::Filter { buffer } => buffer.clone(),
3237 _ => String::new(),
3238 };
3239 next.push(c);
3240 self.mode = Mode::Filter {
3241 buffer: next.clone(),
3242 };
3243 self.apply_filter(&next);
3244 }
3245 KeyCode::Backspace => {
3246 let mut next = match &self.mode {
3247 Mode::Filter { buffer } => buffer.clone(),
3248 _ => String::new(),
3249 };
3250 next.pop();
3251 self.mode = Mode::Filter {
3252 buffer: next.clone(),
3253 };
3254 self.apply_filter(&next);
3255 }
3256 _ => {}
3257 }
3258 Ok(Action::None)
3259 }
3260
3261 fn active_structured_filter(&self) -> &search::StructuredFilter {
3264 self.filter_modal
3265 .as_ref()
3266 .map(|m| m.active_filter())
3267 .unwrap_or(&self.structured)
3268 }
3269
3270 fn open_filter_modal(&mut self) {
3272 self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
3273 self.structured.clone(),
3274 ));
3275 self.mode = Mode::FilterModal;
3276 }
3277
3278 fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
3284 let Some(modal) = self.filter_modal.as_mut() else {
3285 self.mode = Mode::Board;
3286 return Ok(Action::None);
3287 };
3288 match key.code {
3289 KeyCode::Esc => {
3290 self.filter_modal = None;
3291 self.mode = Mode::Board;
3292 self.rebuild_board();
3293 }
3294 KeyCode::Tab | KeyCode::Down => modal.focus_next(),
3295 KeyCode::BackTab | KeyCode::Up => modal.focus_prev(),
3296 KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
3297 KeyCode::Enter => {
3298 if modal.is_date_focus() && !modal.focused_date_is_complete() {
3299 self.open_date_picker(Mode::FilterModal);
3300 } else {
3301 let mut filter = modal.active_filter().clone();
3302 filter.normalize();
3303 self.structured = filter;
3304 self.filter_modal = None;
3305 self.mode = Mode::Board;
3306 self.rebuild_board();
3307 }
3308 }
3309 KeyCode::Backspace => {
3310 modal.backspace();
3311 self.rebuild_board();
3312 }
3313 KeyCode::Char(c) => {
3314 modal.type_char(c);
3315 self.rebuild_board();
3316 }
3317 KeyCode::F(2) => {
3318 if modal.is_date_focus() {
3319 self.open_date_picker(Mode::FilterModal);
3320 }
3321 }
3322 _ => {}
3323 }
3324 Ok(Action::None)
3325 }
3326
3327 fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
3328 match key.code {
3329 KeyCode::Esc => {
3330 self.mode = Mode::Detail;
3331 Ok(Action::None)
3332 }
3333 KeyCode::Enter => self.submit_comment(),
3334 KeyCode::Char(c) => {
3335 if let Mode::Comment { ref mut buffer } = self.mode {
3336 buffer.push(c);
3337 }
3338 Ok(Action::None)
3339 }
3340 KeyCode::Backspace => {
3341 if let Mode::Comment { ref mut buffer } = self.mode {
3342 buffer.pop();
3343 }
3344 Ok(Action::None)
3345 }
3346 _ => Ok(Action::None),
3347 }
3348 }
3349
3350 fn move_selection_by_page(&mut self, direction: i32) {
3355 let page = self.board_page.get();
3356 if page == 0 {
3357 return;
3358 }
3359 let step = i32::try_from(page).unwrap_or(i32::MAX);
3360 self.board.move_selection_by(step * direction);
3361 }
3362
3363 pub fn open_new_issue_form(&mut self) {
3371 let default_status = self
3372 .store
3373 .config
3374 .default_config()
3375 .columns
3376 .first()
3377 .cloned()
3378 .unwrap_or_else(|| "todo".to_string());
3379 let default_assignee = crate::assignee::git_identity()
3380 .map(|id| id.to_string())
3381 .unwrap_or_default();
3382 self.form = Some(FormState::with_default_assignee(
3383 &default_status,
3384 &default_assignee,
3385 ));
3386 self.mode = Mode::Form;
3387 }
3388
3389 fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
3392 self.settings = Some(SettingsModal {
3393 selected: 0,
3394 prompt,
3395 });
3396 self.mode = Mode::Settings;
3397 }
3398
3399 fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
3405 if self.settings.is_none() {
3406 self.mode = Mode::Board;
3407 return Ok(Action::None);
3408 }
3409 let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
3410 let Some(p) = prompt_active else {
3411 return self.handle_settings_browse(key);
3412 };
3413 match key.code {
3414 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3415 if let Some(ref mut m) = self.settings {
3416 m.prompt = None;
3417 }
3418 Ok(Action::None)
3419 }
3420 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
3421 match p {
3422 SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
3423 SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
3424 SettingsPrompt::ConfirmDeleteBoard(name) => {
3425 self.settings_commit_delete_board(name)
3426 }
3427 }
3428 Ok(Action::None)
3429 }
3430 KeyCode::Char(c) => {
3431 if let Some(ref mut m) = self.settings
3432 && let Some(SettingsPrompt::AddColumn(ref mut b))
3433 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
3434 {
3435 b.push(c);
3436 }
3437 Ok(Action::None)
3438 }
3439 KeyCode::Backspace => {
3440 if let Some(ref mut m) = self.settings
3441 && let Some(SettingsPrompt::AddColumn(ref mut b))
3442 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
3443 {
3444 b.pop();
3445 }
3446 Ok(Action::None)
3447 }
3448 _ => Ok(Action::None),
3449 }
3450 }
3451
3452 fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
3454 match key.code {
3455 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3456 self.settings = None;
3457 self.mode = Mode::Board;
3458 self.rebuild_board();
3459 Ok(Action::None)
3460 }
3461 KeyCode::Up | KeyCode::Char('k') => {
3462 let count =
3463 self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
3464 if let Some(ref mut m) = self.settings {
3465 m.move_selected(-1, count);
3466 }
3467 Ok(Action::None)
3468 }
3469 KeyCode::Down | KeyCode::Char('j') => {
3470 let count =
3471 self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
3472 if let Some(ref mut m) = self.settings {
3473 m.move_selected(1, count);
3474 }
3475 Ok(Action::None)
3476 }
3477 KeyCode::Tab => {
3478 self.settings_next_board();
3479 Ok(Action::None)
3480 }
3481 KeyCode::BackTab => {
3482 self.settings_prev_board();
3483 Ok(Action::None)
3484 }
3485 KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
3486 KeyCode::Char('a') => {
3487 if let Some(ref mut m) = self.settings {
3488 m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
3489 }
3490 Ok(Action::None)
3491 }
3492 KeyCode::Char('n') => {
3493 if let Some(ref mut m) = self.settings {
3494 m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
3495 }
3496 Ok(Action::None)
3497 }
3498 KeyCode::Char('x') | KeyCode::Char('X') => {
3499 if self.store.config.configs().count() <= 1 {
3500 self.status_msg = Some("Cannot delete the last board".to_string());
3501 return Ok(Action::None);
3502 }
3503 let name = self.active_board_name().to_string();
3504 if let Some(ref mut m) = self.settings {
3505 m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
3506 }
3507 Ok(Action::None)
3508 }
3509 _ => Ok(Action::None),
3510 }
3511 }
3512
3513 fn settings_next_board(&mut self) {
3514 self.tabs.next();
3515 self.active_config = self.tabs.selected;
3516 self.switch_config();
3517 if let Some(ref mut m) = self.settings {
3518 m.selected = 0;
3519 }
3520 }
3521
3522 fn settings_prev_board(&mut self) {
3523 self.tabs.previous();
3524 self.active_config = self.tabs.selected;
3525 self.switch_config();
3526 if let Some(ref mut m) = self.settings {
3527 m.selected = 0;
3528 }
3529 }
3530
3531 fn open_planning_modal(&mut self) {
3532 let modal = self.planning.get_or_insert_with(PlanningModal::new);
3533 modal.prompt = None;
3534 modal.selected = 0;
3535 self.mode = Mode::PlanningManagement;
3536 }
3537
3538 fn planning_rows(&self) -> (Vec<PlanningRow>, Vec<PlanningRow>) {
3543 let root = self.project_root();
3544 let planner = Planner::from_config(self.store.config.clone(), root);
3545 let issues = self.store.list().unwrap_or_default();
3546
3547 let mut cycles = Vec::new();
3548 for name in planner.cycle_names() {
3549 let cycle = planner.cycle(&name);
3550 let issues = issues
3551 .iter()
3552 .filter(|issue| {
3553 issue
3554 .cycles
3555 .as_ref()
3556 .is_some_and(|cs| cs.iter().any(|c| c == &name))
3557 })
3558 .count();
3559 cycles.push(PlanningRow {
3560 name,
3561 description: cycle
3562 .and_then(|c| c.description.clone())
3563 .unwrap_or_default(),
3564 date_a: cycle.and_then(|c| c.start_date.clone()).unwrap_or_default(),
3565 date_b: Some(cycle.and_then(|c| c.due_date.clone()).unwrap_or_default()),
3566 issues,
3567 });
3568 }
3569
3570 let mut milestones = Vec::new();
3571 if let Ok(list) = planner.list_milestones() {
3572 for ms in list {
3573 let issues = issues
3574 .iter()
3575 .filter(|issue| {
3576 issue
3577 .milestones
3578 .as_ref()
3579 .is_some_and(|ml| ml.iter().any(|x| x == &ms.name))
3580 })
3581 .count();
3582 milestones.push(PlanningRow {
3583 name: ms.name,
3584 description: ms.description,
3585 date_a: ms.target_date,
3586 date_b: None,
3587 issues,
3588 });
3589 }
3590 }
3591 (cycles, milestones)
3592 }
3593
3594 fn planning_rows_len(&self) -> usize {
3596 let (cycles, milestones) = self.planning_rows();
3597 match self.planning.as_ref().map(|m| m.segment) {
3598 Some(PlanningSegment::Cycles) => cycles.len(),
3599 Some(PlanningSegment::Milestones) => milestones.len(),
3600 None => 0,
3601 }
3602 }
3603
3604 fn handle_planning_key(&mut self, key: KeyEvent) -> Result<Action> {
3608 if self.planning.is_none() {
3609 self.mode = Mode::Board;
3610 return Ok(Action::None);
3611 }
3612 if let Some(prompt) = self.planning.as_ref().and_then(|m| m.prompt.clone()) {
3613 return self.handle_planning_confirm_key(key, prompt);
3614 }
3615 match key.code {
3616 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3617 self.planning = None;
3618 self.mode = Mode::Board;
3619 Ok(Action::None)
3620 }
3621 KeyCode::Up | KeyCode::Char('k') => {
3622 let len = self.planning_rows_len();
3623 if let Some(ref mut m) = self.planning {
3624 m.move_selected(-1, len);
3625 }
3626 Ok(Action::None)
3627 }
3628 KeyCode::Down | KeyCode::Char('j') => {
3629 let len = self.planning_rows_len();
3630 if let Some(ref mut m) = self.planning {
3631 m.move_selected(1, len);
3632 }
3633 Ok(Action::None)
3634 }
3635 KeyCode::Tab | KeyCode::BackTab => {
3636 if let Some(ref mut m) = self.planning {
3637 m.switch_segment();
3638 }
3639 Ok(Action::None)
3640 }
3641 KeyCode::Char('n') => {
3642 self.planning_open_create();
3643 Ok(Action::None)
3644 }
3645 KeyCode::Char('e') => self.planning_open_edit(),
3646 KeyCode::Char('x') | KeyCode::Char('X') => self.planning_confirm_delete(),
3647 _ => Ok(Action::None),
3648 }
3649 }
3650
3651 fn handle_planning_confirm_key(
3653 &mut self,
3654 key: KeyEvent,
3655 prompt: PlanningPrompt,
3656 ) -> Result<Action> {
3657 let PlanningPrompt::ConfirmDelete { name, .. } = prompt;
3658 match key.code {
3659 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3660 match self.planning_delete(&name) {
3661 Ok(()) => {
3662 self.status_msg = Some(format!("Deleted {name}"));
3663 }
3664 Err(e) => {
3665 self.status_msg = Some(format!("Cannot delete {name}: {e}"));
3666 }
3667 }
3668 if let Some(ref mut m) = self.planning {
3669 m.prompt = None;
3670 }
3671 Ok(Action::None)
3672 }
3673 _ => {
3674 if let Some(ref mut m) = self.planning {
3675 m.prompt = None;
3676 }
3677 Ok(Action::None)
3678 }
3679 }
3680 }
3681
3682 fn planning_open_create(&mut self) {
3684 let kind = match self.planning.as_ref().map(|m| m.segment) {
3685 Some(PlanningSegment::Milestones) => PlanningKind::Milestone,
3686 _ => PlanningKind::Cycle,
3687 };
3688 self.planning_form = Some(PlanningForm::new(kind));
3689 self.mode = Mode::PlanningForm;
3690 }
3691
3692 fn planning_open_edit(&mut self) -> Result<Action> {
3694 let Some(modal) = self.planning.as_ref() else {
3695 return Ok(Action::None);
3696 };
3697 let root = self.project_root();
3698 let planner = Planner::from_config(self.store.config.clone(), root);
3699 let form = match modal.segment {
3700 PlanningSegment::Cycles => {
3701 let name = match planner.cycle_names().into_iter().nth(modal.selected) {
3702 Some(name) => name,
3703 None => return Ok(Action::None),
3704 };
3705 let Some(cycle) = planner.cycle(&name) else {
3706 return Ok(Action::None);
3707 };
3708 PlanningForm::from_cycle(&name, cycle)
3709 }
3710 PlanningSegment::Milestones => {
3711 let list = planner.list_milestones().unwrap_or_default();
3712 let Some(ms) = list.into_iter().nth(modal.selected) else {
3713 return Ok(Action::None);
3714 };
3715 PlanningForm::from_milestone(&ms)
3716 }
3717 };
3718 self.planning_form = Some(form);
3719 self.mode = Mode::PlanningForm;
3720 Ok(Action::None)
3721 }
3722
3723 fn planning_confirm_delete(&mut self) -> Result<Action> {
3726 let Some(modal) = self.planning.as_ref() else {
3727 return Ok(Action::None);
3728 };
3729 if modal.prompt.is_some() {
3730 return Ok(Action::None);
3731 }
3732 let root = self.project_root();
3733 let planner = Planner::from_config(self.store.config.clone(), root);
3734 let (name, referencing) = match modal.segment {
3735 PlanningSegment::Cycles => {
3736 let Some(name) = planner.cycle_names().into_iter().nth(modal.selected) else {
3737 return Ok(Action::None);
3738 };
3739 let referencing = planner
3740 .delete_impact(&self.store, &name)
3741 .map(|i| i.referencing_issues.len())
3742 .unwrap_or(0);
3743 (name, referencing)
3744 }
3745 PlanningSegment::Milestones => {
3746 let list = planner.list_milestones().unwrap_or_default();
3747 let Some(name) = list.into_iter().nth(modal.selected).map(|m| m.name) else {
3748 return Ok(Action::None);
3749 };
3750 let referencing = planner
3751 .milestone_delete_impact(&self.store, &name)
3752 .map(|i| i.referencing_issues.len())
3753 .unwrap_or(0);
3754 (name, referencing)
3755 }
3756 };
3757 if let Some(ref mut m) = self.planning {
3758 m.prompt = Some(PlanningPrompt::ConfirmDelete { name, referencing });
3759 }
3760 Ok(Action::None)
3761 }
3762
3763 fn planning_delete(&mut self, name: &str) -> Result<()> {
3767 let root = self.project_root();
3768 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
3769 match self.planning.as_ref().map(|m| m.segment) {
3770 Some(PlanningSegment::Cycles) => {
3771 planner.delete_cycle(name)?;
3772 self.reload_config_from(&root)?;
3773 }
3774 Some(PlanningSegment::Milestones) => {
3775 planner.delete_milestone(name)?;
3776 }
3777 None => {}
3778 }
3779 self.refresh_corpus()?;
3780 Ok(())
3781 }
3782
3783 fn handle_planning_form_key(&mut self, key: KeyEvent) -> Result<Action> {
3787 if self.planning_form.is_none() {
3788 self.mode = Mode::PlanningManagement;
3789 return Ok(Action::None);
3790 }
3791 match key.code {
3792 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3793 self.planning_form = None;
3794 self.mode = Mode::PlanningManagement;
3795 Ok(Action::None)
3796 }
3797 _ => self.handle_planning_form_edit_key(key),
3798 }
3799 }
3800
3801 fn handle_planning_form_edit_key(&mut self, key: KeyEvent) -> Result<Action> {
3804 if self.planning_form.is_none() {
3805 return Ok(Action::None);
3806 }
3807 match key.code {
3808 KeyCode::Enter => {
3809 if self
3810 .planning_form
3811 .as_ref()
3812 .is_some_and(|f| f.focused_is_date() && !f.focused_date_is_complete())
3813 {
3814 self.open_date_picker(Mode::PlanningForm);
3815 Ok(Action::None)
3816 } else {
3817 self.planning_form_apply()
3818 }
3819 }
3820 KeyCode::Tab | KeyCode::Down => {
3821 if let Some(ref mut f) = self.planning_form {
3822 f.focus_next();
3823 }
3824 Ok(Action::None)
3825 }
3826 KeyCode::BackTab | KeyCode::Up => {
3827 if let Some(ref mut f) = self.planning_form {
3828 f.focus_prev();
3829 }
3830 Ok(Action::None)
3831 }
3832 KeyCode::Backspace => {
3833 if let Some(ref mut f) = self.planning_form {
3834 f.delete_char();
3835 }
3836 Ok(Action::None)
3837 }
3838 KeyCode::Char(c) => {
3839 if let Some(ref mut f) = self.planning_form {
3840 f.insert_char(c);
3841 }
3842 Ok(Action::None)
3843 }
3844 KeyCode::F(2) => {
3845 if self
3846 .planning_form
3847 .as_ref()
3848 .is_some_and(|f| f.focused_is_date())
3849 {
3850 self.open_date_picker(Mode::PlanningForm);
3851 }
3852 Ok(Action::None)
3853 }
3854 _ => Ok(Action::None),
3855 }
3856 }
3857
3858 fn planning_form_apply(&mut self) -> Result<Action> {
3861 let Some(form) = self.planning_form.clone() else {
3862 self.mode = Mode::PlanningManagement;
3863 return Ok(Action::None);
3864 };
3865 let result = self.planning_form_commit(&form);
3866 match result {
3867 Ok(()) => {
3868 self.planning_form = None;
3869 self.mode = Mode::PlanningManagement;
3870 Ok(Action::None)
3871 }
3872 Err(e) => {
3873 self.status_msg = Some(format!("Cannot save {}: {e}", form.kind.noun()));
3874 Ok(Action::None)
3875 }
3876 }
3877 }
3878
3879 fn planning_form_commit(&mut self, form: &PlanningForm) -> Result<()> {
3880 let root = self.project_root();
3881 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
3882 let name = form.name().trim().to_string();
3883 if name.is_empty() {
3884 return Err(TixError::InvalidInput("a name is required".to_string()));
3885 }
3886 match form.kind {
3887 PlanningKind::Cycle => {
3888 if let Some(old) = form.editing.as_deref() {
3889 if old != name {
3890 planner.rename_cycle_cascade(&self.store, old, &name)?;
3891 }
3892 planner.set_cycle_fields(
3893 &name,
3894 Some(form.description()),
3895 Some(form.date_a()),
3896 Some(form.date_b()),
3897 )?;
3898 } else {
3899 planner.add_cycle(Some(name.clone()))?;
3900 planner.set_cycle_fields(
3901 &name,
3902 Some(form.description()),
3903 Some(form.date_a()),
3904 Some(form.date_b()),
3905 )?;
3906 }
3907 self.reload_config_from(&root)?;
3908 }
3909 PlanningKind::Milestone => {
3910 let target = if form.date_a().trim().is_empty() {
3911 None
3912 } else {
3913 Some(form.date_a())
3914 };
3915 if let Some(old) = form.editing.as_deref() {
3916 if old != name {
3917 let old_milestone = planner.milestone(old)?;
3918 planner.rename_milestone_cascade(&self.store, &old_milestone, &name)?;
3919 }
3920 planner.update_milestone(&name, target, Some(form.description()))?;
3921 } else {
3922 planner.create_milestone_file(&name, target, Some(form.description()))?;
3923 }
3924 }
3925 }
3926 self.refresh_corpus()?;
3927 Ok(())
3928 }
3929
3930 fn settings_toggle_selected(&mut self) -> Result<Action> {
3931 let col_count = self.active_board_config().columns.len();
3932 let selected = self.settings.as_ref().map(|m| m.selected).unwrap_or(0);
3933 if selected >= col_count {
3934 return self.settings_toggle_display_row(selected - col_count);
3935 }
3936 let col = self
3937 .settings
3938 .as_ref()
3939 .and_then(|m| self.active_board_config().columns.get(m.selected))
3940 .cloned();
3941 let Some(col) = col else {
3942 return Ok(Action::None);
3943 };
3944 let board_name = self.active_board_name().to_string();
3945 let now_hidden = {
3946 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
3947 match board_config {
3948 Some(bc) => {
3949 if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
3950 bc.hidden_columns.remove(pos);
3951 false
3952 } else {
3953 bc.hidden_columns.push(col.clone());
3954 true
3955 }
3956 }
3957 None => return Ok(Action::None),
3958 }
3959 };
3960 if !self.persist_config() {
3961 return Ok(Action::None);
3962 }
3963 self.rebuild_board();
3964 let last = self.active_board_config().columns.len().saturating_sub(1);
3965 if let Some(ref mut m) = self.settings
3966 && m.selected > last
3967 {
3968 m.selected = last;
3969 }
3970 self.status_msg = Some(match now_hidden {
3971 true => format!("'{col}' hidden"),
3972 false => format!("'{col}' shown"),
3973 });
3974 Ok(Action::None)
3975 }
3976
3977 fn settings_toggle_display_row(&mut self, row: usize) -> Result<Action> {
3981 let board_name = self.active_board_name().to_string();
3982 let action_msg = {
3983 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
3984 let Some(board_config) = board_config else {
3985 return Ok(Action::None);
3986 };
3987 match row {
3988 0 => {
3989 board_config.wrap_titles = !board_config.wrap_titles;
3990 if board_config.wrap_titles {
3991 "Wrap titles: on".to_string()
3992 } else {
3993 "Wrap titles: off".to_string()
3994 }
3995 }
3996 1 => {
3997 board_config.title_lines = board_config.title_lines % 5 + 1;
3998 format!("Title lines: {}", board_config.title_lines)
3999 }
4000 2 => {
4001 board_config.order_by = board_config.order_by.next();
4002 format!("Order by: {}", board_config.order_by.as_str())
4003 }
4004 _ => return Ok(Action::None),
4005 }
4006 };
4007 if !self.persist_config() {
4008 return Ok(Action::None);
4009 }
4010 self.rebuild_board();
4011 self.status_msg = Some(action_msg);
4012 Ok(Action::None)
4013 }
4014
4015 fn settings_commit_add_column(&mut self, name: String) {
4016 let name = name.trim().to_string();
4017 if name.is_empty() {
4018 self.status_msg = Some("Column name is required".to_string());
4019 return;
4020 }
4021 let board_name = self.active_board_name().to_string();
4022 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
4023 let Some(board_config) = board_config else {
4024 self.status_msg = Some("board not found".to_string());
4025 return;
4026 };
4027 if board_config.columns.iter().any(|c| c == &name) {
4028 self.status_msg = Some(format!("Column '{name}' already exists"));
4029 return;
4030 }
4031 board_config.columns.push(name.clone());
4032 if !self.persist_config() {
4033 return;
4034 }
4035 self.rebuild_board();
4036 let last = self.active_board_config().columns.len().saturating_sub(1);
4037 if let Some(ref mut m) = self.settings {
4038 m.prompt = None;
4039 m.selected = last;
4040 }
4041 self.status_msg = Some(format!("Added column '{name}'"));
4042 }
4043
4044 fn settings_commit_new_board(&mut self, name: String) {
4045 let name = name.trim().to_string();
4046 if name.is_empty() {
4047 self.status_msg = Some("Board name is required".to_string());
4048 return;
4049 }
4050 if self.store.config.kanban.configurations.contains_key(&name) {
4051 self.status_msg = Some(format!("Board '{name}' already exists"));
4052 return;
4053 }
4054 self.store
4055 .config
4056 .kanban
4057 .configurations
4058 .insert(name.clone(), crate::config::BoardConfiguration::default());
4059 if !self.persist_config() {
4060 return;
4061 }
4062 self.reload_tabs();
4063 let idx = self
4064 .store
4065 .config
4066 .configs()
4067 .position(|(n, _)| n == name.as_str())
4068 .unwrap_or(0);
4069 self.active_config = idx;
4070 self.tabs.selected = idx;
4071 self.switch_config();
4072 if let Some(ref mut m) = self.settings {
4073 m.prompt = None;
4074 m.selected = 0;
4075 }
4076 self.status_msg = Some(format!("Added board '{name}'"));
4077 }
4078
4079 fn settings_commit_delete_board(&mut self, name: String) {
4080 if self.store.config.kanban.configurations.len() <= 1 {
4081 self.status_msg = Some("Cannot delete the last board".to_string());
4082 return;
4083 }
4084 let idx = self.active_config;
4085 let removed = self.store.config.kanban.configurations.shift_remove(&name);
4086 if removed.is_none() {
4087 self.status_msg = Some(format!("Board '{name}' not found"));
4088 return;
4089 }
4090 if !self.persist_config() {
4091 return;
4092 }
4093 self.reload_tabs();
4094 let count = self.store.config.kanban.configurations.len();
4095 let target = idx.min(count.saturating_sub(1));
4096 self.active_config = target;
4097 self.tabs.selected = target;
4098 self.switch_config();
4099 if let Some(ref mut m) = self.settings {
4100 m.prompt = None;
4101 m.selected = 0;
4102 }
4103 self.status_msg = Some(format!("Deleted board '{name}'"));
4104 }
4105
4106 fn persist_config(&mut self) -> bool {
4107 match self.store.config_path() {
4108 Ok(path) => match self.store.config.save(&path) {
4109 Ok(()) => true,
4110 Err(e) => {
4111 self.status_msg = Some(format!("settings save: {e}"));
4112 false
4113 }
4114 },
4115 Err(e) => {
4116 self.status_msg = Some(format!("settings path: {e}"));
4117 false
4118 }
4119 }
4120 }
4121
4122 fn reload_tabs(&mut self) {
4123 let names: Vec<String> = self
4124 .store
4125 .config
4126 .configs()
4127 .map(|(name, _)| name.to_string())
4128 .collect();
4129 self.tabs = BoardTabs::new(names);
4130 }
4131
4132 fn active_board_name(&self) -> &str {
4133 self.store
4134 .config
4135 .configs()
4136 .nth(self.active_config)
4137 .map(|(name, _)| name)
4138 .unwrap_or("default")
4139 }
4140
4141 fn submit_comment(&mut self) -> Result<Action> {
4142 let buffer = match &self.mode {
4143 Mode::Comment { buffer } => buffer.clone(),
4144 _ => return Ok(Action::None),
4145 };
4146 let text = buffer.trim().to_string();
4147 if text.is_empty() {
4148 self.status_msg = Some("Comment discarded (empty)".to_string());
4149 self.mode = Mode::Detail;
4150 return Ok(Action::None);
4151 }
4152 let filename = match self.detail.as_ref() {
4153 Some(d) => d.issue.filename.clone(),
4154 None => {
4155 self.mode = Mode::Detail;
4156 return Ok(Action::None);
4157 }
4158 };
4159 let author = crate::assignee::git_identity()
4160 .map(|id| id.to_string())
4161 .unwrap_or_else(|| "anonymous".to_string());
4162 let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
4163 issue.add_comment(&author, &text);
4164 self.store.update(&filename, &issue)?;
4165 if let Some(ref mut d) = self.detail {
4166 d.issue = issue;
4167 d.scroll_offset = 0;
4168 }
4169 self.refresh_corpus()?;
4170 self.status_msg = Some(format!("Commented by {author}"));
4171 self.mode = Mode::Detail;
4172 Ok(Action::None)
4173 }
4174
4175 fn selected_relationship(&self) -> Option<&crate::relationships::Relationship> {
4177 self.detail
4178 .as_ref()
4179 .and_then(|d| d.relationships.get(d.rel_cursor))
4180 }
4181
4182 fn selected_relationship_id(&self) -> Option<String> {
4189 self.selected_relationship().map(|r| r.id.clone())
4190 }
4191
4192 fn move_rel_cursor(&mut self, delta: isize) {
4194 let Some(d) = self.detail.as_mut() else {
4195 return;
4196 };
4197 if d.relationships.is_empty() {
4198 return;
4199 }
4200 let last = d.relationships.len() as isize - 1;
4201 let next = (d.rel_cursor as isize + delta).clamp(0, last);
4202 d.rel_cursor = next as usize;
4203 }
4204
4205 fn handle_link_prompt_key(&mut self, key: KeyEvent) -> Result<Action> {
4213 match key.code {
4214 KeyCode::Esc => {
4215 self.mode = Mode::Detail;
4219 self.status_msg = Some("relationship: cancelled".to_string());
4220 Ok(Action::None)
4221 }
4222 KeyCode::Enter => {
4223 let prompt = match self.mode.clone() {
4224 Mode::LinkCreate { buffer, step, .. } => Some((buffer, step)),
4225 Mode::LinkEdit { buffer, step, .. } => Some((buffer, step)),
4226 _ => None,
4227 };
4228 match prompt {
4229 Some((buffer, step)) => self.submit_link_prompt(buffer, step),
4230 None => Ok(Action::None),
4231 }
4232 }
4233 KeyCode::Backspace => {
4234 if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
4235 buffer.pop();
4236 } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
4237 buffer.pop();
4238 }
4239 Ok(Action::None)
4240 }
4241 KeyCode::Char(c) => {
4242 if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
4243 buffer.push(c);
4244 } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
4245 buffer.push(c);
4246 }
4247 Ok(Action::None)
4248 }
4249 _ => Ok(Action::None),
4250 }
4251 }
4252
4253 fn submit_link_prompt(&mut self, buffer: String, step: LinkStep) -> Result<Action> {
4255 let trimmed = buffer.trim().to_string();
4256 let subject = self.detail.as_ref().map(|d| d.issue.number).unwrap_or(0);
4257 let (is_edit, index) = match &self.mode {
4258 Mode::LinkEdit { index, .. } => (true, *index),
4259 _ => (false, 0),
4260 };
4261
4262 match step {
4263 LinkStep::Target => {
4264 if trimmed.is_empty() {
4265 self.status_msg = Some("relationship: cancelled (no issue given)".into());
4266 self.mode = Mode::Detail;
4267 return Ok(Action::None);
4268 }
4269 let selector = match crate::commands::parse_selector(&self.store, &trimmed) {
4274 Ok(selector) => selector,
4275 Err(e) => {
4276 self.status_msg = Some(e.to_string());
4277 return Ok(Action::None);
4278 }
4279 };
4280 let target = match self.store.get(selector) {
4281 Ok(issue) => issue.number,
4282 Err(e) => {
4283 self.status_msg = Some(e.to_string());
4284 return Ok(Action::None);
4285 }
4286 };
4287 if target == subject {
4288 self.status_msg =
4291 Some("relationship: an issue cannot be related to itself".into());
4292 return Ok(Action::None);
4293 }
4294 self.mode = if is_edit {
4295 Mode::LinkEdit {
4296 index,
4297 buffer: String::new(),
4298 step: LinkStep::Kind,
4299 target: Some(target),
4300 kind: None,
4301 }
4302 } else {
4303 Mode::LinkCreate {
4304 buffer: String::new(),
4305 step: LinkStep::Kind,
4306 target: Some(target),
4307 }
4308 };
4309 Ok(Action::None)
4310 }
4311 LinkStep::Kind => {
4312 let kind = match crate::commands::link::parse_kind(&trimmed) {
4317 Ok(kind) => kind,
4318 Err(e) => {
4319 self.status_msg = Some(e.to_string());
4320 return Ok(Action::None);
4321 }
4322 };
4323 if is_edit {
4324 self.apply_relationship_edit(kind)
4325 } else {
4326 self.apply_relationship_create(subject, kind)
4327 }
4328 }
4329 }
4330 }
4331
4332 fn apply_relationship_create(
4337 &mut self,
4338 subject: u32,
4339 kind: crate::relationships::RelationKind,
4340 ) -> Result<Action> {
4341 let Some(target) = (match &self.mode {
4342 Mode::LinkCreate { target, .. } => *target,
4343 _ => None,
4344 }) else {
4345 self.status_msg = Some("relationship: no target resolved".into());
4348 self.mode = Mode::Detail;
4349 return Ok(Action::None);
4350 };
4351 match self
4355 .store
4356 .link(&subject.to_string(), &target.to_string(), kind)
4357 {
4358 Ok(outcome) => {
4359 self.status_msg = Some(crate::commands::link::link_message(&outcome));
4360 self.mode = Mode::Detail;
4361 self.refresh_detail()?;
4362 }
4363 Err(e) => {
4364 self.status_msg = Some(e.to_string());
4365 self.mode = Mode::Detail;
4366 }
4367 }
4368 Ok(Action::None)
4369 }
4370
4371 fn apply_relationship_edit(
4378 &mut self,
4379 kind: crate::relationships::RelationKind,
4380 ) -> Result<Action> {
4381 let (id, to) = match &self.mode {
4386 Mode::LinkEdit { index, target, .. } => (
4387 self.detail
4388 .as_ref()
4389 .and_then(|d| d.relationships.get(*index))
4390 .map(|r| r.id.clone()),
4391 *target,
4392 ),
4393 _ => (None, None),
4394 };
4395 let Some(id) = id else {
4396 self.status_msg = Some("relationship: nothing selected to edit".into());
4397 self.mode = Mode::Detail;
4398 return Ok(Action::None);
4399 };
4400 match self.store.edit_relation(&id, to, Some(kind)) {
4401 Ok(outcome) => {
4402 self.status_msg = Some(crate::commands::link::edit_message(&outcome));
4403 self.mode = Mode::Detail;
4404 self.refresh_detail()?;
4405 }
4406 Err(e) => {
4407 self.status_msg = Some(e.to_string());
4408 self.mode = Mode::Detail;
4409 }
4410 }
4411 Ok(Action::None)
4412 }
4413
4414 fn handle_remove_relationship_key(&mut self, key: KeyEvent) -> Result<Action> {
4415 match key.code {
4416 KeyCode::Char('y') | KeyCode::Char('Y') => {
4417 let index = match &self.mode {
4418 Mode::ConfirmRemoveRelationship { index } => *index,
4419 _ => return Ok(Action::None),
4420 };
4421 let id = match self
4422 .detail
4423 .as_ref()
4424 .and_then(|d| d.relationships.get(index))
4425 {
4426 Some(rel) => rel.id.clone(),
4427 None => {
4428 self.mode = Mode::Detail;
4429 return Ok(Action::None);
4430 }
4431 };
4432 match self.store.remove_relation(&id) {
4433 Ok(outcome) => {
4434 self.status_msg = Some(crate::commands::link::remove_message(&outcome));
4437 self.mode = Mode::Detail;
4438 self.refresh_detail()?;
4439 }
4440 Err(e) => {
4441 self.status_msg = Some(e.to_string());
4442 self.mode = Mode::Detail;
4443 }
4444 }
4445 Ok(Action::None)
4446 }
4447 _ => {
4448 self.mode = Mode::Detail;
4449 self.status_msg = Some("relationship: removal cancelled".to_string());
4450 Ok(Action::None)
4451 }
4452 }
4453 }
4454
4455 fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
4456 match key.code {
4457 KeyCode::Esc => {
4458 self.mode = Mode::Detail;
4459 Ok(Action::None)
4460 }
4461 KeyCode::Enter => self.submit_asset(),
4462 KeyCode::Char(c) => {
4463 if let Mode::Asset { ref mut buffer } = self.mode {
4464 buffer.push(c);
4465 }
4466 Ok(Action::None)
4467 }
4468 KeyCode::Backspace => {
4469 if let Mode::Asset { ref mut buffer } = self.mode {
4470 buffer.pop();
4471 }
4472 Ok(Action::None)
4473 }
4474 _ => Ok(Action::None),
4475 }
4476 }
4477
4478 fn submit_asset(&mut self) -> Result<Action> {
4479 let buffer = match &self.mode {
4480 Mode::Asset { buffer } => buffer.clone(),
4481 _ => return Ok(Action::None),
4482 };
4483 let path = std::path::PathBuf::from(buffer.trim());
4484 let filename = match self.detail.as_ref() {
4485 Some(d) => d.issue.filename.clone(),
4486 None => {
4487 self.mode = Mode::Detail;
4488 return Ok(Action::None);
4489 }
4490 };
4491 match self.store.add_asset(&filename, &path) {
4492 Ok(added) => {
4493 if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
4494 && let Some(ref mut d) = self.detail
4495 {
4496 d.issue = issue;
4497 d.scroll_offset = 0;
4498 }
4499 self.status_msg = Some(format!("Asset added: {}", added.link));
4500 self.refresh_corpus()?;
4501 self.mode = Mode::Detail;
4502 Ok(Action::None)
4503 }
4504 Err(e) => {
4505 self.status_msg = Some(format!("asset: {e}"));
4506 Ok(Action::None)
4507 }
4508 }
4509 }
4510
4511 fn toggle_column_visibility(&mut self) {
4514 let col_name = self.board.active_column_name().to_string();
4515 let config_name = self.tabs.names[self.active_config].clone();
4516 let board_config = self
4517 .store
4518 .config
4519 .kanban
4520 .configurations
4521 .get_mut(&config_name);
4522 let board_config = match board_config {
4523 Some(bc) => bc,
4524 None => {
4525 self.status_msg = Some("toggle: config not found".into());
4526 return;
4527 }
4528 };
4529 let now_hidden = if let Some(pos) = board_config
4530 .hidden_columns
4531 .iter()
4532 .position(|h| h == &col_name)
4533 {
4534 board_config.hidden_columns.remove(pos);
4535 false
4536 } else {
4537 board_config.hidden_columns.push(col_name.clone());
4538 true
4539 };
4540 match self.store.config_path() {
4541 Ok(path) => {
4542 if let Err(e) = self.store.config.save(&path) {
4543 self.status_msg = Some(format!("toggle save: {e}"));
4544 return;
4545 }
4546 }
4547 Err(e) => {
4548 self.status_msg = Some(format!("toggle path: {e}"));
4549 return;
4550 }
4551 }
4552 self.rebuild_board();
4553 let visible = self.board.columns.len();
4557 self.board.active_col = if now_hidden {
4558 visible
4559 + self
4560 .board
4561 .hidden
4562 .iter()
4563 .position(|c| c.name == col_name)
4564 .unwrap_or_default()
4565 } else {
4566 self.board
4567 .columns
4568 .iter()
4569 .position(|c| c.name == col_name)
4570 .unwrap_or_default()
4571 };
4572 self.board.clamp_selection();
4573 self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
4574 }
4575
4576 fn switch_board(&mut self, index: usize) {
4585 if index < self.tabs.len() {
4586 self.tabs.selected = index;
4587 self.tabs.hovered = None;
4588 self.active_config = index;
4589 self.switch_config();
4590 }
4591 }
4592
4593 fn switch_config(&mut self) {
4594 let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
4595 self.rebuild_board();
4596 self.board.active_col = 0;
4597 if let Some(filename) = selected_filename
4598 && self
4599 .board
4600 .column_cards(0)
4601 .iter()
4602 .any(|c| c.filename == filename)
4603 {
4604 self.board.selected = self
4605 .board
4606 .column_cards(0)
4607 .iter()
4608 .position(|c| c.filename == filename)
4609 .unwrap_or(0);
4610 } else {
4611 self.board.selected = 0;
4612 }
4613 self.board.clamp_selection();
4614 self.status_msg = Some("Switched board".to_string());
4615 }
4616
4617 fn rebuild_board(&mut self) {
4620 let active_col = self.board.active_col;
4621 let selected = self.board.selected;
4622 let board_config = self.active_board_config().clone();
4623 let active_filter = self.active_structured_filter().clone();
4624 let q = self.filter.clone().unwrap_or_default();
4625 if !q.is_empty() || active_filter.has_active_criteria() {
4626 self.board = BoardState::build_filtered(
4627 &board_config,
4628 &self.store.config,
4629 &self.corpus,
4630 &q,
4631 &active_filter,
4632 &self.workflow_resolution(),
4633 );
4634 } else {
4635 self.board =
4636 BoardState::build_with_archived(&self.store, &board_config, self.show_archived);
4637 }
4638 self.board.active_col = active_col;
4639 self.board.selected = selected;
4640 self.board.clamp_selection();
4641 }
4642
4643 fn select_card_by_filename(&mut self, filename: &str) {
4644 let mut columns: Vec<&[crate::ui::board::Card]> = self
4645 .board
4646 .columns
4647 .iter()
4648 .map(|c| c.cards.as_slice())
4649 .collect();
4650 if self.board.show_mismatch {
4651 columns.push(self.board.mismatch.cards.as_slice());
4652 }
4653 if let Some((col_idx, card_idx)) =
4654 columns.iter().enumerate().find_map(|(col_idx, cards)| {
4655 cards
4656 .iter()
4657 .position(|card| card.filename == filename)
4658 .map(|card_idx| (col_idx, card_idx))
4659 })
4660 {
4661 self.board.active_col = col_idx;
4662 self.board.selected = card_idx;
4663 }
4664 }
4665
4666 fn apply_filter(&mut self, buffer: &str) {
4670 if buffer.trim().is_empty() {
4671 self.filter = None;
4672 } else {
4673 self.filter = Some(buffer.trim().to_string());
4674 }
4675 let board_config = self.active_board_config().clone();
4676 let active_filter = self.active_structured_filter().clone();
4677 let q = self.filter.clone().unwrap_or_default();
4678 if !q.is_empty() || active_filter.has_active_criteria() {
4679 self.board = BoardState::build_filtered(
4680 &board_config,
4681 &self.store.config,
4682 &self.corpus,
4683 &q,
4684 &active_filter,
4685 &self.workflow_resolution(),
4686 );
4687 self.status_msg = if self.board.is_empty() {
4688 Some("No matches for current filter".to_string())
4689 } else {
4690 None
4691 };
4692 } else {
4693 self.board = BoardState::build(&self.store, &board_config);
4694 self.status_msg = None;
4695 }
4696 }
4697
4698 fn refresh_detail(&mut self) -> Result<()> {
4713 let Some(detail) = self.detail.as_ref() else {
4714 return Ok(());
4715 };
4716 let number = detail.issue.number;
4717 let filename = detail.issue.filename.clone();
4718 let Some(mut next) = self.store.get(IssueSelector::Filename(filename)).ok() else {
4719 return Ok(());
4720 };
4721 let relationships = self.store.relationships_for(number)?;
4722 let (all, done_column) = self.store.graph_source()?;
4723 let graph = commands::graph_over(&all, &done_column);
4724 let cursor = detail.rel_cursor;
4725 if let Some(d) = self.detail.as_mut() {
4726 d.issue = std::mem::replace(&mut next, d.issue.clone());
4727 d.relationships = relationships;
4728 d.chain = graph.dependency_chain(number);
4729 d.blocked = commands::blocked_note(&graph.blocked_state(number));
4730 d.rel_cursor = cursor;
4731 d.clamp_rel_cursor();
4732 }
4733 self.refresh_corpus()
4734 }
4735
4736 fn refresh_corpus(&mut self) -> Result<()> {
4737 let c = Corpus::build_with(&self.store, self.show_archived)?;
4738 self.corpus = c;
4739 self.rebuild_board();
4740 Ok(())
4741 }
4742
4743 pub fn refresh_from_disk(&mut self) -> Result<()> {
4748 self.refresh_corpus()
4749 }
4750
4751 pub fn after_editor(&mut self, filename: &str) {
4752 match self
4753 .store
4754 .get(IssueSelector::Filename(filename.to_string()))
4755 {
4756 Ok(mut issue) => {
4757 issue.bump_updated();
4758 let location = match issue.assignee {
4762 Some(ref a) => match self.store.reassign(filename, Some(a)) {
4763 Ok(moved) => moved.to_string_lossy().to_string(),
4764 Err(e) => {
4765 self.status_msg = Some(format!("editor: {e}"));
4766 return;
4767 }
4768 },
4769 None => filename.to_string(),
4770 };
4771 if let Err(e) = self.store.update(&location, &issue) {
4772 self.status_msg = Some(format!("editor: {e}"));
4773 } else {
4774 if let Some(ref mut d) = self.detail
4775 && (d.issue.filename == filename || d.issue.filename == location)
4776 {
4777 d.issue = issue;
4778 d.scroll_offset = 0;
4779 }
4780 let _ = self.refresh_corpus();
4781 if let Err(e) = self.warn_if_linted_issue_invalid(&location) {
4782 self.status_msg = Some(format!("editor: {e}"));
4783 } else {
4784 self.status_msg = Some("Edited".to_string());
4785 }
4786 }
4787 }
4788 Err(e) => {
4789 self.status_msg = Some(format!("editor: {e}"));
4790 }
4791 }
4792 }
4793
4794 fn warn_if_linted_issue_invalid(&mut self, filename: &str) -> Result<()> {
4795 let findings =
4796 crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
4797 if let Some(finding) = findings.into_iter().next() {
4798 let message = format!("{}: {}", finding.kind, finding.message);
4799 self.mode = Mode::EditLintWarning {
4800 filename: filename.to_string(),
4801 message,
4802 };
4803 }
4804 Ok(())
4805 }
4806
4807 fn handle_edit_lint_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
4808 let (filename, _) = match &self.mode {
4809 Mode::EditLintWarning { filename, message } => (filename.clone(), message.clone()),
4810 _ => return Ok(Action::None),
4811 };
4812 match key.code {
4813 KeyCode::Char('r') | KeyCode::Char('R') => {
4814 self.mode = Mode::Board;
4815 Ok(Action::OpenEditor(filename))
4816 }
4817 KeyCode::Char('i') | KeyCode::Char('I') | KeyCode::Enter | KeyCode::Esc => {
4818 self.mode = Mode::Board;
4819 Ok(Action::None)
4820 }
4821 _ => Ok(Action::None),
4822 }
4823 }
4824
4825 fn handle_rollover_dialog_key(&mut self, key: KeyEvent) -> Result<Action> {
4826 let Some(dialog) = self.rollover_dialog.as_mut() else {
4827 self.mode = Mode::Board;
4828 return Ok(Action::None);
4829 };
4830 match dialog.handle_key(key.code) {
4831 DialogEvent::ConfirmDo => {
4832 crate::commands::rollover::run(&self.store.config, &self.store, None, true)?;
4833 self.rebuild_board();
4834 self.status_msg = Some("Rollover applied".to_string());
4835 self.rollover_dialog = None;
4836 self.mode = Mode::Board;
4837 }
4838 DialogEvent::ConfirmIgnore | DialogEvent::Dismiss => {
4839 self.rollover_dialog = None;
4840 self.mode = Mode::Board;
4841 }
4842 DialogEvent::None => {}
4843 }
4844 Ok(Action::None)
4845 }
4846}
4847
4848const HINT_HEIGHT: u16 = 1;
4849const TAB_HEIGHT: u16 = 2;
4853const FILTER_HEIGHT: u16 = 1;
4856const HOVER_HEIGHT: u16 = 1;
4859const RENDER_FALLBACK_WIDTH: u16 = 80;
4860const RENDER_FALLBACK_HEIGHT: u16 = 24;
4861const RENDER_BOTTOM_MARGIN: u16 = 4;
4864
4865pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
4866 app.frame_size.set(area);
4867 app.frame_hit_regions.borrow_mut().clear();
4868 match &app.mode {
4869 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
4870 Mode::BulkModal => {
4871 render_board(app, f, area, commandbar::MODE_BOARD);
4874 if let Some(modal) = app.bulk.as_ref() {
4875 crate::ui::bulk::render(f, area, modal);
4876 }
4877 }
4878 Mode::TriageInbox => {
4879 render_board(app, f, area, commandbar::MODE_BOARD);
4883 if let Some(state) = app.triage.as_ref() {
4884 crate::ui::triage::render(f, area, state);
4885 }
4886 }
4887 Mode::RolloverDialog => {
4888 render_board(app, f, area, commandbar::MODE_BOARD);
4889 if let Some(dialog) = app.rollover_dialog.as_ref() {
4890 let rect = crate::ui::modal::modal_rect(area);
4891 f.render_widget(Clear, rect);
4892 let mut lines = Vec::new();
4893 lines.push(Line::from(Span::styled(
4894 "Rollover needed",
4895 Style::default()
4896 .fg(Color::Yellow)
4897 .add_modifier(Modifier::BOLD),
4898 )));
4899 for (from, to, count) in &dialog.plans {
4900 lines.push(Line::from(format!(
4901 "{count} issue(s): \"{from}\" -> \"{to}\""
4902 )));
4903 }
4904 lines.push(Line::from(""));
4905 lines.push(Line::from(format!(
4906 "{}: Do rollover | {}: Toggle choice | {}: Ignore",
4907 app.bindings.label(
4908 InputContext::Rollover,
4909 crate::keybindings::TuiAction::Submit
4910 ),
4911 app.bindings.label(
4912 InputContext::Rollover,
4913 crate::keybindings::TuiAction::Previous
4914 ),
4915 app.bindings.label(
4916 InputContext::Rollover,
4917 crate::keybindings::TuiAction::Cancel
4918 ),
4919 )));
4920 lines.push(Line::from(match dialog.choice {
4921 crate::ui::dialog::DialogChoice::DoRollover => "> Do rollover | Ignore",
4922 crate::ui::dialog::DialogChoice::Ignore => "Do rollover | > Ignore",
4923 }));
4924 let para = Paragraph::new(lines).block(
4925 Block::default()
4926 .borders(Borders::ALL)
4927 .title("Rollover needed")
4928 .style(Style::default().fg(Color::Yellow)),
4929 );
4930 f.render_widget(para, rect);
4931 }
4932 }
4933 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
4934 Mode::Form => {
4935 if let Some(fr) = &app.form {
4936 render_board(app, f, area, commandbar::MODE_BOARD);
4937 let rect = crate::ui::modal::modal_rect(area);
4938 f.render_widget(Clear, rect);
4939 form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
4940 if let Some(picker) = &app.form_picker {
4941 render_form_picker(picker, f, area, &app.bindings);
4942 } else {
4943 form::place_caret(fr, f, rect);
4947 }
4948 }
4949 }
4950 Mode::ConfirmDelete => {
4951 if area.height < 2 {
4952 return;
4953 }
4954 board::render(
4955 &app.board,
4956 f,
4957 area,
4958 &None,
4959 app.drag_preview_col,
4960 board::BoardDisplay::from(app.active_board_config()),
4961 );
4962 let title = app.confirm_delete_target.as_deref().unwrap_or("?");
4963 let prompt_area = confirm_delete_prompt(area);
4964 let prompt = Paragraph::new(vec![
4965 Line::from(Span::styled(
4966 format!("Delete '{title}'? [y/N]"),
4967 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4968 )),
4969 Line::from(""),
4970 Line::from(" [Yes] [No] "),
4971 ])
4972 .block(
4973 Block::default()
4974 .borders(Borders::ALL)
4975 .title("Confirm")
4976 .style(Style::default().fg(Color::Red)),
4977 );
4978 f.render_widget(prompt, prompt_area);
4979 let (yes, no) = confirm_delete_buttons(prompt_area);
4982 app.record_region(yes, Action::ConfirmDeleteYes);
4983 app.record_region(no, Action::ConfirmDeleteNo);
4984 }
4985 Mode::ConfirmBlockedMove { warning, .. } => {
4986 if area.height < 3 {
4987 return;
4988 }
4989 board::render(
4990 &app.board,
4991 f,
4992 area,
4993 &None,
4994 app.drag_preview_col,
4995 board::BoardDisplay::from(app.active_board_config()),
4996 );
4997 let prompt_area = Rect {
4998 y: area.y + area.height - 2,
4999 height: 2,
5000 ..area
5001 };
5002 let prompt = Paragraph::new(vec![
5005 Line::from(Span::styled(
5006 format!("{warning} — move anyway? [y/N]"),
5007 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5008 )),
5009 Line::from(" The decision is recorded in the issue's History."),
5010 ])
5011 .block(
5012 Block::default()
5013 .borders(Borders::ALL)
5014 .title("Blocked move")
5015 .style(Style::default().fg(Color::Red)),
5016 );
5017 f.render_widget(prompt, prompt_area);
5018 }
5019 Mode::MoveIntoDone { buffer, .. } => {
5020 if area.height < 3 {
5021 return;
5022 }
5023 let prompt_area = Rect {
5024 y: area.y + area.height - 1,
5025 height: 1,
5026 ..area
5027 };
5028 let block_area = Rect {
5029 height: area.height - 1,
5030 ..area
5031 };
5032 board::render(
5033 &app.board,
5034 f,
5035 block_area,
5036 &app.status_msg,
5037 app.drag_preview_col,
5038 board::BoardDisplay::from(app.active_board_config()),
5039 );
5040 let prompt_text = format!("Commit hash: {buffer}_");
5041 let prompt = Paragraph::new(Line::from(Span::styled(
5042 prompt_text,
5043 Style::default().fg(Color::Yellow),
5044 )));
5045 f.render_widget(prompt, prompt_area);
5046 }
5047 Mode::TransitionReason { buffer, reason, .. } => {
5052 if area.height < 3 {
5053 return;
5054 }
5055 let block_area = Rect {
5056 height: area.height - 1,
5057 ..area
5058 };
5059 board::render(
5060 &app.board,
5061 f,
5062 block_area,
5063 &app.status_msg,
5064 app.drag_preview_col,
5065 board::BoardDisplay::from(app.active_board_config()),
5066 );
5067 let prompt_area = Rect {
5068 y: area.y + area.height - 1,
5069 height: 1,
5070 ..area
5071 };
5072 let prompt = Paragraph::new(Line::from(Span::styled(
5073 format!("Reason (required): {buffer}_"),
5074 Style::default().fg(Color::Yellow),
5075 )));
5076 f.render_widget(prompt, prompt_area);
5077 if area.height >= 5 {
5082 let notice = Rect {
5083 y: area.y,
5084 height: 3,
5085 ..area
5086 };
5087 let text = Paragraph::new(vec![
5088 Line::from(Span::styled(
5089 "Blocked by the transition policy",
5090 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5091 )),
5092 Line::from(reason.clone()),
5093 ])
5094 .block(
5095 Block::default()
5096 .borders(Borders::ALL)
5097 .title("Override required"),
5098 );
5099 f.render_widget(text, notice);
5100 }
5101 }
5102 Mode::Help { return_to } => render_help(app, f, area, return_to),
5103 Mode::ContextMenu { return_to } => render_context_menu(app, f, area, return_to),
5104 Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
5105 Mode::FilterModal => {
5106 render_board(app, f, area, commandbar::MODE_BOARD);
5107 if let Some(m) = app.filter_modal.as_ref() {
5108 m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
5109 let rect = crate::ui::modal::modal_rect(area);
5112 for (row, field) in FilterField::ALL.iter().enumerate() {
5113 let hit = Rect {
5114 x: rect.x + 1,
5115 y: rect.y + 1 + row as u16,
5116 width: rect.width.saturating_sub(2),
5117 height: 1,
5118 };
5119 app.record_region(hit, Action::FilterModalFocus(*field));
5120 }
5121 }
5122 }
5123 Mode::Settings => {
5124 render_board(app, f, area, commandbar::MODE_BOARD);
5125 if let Some(m) = app.settings.as_ref() {
5126 let board = app.active_board_config();
5127 m.render_with_bindings(
5128 f,
5129 area,
5130 app.active_board_name(),
5131 board,
5132 &app.bindings,
5133 InputContext::Settings,
5134 );
5135 let rect = crate::ui::modal::modal_rect(area);
5138 let total = board.columns.len() + crate::ui::settings::DISPLAY_ROWS;
5139 for row in 0..total {
5140 let hit = Rect {
5141 x: rect.x + 1,
5142 y: rect.y + 1 + row as u16,
5143 width: rect.width.saturating_sub(2),
5144 height: 1,
5145 };
5146 app.record_region(hit, Action::SettingsRow(row));
5147 }
5148 }
5149 }
5150 Mode::PlanningManagement => {
5151 render_board(app, f, area, commandbar::MODE_BOARD);
5152 if let Some(m) = app.planning.as_ref() {
5153 let (cycles, milestones) = app.planning_rows();
5154 m.render_with_bindings(
5155 f,
5156 area,
5157 &cycles,
5158 &milestones,
5159 &app.bindings,
5160 InputContext::PlanningManagement,
5161 );
5162 }
5163 }
5164 Mode::PlanningForm => {
5165 render_board(app, f, area, commandbar::MODE_BOARD);
5166 if let Some(fm) = app.planning_form.as_ref() {
5167 fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
5168 }
5169 }
5170 Mode::DatePicker { return_to } => {
5171 render_board(app, f, area, commandbar::MODE_BOARD);
5172 match return_to.as_ref() {
5173 Mode::Form => {
5174 if let Some(fr) = app.form.as_ref() {
5175 let rect = crate::ui::modal::modal_rect(area);
5176 f.render_widget(Clear, rect);
5177 form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
5178 }
5179 }
5180 Mode::PlanningForm => {
5181 if let Some(fm) = app.planning_form.as_ref() {
5182 fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
5183 }
5184 }
5185 Mode::FilterModal => {
5186 if let Some(m) = app.filter_modal.as_ref() {
5187 m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
5188 }
5189 }
5190 _ => {}
5191 }
5192 if let Some(picker) = app.date_picker.as_ref() {
5193 picker.render_with_bindings(f, area, &app.bindings, InputContext::DatePicker);
5194 let rect = picker.popup_rect(area);
5197 let days_y = rect.y + 3;
5198 let weeks = rect.height.saturating_sub(6);
5199 for row in 0..weeks {
5200 for col in 0..7u16 {
5201 let x = rect.x + 1 + col * 3;
5202 let y = days_y + row;
5203 if let Some(date) = picker.date_at(area, x + 1, y) {
5204 let hit = Rect {
5205 x,
5206 y,
5207 width: 3,
5208 height: 1,
5209 };
5210 app.record_region(
5211 hit,
5212 Action::DatePickerHit(DatePickerHit::Day(
5213 crate::ui::date_picker::format_date(date),
5214 )),
5215 );
5216 }
5217 }
5218 }
5219 let header_y = rect.y + 1;
5220 let header_w = rect.width.saturating_sub(2);
5221 let split = header_w / 2;
5222 if split > 0 {
5223 app.record_region(
5224 Rect {
5225 x: rect.x + 1,
5226 y: header_y,
5227 width: split,
5228 height: 1,
5229 },
5230 Action::DatePickerHit(DatePickerHit::PrevMonth),
5231 );
5232 app.record_region(
5233 Rect {
5234 x: rect.x + 1 + split,
5235 y: header_y,
5236 width: header_w - split,
5237 height: 1,
5238 },
5239 Action::DatePickerHit(DatePickerHit::NextMonth),
5240 );
5241 }
5242 app.record_region(
5243 Rect {
5244 x: rect.x,
5245 y: rect.y + rect.height.saturating_sub(1),
5246 width: rect.width,
5247 height: 1,
5248 },
5249 Action::DatePickerHit(DatePickerHit::Submit),
5250 );
5251 }
5252 }
5253 Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
5254 Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
5255 Mode::EditLintWarning { filename, message } => {
5256 render_edit_lint_warning(app, f, area, filename, message)
5257 }
5258 Mode::LinkCreate { buffer, step, .. } | Mode::LinkEdit { buffer, step, .. } => {
5262 let label = match (step, matches!(app.mode, Mode::LinkEdit { .. })) {
5263 (LinkStep::Target, false) => "Link to issue",
5264 (LinkStep::Target, true) => "Re-target to issue (blank keeps)",
5265 (LinkStep::Kind, false) => "Kind (required)",
5266 (LinkStep::Kind, true) => "Kind (required)",
5267 };
5268 render_detail_input(app, f, area, label, buffer)
5269 }
5270 Mode::ConfirmRemoveRelationship { .. } => {
5271 if let Some(ref d) = app.detail {
5272 detail::render(d, f, area);
5273 }
5274 render_input_line(
5275 f,
5276 Rect {
5277 y: area.y + area.height.saturating_sub(1),
5278 height: 1,
5279 ..area
5280 },
5281 "Remove relationship (y to confirm)",
5282 "",
5283 );
5284 }
5285 }
5286}
5287
5288fn render_form_picker(
5289 picker: &FormPicker,
5290 f: &mut ratatui::Frame,
5291 area: Rect,
5292 bindings: &EffectiveBindings,
5293) {
5294 let rect = crate::ui::modal::modal_rect(area);
5295 let picker_rect = Rect::new(
5296 rect.x.saturating_add(2),
5297 rect.y.saturating_add(2),
5298 rect.width.saturating_sub(4).max(10),
5299 rect.height.saturating_sub(4).max(5),
5300 );
5301 f.render_widget(Clear, picker_rect);
5302
5303 let mut lines = Vec::new();
5304 lines.push(Line::from(format!("Filter: {}", picker.state.filter)));
5305 lines.push(Line::from(""));
5306
5307 if picker.state.mode == PickerMode::InlineCreate {
5308 lines.push(Line::from(Span::styled(
5309 format!("Create: {}_", picker.state.create_buffer),
5310 Style::default().fg(Color::Yellow),
5311 )));
5312 } else {
5313 let visible = picker.state.visible_indices();
5314 for (pos, idx) in visible.into_iter().take(8).enumerate() {
5315 let item = &picker.state.options[idx];
5316 let selected = match &picker.state.selection {
5317 SelectionModel::Single(current) => current.as_deref() == Some(item.id.as_str()),
5318 SelectionModel::Multi(current) => current.contains(&item.id),
5319 };
5320 let cursor = if pos == picker.state.cursor { ">" } else { " " };
5321 let mark = if selected { "[x]" } else { "[ ]" };
5322 lines.push(Line::from(format!("{cursor} {mark} {}", item.label)));
5323 }
5324 }
5325
5326 lines.push(Line::from(""));
5327 lines.push(Line::from(Span::styled(
5328 format!(
5329 "{}/{} close | {} select | {} create",
5330 bindings.label(
5331 InputContext::FormPicker,
5332 crate::keybindings::TuiAction::OpenPicker
5333 ),
5334 bindings.label(
5335 InputContext::FormPicker,
5336 crate::keybindings::TuiAction::Cancel
5337 ),
5338 bindings.label(
5339 InputContext::FormPicker,
5340 crate::keybindings::TuiAction::Submit
5341 ),
5342 bindings.label(
5343 InputContext::FormPicker,
5344 crate::keybindings::TuiAction::Create
5345 ),
5346 ),
5347 Style::default().fg(Color::DarkGray),
5348 )));
5349
5350 let title = match picker.field {
5351 FormPickerField::Labels => "Labels picker",
5352 FormPickerField::Assignee => "Assignee picker",
5353 FormPickerField::Cycles => "Cycles picker",
5354 FormPickerField::Milestones => "Milestones picker",
5355 FormPickerField::Reviewer => "Reviewer picker",
5356 FormPickerField::Parent => "Parent picker",
5357 };
5358 let para = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
5359 f.render_widget(para, picker_rect);
5360}
5361
5362fn render_edit_lint_warning(
5363 app: &App,
5364 f: &mut ratatui::Frame,
5365 area: Rect,
5366 filename: &str,
5367 message: &str,
5368) {
5369 render_board(app, f, area, commandbar::MODE_BOARD);
5370 let rect = crate::ui::modal::modal_rect(area);
5371 f.render_widget(Clear, rect);
5372 let lines = vec![
5373 Line::from(Span::styled("Lint failed", Style::default().fg(Color::Red))),
5374 Line::from(Span::raw(filename.to_string())),
5375 Line::from(Span::styled(
5376 message.to_string(),
5377 Style::default().fg(Color::Yellow),
5378 )),
5379 Line::from(""),
5380 Line::from(Span::styled(
5381 format!(
5382 "{}: re-edit · {}: ignore",
5383 app.bindings.label(
5384 InputContext::EditWarning,
5385 crate::keybindings::TuiAction::Reedit
5386 ),
5387 app.bindings.label(
5388 InputContext::EditWarning,
5389 crate::keybindings::TuiAction::Ignore
5390 ),
5391 ),
5392 Style::default().fg(Color::DarkGray),
5393 )),
5394 ];
5395 let para = Paragraph::new(lines).block(
5396 Block::default()
5397 .borders(Borders::ALL)
5398 .title("Edit Warning")
5399 .style(Style::default().fg(Color::Red)),
5400 );
5401 f.render_widget(para, rect);
5402}
5403
5404fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
5409 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5410 let hover_area = Rect {
5411 y: area.y + tab_h,
5412 height: HOVER_HEIGHT,
5413 ..area
5414 };
5415 let line = match app.board.get_selected_card() {
5416 Some(card) => Line::from(vec![
5417 Span::styled(
5418 format!("#{}", card.number),
5419 Style::default().fg(Color::Cyan),
5420 ),
5421 Span::raw(" "),
5422 Span::styled(card.title.clone(), Style::default()),
5423 ]),
5424 None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
5425 };
5426 f.render_widget(Paragraph::new(line), hover_area);
5427}
5428
5429fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
5434 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5435 let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
5436 FILTER_HEIGHT
5437 } else {
5438 0
5439 };
5440 let hint_h: u16 = if app.one_shot { 0 } else { HINT_HEIGHT };
5441 if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
5442 return;
5443 }
5444 let hint_area = Rect {
5445 y: area.y + area.height - hint_h,
5446 height: hint_h,
5447 ..area
5448 };
5449 let tabs_area = Rect {
5450 y: area.y,
5451 height: tab_h,
5452 ..area
5453 };
5454 let filter_area = Rect {
5455 y: area.y + tab_h + HOVER_HEIGHT,
5456 height: filter_h,
5457 ..area
5458 };
5459 let content = Rect {
5460 y: area.y + tab_h + HOVER_HEIGHT + filter_h,
5461 height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
5462 ..area
5463 };
5464 if tab_h > 0 {
5465 app.tabs.render(f, tabs_area);
5466 for index in 0..app.tabs.len() {
5467 if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
5468 app.record_region(rect, Action::SwitchBoard(index));
5469 }
5470 }
5471 }
5472 render_hover_line(app, f, area);
5473 if filter_h > 0 {
5474 filter_row(app).render(f, filter_area);
5475 }
5476 let page_cards = board::render(
5477 &app.board,
5478 f,
5479 content,
5480 &app.status_msg,
5481 app.drag_preview_col,
5482 board::BoardDisplay::from(app.active_board_config()),
5483 );
5484 app.board_page.set(page_cards);
5488 if hint_h > 0 {
5489 render_hint(f, hint_area, &app.bindings, tag);
5490 }
5491}
5492
5493fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
5495 let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
5496 if area.height <= HINT_HEIGHT + status_h {
5497 return;
5498 }
5499 let hint_area = Rect {
5500 y: area.y + area.height - HINT_HEIGHT,
5501 height: HINT_HEIGHT,
5502 ..area
5503 };
5504 let content = Rect {
5505 height: area.height - status_h - HINT_HEIGHT,
5506 ..area
5507 };
5508 if let Some(ref d) = app.detail {
5509 detail::render(d, f, content);
5510 }
5511 if let Some(msg) = &app.status_msg {
5512 let status_area = Rect {
5513 y: area.y + area.height - status_h - HINT_HEIGHT,
5514 height: status_h,
5515 ..area
5516 };
5517 let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
5518 .style(Style::default().fg(Color::Yellow));
5519 f.render_widget(para, status_area);
5520 }
5521 render_hint(f, hint_area, &app.bindings, tag);
5522}
5523
5524fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5528 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5529 if area.height < tab_h + HOVER_HEIGHT + 1 {
5530 return;
5531 }
5532 let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
5533 FILTER_HEIGHT
5534 } else {
5535 0
5536 };
5537 let input_area = Rect {
5538 y: area.y + area.height - 1,
5539 height: 1,
5540 ..area
5541 };
5542 let tabs_area = Rect {
5543 y: area.y,
5544 height: tab_h,
5545 ..area
5546 };
5547 let filter_area = Rect {
5548 y: area.y + tab_h + HOVER_HEIGHT,
5549 height: filter_h,
5550 ..area
5551 };
5552 let content = Rect {
5553 y: area.y + tab_h + HOVER_HEIGHT + filter_h,
5554 height: area
5555 .height
5556 .saturating_sub(tab_h + HOVER_HEIGHT + filter_h + 1),
5557 ..area
5558 };
5559 if tab_h > 0 {
5560 app.tabs.render(f, tabs_area);
5561 for index in 0..app.tabs.len() {
5562 if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
5563 app.record_region(rect, Action::SwitchBoard(index));
5564 }
5565 }
5566 }
5567 render_hover_line(app, f, area);
5568 if filter_h > 0 {
5569 filter_row(app).render(f, filter_area);
5570 }
5571 let page_cards = board::render(
5572 &app.board,
5573 f,
5574 content,
5575 &app.status_msg,
5576 app.drag_preview_col,
5577 board::BoardDisplay::from(app.active_board_config()),
5578 );
5579 app.board_page.set(page_cards);
5583 render_input_line(f, input_area, label, buffer);
5584}
5585
5586fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5588 if area.height < 2 {
5589 return;
5590 }
5591 let input_area = Rect {
5592 y: area.y + area.height - 1,
5593 height: 1,
5594 ..area
5595 };
5596 let content = Rect {
5597 height: area.height - 1,
5598 ..area
5599 };
5600 if let Some(ref d) = app.detail {
5601 detail::render(d, f, content);
5602 }
5603 render_input_line(f, input_area, label, buffer);
5604}
5605
5606fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5607 let text = format!("{label}: {buffer}_");
5608 let prompt = Paragraph::new(Line::from(Span::styled(
5609 text,
5610 Style::default().fg(Color::Yellow),
5611 )));
5612 f.render_widget(prompt, area);
5613}
5614
5615fn filter_row(app: &App) -> crate::ui::filter_row::FilterRow {
5616 crate::ui::filter_row::FilterRow {
5617 query: app.filter.clone(),
5618 structured: app.structured.clone(),
5619 }
5620}
5621
5622fn render_hint(f: &mut ratatui::Frame, area: Rect, bindings: &EffectiveBindings, tag: &str) {
5623 let context = if tag == commandbar::MODE_DETAIL {
5624 InputContext::Detail
5625 } else {
5626 InputContext::Board
5627 };
5628 let bar = commandbar::hint_bar_for(bindings, context);
5629 let para = Paragraph::new(Line::from(Span::styled(
5630 bar,
5631 Style::default().fg(Color::Gray),
5632 )));
5633 f.render_widget(para, area);
5634}
5635
5636fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
5640 match return_to {
5641 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
5642 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
5643 _ => render_board(app, f, area, commandbar::MODE_BOARD),
5644 }
5645
5646 let help_entries = commandbar::help_overlay(&app.bindings);
5647 let query = app.help_query.trim().to_string();
5652 let mut fuzzy_fallback = false;
5660 let matches: Vec<(String, String, String)> = if query.is_empty() {
5661 help_entries.clone()
5662 } else {
5663 let needle = query.to_lowercase();
5664 let haystack = |entry: &(String, String, String)| {
5665 format!("{} {} {}", entry.0, entry.1, entry.2).to_lowercase()
5666 };
5667 let literal: Vec<(String, String, String)> = help_entries
5668 .iter()
5669 .filter(|entry| haystack(entry).contains(&needle))
5670 .cloned()
5671 .collect();
5672 if literal.is_empty() {
5673 fuzzy_fallback = true;
5674 let mut scored: Vec<(u32, (String, String, String))> = help_entries
5677 .iter()
5678 .filter_map(|entry| {
5679 crate::search::fuzzy_score(&query, &haystack(entry))
5680 .map(|score| (score, entry.clone()))
5681 })
5682 .collect();
5683 scored.sort_by(|a, b| b.0.cmp(&a.0));
5684 scored.into_iter().map(|(_, entry)| entry).collect()
5685 } else {
5686 literal
5687 }
5688 };
5689 let total = help_entries.len();
5690 let frame_rows = area.height.saturating_sub(2);
5694 let visible = (matches.len() as u16).min(frame_rows.saturating_sub(1));
5695 let hidden = matches.len().saturating_sub(visible as usize);
5696 app.help_metrics.set(HelpMetrics {
5699 visible,
5700 max_scroll: u16::try_from(hidden).unwrap_or(u16::MAX),
5701 });
5702 let offset = usize::from(app.help_scroll).min(hidden);
5703 let mut rows: Vec<Line<'static>> = matches
5704 .iter()
5705 .skip(offset)
5706 .take(visible as usize)
5707 .map(|(key, desc, scope)| {
5708 Line::from(vec![
5709 Span::styled(format!("{key:<20}"), Style::default().fg(Color::Cyan)),
5710 Span::styled(format!("{desc:<28}"), Style::default().fg(Color::White)),
5711 Span::styled(scope.clone(), Style::default().fg(Color::DarkGray)),
5712 ])
5713 })
5714 .collect();
5715 let caret = if app.help_searching {
5717 let mut spans = vec![
5718 Span::styled("/", Style::default().fg(Color::Yellow)),
5719 Span::styled(app.help_query.clone(), Style::default().fg(Color::White)),
5720 ];
5721 if !query.is_empty() {
5726 spans.push(Span::styled(
5727 format!(
5728 " — {} of {total} bindings{}",
5729 matches.len(),
5730 if fuzzy_fallback { " (fuzzy)" } else { "" },
5731 ),
5732 Style::default().fg(Color::DarkGray),
5733 ));
5734 }
5735 rows.push(Line::from(spans));
5736 Some(Line::from(format!("/{}", app.help_query)).width())
5737 } else if !query.is_empty() {
5738 let mode = if fuzzy_fallback { " (fuzzy)" } else { "" };
5744 rows.push(Line::from(Span::styled(
5745 match hidden {
5746 0 => format!(
5747 "match \"{query}\" — {} of {total} bindings{mode}",
5748 matches.len()
5749 ),
5750 _ => format!(
5751 "match \"{query}\" — {} of {total} bindings, rows {}-{}{mode}",
5752 matches.len(),
5753 offset + 1,
5754 offset + visible as usize,
5755 ),
5756 },
5757 Style::default().fg(Color::DarkGray),
5758 )));
5759 None
5760 } else if hidden > 0 {
5761 rows.push(Line::from(Span::styled(
5771 format!(
5772 "rows {}-{} of {} — {} above, {} below",
5773 offset + 1,
5774 offset + visible as usize,
5775 matches.len(),
5776 offset,
5777 hidden - offset,
5778 ),
5779 Style::default().fg(Color::DarkGray),
5780 )));
5781 None
5782 } else {
5783 None
5784 };
5785 let block = Block::default()
5786 .borders(Borders::ALL)
5787 .title("Help [? close]")
5788 .style(Style::default().fg(Color::Cyan));
5789 let para = Paragraph::new(rows).block(block);
5790 let notice_rows = u16::from(caret.is_some() || !query.is_empty() || hidden > 0);
5793 let drawn_rows = visible + notice_rows;
5794
5795 let modal_w = 60.min(area.width).max(30);
5796 let modal_h = (drawn_rows + 2).min(area.height).max(4);
5799 let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
5800 let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
5801 let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
5802
5803 if area.height >= modal_h && area.width >= modal_w {
5804 f.render_widget(Clear, modal_area);
5805 f.render_widget(para, modal_area);
5806 }
5807 if let Some(column) = caret
5811 && area.height >= modal_h
5812 && area.width >= modal_w
5813 {
5814 let inner = Block::default().borders(Borders::ALL).inner(modal_area);
5815 let row = inner.y + visible.min(inner.height.saturating_sub(1));
5816 let x = inner.x
5817 + u16::try_from(column)
5818 .unwrap_or(u16::MAX)
5819 .min(inner.width.saturating_sub(1));
5820 f.set_cursor_position(ratatui::layout::Position::new(x, row));
5821 }
5822 app.record_region(area, Action::HelpClose);
5825}
5826
5827fn confirm_delete_prompt(area: Rect) -> Rect {
5831 let prompt_w = 40.min(area.width);
5832 let prompt_h = 4;
5833 let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
5834 let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
5835 Rect::new(prompt_x, prompt_y, prompt_w, prompt_h)
5836}
5837
5838fn confirm_delete_buttons(prompt: Rect) -> (Rect, Rect) {
5842 let yes = Rect::new(prompt.x + 3, prompt.y + 3, 5, 1);
5843 let no = Rect::new(prompt.x + 10, prompt.y + 3, 4, 1);
5844 (yes, no)
5845}
5846
5847fn render_context_menu(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
5851 match return_to {
5852 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
5853 _ => render_board(app, f, area, commandbar::MODE_BOARD),
5854 }
5855 if let Some(menu) = app.context_menu.as_ref() {
5856 menu.render(f, area);
5857 }
5858}
5859
5860pub fn drain_disclosures(app: &mut App) {
5868 for disclosure in crate::storage::take_disclosures() {
5869 app.note_disclosure(disclosure);
5870 }
5871}
5872
5873pub fn run(config: &Config) -> Result<()> {
5874 run_session(config, false)
5875}
5876
5877pub fn run_new_issue(config: &Config) -> Result<()> {
5884 run_session(config, true)
5885}
5886
5887fn run_session(config: &Config, new_issue_form: bool) -> Result<()> {
5888 let mut app = App::new(config)?;
5889 if new_issue_form {
5890 app.open_new_issue_form();
5891 }
5892 let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
5893 enable_raw_mode().map_err(io_err)?;
5894 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
5895 mouse_capture_on(io::stdout()).map_err(io_err)?;
5896 let guard = RestoreGuard;
5897 let backend = CrosstermBackend::new(io::stdout());
5898 let mut terminal = Terminal::new(backend).map_err(io_err)?;
5899
5900 let result = loop {
5901 drain_disclosures(&mut app);
5910 terminal
5911 .draw(|f| render(&app, f, f.area()))
5912 .map_err(io_err)?;
5913
5914 if event::poll(Duration::from_millis(200)).map_err(io_err)? {
5915 let action = match event::read().map_err(io_err)? {
5916 Event::Key(key) => app.handle_key(key)?,
5917 Event::Mouse(mouse) => app.handle_mouse(mouse)?,
5918 _ => Action::None,
5921 };
5922 match action {
5923 Action::None => {}
5924 Action::Quit => break Ok(()),
5925 Action::SwitchBoard(index) => {
5926 app.switch_board(index);
5927 }
5928 Action::ConfirmDeleteYes => {
5929 let _ = app.confirm_delete_yes();
5930 }
5931 Action::ConfirmDeleteNo => {
5932 let _ = app.confirm_delete_no();
5933 }
5934 Action::FilterModalFocus(field) => {
5935 if let Some(modal) = app.filter_modal.as_mut() {
5936 modal.focus_field(field);
5937 }
5938 }
5939 Action::SettingsRow(row) => {
5940 let count =
5941 app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
5942 if let Some(modal) = app.settings.as_mut() {
5943 modal.move_selected_to(row, count);
5944 }
5945 let _ = app.settings_toggle_selected();
5946 }
5947 Action::DatePickerHit(hit) => match hit {
5948 DatePickerHit::Day(date) => {
5949 if let Some(picker) = app.date_picker.as_mut() {
5950 picker.select_date_str(&date);
5951 }
5952 }
5953 DatePickerHit::PrevMonth => {
5954 if let Some(picker) = app.date_picker.as_mut() {
5955 picker.shift_month(-1);
5956 }
5957 }
5958 DatePickerHit::NextMonth => {
5959 if let Some(picker) = app.date_picker.as_mut() {
5960 picker.shift_month(1);
5961 }
5962 }
5963 DatePickerHit::Submit => {
5964 let _ = app.handle_date_picker_key(KeyEvent::new(
5965 KeyCode::Enter,
5966 crossterm::event::KeyModifiers::NONE,
5967 ));
5968 }
5969 },
5970 Action::HelpClose => {
5971 app.close_help();
5972 }
5973 Action::OpenEditor(filename) => {
5974 drop(terminal);
5975 let _ = disable_raw_mode();
5976 let _ = mouse_capture_off(io::stdout());
5977 let _ = execute!(io::stdout(), LeaveAlternateScreen);
5978
5979 let edit_result = (|| -> Result<()> {
5980 let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
5981 let original = app.store.get(IssueSelector::Filename(filename.clone()))?;
5982 let temp = std::env::temp_dir().join(format!(
5983 "tix-edit-{}-{}.md",
5984 std::process::id(),
5985 original.number
5986 ));
5987 std::fs::write(&temp, original.to_file_contents()).map_err(TixError::Io)?;
5988 editor::run_editor(&name, &temp, &app.store.config)?;
5989 let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
5990 let mut parsed = Issue::parse(&edited)?;
5991 let _ = std::fs::remove_file(&temp);
5992 if parsed.title.trim().is_empty() {
5993 app.mode = Mode::EditLintWarning {
5994 filename: filename.clone(),
5995 message: "title is required".to_string(),
5996 };
5997 app.status_msg = Some("editor: title is required".to_string());
5998 return Ok(());
5999 }
6000 parsed.bump_updated();
6001 let location = match parsed.assignee {
6002 Some(ref a) => match app.store.reassign(&original.filename, Some(a)) {
6003 Ok(moved) => moved.to_string_lossy().to_string(),
6004 Err(e) => {
6005 app.status_msg = Some(format!("editor: {e}"));
6006 return Ok(());
6007 }
6008 },
6009 None => original.filename.clone(),
6010 };
6011 app.store.update(&location, &parsed)?;
6012 if let Some(ref mut d) = app.detail
6013 && (d.issue.filename == original.filename
6014 || d.issue.filename == location)
6015 {
6016 d.issue = parsed;
6017 d.scroll_offset = 0;
6018 }
6019 app.refresh_corpus()?;
6020 app.status_msg = Some("Edited".to_string());
6021 Ok(())
6022 })();
6023 if let Err(e) = edit_result {
6024 app.status_msg = Some(format!("editor: {e}"));
6025 }
6026
6027 enable_raw_mode().map_err(io_err)?;
6028 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
6029 mouse_capture_on(io::stdout()).map_err(io_err)?;
6030 let backend = CrosstermBackend::new(io::stdout());
6031 terminal = Terminal::new(backend).map_err(io_err)?;
6032 }
6033 Action::OpenAgent(filename) => {
6034 drop(terminal);
6035 let _ = disable_raw_mode();
6036 let _ = mouse_capture_off(io::stdout());
6037 let _ = execute!(io::stdout(), LeaveAlternateScreen);
6038
6039 let edit_result = (|| -> Result<()> {
6040 let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
6041 let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
6042 let temp = std::env::temp_dir().join(format!(
6043 "tix-agent-{}-{}.md",
6044 std::process::id(),
6045 issue.number
6046 ));
6047 std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
6048 let result = std::process::Command::new(&agent)
6049 .arg(&temp)
6050 .status()
6051 .map_err(|e| {
6052 crate::error::TixError::Editor(format!(
6053 "failed to launch {agent}: {e}"
6054 ))
6055 });
6056 let _ = std::fs::remove_file(&temp);
6057 result?;
6058 Ok(())
6059 })();
6060 if let Err(e) = edit_result {
6061 app.status_msg = Some(format!("agent: {e}"));
6062 }
6063
6064 enable_raw_mode().map_err(io_err)?;
6065 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
6066 mouse_capture_on(io::stdout()).map_err(io_err)?;
6067 let backend = CrosstermBackend::new(io::stdout());
6068 terminal = Terminal::new(backend).map_err(io_err)?;
6069 }
6070 }
6071 }
6072
6073 if watcher.poll()
6077 && let Err(e) = app.refresh_from_disk()
6078 {
6079 app.status_msg = Some(format!("watch: {e}"));
6080 }
6081 };
6082 drop(guard);
6083 result
6084}
6085
6086pub fn render_once(config: &Config) -> Result<String> {
6088 let size = resolve_render_size(crossterm::terminal::size().ok());
6089 render_once_at(config, size.0, size.1)
6090}
6091
6092fn resolve_render_size(measured: Option<(u16, u16)>) -> (u16, u16) {
6093 let (width, height) = measured.unwrap_or((RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT));
6094 (width.max(1), height.max(1))
6095}
6096
6097fn render_once_at(config: &Config, width: u16, height: u16) -> Result<String> {
6098 let render_h = height.max(1).saturating_sub(RENDER_BOTTOM_MARGIN).max(1);
6099 let mut app = App::new(config)?;
6100 app.mode = Mode::Board;
6101 app.rollover_dialog = None;
6102 app.one_shot = true;
6103 let cells = render_to_cells(&app, width, render_h)?;
6104 Ok(cells_to_text(&cells, width, render_h))
6105}
6106
6107fn render_to_cells(app: &App, width: u16, height: u16) -> Result<Vec<String>> {
6108 let backend = ratatui::backend::TestBackend::new(width.max(1), height.max(1));
6109 let mut terminal = ratatui::Terminal::new(backend).map_err(io_err)?;
6110 terminal
6111 .draw(|f| render(app, f, f.area()))
6112 .map_err(io_err)?;
6113 let buffer = terminal.backend().buffer();
6114 Ok(buffer
6115 .content
6116 .iter()
6117 .map(|cell| cell.symbol().to_string())
6118 .collect())
6119}
6120
6121fn cells_to_text(cells: &[String], width: u16, height: u16) -> String {
6122 let mut lines = Vec::new();
6123 for row in 0..height as usize {
6124 let start = row * width as usize;
6125 let end = start + width as usize;
6126 let line = cells[start..end].concat();
6127 lines.push(line.trim_end().to_string());
6128 }
6129 while matches!(lines.last(), Some(last) if last.is_empty()) {
6130 let _ = lines.pop();
6131 }
6132 if lines.is_empty() {
6133 String::new()
6134 } else {
6135 format!("{}\n", lines.join("\n"))
6136 }
6137}
6138
6139fn mouse_capture_on(mut stream: io::Stdout) -> Result<()> {
6143 execute!(stream, EnableMouseCapture).map_err(io_err)
6144}
6145
6146fn mouse_capture_off(mut stream: io::Stdout) -> Result<()> {
6150 execute!(stream, DisableMouseCapture).map_err(io_err)
6151}
6152
6153struct RestoreGuard;
6154
6155impl Drop for RestoreGuard {
6156 fn drop(&mut self) {
6157 let _ = disable_raw_mode();
6158 let _ = mouse_capture_off(io::stdout());
6159 let _ = execute!(io::stdout(), LeaveAlternateScreen);
6160 }
6161}
6162
6163fn io_err<E: std::fmt::Display>(e: E) -> TixError {
6164 TixError::Io(io::Error::other(e.to_string()))
6165}
6166
6167#[cfg(test)]
6168mod tests {
6169 use super::*;
6170 use std::fs;
6171 use tempfile::TempDir;
6172
6173 fn make_app() -> (TempDir, App) {
6174 let tmp = TempDir::new().unwrap();
6175 let dir = tmp.path().join("issues");
6176 fs::create_dir_all(&dir).unwrap();
6177 let config_path = tmp.path().join("tix.toml");
6178 let config = Config {
6179 issues_dir: dir.clone(),
6180 ..Config::default()
6181 };
6182 config.save(&config_path).unwrap();
6183 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6184 let app = App::new(&config).unwrap();
6185 (tmp, app)
6186 }
6187
6188 fn write_issue(
6189 dir: &std::path::Path,
6190 name: &str,
6191 number: u32,
6192 title: &str,
6193 status: &str,
6194 commit: Option<&str>,
6195 ) {
6196 let commit_line = match commit {
6197 Some(h) => format!("commit: \"{h}\""),
6198 None => String::new(),
6199 };
6200 let contents = format!(
6201 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
6202 );
6203 fs::write(dir.join(name), contents).unwrap();
6204 }
6205
6206 fn press(code: KeyCode) -> KeyEvent {
6207 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
6208 }
6209
6210 fn press_with_modifiers(code: KeyCode, modifiers: crossterm::event::KeyModifiers) -> KeyEvent {
6211 KeyEvent::new(code, modifiers)
6212 }
6213
6214 fn make_app_with_planning() -> (TempDir, App) {
6218 let tmp = TempDir::new().unwrap();
6219 let dir = tmp.path().join("issues");
6220 fs::create_dir_all(&dir).unwrap();
6221 let mut config = Config {
6222 issues_dir: dir.clone(),
6223 ..Config::default()
6224 };
6225 config.cycles.insert(
6226 "Cycle 1".into(),
6227 crate::config::PlanningCycle {
6228 description: Some("kickoff".into()),
6229 start_date: Some("2026-10-01".into()),
6230 due_date: Some("2999-12-31".into()),
6231 },
6232 );
6233 config.cycles.insert(
6234 "Cycle 2".into(),
6235 crate::config::PlanningCycle {
6236 description: Some("next".into()),
6237 start_date: Some("2999-01-01".into()),
6238 due_date: Some("2999-01-15".into()),
6239 },
6240 );
6241 let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\nmilestones: [\"GA\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
6242 fs::create_dir_all(dir.join("alice")).unwrap();
6243 fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
6244 let milestones_dir = tmp.path().join("docs/milestones");
6245 fs::create_dir_all(&milestones_dir).unwrap();
6246 fs::write(
6247 milestones_dir.join("2026-01-01-ga.md"),
6248 "---\nname: \"GA\"\ntarget_date: \"2026-12-31\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nship it\n",
6249 )
6250 .unwrap();
6251 let config_path = tmp.path().join("tix.toml");
6252 config.save(&config_path).unwrap();
6253 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6254 let app = App::new(&config).unwrap();
6255 (tmp, app)
6256 }
6257
6258 fn make_app_with_rollover_dialog() -> (TempDir, App) {
6259 let tmp = TempDir::new().unwrap();
6260 let dir = tmp.path().join("issues");
6261 fs::create_dir_all(&dir).unwrap();
6262 let mut config = Config {
6263 issues_dir: dir.clone(),
6264 ..Config::default()
6265 };
6266 config.cycles.clear();
6267 config.cycles.insert(
6268 "Cycle 1".into(),
6269 crate::config::PlanningCycle {
6270 description: Some("expired".into()),
6271 start_date: Some("2000-01-01".into()),
6272 due_date: Some("2000-01-02".into()),
6273 },
6274 );
6275 config.cycles.insert(
6276 "Cycle 2".into(),
6277 crate::config::PlanningCycle {
6278 description: Some("next".into()),
6279 start_date: Some("2999-01-01".into()),
6280 due_date: Some("2999-01-15".into()),
6281 },
6282 );
6283 let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
6284 fs::create_dir_all(dir.join("alice")).unwrap();
6285 fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
6286 let config_path = tmp.path().join("tix.toml");
6287 config.save(&config_path).unwrap();
6288 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6289 let app = App::new(&config).unwrap();
6290 (tmp, app)
6291 }
6292
6293 #[test]
6294 fn quit_on_q() {
6295 let (_tmp, mut app) = make_app();
6296 let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6297 assert!(app.quit);
6298 assert!(matches!(action, Action::Quit));
6299 }
6300
6301 #[test]
6302 fn m_opens_planning_modal_from_board() {
6303 let (_tmp, mut app) = make_app();
6304 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6305 assert!(
6306 matches!(app.mode, Mode::PlanningManagement),
6307 "m flips board mode to planning management"
6308 );
6309 assert!(app.planning.is_some(), "planning modal state is opened");
6310 }
6311
6312 #[test]
6313 fn q_and_esc_close_planning_modal() {
6314 let (_tmp, mut app) = make_app();
6315 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6316 let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6317 assert!(matches!(app.mode, Mode::Board));
6318 assert!(app.planning.is_none());
6319
6320 let (_tmp, mut app) = make_app();
6321 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6322 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6323 assert!(matches!(app.mode, Mode::Board), "Esc closes the modal");
6324 assert!(app.planning.is_none());
6325 }
6326
6327 #[test]
6328 fn esc_cancels_planning_form_back_to_modal() {
6329 let (_tmp, mut app) = make_app();
6330 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6331 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6332 assert!(matches!(app.mode, Mode::PlanningForm));
6333 assert!(app.planning_form.is_some());
6334 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6335 assert!(
6336 matches!(app.mode, Mode::PlanningManagement),
6337 "Esc in the form returns to the modal"
6338 );
6339 assert!(app.planning_form.is_none());
6340 assert!(app.planning.is_some(), "the modal stays open underneath");
6341 }
6342
6343 #[test]
6344 fn planning_browse_navigates_and_switches_segments() {
6345 let (_tmp, mut app) = make_app_with_planning();
6346 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6347
6348 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6349 assert_eq!(app.planning.as_ref().unwrap().selected, 1);
6350 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6351 assert_eq!(
6352 app.planning.as_ref().unwrap().selected,
6353 1,
6354 "cursor clamps at the last cycle"
6355 );
6356 let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
6357 assert_eq!(app.planning.as_ref().unwrap().selected, 0);
6358
6359 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6360 let modal = app.planning.as_ref().unwrap();
6361 assert_eq!(
6362 modal.segment,
6363 crate::ui::planning::PlanningSegment::Milestones,
6364 "Tab switches to the milestones segment"
6365 );
6366 assert_eq!(modal.selected, 0, "segment switch resets the row cursor");
6367 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6368 assert_eq!(
6369 app.planning.as_ref().unwrap().selected,
6370 0,
6371 "a single milestone clamps the cursor"
6372 );
6373
6374 let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
6375 assert_eq!(
6376 app.planning.as_ref().unwrap().segment,
6377 crate::ui::planning::PlanningSegment::Cycles,
6378 "BackTab switches back to the cycles segment"
6379 );
6380
6381 let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6382 assert!(
6383 matches!(app.mode, Mode::Board),
6384 "q cancels out of the modal"
6385 );
6386 assert!(app.planning.is_none());
6387 }
6388
6389 #[test]
6390 fn planning_n_opens_create_form_for_active_segment() {
6391 let (_tmp, mut app) = make_app_with_planning();
6392 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6393 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6394 assert!(matches!(app.mode, Mode::PlanningForm));
6395 assert_eq!(
6396 app.planning_form.as_ref().unwrap().kind,
6397 crate::ui::planning::PlanningKind::Cycle,
6398 "n on the cycles segment opens a cycle form"
6399 );
6400
6401 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6402 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6403 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6404 assert_eq!(
6405 app.planning_form.as_ref().unwrap().kind,
6406 crate::ui::planning::PlanningKind::Milestone,
6407 "n on the milestones segment opens a milestone form"
6408 );
6409 }
6410
6411 #[test]
6412 fn planning_e_opens_edit_form_prefilled() {
6413 let (_tmp, mut app) = make_app_with_planning();
6414 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6415 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6416 let form = app.planning_form.as_ref().unwrap().clone();
6417 assert_eq!(form.kind, crate::ui::planning::PlanningKind::Cycle);
6418 assert_eq!(form.name(), "Cycle 1");
6419 assert_eq!(form.editing.as_deref(), Some("Cycle 1"));
6420 assert_eq!(form.description(), "kickoff");
6421 assert_eq!(form.date_a(), "2026-10-01");
6422
6423 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6424 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6425 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6426 let form = app.planning_form.as_ref().unwrap().clone();
6427 assert_eq!(form.kind, crate::ui::planning::PlanningKind::Milestone);
6428 assert_eq!(form.name(), "GA");
6429 assert_eq!(form.editing.as_deref(), Some("GA"));
6430 assert_eq!(form.date_a(), "2026-12-31");
6431 }
6432
6433 #[test]
6434 fn planning_x_shows_referencing_count_and_cancel_keeps_item() {
6435 let (_tmp, mut app) = make_app_with_planning();
6436 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6437 let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6438 match &app.planning.as_ref().unwrap().prompt {
6439 Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6440 assert_eq!(name, "Cycle 1");
6441 assert_eq!(*referencing, 1, "one issue references Cycle 1");
6442 }
6443 other => panic!("x raises a confirm prompt, got {other:?}"),
6444 }
6445
6446 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6447 assert!(
6448 app.planning.as_ref().unwrap().prompt.is_none(),
6449 "n dismisses the prompt"
6450 );
6451 assert!(
6452 matches!(app.mode, Mode::PlanningManagement),
6453 "cancelling stays in the modal"
6454 );
6455 assert_eq!(
6456 app.planning_rows_len(),
6457 2,
6458 "the cancelled delete leaves the cycle in the registry"
6459 );
6460 }
6461
6462 #[test]
6463 fn the_cut_marker_survives_a_double_width_filename() {
6475 let (_tmp, mut app) = make_app();
6476 app.note_disclosure(
6477 "remove `links:` by hand to silence \u{2014} \
6478 alice/2026-09-14-\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{3068}\u{3066}\u{3082}\u{3044}\u{3044}\u{30d5}\u{30a1}\u{30a4}\u{30eb}\u{540d}_tests.md \
6479 (retired by C-001; never read as typed data, no migration)"
6480 .to_string(),
6481 );
6482 for width in [60u16, 80, 100, 120, 200] {
6485 let cells = render_to_cells(&app, width, 40).unwrap();
6486 let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
6487 let status = rendered
6488 .iter()
6489 .find(|l| l.contains("remove `links:`"))
6490 .unwrap_or_else(|| panic!("width {width}: no status row: {rendered:?}"));
6491 assert!(
6492 status.contains("remove `links:` by hand"),
6493 "width {width}: the action must stay visible, got {status:?}"
6494 );
6495 if width < 200 {
6496 assert!(
6497 status.contains('\u{2026}'),
6498 "width {width}: a cut must be marked, not silent, got {status:?}"
6499 );
6500 }
6501 }
6502 }
6503
6504 #[test]
6510 #[allow(clippy::unicode_not_nfc)] fn a_truncated_status_row_never_exceeds_its_width_in_cells() {
6512 use unicode_width::UnicodeWidthStr;
6513 for msg in [
6514 "remove `links:` by hand to silence \u{2014} plain-ascii-file.md",
6515 "remove `links:` by hand to silence \u{2014} \u{65e5}\u{672c}\u{8a9e}.md",
6516 "short",
6517 "",
6518 ] {
6519 for budget in [1usize, 2, 3, 5, 10, 20] {
6520 let text = crate::ui::board::truncate_to_cells(msg, budget);
6521 assert!(
6522 text.width() <= budget,
6523 "budget {budget}, msg {msg:?}: produced {text:?} at width {}",
6524 text.width()
6525 );
6526 if msg.width() > budget {
6527 assert!(
6528 text.ends_with('\u{2026}'),
6529 "budget {budget}, msg {msg:?}: a cut must be marked, got {text:?}"
6530 );
6531 }
6532 }
6533 }
6534 }
6535
6536 #[test]
6537 fn the_retired_key_warning_stays_actionable_at_every_terminal_width() {
6538 let (_tmp, mut app) = make_app();
6545 app.note_disclosure(
6546 "remove `links:` by hand to silence \u{2014} \
6547 alice/2026-09-14-add-the-ability-to-hide-and-show-kanban-columns.md \
6548 (retired by C-001; never read as typed data, no migration)"
6549 .to_string(),
6550 );
6551 for width in [80u16, 100, 120] {
6552 let cells = render_to_cells(&app, width, 40).unwrap();
6553 let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
6554 let status = rendered
6555 .iter()
6556 .find(|l| l.contains("remove `links:`"))
6557 .unwrap_or_else(|| panic!("width {width}: no status row rendered: {rendered:?}"));
6558 assert!(
6559 status.contains("remove `links:` by hand to silence"),
6560 "width {width}: the action must be visible, got {status:?}"
6561 );
6562 assert!(
6563 status.contains('\u{2026}'),
6564 "width {width}: a cut is marked rather than silent, got {status:?}"
6565 );
6566 }
6567 }
6568
6569 #[test]
6570 fn planning_renders_rows_with_fields_and_counts() {
6571 let (_tmp, mut app) = make_app_with_planning();
6572 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6573 let cells = render_to_cells(&app, 120, 40).unwrap();
6574 let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
6575
6576 assert!(
6577 lines
6578 .iter()
6579 .any(|l| l.contains("Cycle 1 | kickoff | 2026-10-01 | 2999-12-31 | 1 issue")),
6580 "cycle rows show fields and count: {lines:?}"
6581 );
6582 assert!(
6583 lines
6584 .iter()
6585 .any(|l| l.contains("Cycle 2 | next | 2999-01-01 | 2999-01-15 | 0 issues")),
6586 "unreferenced cycles render a zero count"
6587 );
6588
6589 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6590 let cells = render_to_cells(&app, 120, 40).unwrap();
6591 let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
6592 assert!(
6593 lines
6594 .iter()
6595 .any(|l| l.contains("GA | ship it | 2026-12-31 | 1 issue")),
6596 "milestone rows show fields and count: {lines:?}"
6597 );
6598 }
6599
6600 #[test]
6601 fn planning_form_creates_cycle_with_blank_fields_rejected() {
6602 let (_tmp, mut app) = make_app_with_planning();
6603 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6604 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6605
6606 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6608 assert!(
6609 matches!(app.mode, Mode::PlanningForm),
6610 "blank-name submit keeps the form open"
6611 );
6612 let status = app.status_msg.as_deref().unwrap_or("");
6613 assert!(
6614 status.contains("a name is required"),
6615 "blank names surface an error status: {status:?}"
6616 );
6617 assert_eq!(app.planning_rows_len(), 2, "nothing was created");
6618
6619 for c in "Cycle 9".chars() {
6621 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6622 }
6623 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6624 assert!(
6625 matches!(app.mode, Mode::PlanningManagement),
6626 "a valid create returns to the modal"
6627 );
6628 assert!(app.planning_form.is_none());
6629 assert!(
6630 app.store.config.cycles.contains_key("Cycle 9"),
6631 "the new cycle is persisted to tix.toml and reloaded into the store"
6632 );
6633 let (cycles, _) = app.planning_rows();
6634 assert!(
6635 cycles.iter().any(|r| r.name == "Cycle 9"),
6636 "the modal re-query shows the new cycle"
6637 );
6638 }
6639
6640 #[test]
6641 fn planning_form_creates_milestone_and_refreshes_pickers() {
6642 let (tmp, mut app) = make_app_with_planning();
6643 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6644 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6645 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6646 for c in "Beta".chars() {
6647 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6648 }
6649 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6650 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6651 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6652 assert!(matches!(app.mode, Mode::DatePicker { .. }));
6653 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6654 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6655
6656 let milestones_dir = tmp.path().join("docs/milestones");
6657 let created = milestones_dir
6658 .read_dir()
6659 .unwrap()
6660 .filter_map(|e| e.ok())
6661 .filter(|e| e.file_name().to_string_lossy().contains("beta.md"))
6662 .count();
6663 assert_eq!(created, 1, "a milestone registry file is written");
6664
6665 let (_, milestones) = app.planning_rows();
6666 assert!(
6667 milestones
6668 .iter()
6669 .any(|r| { r.name == "Beta" && r.date_a == today() }),
6670 "the modal re-query shows the milestone with its target date"
6671 );
6672
6673 let options = app.picker_options(FormPickerField::Milestones);
6674 assert!(
6675 options.iter().any(|o| o.id == "Beta"),
6676 "a freshly built picker lists the new milestone (no restart)"
6677 );
6678 }
6679
6680 #[test]
6681 fn planning_form_date_row_masked_typing_and_picker() {
6682 let (_tmp, mut app) = make_app_with_planning();
6683 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6684 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6685 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6686 for c in "Gamma".chars() {
6687 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6688 }
6689 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6690 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6691
6692 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6694 assert!(matches!(app.mode, Mode::DatePicker { .. }));
6695 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6696 assert!(matches!(app.mode, Mode::PlanningForm));
6697
6698 for c in "20261130".chars() {
6700 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6701 }
6702 assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6703
6704 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6706 assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-3");
6707 let _ = app.handle_key(press(KeyCode::Char('0'))).unwrap();
6708 assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6709
6710 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6712 assert!(matches!(app.mode, Mode::DatePicker { .. }));
6713 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6714 assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6715 }
6716
6717 #[test]
6718 fn planning_form_creates_cycle_visible_to_pickers() {
6719 let (_tmp, mut app) = make_app_with_planning();
6720 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6721 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6722 for c in "Cycle 9".chars() {
6723 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6724 }
6725 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6726
6727 let options = app.picker_options(crate::app::FormPickerField::Cycles);
6728 assert!(
6729 options.iter().any(|o| o.id == "Cycle 9"),
6730 "a freshly built cycle picker lists the new cycle"
6731 );
6732 }
6733
6734 #[test]
6735 fn planning_form_edits_cycle_fields() {
6736 let (_tmp, mut app) = make_app_with_planning();
6737 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6738 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6739
6740 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6742 for _ in 0..7 {
6743 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6744 }
6745 for c in "sprint".chars() {
6746 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6747 }
6748 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6749
6750 let cycle = &app.store.config.cycles["Cycle 1"];
6751 assert_eq!(cycle.description.as_deref(), Some("sprint"));
6752 assert_eq!(cycle.start_date.as_deref(), Some("2026-10-01"));
6753 assert_eq!(cycle.due_date.as_deref(), Some("2999-12-31"));
6754 assert!(
6755 app.store.config.cycles.contains_key("Cycle 1")
6756 && !app.store.config.cycles.contains_key("sprint"),
6757 "field edits never rename the cycle"
6758 );
6759 }
6760
6761 #[test]
6762 fn planning_form_renames_cycle_cascade_rewrites_issues_and_refreshes() {
6763 let (tmp, mut app) = make_app_with_planning();
6764 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6765 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6766 for _ in 0..7 {
6767 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6768 }
6769 for c in "Cycle One".chars() {
6770 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6771 }
6772 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6773
6774 assert!(
6775 !app.store.config.cycles.contains_key("Cycle 1"),
6776 "old name gone"
6777 );
6778 assert!(
6779 app.store.config.cycles.contains_key("Cycle One"),
6780 "new name persisted"
6781 );
6782
6783 let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6784 assert_eq!(issue.cycles.unwrap(), vec!["Cycle One".to_string()]);
6785
6786 let (cycles, _) = app.planning_rows();
6787 assert!(
6788 cycles.iter().any(|r| r.name == "Cycle One"),
6789 "the modal re-query shows the renamed cycle"
6790 );
6791
6792 let options = app.picker_options(crate::app::FormPickerField::Cycles);
6793 assert!(options.iter().any(|o| o.id == "Cycle One"));
6794 assert!(!options.iter().any(|o| o.id == "Cycle 1"));
6795
6796 app.apply_filter("cycle:One");
6799 let hit: usize = (0..app.board.total_cols())
6800 .map(|c| app.board.column_cards(c).len())
6801 .sum();
6802 assert_eq!(hit, 1, "the board corpus picks up the renamed cycle");
6803 let raw = std::fs::read_to_string(tmp.path().join("issues/alice/2026-01-01-a.md")).unwrap();
6804 assert!(
6805 raw.contains("Cycle One"),
6806 "issue file carries the new spelling"
6807 );
6808 assert!(!raw.contains("Cycle 1"), "stale spelling fully rewritten");
6809 }
6810
6811 #[test]
6812 fn planning_form_duplicate_name_surfaces_error_keeps_form() {
6813 let (_tmp, mut app) = make_app_with_planning();
6814 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6815 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6816 for _ in 0..7 {
6817 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6818 }
6819 for c in "Cycle 2".chars() {
6820 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6821 }
6822 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6823
6824 assert!(matches!(app.mode, Mode::PlanningForm));
6825 let status = app.status_msg.as_deref().unwrap_or("");
6826 assert!(
6827 status.contains("already exists"),
6828 "duplicate names surface an error status: {status:?}"
6829 );
6830 assert!(app.store.config.cycles.contains_key("Cycle 1"));
6831 assert!(app.store.config.cycles.contains_key("Cycle 2"));
6832 assert_eq!(
6833 app.store.config.cycles.len(),
6834 2,
6835 "the rejected rename changed nothing"
6836 );
6837 }
6838
6839 #[test]
6840 fn planning_form_renames_milestone_cascade_edge_to_edge() {
6841 let (tmp, mut app) = make_app_with_planning();
6842 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6843 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6844 let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6845 for _ in 0..2 {
6846 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6847 }
6848 for c in "GAX".chars() {
6849 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6850 }
6851 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6852 for _ in 0..7 {
6853 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6854 }
6855 for c in "beta".chars() {
6856 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6857 }
6858 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6859 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6860 assert!(matches!(app.mode, Mode::DatePicker { .. }));
6861 let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
6862 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6863 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6864
6865 let milestones_dir = tmp.path().join("docs/milestones");
6866 let files: Vec<String> = milestones_dir
6867 .read_dir()
6868 .unwrap()
6869 .filter_map(|e| e.ok())
6870 .map(|e| e.file_name().to_string_lossy().to_string())
6871 .collect();
6872 assert!(
6873 files.iter().any(|f| f.ends_with("-gax.md")),
6874 "the milestone registry file is renamed: {files:?}"
6875 );
6876 assert!(
6877 !files.iter().any(|f| f.ends_with("-ga.md")),
6878 "the old registry file is gone: {files:?}"
6879 );
6880
6881 let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6882 assert_eq!(issue.milestones.unwrap(), vec!["GAX".to_string()]);
6883
6884 let planner = Planner::load_from(&tmp.path().join("tix.toml")).unwrap();
6885 let ms = planner.milestone("GAX").unwrap();
6886 assert_eq!(ms.target_date.as_str(), "2027-01-31");
6887 assert!(ms.description.contains("beta"), "description was edited");
6888 }
6889
6890 #[test]
6891 fn planning_delete_confirm_removes_cycle_and_refreshes_grouping() {
6892 let (_tmp, mut app) = make_app_with_planning();
6893 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6894 let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6895 match &app.planning.as_ref().unwrap().prompt {
6896 Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6897 assert_eq!(name, "Cycle 1");
6898 assert_eq!(*referencing, 1, "the confirm prompt reports the count");
6899 }
6900 other => panic!("x raises a confirm prompt, got {other:?}"),
6901 }
6902
6903 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6905 assert!(
6906 app.store.config.cycles.contains_key("Cycle 1"),
6907 "cancelling keeps the item"
6908 );
6909 let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6910 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6911
6912 assert!(!app.store.config.cycles.contains_key("Cycle 1"));
6914 assert!(app.store.config.cycles.contains_key("Cycle 2"));
6915 let (cycles, _) = app.planning_rows();
6916 assert!(
6917 cycles.iter().all(|r| r.name != "Cycle 1"),
6918 "the modal re-query drops the deleted cycle"
6919 );
6920 let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6921 assert_eq!(
6922 issue.cycles.unwrap(),
6923 vec!["Cycle 1".to_string()],
6924 "references preserved"
6925 );
6926
6927 let options = app.picker_options(crate::app::FormPickerField::Cycles);
6928 assert!(
6929 !options.iter().any(|o| o.id == "Cycle 1"),
6930 "no stale picker entry"
6931 );
6932 app.apply_filter("cycle:Cycle");
6933 let hit: usize = (0..app.board.total_cols())
6934 .map(|c| app.board.column_cards(c).len())
6935 .sum();
6936 assert_eq!(hit, 1, "the referencing issue stays grouped on the board");
6937 }
6938
6939 #[test]
6940 fn planning_delete_confirm_removes_milestone_file_preserves_issues() {
6941 let (tmp, mut app) = make_app_with_planning();
6942 let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6943 let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6944 let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6945 match &app.planning.as_ref().unwrap().prompt {
6946 Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6947 assert_eq!(name, "GA");
6948 assert_eq!(*referencing, 1);
6949 }
6950 other => panic!("x raises a confirm prompt, got {other:?}"),
6951 }
6952 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6953 let milestones_dir = tmp.path().join("docs/milestones");
6954 assert!(
6955 milestones_dir.join("2026-01-01-ga.md").exists(),
6956 "cancel keeps the milestone file"
6957 );
6958
6959 let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6960 let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
6961 assert!(
6962 !milestones_dir.join("2026-01-01-ga.md").exists(),
6963 "confirm removes the registry file"
6964 );
6965 let (_, milestones) = app.planning_rows();
6966 assert!(milestones.is_empty(), "the milestone segment is now empty");
6967 let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6968 assert_eq!(issue.milestones.unwrap(), vec!["GA".to_string()]);
6969 let options = app.picker_options(FormPickerField::Milestones);
6970 assert!(
6971 !options.iter().any(|o| o.id == "GA"),
6972 "no stale picker entry"
6973 );
6974 }
6975
6976 #[test]
6977 fn startup_shows_rollover_dialog_when_plan_exists() {
6978 let (_tmp, app) = make_app_with_rollover_dialog();
6979 assert!(matches!(app.mode, Mode::RolloverDialog));
6980 assert!(app.rollover_dialog.is_some());
6981 }
6982
6983 #[test]
6984 fn rollover_dialog_enter_applies_rollover() {
6985 let (_tmp, mut app) = make_app_with_rollover_dialog();
6986 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6987 assert!(matches!(app.mode, Mode::Board));
6988 let issue = app.store.list().unwrap().into_iter().next().unwrap();
6989 let cycles = issue.cycles.unwrap_or_default();
6990 assert!(cycles.iter().any(|c| c == "Cycle 2"));
6991 assert!(!cycles.iter().any(|c| c == "Cycle 1"));
6992 }
6993
6994 #[test]
6995 fn quit_on_esc() {
6996 let (_tmp, mut app) = make_app();
6997 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6998 assert!(app.quit);
6999 }
7000
7001 #[test]
7002 fn cursor_clamp_empty_column() {
7003 let (_tmp, mut app) = make_app();
7004 app.board.move_left();
7005 assert_eq!(app.board.active_col, app.board.total_cols() - 1);
7006 app.board.move_right();
7007 assert_eq!(app.board.active_col, 0);
7008 }
7009
7010 #[test]
7011 fn cursor_clamp_single_card() {
7012 let (tmp, mut app) = make_app();
7013 let dir = tmp.path().join("issues");
7014 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7015 let config = app.store.config.clone();
7016 app.board = BoardState::build(&app.store, config.default_config());
7017 assert_eq!(app.board.column_cards(0).len(), 1);
7018 app.board.move_down();
7019 assert_eq!(app.board.selected, 0);
7020 app.board.move_up();
7021 assert_eq!(app.board.selected, 0);
7022 }
7023
7024 #[test]
7025 fn wrap_around_columns() {
7026 let (_tmp, mut app) = make_app();
7027 let total = app.board.total_cols();
7028 for _ in 0..total + 2 {
7029 app.board.move_right();
7030 }
7031 assert_eq!(app.board.active_col, 2 % total);
7032 for _ in 0..total + 2 {
7033 app.board.move_left();
7034 }
7035 }
7036
7037 #[test]
7038 fn refresh_from_disk_picks_up_new_file() {
7039 let (tmp, mut app) = make_app();
7040 let dir = tmp.path().join("issues");
7041 assert_eq!(app.board.column_cards(0).len(), 0);
7042 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7043 app.refresh_from_disk().unwrap();
7044 assert_eq!(app.board.column_cards(0).len(), 1);
7045 }
7046
7047 #[test]
7048 fn refresh_from_disk_reflects_external_status_move() {
7049 let (tmp, mut app) = make_app();
7050 let dir = tmp.path().join("issues");
7051 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7052 app.refresh_from_disk().unwrap();
7053 assert_eq!(app.board.column_cards(0).len(), 1);
7054
7055 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
7056 app.refresh_from_disk().unwrap();
7057 assert_eq!(app.board.column_cards(0).len(), 0);
7058 assert_eq!(app.board.column_cards(3).len(), 1);
7059 }
7060
7061 #[test]
7062 fn refresh_from_disk_keeps_board_on_bad_file() {
7063 let (tmp, mut app) = make_app();
7064 let dir = tmp.path().join("issues");
7065 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7066 app.refresh_from_disk().unwrap();
7067 assert_eq!(app.board.column_cards(0).len(), 1);
7068
7069 fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
7070 assert!(app.refresh_from_disk().is_err());
7071 assert_eq!(
7072 app.board.column_cards(0).len(),
7073 1,
7074 "a malformed file must not wipe the board"
7075 );
7076 }
7077
7078 #[test]
7079 fn enter_opens_detail() {
7080 let (tmp, mut app) = make_app();
7081 let dir = tmp.path().join("issues");
7082 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7083 let config = app.store.config.clone();
7084 app.board = BoardState::build(&app.store, config.default_config());
7085 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7086 assert!(matches!(app.mode, Mode::Detail));
7087 assert!(app.detail.is_some());
7088 }
7089
7090 #[test]
7091 fn n_opens_form() {
7092 let (_tmp, mut app) = make_app();
7093 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7094 assert!(matches!(app.mode, Mode::Form));
7095 assert!(app.form.is_some());
7096 }
7097
7098 fn filter_modal_focus(app: &App) -> crate::search::FilterField {
7099 app.filter_modal
7100 .as_ref()
7101 .expect("filter modal open")
7102 .focus()
7103 }
7104
7105 #[test]
7106 fn filter_modal_arrows_navigate_rows() {
7107 let (_tmp, mut app) = make_app();
7108 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7109 assert!(matches!(app.mode, Mode::FilterModal));
7110 use crate::search::FilterField::*;
7111 assert_eq!(filter_modal_focus(&app), Assignee);
7112 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7113 assert_eq!(filter_modal_focus(&app), Label);
7114 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7115 assert_eq!(filter_modal_focus(&app), Assignee);
7116 for _ in 0..crate::search::FilterField::ALL.len() {
7122 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7123 }
7124 assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
7125 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7126 assert_eq!(
7127 filter_modal_focus(&app),
7128 Category,
7129 "Up wraps backward, to the last row — which is now `Category` (WP08 \
7130 appended it after `Parent`)"
7131 );
7132 }
7133
7134 #[test]
7135 fn filter_modal_arrow_press_keeps_criterion_value() {
7136 let (_tmp, mut app) = make_app();
7137 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7138 for c in "alice".chars() {
7139 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7140 }
7141 let before = app
7142 .filter_modal
7143 .as_ref()
7144 .unwrap()
7145 .active_filter()
7146 .assignee
7147 .clone();
7148 assert_eq!(before.as_deref(), Some("alice"));
7149 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7150 let after = app
7151 .filter_modal
7152 .as_ref()
7153 .unwrap()
7154 .active_filter()
7155 .assignee
7156 .clone();
7157 assert_eq!(after, before, "arrow press must not alter the value");
7158 use crate::search::FilterField::*;
7159 assert_eq!(filter_modal_focus(&app), Label);
7160 }
7161
7162 #[test]
7163 fn filter_modal_j_and_k_type_their_letters() {
7164 let (_tmp, mut app) = make_app();
7165 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7166 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
7167 let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
7168 let modal = app.filter_modal.as_ref().unwrap();
7169 assert_eq!(
7170 modal.active_filter().assignee.as_deref(),
7171 Some("jk"),
7172 "j and k must type into the focused criterion, not navigate"
7173 );
7174 use crate::search::FilterField::*;
7175 assert_eq!(modal.focus(), Assignee);
7176 }
7177
7178 #[test]
7179 fn filter_modal_date_field_masked_typing_and_picker() {
7180 let (_tmp, mut app) = make_app();
7181 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7182 use crate::search::FilterField::*;
7183 for _ in 0..7 {
7184 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7185 }
7186 assert_eq!(filter_modal_focus(&app), StartDate);
7187
7188 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7190 assert!(matches!(app.mode, Mode::DatePicker { .. }));
7191 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7192 assert!(matches!(app.mode, Mode::FilterModal));
7193
7194 for c in "20260910".chars() {
7196 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7197 }
7198 assert_eq!(
7199 app.filter_modal
7200 .as_ref()
7201 .unwrap()
7202 .active_filter()
7203 .start_date
7204 .as_deref(),
7205 Some("2026-09-10")
7206 );
7207
7208 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7210 assert!(matches!(app.mode, Mode::Board));
7211 assert_eq!(app.structured.start_date.as_deref(), Some("2026-09-10"));
7212 }
7213
7214 #[test]
7215 fn d_opens_confirm_delete() {
7216 let (tmp, mut app) = make_app();
7217 let dir = tmp.path().join("issues");
7218 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7219 let config = app.store.config.clone();
7220 app.board = BoardState::build(&app.store, config.default_config());
7221 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7222 assert!(matches!(app.mode, Mode::ConfirmDelete));
7223 assert!(app.confirm_delete_target.is_some());
7224 }
7225
7226 #[test]
7227 fn move_to_done_triggers_commit_prompt() {
7228 let (tmp, mut app) = make_app();
7229 let dir = tmp.path().join("issues");
7230 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7232 let config = app.store.config.clone();
7233 app.board = BoardState::build(&app.store, config.default_config());
7234 app.board.select_column(2);
7235 app.board.selected = 0;
7236
7237 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7238 assert!(
7240 matches!(
7241 &app.mode,
7242 Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
7243 ),
7244 "expected MoveIntoDone, got {:?}",
7245 app.mode
7246 );
7247
7248 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7250 assert!(matches!(app.mode, Mode::Board));
7251 let issue = app
7252 .store
7253 .get(crate::storage::IssueSelector::Filename(
7254 "2026-01-01-a.md".into(),
7255 ))
7256 .unwrap();
7257 assert_eq!(issue.status.display(), "in review");
7258 assert!(issue.commit.is_none());
7259 }
7260
7261 #[test]
7262 fn move_into_done_with_commit_applies() {
7263 let (tmp, mut app) = make_app();
7264 let dir = tmp.path().join("issues");
7265 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7266 let config = app.store.config.clone();
7267 app.board = BoardState::build(&app.store, config.default_config());
7268 app.board.select_column(2);
7269 app.board.selected = 0;
7270
7271 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7272 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
7273
7274 for c in "abc123".chars() {
7275 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7276 }
7277 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7278
7279 assert!(matches!(app.mode, Mode::Board));
7280 let issue = app
7281 .store
7282 .get(crate::storage::IssueSelector::Filename(
7283 "2026-01-01-a.md".into(),
7284 ))
7285 .unwrap();
7286 assert_eq!(issue.status.display(), "done");
7287 assert_eq!(issue.commit.as_deref(), Some("abc123"));
7288 }
7289
7290 #[test]
7291 fn move_into_done_with_empty_commit_moves_without_hash() {
7292 let (tmp, mut app) = make_app();
7293 let dir = tmp.path().join("issues");
7294 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7295 let config = app.store.config.clone();
7296 app.board = BoardState::build(&app.store, config.default_config());
7297 app.board.select_column(2);
7298 app.board.selected = 0;
7299
7300 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7301 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
7302 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7303
7304 assert!(matches!(app.mode, Mode::Board));
7305 let issue = app
7306 .store
7307 .get(crate::storage::IssueSelector::Filename(
7308 "2026-01-01-a.md".into(),
7309 ))
7310 .unwrap();
7311 assert_eq!(issue.status.display(), "done");
7312 assert!(
7313 issue.commit.is_none(),
7314 "no commit recorded when moving to done without a hash"
7315 );
7316 }
7317
7318 #[test]
7319 fn move_out_of_done_clears_commit() {
7320 let (tmp, mut app) = make_app();
7321 let dir = tmp.path().join("issues");
7322 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
7323 let config = app.store.config.clone();
7324 app.board = BoardState::build(&app.store, config.default_config());
7325 app.board.select_column(3);
7326 app.board.selected = 0;
7327
7328 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
7329 assert!(matches!(app.mode, Mode::Board));
7330 let issue = app
7331 .store
7332 .get(crate::storage::IssueSelector::Filename(
7333 "2026-01-01-a.md".into(),
7334 ))
7335 .unwrap();
7336 assert_eq!(issue.status.display(), "in review");
7337 assert!(issue.commit.is_none());
7338 }
7339
7340 #[test]
7341 fn move_right_wraps_across_real_columns_skipping_mismatch() {
7342 let (tmp, mut app) = make_app();
7343 let dir = tmp.path().join("issues");
7344 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
7345 let config = app.store.config.clone();
7346 app.board = BoardState::build(&app.store, config.default_config());
7347 app.board.select_column(3); app.board.selected = 0;
7349 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7350 assert_eq!(app.board.active_col, 0);
7351 let issue = app
7352 .store
7353 .get(crate::storage::IssueSelector::Filename(
7354 "2026-01-01-a.md".into(),
7355 ))
7356 .unwrap();
7357 assert_eq!(issue.status.display(), "todo");
7358 }
7359
7360 #[test]
7361 fn move_left_wraps_across_real_columns_skipping_mismatch() {
7362 let (tmp, mut app) = make_app();
7363 let dir = tmp.path().join("issues");
7364 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
7365 let config = app.store.config.clone();
7366 app.board = BoardState::build(&app.store, config.default_config());
7367 app.board.select_column(0);
7368 app.board.selected = 0;
7369 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
7370 assert_eq!(app.board.active_col, 3);
7371 let issue = app
7372 .store
7373 .get(crate::storage::IssueSelector::Filename(
7374 "2026-01-01-a.md".into(),
7375 ))
7376 .unwrap();
7377 assert_eq!(issue.status.display(), "done");
7378 }
7379
7380 #[test]
7381 fn move_left_right_board() {
7382 let (_tmp, mut app) = make_app();
7383 let initial = app.board.active_col;
7384 let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
7385 assert_eq!(app.board.active_col, initial + 1);
7386 let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
7387 assert_eq!(app.board.active_col, initial);
7388 }
7389
7390 #[test]
7391 fn refresh_rebuilds_board() {
7392 let (tmp, mut app) = make_app();
7393 let dir = tmp.path().join("issues");
7394 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7395 let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
7396 assert_eq!(app.board.column_cards(0).len(), 1);
7397 assert_eq!(app.status_msg, Some("Refreshed".to_string()));
7398 }
7399
7400 #[test]
7401 fn detail_esc_back_to_board() {
7402 let (tmp, mut app) = make_app();
7403 let dir = tmp.path().join("issues");
7404 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7405 let config = app.store.config.clone();
7406 app.board = BoardState::build(&app.store, config.default_config());
7407 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7408 assert!(matches!(app.mode, Mode::Detail));
7409 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7410 assert!(matches!(app.mode, Mode::Board));
7411 assert!(app.detail.is_none());
7412 }
7413
7414 #[test]
7415 fn confirm_delete_y_removes_issue() {
7416 let (tmp, mut app) = make_app();
7417 let dir = tmp.path().join("issues");
7418 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7419 let config = app.store.config.clone();
7420 app.board = BoardState::build(&app.store, config.default_config());
7421 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7422 assert!(matches!(app.mode, Mode::ConfirmDelete));
7423 let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
7424 assert!(matches!(app.mode, Mode::Board));
7425 let config = app.store.config.clone();
7426 app.board = BoardState::build(&app.store, config.default_config());
7427 assert_eq!(app.board.column_cards(0).len(), 0);
7428 }
7429
7430 #[test]
7431 fn confirm_delete_n_cancels() {
7432 let (tmp, mut app) = make_app();
7433 let dir = tmp.path().join("issues");
7434 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7435 let config = app.store.config.clone();
7436 app.board = BoardState::build(&app.store, config.default_config());
7437 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7438 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7439 assert!(matches!(app.mode, Mode::Board));
7440 assert!(app.confirm_delete_target.is_none());
7441 let config = app.store.config.clone();
7442 app.board = BoardState::build(&app.store, config.default_config());
7443 assert_eq!(app.board.column_cards(0).len(), 1);
7444 }
7445
7446 #[test]
7447 fn form_submit_creates_issue() {
7448 let (_tmp, mut app) = make_app();
7449 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7450 if let Mode::Form = &app.mode {
7451 for c in "My Issue".chars() {
7453 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7454 }
7455 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7457 }
7458 assert!(matches!(app.mode, Mode::Board));
7459 assert!(
7460 app.status_msg
7461 .as_deref()
7462 .is_some_and(|m| m.starts_with("Created ")),
7463 "got {:?}",
7464 app.status_msg
7465 );
7466 }
7467
7468 #[test]
7469 fn form_submit_selects_created_issue() {
7470 let (_tmp, mut app) = make_app();
7471 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7472 if let Mode::Form = &app.mode {
7473 for c in "Selected Issue".chars() {
7474 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7475 }
7476 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7477 }
7478 assert!(matches!(app.mode, Mode::Board));
7479 let card = app.board.get_selected_card().expect("selected card");
7480 assert_eq!(card.title, "Selected Issue");
7481 assert_eq!(app.board.active_col, 0);
7482 }
7483
7484 #[test]
7485 fn form_submit_selects_created_issue_in_assignee_segment() {
7486 let (tmp, mut app) = make_app();
7487 let dir = tmp.path().join("issues");
7488 write_issue(&dir, "2026-01-01-a.md", 1, "Old", "todo", None);
7489 let config = app.store.config.clone();
7490 app.board = BoardState::build(&app.store, config.default_config());
7491
7492 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7493 if let Mode::Form = &app.mode {
7494 for c in "New".chars() {
7495 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7496 }
7497 }
7498 app.form.as_mut().unwrap().assignee = "alice".to_string();
7499 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7500
7501 assert!(matches!(app.mode, Mode::Board));
7502 let card = app.board.get_selected_card().expect("selected card");
7503 assert_eq!(
7504 card.title, "New",
7505 "the just-created issue is selected, not the previously selected one"
7506 );
7507 assert!(card.filename.starts_with("alice/"), "got {}", card.filename);
7508 }
7509
7510 #[test]
7511 fn editor_return_warns_on_invalid_issue() {
7512 let (_tmp, mut app) = make_app();
7513 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7514 if let Mode::Form = &app.mode {
7515 for c in "Lint Warn".chars() {
7516 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7517 }
7518 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7519 }
7520 let issue = app
7521 .board
7522 .get_selected_card()
7523 .cloned()
7524 .expect("selected issue");
7525 let path = app.store.issues_dir.join(&issue.filename);
7526 let mut text = std::fs::read_to_string(&path).unwrap();
7527 let before = text.clone();
7534 text = text
7535 .lines()
7536 .map(|l| {
7537 if l.starts_with("priority:") {
7538 "priority: strange"
7539 } else {
7540 l
7541 }
7542 })
7543 .collect::<Vec<_>>()
7544 .join("\n");
7545 assert_ne!(
7546 text, before,
7547 "the fixture must actually change the priority"
7548 );
7549 std::fs::write(&path, text).unwrap();
7550
7551 app.after_editor(&issue.filename);
7552
7553 assert!(matches!(app.mode, Mode::EditLintWarning { .. }));
7554 }
7555
7556 #[test]
7557 fn form_empty_title_rejected() {
7558 let (_tmp, mut app) = make_app();
7559 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7560 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7561 assert!(matches!(app.mode, Mode::Form));
7562 assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
7563 }
7564
7565 #[test]
7566 fn form_esc_cancels() {
7567 let (_tmp, mut app) = make_app();
7568 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7569 assert!(matches!(app.mode, Mode::Form));
7570 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7571 assert!(matches!(app.mode, Mode::Board));
7572 }
7573
7574 fn form_focus(app: &App) -> crate::ui::form::FormFocus {
7575 app.form.as_ref().expect("form open").focus
7576 }
7577
7578 #[test]
7579 fn form_arrows_navigate_fields() {
7580 let (_tmp, mut app) = make_app();
7581 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7582 use crate::ui::form::FormFocus::*;
7583 assert_eq!(form_focus(&app), Title);
7584 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7585 assert_eq!(form_focus(&app), Status);
7586 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7587 assert_eq!(form_focus(&app), Title);
7588 for _ in 0..crate::ui::form::FormFocus::RING.len() {
7589 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7590 }
7591 assert_eq!(form_focus(&app), Title, "Down wraps forward");
7592 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7593 assert_eq!(
7594 form_focus(&app),
7595 *crate::ui::form::FormFocus::RING
7596 .last()
7597 .expect("RING is non-empty"),
7598 "Up wraps backward, from the last field in the ring"
7599 );
7600 }
7601
7602 #[test]
7603 fn form_j_and_k_type_their_letters() {
7604 let (_tmp, mut app) = make_app();
7605 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7606 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
7607 let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
7608 let form = app.form.as_ref().unwrap();
7609 assert_eq!(form.title, "jk", "j and k must type, not navigate");
7610 assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
7611 }
7612
7613 #[test]
7614 fn form_arrow_navigated_status_creates_in_that_column() {
7615 let (_tmp, mut app) = make_app();
7616 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7617 for c in "Arrow Status".chars() {
7618 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7619 }
7620 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7621 let prefilled = app.store.config.default_config().columns[0].len();
7622 for _ in 0..prefilled {
7623 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
7624 }
7625 for c in "in progress".chars() {
7626 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7627 }
7628 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7629 assert!(matches!(app.mode, Mode::Board));
7630 let names: Vec<String> = (0..app.board.total_cols())
7631 .map(|i| app.board.column_name(i).to_string())
7632 .collect();
7633 let idx = names
7634 .iter()
7635 .position(|n| n == "in progress")
7636 .expect("in progress column exists");
7637 assert_eq!(
7638 app.board.column_cards(idx).len(),
7639 1,
7640 "arrow-navigated status must land the issue in 'in progress'"
7641 );
7642 }
7643
7644 #[test]
7645 fn form_invalid_date_order_is_rejected() {
7646 let (_tmp, mut app) = make_app();
7647 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7648 for c in "Date check".chars() {
7649 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7650 }
7651 for _ in 0..9 {
7652 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7653 }
7654 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7655 assert!(matches!(app.mode, Mode::DatePicker { .. }));
7656 let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
7657 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7658 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7659 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7660 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7661 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7662 assert!(matches!(app.mode, Mode::Form));
7663 assert_eq!(
7664 app.status_msg.as_deref(),
7665 Some("Due date must be >= start date")
7666 );
7667 }
7668
7669 #[test]
7670 fn form_persists_planning_fields() {
7671 let (_tmp, mut app) = make_app();
7672 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7673 for c in "Planning fields".chars() {
7674 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7675 }
7676 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7677 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7678 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7679 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7680 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7681 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7682 for c in "Cycle 1,Cycle 2".chars() {
7683 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7684 }
7685 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7686 for c in "Backlog".chars() {
7687 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7688 }
7689 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7690 for c in "alice".chars() {
7691 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7692 }
7693 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7694 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7695 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7696 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7697 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7698 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7699 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7700 assert!(matches!(app.mode, Mode::Board));
7701 let issues = app.store.list().unwrap();
7702 assert_eq!(issues.len(), 1);
7703 let issue = &issues[0];
7704 assert_eq!(
7705 issue.cycles.as_deref(),
7706 Some(&["Cycle 1".to_string(), "Cycle 2".to_string()][..])
7707 );
7708 assert_eq!(
7709 issue.milestones.as_deref(),
7710 Some(&["Backlog".to_string()][..])
7711 );
7712 assert_eq!(issue.reviewer.as_deref(), Some("alice"));
7713 assert_eq!(issue.start_date.as_deref(), Some(today().as_str()));
7714 assert_eq!(issue.due_date.as_deref(), Some(today().as_str()));
7715 }
7716
7717 #[test]
7718 fn form_date_field_masked_typing_and_picker() {
7719 let (_tmp, mut app) = make_app();
7720 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7721 for c in "Dated".chars() {
7722 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7723 }
7724 for _ in 0..9 {
7725 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7726 }
7727 assert!(app.form.as_ref().unwrap().is_date_focus());
7728
7729 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7731 assert!(matches!(app.mode, Mode::DatePicker { .. }));
7732 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7733 assert!(matches!(app.mode, Mode::Form));
7734
7735 for c in "20260910".chars() {
7737 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7738 }
7739 assert_eq!(app.form.as_ref().unwrap().start_date, "2026-09-10");
7740
7741 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7743 assert!(matches!(app.mode, Mode::Board));
7744 let issues = app.store.list().unwrap();
7745 let issue = issues
7746 .iter()
7747 .find(|i| i.title == "Dated")
7748 .expect("the form created the issue");
7749 assert_eq!(issue.start_date.as_deref(), Some("2026-09-10"));
7750 }
7751
7752 #[test]
7753 fn form_date_picker_select_and_clear() {
7754 let (_tmp, mut app) = make_app();
7755 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7756 for c in "Dated".chars() {
7757 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7758 }
7759 for _ in 0..9 {
7760 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7761 }
7762
7763 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7764 assert!(matches!(app.mode, Mode::DatePicker { .. }));
7765 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7766 assert!(matches!(app.mode, Mode::Form));
7767 assert_eq!(app.form.as_ref().unwrap().start_date, today());
7768
7769 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7770 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7771 assert_eq!(
7772 app.form.as_ref().unwrap().start_date,
7773 today(),
7774 "Esc leaves the date unchanged"
7775 );
7776
7777 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7778 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
7779 assert_eq!(
7780 app.form.as_ref().unwrap().start_date,
7781 "",
7782 "c clears the date field"
7783 );
7784 }
7785
7786 fn drawn_date_picker_day(app: &App, target: &str) -> (u16, u16) {
7789 use ratatui::backend::TestBackend;
7790
7791 let backend = TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
7792 let mut terminal = ratatui::Terminal::new(backend).unwrap();
7793 terminal
7794 .draw(|f| crate::app::render(app, f, f.area()))
7795 .unwrap();
7796
7797 let picker = app.date_picker.as_ref().expect("picker open");
7798 let rect = picker.popup_rect(app.frame_size.get());
7799 let days_y = rect.y + 3;
7800 let weeks = rect.height.saturating_sub(6);
7801 for row in 0..weeks {
7802 for col in 0..7u16 {
7803 let x = rect.x + 1 + col * 3 + 1;
7804 let y = days_y + row;
7805 if picker
7806 .date_at(app.frame_size.get(), x, y)
7807 .map(crate::ui::date_picker::format_date)
7808 .as_deref()
7809 == Some(target)
7810 {
7811 return (x, y);
7812 }
7813 }
7814 }
7815 panic!("no day cell found for {target}");
7816 }
7817
7818 #[test]
7819 fn date_picker_day_click_changes_selected_date() {
7820 let (_tmp, mut app) = make_app();
7821 app.frame_size.set(Rect::new(0, 0, 80, 24));
7822 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7823 for _ in 0..9 {
7824 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7825 }
7826 app.form.as_mut().unwrap().start_date = "2026-03-15".into();
7827 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7828 assert!(matches!(app.mode, Mode::DatePicker { .. }));
7829
7830 let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
7831 let action = app.handle_mouse(down_left(x, y)).unwrap();
7832 assert_eq!(
7833 action,
7834 Action::DatePickerHit(DatePickerHit::Day("2026-03-16".into()))
7835 );
7836 if let Action::DatePickerHit(DatePickerHit::Day(date)) = action {
7837 app.date_picker.as_mut().unwrap().select_date_str(&date);
7838 }
7839 assert_eq!(app.date_picker.as_ref().unwrap().selected(), "2026-03-16");
7840 }
7841
7842 #[test]
7843 fn date_picker_ok_click_submits_selected_date() {
7844 let (_tmp, mut app) = make_app();
7845 app.frame_size.set(Rect::new(0, 0, 80, 24));
7846 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7847 for _ in 0..9 {
7848 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7849 }
7850 app.form.as_mut().unwrap().start_date = "2026-03-15".into();
7851 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7852
7853 let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
7854 let _ = app.handle_mouse(down_left(x, y)).unwrap();
7855 app.date_picker
7856 .as_mut()
7857 .unwrap()
7858 .select_date_str("2026-03-16");
7859
7860 let picker = app.date_picker.as_ref().unwrap();
7861 let rect = picker.popup_rect(app.frame_size.get());
7862 let ok_x = rect.x + rect.width / 2;
7863 let ok_y = rect.y + rect.height.saturating_sub(1);
7864 let action = app.handle_mouse(down_left(ok_x, ok_y)).unwrap();
7865 assert_eq!(action, Action::DatePickerHit(DatePickerHit::Submit));
7866
7867 let _ = app.handle_date_picker_key(press(KeyCode::Enter)).unwrap();
7868 assert!(matches!(app.mode, Mode::Form));
7869 assert_eq!(app.form.as_ref().unwrap().start_date, "2026-03-16");
7870 }
7871
7872 #[test]
7873 fn form_non_date_field_still_opens_list_picker() {
7874 let (_tmp, mut app) = make_app();
7875 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7876 for _ in 0..6 {
7877 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7878 }
7879 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7880 assert!(
7881 app.form_picker.is_some(),
7882 "cycles field opens the list picker"
7883 );
7884 assert!(!matches!(app.mode, Mode::DatePicker { .. }));
7885 }
7886
7887 #[test]
7888 fn form_picker_inline_create_cycle_persists_and_populates_field() {
7889 let (_tmp, mut app) = make_app();
7890 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7891 for c in "Picker cycle".chars() {
7892 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7893 }
7894 for _ in 0..6 {
7895 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7896 }
7897
7898 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7899 assert!(app.form_picker.is_some(), "picker should open on cycles");
7900 let _ = app.handle_key(press(KeyCode::Char('+'))).unwrap();
7901 for c in "Cycle 42".chars() {
7902 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7903 }
7904 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7905
7906 let form = app.form.as_ref().unwrap();
7907 assert!(
7908 form.cycles.split(',').any(|c| c.trim() == "Cycle 42"),
7909 "created cycle should populate form field, got {:?}",
7910 form.cycles
7911 );
7912 assert!(
7913 app.store.config.cycles.contains_key("Cycle 42"),
7914 "new cycle should be persisted in config"
7915 );
7916
7917 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7918 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7919 assert!(matches!(app.mode, Mode::Board));
7920 let issue = app.store.list().unwrap().into_iter().next().unwrap();
7921 assert!(
7922 issue
7923 .cycles
7924 .unwrap_or_default()
7925 .iter()
7926 .any(|c| c == "Cycle 42")
7927 );
7928 }
7929
7930 #[test]
7931 fn form_picker_selects_existing_label() {
7932 let (tmp, mut app) = make_app();
7933 let dir = tmp.path().join("issues");
7934 let seed = "---\nnumber: 1\ntitle: \"Seed\"\nstatus: \"todo\"\nlabels: [\"bug\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
7935 fs::write(dir.join("2026-01-01-seed.md"), seed).unwrap();
7936 app.rebuild_board();
7937
7938 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7939 for c in "Picker labels".chars() {
7940 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7941 }
7942 for _ in 0..3 {
7943 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7944 }
7945 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7946 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7947 let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7948 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7949
7950 assert!(matches!(app.mode, Mode::Board));
7951 let issues = app.store.list().unwrap();
7952 let created = issues
7953 .into_iter()
7954 .find(|i| i.title == "Picker labels")
7955 .expect("created issue");
7956 assert!(created.labels.iter().any(|l| l == "bug"));
7957 }
7958
7959 #[test]
7960 fn key_release_ignored() {
7961 let (_tmp, mut app) = make_app();
7962 let key = KeyEvent {
7963 code: KeyCode::Char('q'),
7964 modifiers: crossterm::event::KeyModifiers::NONE,
7965 kind: KeyEventKind::Release,
7966 state: crossterm::event::KeyEventState::NONE,
7967 };
7968 let _ = app.handle_key(key).unwrap();
7969 assert!(!app.quit);
7970 }
7971
7972 #[test]
7973 fn handle_mouse_in_mode_without_mouse_support_is_noop() {
7974 let (_tmp, mut app) = make_app();
7975 app.mode = Mode::Filter {
7976 buffer: "query".into(),
7977 };
7978 let before = app.mode.clone();
7979 let ev = MouseEvent {
7980 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
7981 column: 1,
7982 row: 1,
7983 modifiers: crossterm::event::KeyModifiers::NONE,
7984 };
7985 let action = app.handle_mouse(ev).unwrap();
7986 assert_eq!(action, Action::None);
7987 assert_eq!(app.mode, before);
7988 }
7989
7990 #[test]
7991 fn frame_hit_regions_are_empty_until_recorded_and_resolve_to_actions() {
7992 let (_tmp, app) = make_app();
7993 assert!(
7994 app.frame_hit_regions.borrow().is_empty(),
7995 "no regions before a draw"
7996 );
7997
7998 let rect_a = Rect::new(0, 0, 10, 5);
7999 let rect_b = Rect::new(2, 1, 2, 2);
8000 app.record_region(rect_a, Action::OpenEditor("a.md".into()));
8001 app.record_region(rect_b, Action::Quit);
8002 assert_eq!(app.frame_hit_regions.borrow().len(), 2);
8003
8004 assert_eq!(
8006 app.resolve_region(0, 0),
8007 Some(Action::OpenEditor("a.md".into()))
8008 );
8009 assert_eq!(app.resolve_region(3, 2), Some(Action::Quit));
8011 assert_eq!(app.resolve_region(11, 2), None);
8013 assert_eq!(app.resolve_region(3, 6), None);
8014 }
8015
8016 #[test]
8017 fn draw_clears_regions_so_stale_targets_never_fire() {
8018 use ratatui::backend::TestBackend;
8019 let (_tmp, app) = make_app();
8020 app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
8021 app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
8022 let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
8023 terminal.draw(|f| render(&app, f, f.area())).unwrap();
8024 assert!(
8025 app.frame_hit_regions.borrow().is_empty(),
8026 "a draw clears regions recorded before it"
8027 );
8028 }
8029
8030 fn make_app_with_cards(n: u32) -> (TempDir, App) {
8036 let (tmp, mut app) = make_app();
8037 let dir = app.store.config.issues_dir.clone();
8038 for i in 1..=n {
8039 write_issue(
8040 &dir,
8041 &format!("card-{i:02}.md"),
8042 i,
8043 &format!("Card {i:02}"),
8044 "todo",
8045 None,
8046 );
8047 }
8048 app.rebuild_board();
8049 (tmp, app)
8050 }
8051
8052 fn make_mouse_app() -> (TempDir, App) {
8057 let (tmp, mut app) = make_app();
8058 app.mouse.double_click_window = Duration::from_secs(60);
8059 let dir = app.store.config.issues_dir.clone();
8060 write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
8061 write_issue(&dir, "b.md", 2, "Beta", "todo", None);
8062 write_issue(&dir, "c.md", 3, "Gamma", "in progress", None);
8063 write_issue(&dir, "d.md", 4, "Delta", "in review", None);
8064 write_issue(&dir, "e.md", 5, "Epsilon", "done", Some("abc123"));
8065 app.rebuild_board();
8066 app.frame_size.set(Rect::new(0, 0, 80, 40));
8067 (tmp, app)
8068 }
8069
8070 fn make_sparse_order_mouse_app() -> (TempDir, App) {
8071 let (tmp, mut app) = make_app();
8072 app.mouse.double_click_window = Duration::from_secs(60);
8073 let dir = app.store.config.issues_dir.clone();
8074 write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
8075 write_issue(&dir, "b.md", 2, "Beta", "todo", None);
8076 write_issue(&dir, "c.md", 3, "Gamma", "todo", None);
8077 write_issue(&dir, "d.md", 4, "Delta", "todo", None);
8078 app.rebuild_board();
8079 app.frame_size.set(Rect::new(0, 0, 80, 40));
8080 (tmp, app)
8081 }
8082
8083 fn make_blocked_mouse_app() -> (TempDir, App) {
8090 let (tmp, mut app) = make_mouse_app();
8091 app.store
8092 .link("1", "2", crate::relationships::RelationKind::Blocks)
8093 .unwrap();
8094 app.rebuild_board();
8095 (tmp, app)
8096 }
8097
8098 fn done_col(app: &App) -> usize {
8101 let name = app
8102 .store
8103 .config
8104 .default_config()
8105 .effective_done_column()
8106 .expect("the fixture board has a done column");
8107 (0..app.board.total_cols())
8108 .find(|c| app.board.column_name(*c) == name)
8109 .unwrap_or_else(|| panic!("no column named {name}"))
8110 }
8111
8112 fn down_left(x: u16, y: u16) -> MouseEvent {
8113 MouseEvent {
8114 kind: MouseEventKind::Down(MouseButton::Left),
8115 column: x,
8116 row: y,
8117 modifiers: crossterm::event::KeyModifiers::NONE,
8118 }
8119 }
8120
8121 fn down_right(x: u16, y: u16) -> MouseEvent {
8122 MouseEvent {
8123 kind: MouseEventKind::Down(MouseButton::Right),
8124 column: x,
8125 row: y,
8126 modifiers: crossterm::event::KeyModifiers::NONE,
8127 }
8128 }
8129
8130 fn up_left(x: u16, y: u16) -> MouseEvent {
8131 MouseEvent {
8132 kind: MouseEventKind::Up(MouseButton::Left),
8133 column: x,
8134 row: y,
8135 modifiers: crossterm::event::KeyModifiers::NONE,
8136 }
8137 }
8138
8139 fn drag_event(x: u16, y: u16) -> MouseEvent {
8140 MouseEvent {
8141 kind: MouseEventKind::Drag(MouseButton::Left),
8142 column: x,
8143 row: y,
8144 modifiers: crossterm::event::KeyModifiers::NONE,
8145 }
8146 }
8147
8148 fn card_cell(app: &App, col: usize, row: usize) -> (u16, u16) {
8151 let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
8152 let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
8153 let mut y = rect.y + 1;
8154 for before in 0..row {
8155 y += crate::ui::widgets::card_line_count(
8156 &app.board.columns[col].cards[before],
8157 rect.width as usize,
8158 true,
8159 3,
8160 );
8161 }
8162 (rect.x + 2, y)
8163 }
8164
8165 fn column_cell(app: &App, col: usize) -> (u16, u16) {
8167 let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
8168 let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
8169 (rect.x + 2, rect.y + rect.height - 2)
8170 }
8171
8172 #[test]
8178 fn a_drag_of_a_blocked_card_into_done_asks_before_it_writes() {
8179 let (_tmp, mut app) = make_blocked_mouse_app();
8180 let (sx, sy) = card_cell(&app, 0, 1); let (tx, ty) = column_cell(&app, done_col(&app));
8182
8183 app.handle_mouse(down_left(sx, sy)).unwrap();
8184 app.handle_mouse(drag_event(tx, ty)).unwrap();
8185 app.handle_mouse(up_left(tx, ty)).unwrap();
8186
8187 assert!(
8188 matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
8189 "a drag into done must raise the acknowledgement prompt, got {:?}",
8190 app.mode
8191 );
8192 app.handle_key(press(KeyCode::Esc)).unwrap();
8194 assert!(
8195 app.board.column_cards(0).iter().any(|c| c.number == 2),
8196 "a cancelled drag must not move the card"
8197 );
8198 }
8199
8200 #[test]
8204 fn a_confirmed_blocked_drag_into_done_records_its_commit() {
8205 let (_tmp, mut app) = make_blocked_mouse_app();
8206 let dir = app.store.config.issues_dir.clone();
8207 let (sx, sy) = card_cell(&app, 0, 1);
8208 let (tx, ty) = column_cell(&app, done_col(&app));
8209
8210 app.handle_mouse(down_left(sx, sy)).unwrap();
8211 app.handle_mouse(drag_event(tx, ty)).unwrap();
8212 app.handle_mouse(up_left(tx, ty)).unwrap();
8213 app.handle_key(press(KeyCode::Char('y'))).unwrap();
8214
8215 assert!(
8216 matches!(app.mode, Mode::MoveIntoDone { .. }),
8217 "confirming must still ask for the commit hash, got {:?}",
8218 app.mode
8219 );
8220 for c in "deadbee".chars() {
8221 app.handle_key(press(KeyCode::Char(c))).unwrap();
8222 }
8223 app.handle_key(press(KeyCode::Enter)).unwrap();
8224
8225 let file = fs::read_to_string(dir.join("b.md")).unwrap();
8226 assert!(file.contains("commit: deadbee"), "commit recorded:\n{file}");
8227 assert!(
8228 file.contains("blocked_move_acknowledged"),
8229 "and the acknowledgement is in the same record:\n{file}"
8230 );
8231 }
8232
8233 #[test]
8236 fn a_context_menu_move_of_a_blocked_card_asks_before_it_writes() {
8237 let (_tmp, mut app) = make_blocked_mouse_app();
8238 let (sx, sy) = card_cell(&app, 0, 1); let target = done_col(&app);
8240
8241 app.handle_mouse(down_right(sx, sy)).unwrap();
8242 assert!(
8243 matches!(app.mode, Mode::ContextMenu { .. }),
8244 "right-click opens the menu, got {:?}",
8245 app.mode
8246 );
8247 let index = app
8249 .context_menu
8250 .as_ref()
8251 .unwrap()
8252 .items()
8253 .iter()
8254 .position(|i| i.kind == MenuItemKind::MoveTo { column: target })
8255 .expect("the menu offers a move into the done column");
8256 for _ in 0..index {
8257 app.handle_key(press(KeyCode::Down)).unwrap();
8258 }
8259 app.handle_key(press(KeyCode::Enter)).unwrap();
8260
8261 assert!(
8262 matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
8263 "a menu move into done must raise the acknowledgement prompt, got {:?}",
8264 app.mode
8265 );
8266 app.handle_key(press(KeyCode::Esc)).unwrap();
8267 assert!(
8268 app.board.column_cards(0).iter().any(|c| c.number == 2),
8269 "a cancelled menu move must not move the card"
8270 );
8271 }
8272
8273 #[test]
8274 fn mouse_click_selects_card() {
8275 let (_tmp, mut app) = make_mouse_app();
8276 let (x, y) = card_cell(&app, 0, 1);
8277 let action = app.handle_mouse(down_left(x, y)).unwrap();
8278 assert_eq!(action, Action::None);
8279 assert_eq!((app.board.active_col, app.board.selected), (0, 1));
8280 assert_eq!(
8281 app.board.get_selected_card().map(|c| c.number),
8282 Some(2),
8283 "second todo card selected"
8284 );
8285 }
8286
8287 #[test]
8288 fn mouse_click_on_empty_column_space_selects_column() {
8289 let (_tmp, mut app) = make_mouse_app();
8290 let (x, y) = column_cell(&app, 1);
8291 let action = app.handle_mouse(down_left(x, y)).unwrap();
8292 assert_eq!(action, Action::None);
8293 assert_eq!(app.board.active_col, 1);
8294 }
8295
8296 #[test]
8297 fn mouse_double_click_opens_detail() {
8298 let (_tmp, mut app) = make_mouse_app();
8299 let (x, y) = card_cell(&app, 0, 0);
8300 app.handle_mouse(down_left(x, y)).unwrap();
8301 app.handle_mouse(up_left(x, y)).unwrap();
8302 app.handle_mouse(down_left(x, y)).unwrap();
8303 assert!(matches!(app.mode, Mode::Detail));
8304 assert_eq!(
8305 app.detail.as_ref().map(|d| d.issue.number),
8306 Some(1),
8307 "double-clicked card detail is open"
8308 );
8309 }
8310
8311 #[test]
8312 fn mouse_single_click_does_not_open_detail() {
8313 let (_tmp, mut app) = make_mouse_app();
8314 let (x, y) = card_cell(&app, 0, 0);
8315 app.handle_mouse(down_left(x, y)).unwrap();
8316 app.handle_mouse(up_left(x, y)).unwrap();
8317 assert!(matches!(app.mode, Mode::Board));
8318 assert!(app.detail.is_none());
8319 }
8320
8321 #[test]
8322 fn mouse_drag_moves_issue_to_another_real_column() {
8323 let (_tmp, mut app) = make_mouse_app();
8324 let (sx, sy) = card_cell(&app, 0, 1);
8325 let target = column_cell(&app, 1);
8326 app.handle_mouse(down_left(sx, sy)).unwrap();
8327 app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8328 let action = app.handle_mouse(up_left(target.0, target.1)).unwrap();
8329 assert_eq!(action, Action::None);
8330 assert_eq!(app.board.active_col, 1);
8331 assert!(
8332 app.board.columns[1].cards.iter().any(|c| c.number == 2),
8333 "Beta landed in 'in progress'"
8334 );
8335 assert!(
8336 app.board.columns[0].cards.iter().all(|c| c.number != 2),
8337 "Beta left 'todo'"
8338 );
8339 assert_eq!(app.status_msg.as_deref(), Some("Moved #2 to in progress"));
8340 }
8341
8342 #[test]
8343 fn mouse_drag_updates_preview_column_while_held() {
8344 let (_tmp, mut app) = make_mouse_app();
8345 let (sx, sy) = card_cell(&app, 0, 1);
8346 let target = column_cell(&app, 1);
8347 app.handle_mouse(down_left(sx, sy)).unwrap();
8348 app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8349 assert_eq!(app.drag_preview_col, Some(1));
8350 assert_eq!(app.board.hover, None);
8351 app.handle_mouse(up_left(target.0, target.1)).unwrap();
8352 assert_eq!(app.drag_preview_col, None);
8353 }
8354
8355 #[test]
8356 fn ctrl_up_and_down_reorder_the_selected_card() {
8357 let (_tmp, mut app) = make_mouse_app();
8358 app.board.active_col = 0;
8359 app.board.selected = 1;
8360 let up = app
8361 .handle_key(press_with_modifiers(
8362 KeyCode::Up,
8363 crossterm::event::KeyModifiers::CONTROL,
8364 ))
8365 .unwrap();
8366 assert_eq!(up, Action::None);
8367 assert_eq!(
8368 app.board
8369 .column_cards(0)
8370 .iter()
8371 .map(|c| c.title.as_str())
8372 .collect::<Vec<_>>(),
8373 ["Beta", "Alpha"],
8374 "ctrl-up moves the selected card one slot earlier"
8375 );
8376 assert_eq!(app.board.selected, 0);
8377 let down = app
8378 .handle_key(press_with_modifiers(
8379 KeyCode::Down,
8380 crossterm::event::KeyModifiers::CONTROL,
8381 ))
8382 .unwrap();
8383 assert_eq!(down, Action::None);
8384 assert_eq!(
8385 app.board
8386 .column_cards(0)
8387 .iter()
8388 .map(|c| c.title.as_str())
8389 .collect::<Vec<_>>(),
8390 ["Alpha", "Beta"],
8391 "ctrl-down restores the original order"
8392 );
8393 }
8394
8395 #[test]
8396 fn mouse_drop_on_source_column_is_noop() {
8397 let (_tmp, mut app) = make_mouse_app();
8398 let (sx, sy) = card_cell(&app, 0, 1);
8399 let target = column_cell(&app, 0);
8400 app.handle_mouse(down_left(sx, sy)).unwrap();
8401 app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8402 app.handle_mouse(up_left(target.0, target.1)).unwrap();
8403 assert!(
8404 app.board.columns[0].cards.iter().any(|c| c.number == 2),
8405 "Beta stayed in 'todo'"
8406 );
8407 }
8408
8409 #[test]
8410 fn mouse_drag_reorders_cards_within_same_column_and_persists_order() {
8411 let (_tmp, mut app) = make_mouse_app();
8412 let (sx, sy) = card_cell(&app, 0, 1);
8413 let (tx, ty) = card_cell(&app, 0, 0);
8414 app.handle_mouse(down_left(sx, sy)).unwrap();
8415 app.handle_mouse(drag_event(tx, ty)).unwrap();
8416 let action = app.handle_mouse(up_left(tx, ty)).unwrap();
8417 assert_eq!(action, Action::None);
8418 assert_eq!(
8419 app.board
8420 .column_cards(0)
8421 .iter()
8422 .map(|c| c.title.as_str())
8423 .collect::<Vec<_>>(),
8424 ["Beta", "Alpha"],
8425 "dropping on another card in the same column reorders it"
8426 );
8427 let board_name = app.active_board_name().to_string();
8428 let board_cfg = app
8429 .store
8430 .config
8431 .kanban
8432 .configurations
8433 .get(&board_name)
8434 .unwrap();
8435 assert_eq!(
8436 board_cfg.card_order,
8437 vec!["b.md".to_string(), "a.md".to_string()],
8438 "same-column drag persists the custom order in config"
8439 );
8440 }
8441
8442 #[test]
8443 fn mouse_drag_persists_only_cards_that_differ_from_default_order() {
8444 let (_tmp, mut app) = make_sparse_order_mouse_app();
8445 let (sx, sy) = card_cell(&app, 0, 3);
8446 let (tx, ty) = card_cell(&app, 0, 1);
8447 app.handle_mouse(down_left(sx, sy)).unwrap();
8448 app.handle_mouse(drag_event(tx, ty)).unwrap();
8449 app.handle_mouse(up_left(tx, ty)).unwrap();
8450 assert_eq!(
8451 app.board
8452 .column_cards(0)
8453 .iter()
8454 .map(|c| c.title.as_str())
8455 .collect::<Vec<_>>(),
8456 ["Alpha", "Delta", "Beta", "Gamma"],
8457 "dragging the last card into the middle updates the visible order"
8458 );
8459 let board_name = app.active_board_name().to_string();
8460 let board_cfg = app
8461 .store
8462 .config
8463 .kanban
8464 .configurations
8465 .get(&board_name)
8466 .unwrap();
8467 assert_eq!(
8468 board_cfg.card_order,
8469 vec!["d.md".to_string(), "b.md".to_string(), "c.md".to_string()],
8470 "only the cards whose positions changed are persisted"
8471 );
8472 }
8473
8474 #[test]
8475 fn mouse_drop_on_no_column_area_is_noop() {
8476 let (_tmp, mut app) = make_mouse_app();
8477 let (sx, sy) = card_cell(&app, 0, 0);
8478 app.handle_mouse(down_left(sx, sy)).unwrap();
8479 let outside = (sx, 0u16); app.handle_mouse(drag_event(outside.0, outside.1)).unwrap();
8481 app.handle_mouse(up_left(outside.0, outside.1)).unwrap();
8482 assert!(
8483 app.board.columns[0].cards.iter().any(|c| c.number == 1),
8484 "Alpha stayed in 'todo'"
8485 );
8486 assert_eq!(app.status_msg, None);
8487 }
8488
8489 #[test]
8490 fn mouse_drop_on_mismatch_column_is_noop() {
8491 let (_tmp, mut app) = make_app();
8492 app.mouse.double_click_window = Duration::from_secs(60);
8493 let dir = app.store.config.issues_dir.clone();
8494 write_issue(&dir, "x.md", 1, "Alpha", "todo", None);
8495 write_issue(&dir, "w.md", 9, "Oddball", "weird", None);
8496 app.rebuild_board();
8497 app.board.show_mismatch = true;
8498 app.frame_size.set(Rect::new(0, 0, 80, 40));
8499
8500 let (sx, sy) = card_cell(&app, 0, 0);
8501 let mismatch_col = app.board.columns.len();
8502 let target = column_cell(&app, mismatch_col);
8503 app.handle_mouse(down_left(sx, sy)).unwrap();
8504 app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8505 app.handle_mouse(up_left(target.0, target.1)).unwrap();
8506 assert!(
8507 app.board.columns[0].cards.iter().any(|c| c.number == 1),
8508 "Alpha stayed in 'todo' (mismatched column is not a move target)"
8509 );
8510 }
8511
8512 #[test]
8513 fn mouse_drag_into_done_without_commit_opens_prompt() {
8514 let (_tmp, mut app) = make_mouse_app();
8515 let (sx, sy) = card_cell(&app, 0, 0);
8516 let target = column_cell(&app, 3); app.handle_mouse(down_left(sx, sy)).unwrap();
8518 app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8519 app.handle_mouse(up_left(target.0, target.1)).unwrap();
8520 assert!(
8521 matches!(&app.mode, Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()),
8522 "drag into done without a commit opens the MoveIntoDone prompt, got {:?}",
8523 app.mode
8524 );
8525 }
8526
8527 fn moved_to(x: u16, y: u16) -> MouseEvent {
8528 MouseEvent {
8529 kind: MouseEventKind::Moved,
8530 column: x,
8531 row: y,
8532 modifiers: crossterm::event::KeyModifiers::NONE,
8533 }
8534 }
8535
8536 fn scroll_up(x: u16, y: u16) -> MouseEvent {
8537 MouseEvent {
8538 kind: MouseEventKind::ScrollUp,
8539 column: x,
8540 row: y,
8541 modifiers: crossterm::event::KeyModifiers::NONE,
8542 }
8543 }
8544
8545 fn scroll_down(x: u16, y: u16) -> MouseEvent {
8546 MouseEvent {
8547 kind: MouseEventKind::ScrollDown,
8548 column: x,
8549 row: y,
8550 modifiers: crossterm::event::KeyModifiers::NONE,
8551 }
8552 }
8553
8554 #[test]
8555 fn mouse_hover_updates_without_selection_change() {
8556 let (_tmp, mut app) = make_mouse_app();
8557 let (x, y) = card_cell(&app, 0, 1);
8558 let before = (app.board.active_col, app.board.selected);
8559 let action = app.handle_mouse(moved_to(x, y)).unwrap();
8560 assert_eq!(action, Action::None);
8561 assert_eq!(app.board.hover, Some((0, 1)));
8562 assert_eq!((app.board.active_col, app.board.selected), before);
8563 }
8564
8565 #[test]
8566 fn mouse_hover_cleared_on_up_or_pointer_leaving_board() {
8567 let (_tmp, mut app) = make_mouse_app();
8568 let (x, y) = card_cell(&app, 0, 0);
8569 app.handle_mouse(moved_to(x, y)).unwrap();
8570 assert_eq!(app.board.hover, Some((0, 0)));
8571
8572 app.handle_mouse(moved_to(x, 0)).unwrap();
8574 assert_eq!(app.board.hover, None);
8575
8576 app.handle_mouse(moved_to(x, y)).unwrap();
8578 assert!(app.board.hover.is_some());
8579 app.handle_mouse(up_left(x, y)).unwrap();
8580 assert_eq!(app.board.hover, None);
8581 }
8582
8583 #[test]
8584 fn mouse_scroll_moves_board_selection_within_active_column() {
8585 let (_tmp, mut app) = make_mouse_app();
8586 let (x, y) = card_cell(&app, 0, 1);
8587 app.handle_mouse(down_left(x, y)).unwrap();
8588 assert_eq!(app.board.selected, 1);
8589
8590 let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8591 assert_eq!(action, Action::None);
8592 assert_eq!(app.board.selected, 0);
8593
8594 let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8595 assert_eq!(action, Action::None);
8596 assert_eq!(app.board.selected, 1);
8597 }
8598
8599 #[test]
8600 fn mouse_scroll_scrolls_detail_and_context_menu() {
8601 let (_tmp, mut app) = make_mouse_app();
8602 let (x, y) = card_cell(&app, 0, 0);
8603
8604 app.handle_mouse(down_left(x, y)).unwrap();
8605 app.handle_mouse(up_left(x, y)).unwrap();
8606 app.handle_mouse(down_left(x, y)).unwrap();
8607 assert!(matches!(app.mode, Mode::Detail));
8608 if let Some(d) = app.detail.as_mut() {
8611 d.viewport.set(20);
8612 d.max_scroll.set(8);
8613 }
8614 let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8615 assert_eq!(action, Action::None);
8616 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 5);
8617 app.handle_mouse(scroll_down(x, y)).unwrap();
8620 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 8);
8621 let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8622 assert_eq!(action, Action::None);
8623 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 3);
8624 for _ in 0..5 {
8625 app.handle_mouse(scroll_up(x, y)).unwrap();
8626 }
8627 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 0);
8628
8629 let (_tmp, mut app) = make_mouse_app();
8630 let (x, y) = card_cell(&app, 0, 0);
8631 app.handle_mouse(down_right(x, y)).unwrap();
8632 assert!(matches!(app.mode, Mode::ContextMenu { .. }));
8633 let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8634 assert_eq!(action, Action::None);
8635 assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(1));
8636 let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8637 assert_eq!(action, Action::None);
8638 assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(0));
8639 }
8640
8641 #[test]
8650 fn new_issue_form_is_the_form_the_board_key_opens() {
8651 let (_tmp, mut by_key) = make_app();
8652 by_key.mode = Mode::Board;
8653 let _ = by_key.handle_key(press(KeyCode::Char('n'))).unwrap();
8654
8655 let (_tmp2, mut by_command) = make_app();
8656 by_command.open_new_issue_form();
8657
8658 assert!(matches!(by_command.mode, Mode::Form));
8659 assert!(matches!(by_key.mode, Mode::Form));
8660 let key_form = by_key.form.as_ref().expect("n opens a form");
8661 let cmd_form = by_command.form.as_ref().expect("the command opens a form");
8662 assert_eq!(cmd_form.title, key_form.title);
8663 assert_eq!(cmd_form.status, key_form.status);
8664 assert_eq!(cmd_form.assignee, key_form.assignee);
8665 assert_eq!(cmd_form.focus, key_form.focus);
8666 }
8667
8668 #[test]
8671 fn detail_page_keys_scroll_one_page_and_stop_at_the_end() {
8672 let (_tmp, mut app) = make_mouse_app();
8673 let (x, y) = card_cell(&app, 0, 0);
8674 app.handle_mouse(down_left(x, y)).unwrap();
8675 app.handle_mouse(up_left(x, y)).unwrap();
8676 app.handle_mouse(down_left(x, y)).unwrap();
8677 assert!(matches!(app.mode, Mode::Detail));
8678
8679 if let Some(d) = app.detail.as_mut() {
8680 d.viewport.set(20);
8681 d.max_scroll.set(45);
8682 }
8683 let page = app.detail.as_ref().unwrap().page();
8685 assert_eq!(page, 19);
8686
8687 app.handle_key(press(KeyCode::PageDown)).unwrap();
8688 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 19);
8689 app.handle_key(press(KeyCode::PageDown)).unwrap();
8690 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 38);
8691 app.handle_key(press(KeyCode::PageDown)).unwrap();
8693 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 45);
8694 app.handle_key(press(KeyCode::PageUp)).unwrap();
8695 assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 26);
8696 }
8697
8698 #[test]
8701 fn help_overlay_scrolls_by_row_page_and_wheel() {
8702 use ratatui::Terminal;
8703 use ratatui::backend::TestBackend;
8704
8705 let (_tmp, mut app) = make_app();
8706 app.open_help(Mode::Board);
8707 let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
8711 terminal.draw(|f| render(&app, f, f.area())).unwrap();
8712 let metrics = app.help_metrics.get();
8713 assert!(
8714 metrics.max_scroll > 0,
8715 "the test frame is meant to hide rows: {metrics:?}"
8716 );
8717
8718 app.handle_key(press(KeyCode::Char('j'))).unwrap();
8719 assert_eq!(app.help_scroll, 1, "one row down");
8720 app.handle_key(press(KeyCode::Char('k'))).unwrap();
8721 assert_eq!(app.help_scroll, 0, "and back");
8722
8723 app.handle_key(press(KeyCode::PageDown)).unwrap();
8724 assert_eq!(app.help_scroll, app.help_page(), "one page down");
8725 app.handle_key(press(KeyCode::PageUp)).unwrap();
8726 assert_eq!(app.help_scroll, 0, "and back");
8727
8728 app.handle_mouse(scroll_down(1, 1)).unwrap();
8729 assert_eq!(app.help_scroll, WHEEL_ROWS, "one wheel notch");
8730 app.handle_mouse(scroll_up(1, 1)).unwrap();
8731 assert_eq!(app.help_scroll, 0);
8732
8733 for _ in 0..50 {
8734 app.handle_key(press(KeyCode::PageDown)).unwrap();
8735 }
8736 assert_eq!(
8737 app.help_scroll, metrics.max_scroll,
8738 "the last row is reachable"
8739 );
8740 for _ in 0..50 {
8741 app.handle_key(press(KeyCode::PageUp)).unwrap();
8742 }
8743 assert_eq!(app.help_scroll, 0, "and the first row stays the first");
8744 }
8745
8746 #[test]
8749 fn help_overlay_scrolls_its_rows_and_reopens_at_the_top() {
8750 use ratatui::Terminal;
8751 use ratatui::backend::TestBackend;
8752
8753 let (_tmp, mut app) = make_app();
8754 let entries = crate::ui::commandbar::help_overlay(&app.bindings);
8755 assert!(
8756 entries.len() > 12,
8757 "the overlay must be longer than the frame"
8758 );
8759
8760 let draw = |app: &App| {
8761 let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
8762 terminal.draw(|f| render(app, f, f.area())).unwrap();
8763 let buf = terminal.backend().buffer();
8764 let mut text = String::new();
8765 for y in 0..buf.area.height {
8766 for x in 0..buf.area.width {
8767 text.push_str(buf[(x, y)].symbol());
8768 }
8769 text.push('\n');
8770 }
8771 text
8772 };
8773
8774 app.open_help(Mode::Board);
8775 let first = draw(&app);
8776 assert!(
8777 first.contains(&entries[0].0),
8778 "the top of the list is on screen: {first}"
8779 );
8780
8781 app.handle_key(press(KeyCode::PageDown)).unwrap();
8782 let second = draw(&app);
8783 assert_ne!(
8784 second, first,
8785 "scrolling a page must change what is drawn, not just a counter"
8786 );
8787 let page = app.help_page() as usize;
8788 assert!(
8789 second.contains(&entries[page].0),
8790 "the row one page down is now visible: {second}"
8791 );
8792 assert!(
8793 !second.contains(&entries[0].0),
8794 "and the first page scrolled away"
8795 );
8796
8797 app.close_help();
8800 app.open_help(Mode::Board);
8801 assert_eq!(app.help_scroll, 0);
8802 assert!(draw(&app).contains(&entries[0].0));
8803 }
8804
8805 #[test]
8811 fn board_page_keys_move_the_selection_by_a_screen() {
8812 use ratatui::Terminal;
8813 use ratatui::backend::TestBackend;
8814
8815 let (_tmp, mut app) = make_app_with_cards(30);
8816 let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
8818 terminal.draw(|f| render(&app, f, f.area())).unwrap();
8819 let page = app.board_page.get();
8820 assert!(
8821 page > 1,
8822 "a 20-row column must hold more than one card, got {page}"
8823 );
8824
8825 assert_eq!(app.board.selected, 0);
8826 app.handle_key(press(KeyCode::PageDown)).unwrap();
8827 assert_eq!(app.board.selected, page, "one screen down");
8828 app.handle_key(press(KeyCode::PageDown)).unwrap();
8829 assert_eq!(app.board.selected, page * 2, "two screens down");
8830 app.handle_key(press(KeyCode::PageUp)).unwrap();
8831 assert_eq!(app.board.selected, page, "and back one");
8832
8833 for _ in 0..20 {
8834 app.handle_key(press(KeyCode::PageDown)).unwrap();
8835 }
8836 assert_eq!(
8837 app.board.selected, 29,
8838 "paging past the end lands on the last card"
8839 );
8840 for _ in 0..20 {
8841 app.handle_key(press(KeyCode::PageUp)).unwrap();
8842 }
8843 assert_eq!(app.board.selected, 0, "and paging up lands on the first");
8844 }
8845
8846 #[test]
8849 fn board_page_keys_do_nothing_in_an_empty_column() {
8850 use ratatui::Terminal;
8851 use ratatui::backend::TestBackend;
8852
8853 let (_tmp, mut app) = make_app();
8854 let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
8855 terminal.draw(|f| render(&app, f, f.area())).unwrap();
8856
8857 assert_eq!(app.board_page.get(), 0, "nothing to page");
8858 app.handle_key(press(KeyCode::PageDown)).unwrap();
8859 assert_eq!(app.board.selected, 0);
8860 assert!(matches!(app.mode, Mode::Board));
8861 }
8862
8863 fn draw_help(app: &App, w: u16, h: u16) -> String {
8868 use ratatui::Terminal;
8869 use ratatui::backend::TestBackend;
8870
8871 let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
8872 terminal.draw(|f| render(app, f, f.area())).unwrap();
8873 let buf = terminal.backend().buffer();
8874 let mut text = String::new();
8875 for y in 0..buf.area.height {
8876 for x in 0..buf.area.width {
8877 text.push_str(buf[(x, y)].symbol());
8878 }
8879 text.push('\n');
8880 }
8881 text
8882 }
8883
8884 #[test]
8888 fn help_overlay_search_filters_the_list() {
8889 let (_tmp, mut app) = make_app();
8890 let entries = crate::ui::commandbar::help_overlay(&app.bindings);
8891 let first_key = entries[0].0.clone();
8892
8893 app.open_help(Mode::Board);
8894 assert!(
8895 draw_help(&app, 80, 24).contains(&first_key),
8896 "the unfiltered list starts at the top"
8897 );
8898
8899 app.handle_key(press(KeyCode::Char('/'))).unwrap();
8900 assert!(app.help_searching, "the input line has the keyboard");
8901 for c in "clone".chars() {
8902 app.handle_key(press(KeyCode::Char(c))).unwrap();
8903 }
8904 assert_eq!(app.help_query, "clone");
8905
8906 let filtered = draw_help(&app, 80, 24);
8907 assert!(
8908 filtered.contains("clone issue"),
8909 "the match is listed: {filtered}"
8910 );
8911 assert!(
8912 filtered.contains("of") && filtered.contains("bindings"),
8913 "and the summary says how many rows the filter kept: {filtered}"
8914 );
8915 assert!(
8916 !filtered.contains("open detail"),
8917 "an unrelated binding is filtered out: {filtered}"
8918 );
8919
8920 app.handle_key(press(KeyCode::Enter)).unwrap();
8922 assert!(!app.help_searching);
8923 assert_eq!(app.help_query, "clone");
8924 assert!(draw_help(&app, 80, 24).contains("clone issue"));
8925
8926 app.handle_key(press(KeyCode::Char('/'))).unwrap();
8928 app.handle_key(press(KeyCode::Backspace)).unwrap();
8929 app.handle_key(press(KeyCode::Backspace)).unwrap();
8930 assert_eq!(app.help_query, "clo");
8931 assert!(
8932 draw_help(&app, 80, 24).contains("clone issue"),
8933 "still a match"
8934 );
8935
8936 app.handle_key(press(KeyCode::Esc)).unwrap();
8939 assert!(!app.help_searching);
8940 assert!(app.help_query.is_empty());
8941 assert!(
8942 matches!(app.mode, Mode::Help { .. }),
8943 "clearing a search is not closing the help"
8944 );
8945 assert!(
8946 draw_help(&app, 80, 24).contains(&first_key),
8947 "the whole list is back"
8948 );
8949
8950 app.handle_key(press(KeyCode::Esc)).unwrap();
8952 assert!(!matches!(app.mode, Mode::Help { .. }));
8953 }
8954
8955 #[test]
8958 fn help_overlay_search_is_fuzzy() {
8959 let (_tmp, mut app) = make_app();
8960 app.open_help(Mode::Board);
8961 app.handle_key(press(KeyCode::Char('/'))).unwrap();
8962 for c in "nwiss".chars() {
8963 app.handle_key(press(KeyCode::Char(c))).unwrap();
8964 }
8965 let text = draw_help(&app, 80, 24);
8966 assert!(
8967 text.contains("new issue"),
8968 "a non-contiguous query still matches: {text}"
8969 );
8970 }
8971
8972 #[test]
8975 fn help_overlay_search_resets_when_reopened() {
8976 let (_tmp, mut app) = make_app();
8977 app.open_help(Mode::Board);
8978 app.handle_key(press(KeyCode::Char('/'))).unwrap();
8979 app.handle_key(press(KeyCode::Char('x'))).unwrap();
8980 app.handle_key(press(KeyCode::Enter)).unwrap();
8981 assert_eq!(app.help_query, "x");
8982
8983 app.close_help();
8984 app.open_help(Mode::Board);
8985 assert!(app.help_query.is_empty(), "the query did not survive");
8986 assert!(!app.help_searching);
8987 }
8988
8989 #[test]
8992 fn help_overlay_search_puts_the_caret_on_the_input_line() {
8993 use ratatui::Terminal;
8994 use ratatui::backend::TestBackend;
8995
8996 let (_tmp, mut app) = make_app();
8997 app.open_help(Mode::Board);
8998 app.handle_key(press(KeyCode::Char('/'))).unwrap();
8999 for c in "clone".chars() {
9000 app.handle_key(press(KeyCode::Char(c))).unwrap();
9001 }
9002
9003 let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
9004 terminal.draw(|f| render(&app, f, f.area())).unwrap();
9005 let cursor = terminal.backend().cursor_position();
9006
9007 let buf = terminal.backend().buffer();
9011 let mut found = None;
9012 for y in 0..buf.area.height {
9013 let mut row = String::new();
9014 for x in 0..buf.area.width {
9015 row.push_str(buf[(x, y)].symbol());
9016 }
9017 if let Some(byte) = row.find("/clone") {
9020 let column = row[..byte].chars().count() as u16 + "/clone".chars().count() as u16;
9021 found = Some((y, column));
9022 }
9023 }
9024 let (row, column) = found.expect("the prompt is drawn");
9025 assert_eq!(
9026 cursor,
9027 ratatui::layout::Position::new(column, row),
9028 "the prompt row was: {:?}",
9029 {
9030 let mut text = String::new();
9031 for x in 0..buf.area.width {
9032 text.push_str(buf[(x, row)].symbol());
9033 }
9034 text
9035 }
9036 );
9037 }
9038
9039 #[test]
9045 fn help_overlay_search_accepts_the_keys_that_close_the_overlay() {
9046 let (_tmp, mut app) = make_app();
9047 app.open_help(Mode::Board);
9048 app.handle_key(press(KeyCode::Char('/'))).unwrap();
9049 for c in "quit?".chars() {
9050 app.handle_key(press(KeyCode::Char(c))).unwrap();
9051 }
9052 assert_eq!(app.help_query, "quit?");
9053 assert!(app.help_searching, "still typing");
9054 assert!(
9055 matches!(app.mode, Mode::Help { .. }),
9056 "neither `q` nor `?` closed the overlay"
9057 );
9058
9059 app.handle_key(press(KeyCode::Enter)).unwrap();
9061 app.handle_key(press(KeyCode::Char('q'))).unwrap();
9062 assert!(!matches!(app.mode, Mode::Help { .. }));
9063 }
9064
9065 fn open_detail(app: &mut App) {
9070 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9071 assert!(matches!(app.mode, Mode::Detail));
9072 assert!(app.detail.is_some());
9073 }
9074
9075 #[test]
9076 fn help_opens_from_board_and_closes() {
9077 let (_tmp, mut app) = make_app();
9078 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9079 assert!(
9080 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
9081 "got {:?}",
9082 app.mode
9083 );
9084 let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
9085 assert!(matches!(app.mode, Mode::Board));
9086 }
9087
9088 #[test]
9089 fn help_from_detail_returns_to_detail() {
9090 let (tmp, mut app) = make_app();
9091 let dir = tmp.path().join("issues");
9092 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9093 let config = app.store.config.clone();
9094 app.board = BoardState::build(&app.store, config.default_config());
9095 open_detail(&mut app);
9096 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9097 assert!(
9098 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
9099 "got {:?}",
9100 app.mode
9101 );
9102 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9103 assert!(matches!(app.mode, Mode::Detail));
9104 }
9105
9106 #[test]
9107 fn help_overlay_click_closes_via_recorded_region() {
9108 let (_tmp, mut app) = make_app();
9109 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9110 assert!(matches!(app.mode, Mode::Help { .. }));
9111
9112 let _ = draw_board(&app, 80, 24);
9113
9114 let action = app.handle_mouse(down_left(0, 0)).unwrap();
9115 assert_eq!(action, Action::HelpClose);
9116 app.close_help();
9117
9118 assert!(matches!(app.mode, Mode::Board));
9119 }
9120
9121 #[test]
9122 fn help_overlay_lists_mouse_bindings() {
9123 let (_tmp, mut app) = make_app();
9124 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9125 let cells = draw_board(&app, 80, 24);
9126 let text: String = cells.concat();
9127 assert!(text.contains("click"), "got {text:?}");
9128 assert!(text.contains("double-click"), "got {text:?}");
9129 assert!(text.contains("right-click"), "got {text:?}");
9130 assert!(text.contains("drag"), "got {text:?}");
9131 }
9132
9133 #[test]
9134 fn filter_narrows_board_live() {
9135 let (tmp, mut app) = make_app();
9136 let dir = tmp.path().join("issues");
9137 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9138 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
9139 let config = app.store.config.clone();
9140 app.board = BoardState::build(&app.store, config.default_config());
9141 app.refresh_corpus().unwrap();
9142 assert_eq!(app.board.column_cards(0).len(), 2);
9143
9144 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9145 assert!(matches!(app.mode, Mode::Filter { .. }));
9146 for c in "arget".chars() {
9147 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9148 }
9149 assert_eq!(app.filter, Some("arget".into()));
9150 assert_eq!(app.board.column_cards(0).len(), 1);
9151 assert_eq!(app.board.column_cards(0)[0].title, "Target");
9152 assert!(matches!(app.mode, Mode::Filter { .. }));
9153 }
9154
9155 #[test]
9156 fn filter_esc_clears_and_returns_to_board() {
9157 let (tmp, mut app) = make_app();
9158 let dir = tmp.path().join("issues");
9159 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9160 let config = app.store.config.clone();
9161 app.board = BoardState::build(&app.store, config.default_config());
9162 app.refresh_corpus().unwrap();
9163 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9164 for c in "zzw".chars() {
9165 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9166 }
9167 assert_eq!(app.board.column_cards(0).len(), 0);
9168 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9169 assert!(matches!(app.mode, Mode::Board));
9170 assert_eq!(app.filter, None);
9171 assert_eq!(app.board.column_cards(0).len(), 1);
9172 }
9173
9174 #[test]
9175 fn filter_enter_commits_and_stays_filtered() {
9176 let (tmp, mut app) = make_app();
9177 let dir = tmp.path().join("issues");
9178 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9179 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
9180 let config = app.store.config.clone();
9181 app.board = BoardState::build(&app.store, config.default_config());
9182 app.refresh_corpus().unwrap();
9183 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9184 for c in "arget".chars() {
9185 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9186 }
9187 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9188 assert!(matches!(app.mode, Mode::Board));
9189 assert_eq!(app.filter, Some("arget".into()));
9190 assert_eq!(app.board.column_cards(0).len(), 1);
9191 }
9192
9193 #[test]
9194 fn filter_zero_match_reports_status() {
9195 let (tmp, mut app) = make_app();
9196 let dir = tmp.path().join("issues");
9197 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9198 let config = app.store.config.clone();
9199 app.board = BoardState::build(&app.store, config.default_config());
9200 app.refresh_corpus().unwrap();
9201 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9202 for c in "zzw".chars() {
9203 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9204 }
9205 assert_eq!(app.board.column_cards(0).len(), 0);
9206 assert!(
9207 app.status_msg
9208 .as_deref()
9209 .is_some_and(|m| m.contains("No matches")),
9210 "got {:?}",
9211 app.status_msg
9212 );
9213 }
9214
9215 #[test]
9216 fn filter_zero_match_number_reports_status() {
9217 let (tmp, mut app) = make_app();
9218 let dir = tmp.path().join("issues");
9219 write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
9220 let config = app.store.config.clone();
9224 app.board = BoardState::build(&app.store, config.default_config());
9225 app.refresh_corpus().unwrap();
9226 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9227 for c in "999".chars() {
9228 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9229 }
9230 assert_eq!(app.filter, Some("999".into()));
9231 assert_eq!(app.board.column_cards(0).len(), 0);
9232 assert!(
9233 app.status_msg
9234 .as_deref()
9235 .is_some_and(|m| m.contains("No matches")),
9236 "got {:?}",
9237 app.status_msg
9238 );
9239 }
9240
9241 #[test]
9242 fn comment_round_trip_via_detail() {
9243 let (tmp, mut app) = make_app();
9244 let dir = tmp.path().join("issues");
9245 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9246 let config = app.store.config.clone();
9247 app.board = BoardState::build(&app.store, config.default_config());
9248 open_detail(&mut app);
9249
9250 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9251 assert!(matches!(app.mode, Mode::Comment { .. }));
9252 for c in "hello world".chars() {
9253 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9254 }
9255 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9256 assert!(matches!(app.mode, Mode::Detail));
9257
9258 let issue = app
9259 .store
9260 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9261 .unwrap();
9262 let comments = issue.comments();
9263 assert_eq!(comments.len(), 1);
9264 assert_eq!(comments[0].text.trim(), "hello world");
9265 }
9266
9267 #[test]
9268 fn comment_esc_cancels() {
9269 let (tmp, mut app) = make_app();
9270 let dir = tmp.path().join("issues");
9271 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9272 let config = app.store.config.clone();
9273 app.board = BoardState::build(&app.store, config.default_config());
9274 open_detail(&mut app);
9275 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9276 for c in "partial".chars() {
9277 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9278 }
9279 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9280 assert!(matches!(app.mode, Mode::Detail));
9281 let issue = app
9282 .store
9283 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9284 .unwrap();
9285 assert!(issue.comments().is_empty());
9286 }
9287
9288 #[test]
9289 fn comment_empty_discarded() {
9290 let (tmp, mut app) = make_app();
9291 let dir = tmp.path().join("issues");
9292 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9293 let config = app.store.config.clone();
9294 app.board = BoardState::build(&app.store, config.default_config());
9295 open_detail(&mut app);
9296 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9297 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9298 assert!(matches!(app.mode, Mode::Detail));
9299 let issue = app
9300 .store
9301 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9302 .unwrap();
9303 assert!(issue.comments().is_empty());
9304 assert!(
9305 app.status_msg
9306 .as_deref()
9307 .is_some_and(|m| m.contains("empty"))
9308 );
9309 }
9310
9311 #[test]
9312 fn f4_a_disclosure_is_composed_with_the_confirmation_not_substituted() {
9313 let (_tmp, mut app) = make_app();
9324 app.status_msg = Some("Moved #1 to in progress".to_string());
9325 app.note_disclosure("legacy-1.md: this write removed the retired `links:` key.".into());
9326 let status = app.status_msg.as_deref().unwrap();
9327 assert!(
9328 status.contains("Moved #1 to in progress"),
9329 "the confirmation must survive: {status}"
9330 );
9331 assert!(
9332 status.contains("retired `links:` key"),
9333 "and the disclosure must be shown: {status}"
9334 );
9335 let disclosure_at = status.find("retired").expect("disclosure is present");
9339 let confirmation_at = status.find("Moved #1").expect("confirmation is present");
9340 assert!(
9341 disclosure_at < confirmation_at,
9342 "the disclosure leads, so its actionable tail survives truncation: {status}"
9343 );
9344
9345 let (_tmp2, mut app2) = make_app();
9347 app2.note_disclosure("alone".into());
9348 assert_eq!(app2.status_msg.as_deref(), Some("alone"));
9349 }
9350
9351 #[test]
9352 fn f4_the_loop_drain_moves_a_raised_disclosure_into_the_status_line() {
9353 let (_tmp, mut app) = make_app();
9359 app.status_msg = Some("Moved #1 to in progress".to_string());
9360
9361 crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
9362 assert_eq!(
9363 crate::storage::take_disclosures().len(),
9364 1,
9365 "precondition: a disclosure is queued and nothing has drained it"
9366 );
9367 crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
9369
9370 super::drain_disclosures(&mut app);
9371 let status = app.status_msg.as_deref().unwrap_or_default();
9372 assert!(
9373 status.contains("Moved #1 to in progress"),
9374 "the confirmation survives: {status:?}"
9375 );
9376 assert!(
9377 status.contains("retired"),
9378 "and the loop's drain moved the disclosure in: {status:?}"
9379 );
9380 assert!(
9381 crate::storage::take_disclosures().is_empty(),
9382 "the queue is empty, so the next frame does not repeat it"
9383 );
9384 }
9385
9386 #[test]
9409 fn every_printable_character_is_typeable_in_a_free_text_prompt() {
9410 let (_tmp, app) = make_app();
9411 let free_text = [
9412 InputContext::Comment,
9413 InputContext::Asset,
9414 InputContext::Filter,
9415 InputContext::MoveIntoDone,
9416 ];
9417
9418 for context in free_text {
9422 for ch in ['q', 'Q'] {
9423 let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
9424 assert_ne!(
9425 app.bindings.action(context, event),
9426 Some(crate::keybindings::TuiAction::Cancel),
9427 "{context:?} must be able to type `{ch}`: it is bound to cancel, so \
9428 pressing it leaves the mode and the rest of the input is applied as \
9429 commands underneath"
9430 );
9431 }
9432 }
9433
9434 for context in free_text {
9438 let mut stolen: Vec<char> = Vec::new();
9439 for code in 0x20u8..0x7f {
9440 let ch = code as char;
9441 let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
9442 if app.bindings.action(context, event).is_some() {
9443 stolen.push(ch);
9444 }
9445 }
9446 assert!(
9447 stolen.is_empty(),
9448 "{context:?} steals these printable characters, so they cannot be \
9449 typed: {stolen:?}"
9450 );
9451 }
9452
9453 let (tmp, mut app) = make_app();
9456 let dir = tmp.path().join("issues");
9457 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9458 app.board = BoardState::build(&app.store, app.store.config.default_config());
9459 open_detail(&mut app);
9460 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9461 assert!(
9462 matches!(app.mode, Mode::Comment { .. }),
9463 "enter the comment prompt"
9464 );
9465 for c in "has a q in it".chars() {
9466 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9467 }
9468 if let Mode::Comment { ref buffer, .. } = app.mode {
9469 assert_eq!(
9470 buffer, "has a q in it",
9471 "every character reached the buffer"
9472 );
9473 } else {
9474 panic!("the prompt must still be open, got {:?}", app.mode);
9475 }
9476 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9477 assert!(
9478 !matches!(app.mode, Mode::Comment { .. }),
9479 "esc must still cancel, got {:?}",
9480 app.mode
9481 );
9482 }
9483
9484 #[test]
9485 fn asset_round_trip_via_detail() {
9486 let (tmp, mut app) = make_app();
9487 let dir = tmp.path().join("issues");
9488 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9489 let config = app.store.config.clone();
9490 app.board = BoardState::build(&app.store, config.default_config());
9491 open_detail(&mut app);
9492
9493 let asset = tmp.path().join("pic.png");
9494 fs::write(&asset, b"fake-png").unwrap();
9495
9496 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
9497 assert!(matches!(app.mode, Mode::Asset { .. }));
9498 for c in asset.to_string_lossy().chars() {
9499 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9500 }
9501 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9502 assert!(matches!(app.mode, Mode::Detail));
9503
9504 assert!(
9505 dir.join("a-assets").join("pic.png").is_file(),
9506 "asset must be copied into the issue asset dir"
9507 );
9508 let issue = app
9509 .store
9510 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9511 .unwrap();
9512 assert!(
9513 issue.body.contains("pic.png"),
9514 "body must reference the asset, got {:?}",
9515 issue.body
9516 );
9517 }
9518
9519 #[test]
9520 fn asset_error_stays_in_mode() {
9521 let (tmp, mut app) = make_app();
9522 let dir = tmp.path().join("issues");
9523 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9524 let config = app.store.config.clone();
9525 app.board = BoardState::build(&app.store, config.default_config());
9526 open_detail(&mut app);
9527 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
9528 for c in "/no/such/file.png".chars() {
9529 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9530 }
9531 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9532 assert!(
9533 matches!(app.mode, Mode::Asset { .. }),
9534 "asset error must stay in entry mode, got {:?}",
9535 app.mode
9536 );
9537 assert!(app.status_msg.is_some());
9538 }
9539
9540 fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
9541 render_to_cells(app, w, h).unwrap()
9542 }
9543
9544 fn flush_line(cells: &[String], width: u16, row: u16) -> String {
9545 let start = (row as usize) * (width as usize);
9546 cells[start..start + width as usize].concat()
9547 }
9548
9549 #[test]
9550 fn render_board_unfiltered_has_no_filter_row() {
9551 let (_tmp, app) = make_app();
9552 let cells = draw_board(&app, 80, 24);
9553 assert!(
9554 !cells.iter().any(|s| s.contains("Filters:")),
9555 "no filter row expected"
9556 );
9557 }
9558
9559 #[test]
9560 fn render_board_filtered_shows_filter_row_beneath_tabs() {
9561 let (_tmp, mut app) = make_app();
9562 app.filter = Some("55".into());
9563 app.board = BoardState::build(&app.store, app.store.config.default_config());
9564 let cells = draw_board(&app, 80, 24);
9565 assert!(
9568 flush_line(&cells, 80, 1).contains("Filters: #55"),
9569 "row 1 should read the filter, got {}",
9570 flush_line(&cells, 80, 1)
9571 );
9572 }
9573
9574 #[test]
9575 fn render_board_structured_filter_shows_row() {
9576 let (_tmp, mut app) = make_app();
9577 app.filter = None;
9578 app.structured = search::StructuredFilter {
9579 status: Some("todo".into()),
9580 ..Default::default()
9581 };
9582 app.board = BoardState::build(&app.store, app.store.config.default_config());
9583 let cells = draw_board(&app, 80, 24);
9584 assert!(
9585 flush_line(&cells, 80, 1).contains("Filters:"),
9586 "got {}",
9587 flush_line(&cells, 80, 1)
9588 );
9589 }
9590
9591 #[test]
9592 fn render_board_filter_input_draws_row_and_prompt() {
9593 let (_tmp, mut app) = make_app();
9594 app.mode = Mode::Filter {
9595 buffer: "55".to_string(),
9596 };
9597 app.filter = Some("55".into());
9598 app.board = BoardState::build(&app.store, app.store.config.default_config());
9599 let cells = draw_board(&app, 80, 24);
9600 assert!(
9601 flush_line(&cells, 80, 1).contains("Filters: #55"),
9602 "row 1 should read the filter while typing"
9603 );
9604 assert!(
9605 flush_line(&cells, 80, 23).contains("Filter: 55"),
9606 "bottom row should hold the input prompt"
9607 );
9608 }
9609
9610 #[test]
9611 fn render_board_tiny_terminal_filtered_does_not_panic() {
9612 let (_tmp, mut app) = make_app();
9613 app.filter = Some("55".into());
9614 app.board = BoardState::build(&app.store, app.store.config.default_config());
9615 app.mode = Mode::Filter {
9616 buffer: "55".to_string(),
9617 };
9618 for h in [2_u16, 3, 4, 5] {
9619 let _ = draw_board(&app, 80, h);
9620 }
9621 }
9622
9623 #[test]
9624 fn filter_esc_clears_row_and_resolves_to_board() {
9625 let (tmp, mut app) = make_app();
9626 let dir = tmp.path().join("issues");
9627 write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
9628 let config = app.store.config.clone();
9629 app.board = BoardState::build(&app.store, config.default_config());
9630 app.refresh_corpus().unwrap();
9631 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9632 for c in "55".chars() {
9633 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9634 }
9635 let cells = draw_board(&app, 80, 24);
9636 assert!(
9637 cells.concat().contains("Filters:"),
9638 "filtered board must show the row; filter={:?} cells_row1={}",
9639 app.filter,
9640 flush_line(&cells, 80, 1)
9641 );
9642 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9643 assert!(matches!(app.mode, Mode::Board));
9644 assert_eq!(app.filter, None);
9645 let after = draw_board(&app, 80, 24).concat();
9646 assert!(
9647 !after.contains("Filters:"),
9648 "esc cleared the filter, row must disappear, got {after:?}"
9649 );
9650 }
9651
9652 #[test]
9653 fn render_once_uses_default_board_configuration() {
9654 let tmp = TempDir::new().unwrap();
9655 let dir = tmp.path().join("issues");
9656 fs::create_dir_all(&dir).unwrap();
9657
9658 let config_path = tmp.path().join("tix.toml");
9659 let config = Config {
9660 issues_dir: dir.clone(),
9661 ..Config::default()
9662 };
9663 config.save(&config_path).unwrap();
9664 let mut config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9665 config.kanban.configurations.clear();
9666 config.kanban.configurations.insert(
9667 "release".to_string(),
9668 crate::config::BoardConfiguration {
9669 columns: vec!["todo".to_string(), "done".to_string()],
9670 done_column: Some("done".to_string()),
9671 category_map: indexmap::IndexMap::new(),
9672 hidden_columns: vec![],
9673 show_mismatch: false,
9674 wrap_titles: true,
9675 title_lines: 2,
9676 card_order: vec![],
9677 order_by: crate::config::OrderBy::CreatedAtDesc,
9678 },
9679 );
9680 config.kanban.configurations.insert(
9681 "default".to_string(),
9682 crate::config::BoardConfiguration {
9683 columns: vec!["backlog".to_string(), "done".to_string()],
9684 done_column: Some("done".to_string()),
9685 category_map: indexmap::IndexMap::new(),
9686 hidden_columns: vec![],
9687 show_mismatch: false,
9688 wrap_titles: true,
9689 title_lines: 2,
9690 card_order: vec![],
9691 order_by: crate::config::OrderBy::CreatedAtDesc,
9692 },
9693 );
9694 config.save(&config_path).unwrap();
9695 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9696
9697 write_issue(&dir, "2026-01-01-a.md", 1, "A", "backlog", None);
9698 let rendered = render_once_at(&config, 80, 24).unwrap();
9699 assert!(
9700 rendered.contains("backlog"),
9701 "rendered board should include default board columns, got:\n{rendered}"
9702 );
9703 }
9704
9705 #[test]
9706 fn render_once_tiny_terminal_is_stable() {
9707 let (tmp, _app) = make_app();
9708 let config_path = tmp.path().join("tix.toml");
9709 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9710 write_issue(
9711 &tmp.path().join("issues"),
9712 "2026-01-01-a.md",
9713 1,
9714 "Alpha",
9715 "todo",
9716 None,
9717 );
9718 let first = render_once_at(&config, 20, 4).unwrap();
9719 let second = render_once_at(&config, 20, 4).unwrap();
9720 assert_eq!(first, second, "tiny render should be deterministic");
9721 }
9722
9723 #[test]
9724 fn render_once_omits_keyboard_hints() {
9725 let (tmp, _app) = make_app();
9726 let config_path = tmp.path().join("tix.toml");
9727 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9728 write_issue(
9729 &tmp.path().join("issues"),
9730 "2026-01-01-a.md",
9731 1,
9732 "Alpha",
9733 "todo",
9734 None,
9735 );
9736 let rendered = render_once_at(&config, 80, 24).unwrap();
9737 for hint in [
9738 "j/k nav",
9739 "n new",
9740 "? help",
9741 "h/l column",
9742 "enter open",
9743 "d delete",
9744 ] {
9745 assert!(
9746 !rendered.contains(hint),
9747 "one-shot output must not contain the keyboard hint {hint:?}:\n{rendered}"
9748 );
9749 }
9750 }
9751
9752 #[test]
9753 fn render_once_reserves_bottom_margin_lines() {
9754 let (tmp, _app) = make_app();
9755 let config_path = tmp.path().join("tix.toml");
9756 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9757 write_issue(
9758 &tmp.path().join("issues"),
9759 "2026-01-01-a.md",
9760 1,
9761 "Alpha",
9762 "todo",
9763 None,
9764 );
9765 let height = 24u16;
9766 let rendered = render_once_at(&config, 80, height).unwrap();
9767 let line_count = rendered.lines().count();
9768 assert!(
9769 line_count <= (height - RENDER_BOTTOM_MARGIN) as usize,
9770 "one-shot render must leave {RENDER_BOTTOM_MARGIN} spare lines, \
9771 got {line_count} lines for a {height}-row terminal:\n{rendered}"
9772 );
9773 assert!(
9774 rendered.lines().any(|l| l.chars().count() == 80),
9775 "one-shot render should still span the full width:\n{rendered}"
9776 );
9777 }
9778
9779 #[test]
9780 fn resolve_render_size_falls_back_for_non_tty() {
9781 assert_eq!(
9782 resolve_render_size(None),
9783 (RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT)
9784 );
9785 assert_eq!(resolve_render_size(Some((0, 0))), (1, 1));
9786 }
9787
9788 fn make_multi_board_app() -> (TempDir, App) {
9792 let tmp = TempDir::new().unwrap();
9793 let dir = tmp.path().join("issues");
9794 fs::create_dir_all(&dir).unwrap();
9795 let config_path = tmp.path().join("tix.toml");
9796 let mut config = Config {
9797 issues_dir: dir.clone(),
9798 ..Config::default()
9799 };
9800 config.kanban.configurations.insert(
9801 "release".into(),
9802 crate::config::BoardConfiguration::default(),
9803 );
9804 config.save(&config_path).unwrap();
9805 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9806 let app = App::new(&config).unwrap();
9807 app.frame_size.set(Rect::new(0, 0, 80, 24));
9808 (tmp, app)
9809 }
9810
9811 #[test]
9812 fn tab_click_switches_board_via_recorded_region() {
9813 use ratatui::Terminal;
9814 use ratatui::backend::TestBackend;
9815
9816 let (_tmp, mut app) = make_multi_board_app();
9817 assert!(app.tabs.needs_bar());
9818 assert_eq!(app.active_config, 0);
9819
9820 let backend = TestBackend::new(80, 24);
9821 let mut terminal = Terminal::new(backend).unwrap();
9822 terminal
9823 .draw(|f| crate::app::render(&app, f, f.area()))
9824 .unwrap();
9825
9826 let tabs_area = Rect::new(0, 0, 80, 2);
9828 let second = app.tabs.tab_rect(1, tabs_area).unwrap();
9829 let action = app.handle_mouse(down_left(second.x + 1, 1)).unwrap();
9830 assert_eq!(
9831 action,
9832 Action::SwitchBoard(1),
9833 "tab click resolves the region"
9834 );
9835
9836 if let Action::SwitchBoard(index) = action {
9839 app.switch_board(index);
9840 }
9841 assert_eq!(app.active_config, 1);
9842 assert_eq!(app.tabs.selected, 1);
9843 assert_eq!(app.status_msg.as_deref(), Some("Switched board"));
9844 }
9845
9846 #[test]
9847 fn tab_hover_updates_hover_state_without_switching_board() {
9848 let (_tmp, mut app) = make_multi_board_app();
9849 let tabs_area = Rect::new(0, 0, 80, 2);
9850 let second = app.tabs.tab_rect(1, tabs_area).unwrap();
9851
9852 let action = app.handle_mouse(moved_to(second.x + 1, 0)).unwrap();
9853 assert_eq!(action, Action::None);
9854 assert_eq!(app.active_config, 0);
9855 assert_eq!(app.tabs.selected, 0);
9856 assert_eq!(app.tabs.hovered, Some(1));
9857 assert_eq!(app.board.hover, None);
9858 }
9859
9860 fn drawn_confirm_buttons(app: &App) -> ((u16, u16), (u16, u16)) {
9865 let backend = ratatui::backend::TestBackend::new(
9866 app.frame_size.get().width,
9867 app.frame_size.get().height,
9868 );
9869 let mut terminal = ratatui::Terminal::new(backend).unwrap();
9870 terminal
9871 .draw(|f| crate::app::render(app, f, f.area()))
9872 .unwrap();
9873 let prompt = confirm_delete_prompt(app.frame_size.get());
9874 let (yes, no) = confirm_delete_buttons(prompt);
9875 ((yes.x + 2, yes.y), (no.x + 1, no.y))
9876 }
9877
9878 #[test]
9879 fn confirm_delete_yes_click_deletes_issue() {
9880 let (_tmp, mut app) = make_app();
9881 let dir = app.store.config.issues_dir.clone();
9882 write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
9883 app.rebuild_board();
9884 app.frame_size.set(Rect::new(0, 0, 80, 24));
9885 app.confirm_delete_target = Some("a.md".into());
9886 app.mode = Mode::ConfirmDelete;
9887
9888 let ((yes_x, yes_y), _) = drawn_confirm_buttons(&app);
9889 let action = app.handle_mouse(down_left(yes_x, yes_y)).unwrap();
9890 assert_eq!(action, Action::ConfirmDeleteYes);
9891
9892 let _ = app.confirm_delete_yes();
9893 assert_eq!(app.mode, Mode::Board);
9894 assert_eq!(app.confirm_delete_target, None);
9895 assert_eq!(
9896 app.status_msg.as_deref(),
9897 Some("Deleted a.md"),
9898 "Yes click deletes the issue (same branch as the y key)"
9899 );
9900 assert!(
9901 !dir.join("a.md").exists(),
9902 "the target file is actually removed"
9903 );
9904 }
9905
9906 #[test]
9907 fn confirm_delete_no_click_cancels() {
9908 let (_tmp, mut app) = make_app();
9909 let dir = app.store.config.issues_dir.clone();
9910 write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
9911 app.rebuild_board();
9912 app.frame_size.set(Rect::new(0, 0, 80, 24));
9913 app.confirm_delete_target = Some("a.md".into());
9914 app.mode = Mode::ConfirmDelete;
9915
9916 let (_, (no_x, no_y)) = drawn_confirm_buttons(&app);
9917 let action = app.handle_mouse(down_left(no_x, no_y)).unwrap();
9918 assert_eq!(action, Action::ConfirmDeleteNo);
9919
9920 let _ = app.confirm_delete_no();
9921 assert_eq!(app.mode, Mode::Board);
9922 assert_eq!(app.confirm_delete_target, None);
9923 assert!(
9924 dir.join("a.md").exists(),
9925 "No click cancels: the issue is left in place (same branch as Esc)"
9926 );
9927 }
9928
9929 #[test]
9932 fn filter_modal_row_click_moves_focus_to_that_criterion() {
9933 let (_tmp, mut app) = make_app();
9934 app.frame_size.set(Rect::new(0, 0, 80, 24));
9935 app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
9936 app.mode = Mode::FilterModal;
9937
9938 let backend = ratatui::backend::TestBackend::new(80, 24);
9939 let mut terminal = ratatui::Terminal::new(backend).unwrap();
9940 terminal
9941 .draw(|f| crate::app::render(&app, f, f.area()))
9942 .unwrap();
9943
9944 let rect = crate::ui::modal::modal_rect(Rect::new(0, 0, 80, 24));
9946 let status_row = rect.y + 1 + 2;
9947 let status_field = FilterField::ALL[2];
9948 assert_eq!(status_field, FilterField::Status);
9949
9950 assert_ne!(
9951 app.filter_modal.as_ref().unwrap().focus(),
9952 FilterField::Status
9953 );
9954 let action = app.handle_mouse(down_left(rect.x + 2, status_row)).unwrap();
9955 assert_eq!(
9956 action,
9957 Action::FilterModalFocus(FilterField::Status),
9958 "row click resolves the criterion region"
9959 );
9960 if let Action::FilterModalFocus(field) = action {
9961 app.filter_modal.as_mut().unwrap().focus_field(field);
9962 }
9963 assert_eq!(
9964 app.filter_modal.as_ref().unwrap().focus(),
9965 FilterField::Status,
9966 "clicked row becomes the focused criterion"
9967 );
9968 }
9969
9970 fn drawn_settings_row(app: &App, row: usize) -> (u16, u16) {
9973 let backend = ratatui::backend::TestBackend::new(
9974 app.frame_size.get().width,
9975 app.frame_size.get().height,
9976 );
9977 let mut terminal = ratatui::Terminal::new(backend).unwrap();
9978 terminal
9979 .draw(|f| crate::app::render(app, f, f.area()))
9980 .unwrap();
9981 let rect = crate::ui::modal::modal_rect(app.frame_size.get());
9982 (rect.x + 2, rect.y + 1 + row as u16)
9983 }
9984
9985 #[test]
9986 fn settings_row_click_selects_and_toggles_like_enter() {
9987 let (_tmp, mut app) = make_app();
9988 app.frame_size.set(Rect::new(0, 0, 80, 24));
9989 let board_name = app.active_board_name().to_string();
9990 let col = "in progress".to_string();
9991 app.settings = Some(crate::ui::settings::SettingsModal::new());
9992 app.mode = Mode::Settings;
9993
9994 let (x, y) = drawn_settings_row(&app, 1);
9998 let action = app.handle_mouse(down_left(x, y)).unwrap();
9999 assert_eq!(action, Action::SettingsRow(1), "settings row resolves");
10000
10001 if let Action::SettingsRow(row) = action {
10002 let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
10003 app.settings.as_mut().unwrap().move_selected_to(row, count);
10004 let _ = app.settings_toggle_selected();
10005 }
10006 assert_eq!(app.settings.as_ref().unwrap().selected, 1);
10007 assert!(
10008 app.store
10009 .config
10010 .kanban
10011 .configurations
10012 .get(&board_name)
10013 .unwrap()
10014 .hidden_columns
10015 .contains(&col),
10016 "clicking the column row runs the Enter toggle (hides the column)"
10017 );
10018 }
10019
10020 #[test]
10021 fn settings_display_row_click_mirrors_entering_browse() {
10022 let (_tmp, mut app) = make_app();
10023 app.frame_size.set(Rect::new(0, 0, 80, 24));
10024 let board_name = app.active_board_name().to_string();
10025 app.settings = Some(crate::ui::settings::SettingsModal::new());
10026 app.mode = Mode::Settings;
10027
10028 let display_row = app.active_board_config().columns.len();
10030 let (x, y) = drawn_settings_row(&app, display_row);
10031 let action = app.handle_mouse(down_left(x, y)).unwrap();
10032 assert_eq!(action, Action::SettingsRow(display_row));
10033
10034 if let Action::SettingsRow(row) = action {
10035 let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
10036 app.settings.as_mut().unwrap().move_selected_to(row, count);
10037 let _ = app.settings_toggle_selected();
10038 }
10039 assert_eq!(app.settings.as_ref().unwrap().selected, display_row);
10040 assert_eq!(
10041 app.status_msg.as_deref(),
10042 Some("Wrap titles: off"),
10043 "display row click runs the same edit as Enter on that row"
10044 );
10045 assert!(
10046 !app.store
10047 .config
10048 .kanban
10049 .configurations
10050 .get(&board_name)
10051 .unwrap()
10052 .wrap_titles
10053 );
10054 }
10055
10056 fn menu_kinds(app: &App) -> Vec<MenuItemKind> {
10058 app.context_menu
10059 .as_ref()
10060 .expect("menu open")
10061 .items()
10062 .iter()
10063 .map(|item| item.kind.clone())
10064 .collect()
10065 }
10066
10067 #[test]
10068 fn right_click_card_opens_menu_with_all_actions() {
10069 let (_tmp, mut app) = make_mouse_app();
10070 let (x, y) = card_cell(&app, 0, 1);
10071 app.handle_mouse(down_right(x, y)).unwrap();
10072
10073 assert_eq!((app.board.active_col, app.board.selected), (0, 1));
10074 let Some(menu) = app.context_menu.as_ref() else {
10075 panic!("menu not open");
10076 };
10077 assert_eq!(menu.len(), 7, "open, edit, 3 moves, delete, refresh");
10078 let kinds = menu_kinds(&app);
10079 assert_eq!(kinds[0], MenuItemKind::OpenDetail);
10080 assert_eq!(kinds[1], MenuItemKind::EditInEditor);
10081 assert!(matches!(kinds[2], MenuItemKind::MoveTo { column: 1 }));
10082 assert!(matches!(kinds[3], MenuItemKind::MoveTo { column: 2 }));
10083 assert!(matches!(kinds[4], MenuItemKind::MoveTo { column: 3 }));
10084 assert_eq!(kinds[5], MenuItemKind::Delete);
10085 assert_eq!(kinds[6], MenuItemKind::RefreshBoard);
10086 assert!(
10087 menu.item(2).unwrap().label.contains("in progress"),
10088 "move entries are labeled with the target column"
10089 );
10090 assert!(
10091 !kinds
10092 .iter()
10093 .any(|k| matches!(k, MenuItemKind::MoveTo { column: 0 })),
10094 "current column is not offered as a move target"
10095 );
10096 assert!(matches!(app.mode, Mode::ContextMenu { .. }));
10097 }
10098
10099 #[test]
10100 fn right_click_empty_column_space_is_a_noop() {
10101 let (_tmp, mut app) = make_mouse_app();
10102 let (x, y) = column_cell(&app, 1);
10103 app.handle_mouse(down_right(x, y)).unwrap();
10104 assert!(
10105 !matches!(app.mode, Mode::ContextMenu { .. }),
10106 "no menu on empty column space"
10107 );
10108 assert!(app.context_menu.is_none());
10109 }
10110
10111 #[test]
10112 fn context_menu_open_detail_matches_enter_key() {
10113 let (_tmp, mut app) = make_mouse_app();
10114 let (x, y) = card_cell(&app, 0, 1);
10115 let issue_before = app.board.column_cards(0)[1].number;
10116 app.handle_mouse(down_right(x, y)).unwrap();
10117 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10118
10119 assert!(!matches!(app.mode, Mode::ContextMenu { .. }));
10120 assert!(app.context_menu.is_none());
10121 let Some(detail) = &app.detail else {
10122 panic!("menu Open detail should open the detail view");
10123 };
10124 assert_eq!(detail.issue.number, issue_before);
10125
10126 let (_tmp, mut app) = make_mouse_app();
10127 let (x, y) = card_cell(&app, 0, 1);
10128 let _ = app.handle_mouse(down_left(x, y)).unwrap();
10129 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10130 assert_eq!(
10131 app.detail.as_ref().map(|d| d.issue.number),
10132 Some(issue_before),
10133 "menu Open detail and board Enter open the same issue"
10134 );
10135 }
10136
10137 #[test]
10138 fn context_menu_edit_in_editor_matches_e_key() {
10139 let (_tmp, mut app) = make_mouse_app();
10140 let (x, y) = card_cell(&app, 0, 1);
10141 app.handle_mouse(down_right(x, y)).unwrap();
10142 app.handle_key(press(KeyCode::Down)).unwrap(); let action = app.handle_key(press(KeyCode::Enter)).unwrap();
10144
10145 let selected = app.board.get_selected_card().cloned().unwrap().filename;
10146 assert_eq!(
10147 action,
10148 Action::OpenEditor(selected.clone()),
10149 "menu Edit in editor returns OpenEditor for the selected card"
10150 );
10151
10152 let (_tmp, mut app) = make_mouse_app();
10153 let (x, y) = card_cell(&app, 0, 1);
10154 let _ = app.handle_mouse(down_left(x, y)).unwrap();
10155 let key_action = app.handle_key(press(KeyCode::Char('e'))).unwrap();
10156 let key_filename = match app.board.get_selected_card() {
10157 Some(card) => card.filename.clone(),
10158 None => String::new(),
10159 };
10160 assert_eq!(
10161 key_action,
10162 Action::OpenEditor(key_filename),
10163 "board 'e' returns OpenEditor for the same card"
10164 );
10165 }
10166
10167 #[test]
10168 fn context_menu_move_to_matches_cardinal_move_key() {
10169 fn moved_via_menu() -> Status {
10172 let (_tmp, mut app) = make_mouse_app();
10173 let (x, y) = card_cell(&app, 0, 0);
10174 app.handle_mouse(down_right(x, y)).unwrap();
10175 app.handle_key(press(KeyCode::Down)).unwrap();
10179 app.handle_key(press(KeyCode::Down)).unwrap();
10180 app.handle_key(press(KeyCode::Down)).unwrap();
10181 app.handle_key(press(KeyCode::Enter)).unwrap();
10182 app.store
10183 .get(IssueSelector::Filename("a.md".to_string()))
10184 .unwrap()
10185 .status
10186 }
10187
10188 fn moved_via_keys() -> Status {
10189 let (_tmp, mut app) = make_mouse_app();
10190 let (x, y) = card_cell(&app, 0, 0);
10191 let _ = app.handle_mouse(down_left(x, y)).unwrap();
10192 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
10193 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
10194 app.store
10195 .get(IssueSelector::Filename("a.md".to_string()))
10196 .unwrap()
10197 .status
10198 }
10199
10200 assert_eq!(moved_via_menu(), moved_via_keys());
10201 }
10202
10203 #[test]
10204 fn context_menu_delete_sets_up_confirm() {
10205 let (_tmp, mut app) = make_mouse_app();
10206 let (x, y) = card_cell(&app, 0, 1);
10207 app.handle_mouse(down_right(x, y)).unwrap();
10208 app.handle_key(press(KeyCode::Down)).unwrap();
10210 app.handle_key(press(KeyCode::Down)).unwrap();
10211 app.handle_key(press(KeyCode::Down)).unwrap();
10212 app.handle_key(press(KeyCode::Down)).unwrap();
10213 app.handle_key(press(KeyCode::Down)).unwrap();
10214 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10215
10216 assert_eq!(app.mode, Mode::ConfirmDelete);
10217 assert_eq!(
10218 app.confirm_delete_target.as_deref(),
10219 Some("b.md"),
10220 "delete targets the right-clicked card"
10221 );
10222 assert!(!matches!(&app.mode, Mode::ContextMenu { .. }));
10223 }
10224
10225 #[test]
10226 fn context_menu_refresh_rebuilds_board() {
10227 let (_tmp, mut app) = make_mouse_app();
10228 let (x, y) = card_cell(&app, 0, 0);
10229 app.handle_mouse(down_right(x, y)).unwrap();
10230 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10232 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10233 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10234 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10235 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10236 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10237 app.handle_key(press(KeyCode::Char('j'))).unwrap();
10238 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10239
10240 assert_eq!(app.mode, Mode::Board);
10241 assert_eq!(app.status_msg.as_deref(), Some("Refreshed"));
10242 assert!(app.context_menu.is_none());
10243 }
10244
10245 #[test]
10246 fn context_menu_esc_closes_without_side_effects() {
10247 let (_tmp, mut app) = make_mouse_app();
10248 let (card_x, card_y) = card_cell(&app, 0, 1);
10249 app.handle_mouse(down_right(card_x, card_y)).unwrap();
10250 let status_before = app
10251 .store
10252 .get(IssueSelector::Filename("b.md".to_string()))
10253 .unwrap()
10254 .status
10255 .clone();
10256
10257 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
10258
10259 assert_eq!(app.mode, Mode::Board);
10260 assert!(app.context_menu.is_none());
10261 assert_eq!((app.board.active_col, app.board.selected), (0, 1));
10262 assert!(app.confirm_delete_target.is_none());
10263 assert_eq!(
10264 app.store
10265 .get(IssueSelector::Filename("b.md".to_string()))
10266 .unwrap()
10267 .status,
10268 status_before,
10269 "Esc closes the menu without mutating any issue"
10270 );
10271 }
10272
10273 #[test]
10274 fn context_menu_outside_click_closes_menu() {
10275 let (_tmp, mut app) = make_mouse_app();
10276 let (card_x, card_y) = card_cell(&app, 0, 1);
10277 app.handle_mouse(down_right(card_x, card_y)).unwrap();
10278
10279 let action = app.handle_mouse(down_left(40, 39)).unwrap();
10281 assert_eq!(action, Action::None);
10282 assert!(app.context_menu.is_none());
10283 assert_eq!(app.mode, Mode::Board);
10284 assert!(
10285 app.mouse.drag.is_none(),
10286 "the dismissing click must not start a drag"
10287 );
10288 }
10289
10290 #[test]
10291 fn context_menu_item_click_activates() {
10292 let (_tmp, mut app) = make_mouse_app();
10293 let (card_x, card_y) = card_cell(&app, 0, 1);
10294 app.handle_mouse(down_right(card_x, card_y)).unwrap();
10295 let menu = app.context_menu.as_ref().unwrap();
10296 let bounds = menu.bounds(app.frame_size.get());
10297 let item_x = bounds.x + 2;
10298 let item_y = bounds.y + 1 + 5;
10299 let _ = app.handle_mouse(down_left(item_x, item_y)).unwrap();
10300
10301 assert_eq!(app.mode, Mode::ConfirmDelete);
10302 assert_eq!(app.confirm_delete_target.as_deref(), Some("b.md"));
10303 assert!(app.context_menu.is_none());
10304 }
10305
10306 #[test]
10307 fn custom_board_binding_replaces_default() {
10308 let tmp = TempDir::new().unwrap();
10309 let dir = tmp.path().join("issues");
10310 fs::create_dir_all(&dir).unwrap();
10311 let mut config = Config {
10312 issues_dir: dir,
10313 ..Config::default()
10314 };
10315 config.keybindings.board.new_issue = Some(vec!["ctrl+n".into()]);
10316 let mut app = App::new(&config).unwrap();
10317 app.handle_key(press(KeyCode::Char('n'))).unwrap();
10318 assert!(matches!(app.mode, Mode::Board));
10319 app.handle_key(press_with_modifiers(
10320 KeyCode::Char('n'),
10321 crossterm::event::KeyModifiers::CONTROL,
10322 ))
10323 .unwrap();
10324 assert!(matches!(app.mode, Mode::Form));
10325 }
10326}