1use std::io;
2use std::time::Duration;
3
4use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
5use crossterm::execute;
6use crossterm::terminal::{
7 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
8};
9use ratatui::Terminal;
10use ratatui::backend::CrosstermBackend;
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Clear, Paragraph};
15
16use crate::assignee::{git_identity, resolve_me};
17use crate::config::{BoardConfiguration, Config};
18use crate::error::{Result, TixError};
19use crate::model::{Issue, Priority, Status, today};
20use crate::search::{self, Corpus};
21use crate::storage::{IssueSelector, Store};
22
23use crate::editor;
24use crate::ui::board::{self, BoardState};
25use crate::ui::commandbar;
26use crate::ui::detail::{self, DetailState};
27use crate::ui::form::{self, FormState};
28use crate::ui::settings::{SettingsModal, SettingsPrompt};
29use crate::ui::tabs::BoardTabs;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Action {
33 None,
34 Quit,
35 OpenEditor(String),
36 OpenAgent(String),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Mode {
41 Board,
42 Detail,
43 Form,
44 ConfirmDelete,
45 MoveIntoDone { target_col: usize, buffer: String },
46 Help { return_to: Box<Mode> },
47 Filter { buffer: String },
48 FilterModal,
49 Settings,
50 Comment { buffer: String },
51 Asset { buffer: String },
52 EditLintWarning { filename: String, message: String },
53}
54
55pub struct App {
56 pub mode: Mode,
57 pub board: BoardState,
58 pub detail: Option<DetailState>,
59 pub form: Option<FormState>,
60 pub confirm_delete_target: Option<String>,
61 pub store: Store,
62 pub status_msg: Option<String>,
63 pub quit: bool,
64 pub active_config: usize,
68 pub tabs: BoardTabs,
70 filter: Option<String>,
71 corpus: Corpus,
72 pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
73 pub settings: Option<SettingsModal>,
75 structured: search::StructuredFilter,
78}
79
80impl App {
81 pub fn new(config: &Config) -> Result<Self> {
82 let store = Store::open(config)?;
83 let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
84 let board = BoardState::build(&store, config.default_config());
85 let corpus = Corpus::build(&store)?;
86 Ok(App {
87 mode: Mode::Board,
88 board,
89 detail: None,
90 form: None,
91 confirm_delete_target: None,
92 store,
93 status_msg: None,
94 quit: false,
95 active_config: 0,
96 tabs,
97 filter: None,
98 corpus,
99 filter_modal: None,
100 settings: None,
101 structured: search::StructuredFilter::default(),
102 })
103 }
104
105 fn active_board_config(&self) -> &BoardConfiguration {
107 self.store
108 .config
109 .configs()
110 .nth(self.active_config)
111 .map(|(_, board)| board)
112 .unwrap_or_else(|| self.store.config.default_config())
113 }
114
115 pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
116 if key.kind != KeyEventKind::Press {
117 return Ok(Action::None);
118 }
119 match self.mode.clone() {
120 Mode::Board => self.handle_board_key(key),
121 Mode::Detail => self.handle_detail_key(key),
122 Mode::Form => self.handle_form_key(key),
123 Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
124 Mode::MoveIntoDone { target_col, .. } => {
125 self.handle_move_into_done_key(key, target_col)
126 }
127 Mode::Help { .. } => self.handle_help_key(key),
128 Mode::Filter { .. } => self.handle_filter_key(key),
129 Mode::FilterModal => self.handle_filter_modal_key(key),
130 Mode::Settings => self.handle_settings_key(key),
131 Mode::Comment { .. } => self.handle_comment_key(key),
132 Mode::Asset { .. } => self.handle_asset_key(key),
133 Mode::EditLintWarning { .. } => self.handle_edit_lint_warning_key(key),
134 }
135 }
136
137 fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
138 match key.code {
139 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
140 self.quit = true;
141 Ok(Action::Quit)
142 }
143 KeyCode::Tab => {
144 self.tabs.next();
145 self.active_config = self.tabs.selected;
146 self.switch_config();
147 Ok(Action::None)
148 }
149 KeyCode::BackTab => {
150 self.tabs.previous();
151 self.active_config = self.tabs.selected;
152 self.switch_config();
153 Ok(Action::None)
154 }
155 KeyCode::Left | KeyCode::Char('h') => {
156 self.board.move_left();
157 Ok(Action::None)
158 }
159 KeyCode::Right | KeyCode::Char('l') => {
160 self.board.move_right();
161 Ok(Action::None)
162 }
163 KeyCode::Up | KeyCode::Char('k') => {
164 self.board.move_up();
165 Ok(Action::None)
166 }
167 KeyCode::Down | KeyCode::Char('j') => {
168 self.board.move_down();
169 Ok(Action::None)
170 }
171 KeyCode::Enter => {
172 if let Some(card) = self.board.get_selected_card().cloned() {
173 let issue = self.store.get(IssueSelector::Filename(card.filename))?;
174 self.detail = Some(DetailState {
175 issue,
176 scroll_offset: 0,
177 });
178 self.mode = Mode::Detail;
179 }
180 Ok(Action::None)
181 }
182 KeyCode::Char('e') => {
183 if let Some(card) = self.board.get_selected_card() {
184 Ok(Action::OpenEditor(card.filename.clone()))
185 } else {
186 Ok(Action::None)
187 }
188 }
189 KeyCode::Char('A') => {
190 if let Some(card) = self.board.get_selected_card() {
191 Ok(Action::OpenAgent(card.filename.clone()))
192 } else {
193 Ok(Action::None)
194 }
195 }
196 KeyCode::Char('c') => {
197 self.toggle_column_visibility();
198 Ok(Action::None)
199 }
200 KeyCode::Char('f') => {
201 self.open_filter_modal();
202 Ok(Action::None)
203 }
204 KeyCode::Char('<') => {
205 let real_cols = self.board.real_cols();
206 if real_cols > 0 {
207 let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
208 {
209 real_cols - 1
210 } else {
211 self.board.active_col - 1
212 };
213 self.move_selected_to(target)
214 } else {
215 Ok(Action::None)
216 }
217 }
218 KeyCode::Char('>') => {
219 let real_cols = self.board.real_cols();
220 if real_cols > 0 {
221 let target = if self.board.active_col >= real_cols {
222 0
223 } else {
224 (self.board.active_col + 1) % real_cols
225 };
226 self.move_selected_to(target)
227 } else {
228 Ok(Action::None)
229 }
230 }
231 KeyCode::Char('n') => {
232 let default_status = self
233 .store
234 .config
235 .default_config()
236 .columns
237 .first()
238 .cloned()
239 .unwrap_or_else(|| "todo".to_string());
240 let default_assignee = crate::assignee::git_identity()
243 .map(|id| id.to_string())
244 .unwrap_or_default();
245 self.form = Some(FormState::with_default_assignee(
246 &default_status,
247 &default_assignee,
248 ));
249 self.mode = Mode::Form;
250 Ok(Action::None)
251 }
252 KeyCode::Char('d') => {
253 if let Some(card) = self.board.get_selected_card().cloned() {
254 self.confirm_delete_target = Some(card.filename);
255 self.mode = Mode::ConfirmDelete;
256 }
257 Ok(Action::None)
258 }
259 KeyCode::Char('r') => {
260 self.rebuild_board();
261 self.status_msg = Some("Refreshed".to_string());
262 Ok(Action::None)
263 }
264 KeyCode::Char('s') => {
265 self.open_settings(None);
266 Ok(Action::None)
267 }
268 KeyCode::Char('b') => {
269 self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
270 Ok(Action::None)
271 }
272 KeyCode::Char('?') => {
273 self.mode = Mode::Help {
274 return_to: Box::new(Mode::Board),
275 };
276 Ok(Action::None)
277 }
278 KeyCode::Char('/') => {
279 let buffer = self.filter.clone().unwrap_or_default();
280 self.mode = Mode::Filter { buffer };
281 Ok(Action::None)
282 }
283 _ => Ok(Action::None),
284 }
285 }
286
287 fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
288 let card = match self.board.get_selected_card().cloned() {
289 Some(c) => c,
290 None => return Ok(Action::None),
291 };
292
293 let target_name = self.board.column_name(target_col).to_string();
294 let done_col = self.store.config.default_config().effective_done_column();
295 let is_done_target = done_col == Some(&target_name);
296 let current_col_name = self.board.column_name(self.board.active_col);
297 let was_done = done_col == Some(current_col_name);
298
299 if is_done_target && card.commit.is_none() {
300 self.mode = Mode::MoveIntoDone {
301 target_col,
302 buffer: String::new(),
303 };
304 return Ok(Action::None);
305 }
306
307 let mut issue = self
308 .store
309 .get(IssueSelector::Filename(card.filename.clone()))?;
310 issue.status = Status::new(target_name.clone());
311 if was_done {
312 issue.commit = None;
313 }
314 issue.bump_updated();
315 self.store.update(&issue.filename, &issue)?;
316
317 self.rebuild_board();
318 self.board.select_column(target_col);
319 self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
320 Ok(Action::None)
321 }
322
323 fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
324 match key.code {
325 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
326 self.mode = Mode::Board;
327 self.detail = None;
328 Ok(Action::None)
329 }
330 KeyCode::Char('e') => {
331 if let Some(ref d) = self.detail {
332 let filename = d.issue.filename.clone();
333 Ok(Action::OpenEditor(filename))
334 } else {
335 Ok(Action::None)
336 }
337 }
338 KeyCode::PageUp => {
339 if let Some(ref mut d) = self.detail {
340 d.scroll_offset = d.scroll_offset.saturating_sub(5);
341 }
342 Ok(Action::None)
343 }
344 KeyCode::PageDown => {
345 if let Some(ref mut d) = self.detail {
346 d.scroll_offset = d.scroll_offset.saturating_add(5);
347 }
348 Ok(Action::None)
349 }
350 KeyCode::Char('?') => {
351 self.mode = Mode::Help {
352 return_to: Box::new(Mode::Detail),
353 };
354 Ok(Action::None)
355 }
356 KeyCode::Char('c') => {
357 self.mode = Mode::Comment {
358 buffer: String::new(),
359 };
360 Ok(Action::None)
361 }
362 KeyCode::Char('a') => {
363 self.mode = Mode::Asset {
364 buffer: String::new(),
365 };
366 Ok(Action::None)
367 }
368 _ => Ok(Action::None),
369 }
370 }
371
372 fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
373 let form = match self.form.as_mut() {
374 Some(f) => f,
375 None => return Ok(Action::None),
376 };
377 match key.code {
378 KeyCode::Esc => {
379 self.form = None;
380 self.mode = Mode::Board;
381 Ok(Action::None)
382 }
383 KeyCode::Enter => {
384 if form.title.trim().is_empty() {
385 self.status_msg = Some("Title is required".to_string());
386 return Ok(Action::None);
387 }
388 let title = form.title.trim().to_string();
389 let status_str = if form.status.trim().is_empty() {
390 self.store
391 .config
392 .default_config()
393 .columns
394 .first()
395 .cloned()
396 .unwrap_or_else(|| "todo".to_string())
397 } else {
398 form.status.trim().to_string()
399 };
400 let priority: Priority = form.priority.parse().unwrap_or_default();
401 let identity = git_identity();
402 let assignee = if form.assignee.trim().is_empty() {
403 None
404 } else {
405 resolve_me(form.assignee.trim(), identity.as_ref())
406 };
407 let labels: Vec<String> = form
408 .labels
409 .split(',')
410 .map(|s| s.trim().to_string())
411 .filter(|s| !s.is_empty())
412 .collect();
413 let estimate = if form.estimate.trim().is_empty() {
414 None
415 } else {
416 Some(form.estimate.trim().to_string())
417 };
418
419 let mut issue = Issue {
420 number: 0,
421 title,
422 status: Status::new(status_str),
423 labels,
424 priority,
425 assignee,
426 estimate,
427 commit: None,
428 gitlab_id: None,
429 linear_id: None,
430 mission: None,
431 task: None,
432 links: vec![],
433 files: vec![],
434 created: today(),
435 updated: today(),
436 filename: String::new(),
437 body: String::new(),
438 };
439 let path = self.store.create(&mut issue, "", None)?;
440 let name = path.file_name().unwrap().to_string_lossy().to_string();
441 self.status_msg = Some(format!("Created {name}"));
442 self.form = None;
443 self.mode = Mode::Board;
444 self.rebuild_board();
445 self.select_card_by_filename(&name);
446 Ok(Action::None)
447 }
448 KeyCode::Tab | KeyCode::Down => {
449 form.move_focus_next();
450 Ok(Action::None)
451 }
452 KeyCode::BackTab | KeyCode::Up => {
453 form.move_focus_prev();
454 Ok(Action::None)
455 }
456 KeyCode::Char(c) => {
457 form.insert_char(c);
458 Ok(Action::None)
459 }
460 KeyCode::Backspace => {
461 form.delete_char();
462 Ok(Action::None)
463 }
464 _ => Ok(Action::None),
465 }
466 }
467
468 fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
469 match key.code {
470 KeyCode::Char('y') | KeyCode::Char('Y') => {
471 if let Some(filename) = self.confirm_delete_target.take() {
472 self.store.delete(&filename)?;
473 self.status_msg = Some(format!("Deleted {filename}"));
474 }
475 self.mode = Mode::Board;
476 self.rebuild_board();
477 Ok(Action::None)
478 }
479 _ => {
480 self.confirm_delete_target = None;
481 self.mode = Mode::Board;
482 Ok(Action::None)
483 }
484 }
485 }
486
487 fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
488 match key.code {
489 KeyCode::Enter => {
490 let buffer = match &self.mode {
491 Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
492 _ => unreachable!(),
493 };
494
495 let card = match self.board.get_selected_card().cloned() {
496 Some(c) => c,
497 None => {
498 self.mode = Mode::Board;
499 return Ok(Action::None);
500 }
501 };
502
503 let mut issue = self
504 .store
505 .get(IssueSelector::Filename(card.filename.clone()))?;
506 let target_name = self.board.column_name(target_col).to_string();
507 issue.status = Status::new(target_name.clone());
508 let trimmed = buffer.trim();
509 if trimmed.is_empty() {
510 issue.commit = None;
511 } else {
512 issue.commit = Some(trimmed.to_string());
513 }
514 issue.bump_updated();
515 self.store.update(&issue.filename, &issue)?;
516
517 self.rebuild_board();
518 self.board.select_column(target_col);
519 self.status_msg = Some(match trimmed {
520 "" => format!(
521 "Moved #{} to {target_name} (no commit recorded)",
522 card.number
523 ),
524 _ => format!(
525 "Moved #{} to {target_name} (commit: {})",
526 card.number, trimmed
527 ),
528 });
529 self.mode = Mode::Board;
530 Ok(Action::None)
531 }
532 KeyCode::Esc => {
533 self.mode = Mode::Board;
534 Ok(Action::None)
535 }
536 KeyCode::Char(c) => {
537 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
538 buffer.push(c);
539 }
540 Ok(Action::None)
541 }
542 KeyCode::Backspace => {
543 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
544 buffer.pop();
545 }
546 Ok(Action::None)
547 }
548 _ => Ok(Action::None),
549 }
550 }
551
552 fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
553 match key.code {
554 KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
555 if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
556 self.mode = *return_to;
557 }
558 }
559 _ => {}
560 }
561 Ok(Action::None)
562 }
563
564 fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
565 match key.code {
566 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
567 self.filter = None;
568 self.board = BoardState::build(&self.store, self.active_board_config());
569 self.status_msg = None;
570 self.mode = Mode::Board;
571 }
572 KeyCode::Enter => {
573 self.mode = Mode::Board;
574 }
575 KeyCode::Char(c) => {
576 let mut next = match &self.mode {
577 Mode::Filter { buffer } => buffer.clone(),
578 _ => String::new(),
579 };
580 next.push(c);
581 self.mode = Mode::Filter {
582 buffer: next.clone(),
583 };
584 self.apply_filter(&next);
585 }
586 KeyCode::Backspace => {
587 let mut next = match &self.mode {
588 Mode::Filter { buffer } => buffer.clone(),
589 _ => String::new(),
590 };
591 next.pop();
592 self.mode = Mode::Filter {
593 buffer: next.clone(),
594 };
595 self.apply_filter(&next);
596 }
597 _ => {}
598 }
599 Ok(Action::None)
600 }
601
602 fn active_structured_filter(&self) -> &search::StructuredFilter {
605 self.filter_modal
606 .as_ref()
607 .map(|m| m.active_filter())
608 .unwrap_or(&self.structured)
609 }
610
611 fn open_filter_modal(&mut self) {
613 self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
614 self.structured.clone(),
615 ));
616 self.mode = Mode::FilterModal;
617 }
618
619 fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
625 let Some(modal) = self.filter_modal.as_mut() else {
626 self.mode = Mode::Board;
627 return Ok(Action::None);
628 };
629 match key.code {
630 KeyCode::Esc => {
631 self.filter_modal = None;
632 self.mode = Mode::Board;
633 self.rebuild_board();
634 }
635 KeyCode::Tab | KeyCode::Down => modal.focus_next(),
636 KeyCode::BackTab | KeyCode::Up => modal.focus_prev(),
637 KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
638 KeyCode::Enter => {
639 let mut filter = modal.active_filter().clone();
640 filter.normalize();
641 self.structured = filter;
642 self.filter_modal = None;
643 self.mode = Mode::Board;
644 self.rebuild_board();
645 }
646 KeyCode::Backspace => {
647 let field = modal.focus();
648 field.value_mut(modal.active_filter_mut()).pop();
649 self.rebuild_board();
650 }
651 KeyCode::Char(c) => {
652 let field = modal.focus();
653 field.value_mut(modal.active_filter_mut()).push(c);
654 self.rebuild_board();
655 }
656 _ => {}
657 }
658 Ok(Action::None)
659 }
660
661 fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
662 match key.code {
663 KeyCode::Esc => {
664 self.mode = Mode::Detail;
665 Ok(Action::None)
666 }
667 KeyCode::Enter => self.submit_comment(),
668 KeyCode::Char(c) => {
669 if let Mode::Comment { ref mut buffer } = self.mode {
670 buffer.push(c);
671 }
672 Ok(Action::None)
673 }
674 KeyCode::Backspace => {
675 if let Mode::Comment { ref mut buffer } = self.mode {
676 buffer.pop();
677 }
678 Ok(Action::None)
679 }
680 _ => Ok(Action::None),
681 }
682 }
683
684 fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
687 self.settings = Some(SettingsModal {
688 selected: 0,
689 prompt,
690 });
691 self.mode = Mode::Settings;
692 }
693
694 fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
700 if self.settings.is_none() {
701 self.mode = Mode::Board;
702 return Ok(Action::None);
703 }
704 let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
705 let Some(p) = prompt_active else {
706 return self.handle_settings_browse(key);
707 };
708 match key.code {
709 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
710 if let Some(ref mut m) = self.settings {
711 m.prompt = None;
712 }
713 Ok(Action::None)
714 }
715 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
716 match p {
717 SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
718 SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
719 SettingsPrompt::ConfirmDeleteBoard(name) => {
720 self.settings_commit_delete_board(name)
721 }
722 }
723 Ok(Action::None)
724 }
725 KeyCode::Char(c) => {
726 if let Some(ref mut m) = self.settings
727 && let Some(SettingsPrompt::AddColumn(ref mut b))
728 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
729 {
730 b.push(c);
731 }
732 Ok(Action::None)
733 }
734 KeyCode::Backspace => {
735 if let Some(ref mut m) = self.settings
736 && let Some(SettingsPrompt::AddColumn(ref mut b))
737 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
738 {
739 b.pop();
740 }
741 Ok(Action::None)
742 }
743 _ => Ok(Action::None),
744 }
745 }
746
747 fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
749 match key.code {
750 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
751 self.settings = None;
752 self.mode = Mode::Board;
753 self.rebuild_board();
754 Ok(Action::None)
755 }
756 KeyCode::Up | KeyCode::Char('k') => {
757 let count =
758 self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
759 if let Some(ref mut m) = self.settings {
760 m.move_selected(-1, count);
761 }
762 Ok(Action::None)
763 }
764 KeyCode::Down | KeyCode::Char('j') => {
765 let count =
766 self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
767 if let Some(ref mut m) = self.settings {
768 m.move_selected(1, count);
769 }
770 Ok(Action::None)
771 }
772 KeyCode::Tab => {
773 self.settings_next_board();
774 Ok(Action::None)
775 }
776 KeyCode::BackTab => {
777 self.settings_prev_board();
778 Ok(Action::None)
779 }
780 KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
781 KeyCode::Char('a') => {
782 if let Some(ref mut m) = self.settings {
783 m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
784 }
785 Ok(Action::None)
786 }
787 KeyCode::Char('n') => {
788 if let Some(ref mut m) = self.settings {
789 m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
790 }
791 Ok(Action::None)
792 }
793 KeyCode::Char('x') | KeyCode::Char('X') => {
794 if self.store.config.configs().count() <= 1 {
795 self.status_msg = Some("Cannot delete the last board".to_string());
796 return Ok(Action::None);
797 }
798 let name = self.active_board_name().to_string();
799 if let Some(ref mut m) = self.settings {
800 m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
801 }
802 Ok(Action::None)
803 }
804 _ => Ok(Action::None),
805 }
806 }
807
808 fn settings_next_board(&mut self) {
809 self.tabs.next();
810 self.active_config = self.tabs.selected;
811 self.switch_config();
812 if let Some(ref mut m) = self.settings {
813 m.selected = 0;
814 }
815 }
816
817 fn settings_prev_board(&mut self) {
818 self.tabs.previous();
819 self.active_config = self.tabs.selected;
820 self.switch_config();
821 if let Some(ref mut m) = self.settings {
822 m.selected = 0;
823 }
824 }
825
826 fn settings_toggle_selected(&mut self) -> Result<Action> {
827 let col_count = self.active_board_config().columns.len();
828 let selected = self.settings.as_ref().map(|m| m.selected).unwrap_or(0);
829 if selected >= col_count {
830 return self.settings_toggle_display_row(selected - col_count);
831 }
832 let col = self
833 .settings
834 .as_ref()
835 .and_then(|m| self.active_board_config().columns.get(m.selected))
836 .cloned();
837 let Some(col) = col else {
838 return Ok(Action::None);
839 };
840 let board_name = self.active_board_name().to_string();
841 let now_hidden = {
842 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
843 match board_config {
844 Some(bc) => {
845 if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
846 bc.hidden_columns.remove(pos);
847 false
848 } else {
849 bc.hidden_columns.push(col.clone());
850 true
851 }
852 }
853 None => return Ok(Action::None),
854 }
855 };
856 if !self.persist_config() {
857 return Ok(Action::None);
858 }
859 self.rebuild_board();
860 let last = self.active_board_config().columns.len().saturating_sub(1);
861 if let Some(ref mut m) = self.settings
862 && m.selected > last
863 {
864 m.selected = last;
865 }
866 self.status_msg = Some(match now_hidden {
867 true => format!("'{col}' hidden"),
868 false => format!("'{col}' shown"),
869 });
870 Ok(Action::None)
871 }
872
873 fn settings_toggle_display_row(&mut self, row: usize) -> Result<Action> {
877 let board_name = self.active_board_name().to_string();
878 let action_msg = {
879 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
880 let Some(board_config) = board_config else {
881 return Ok(Action::None);
882 };
883 match row {
884 0 => {
885 board_config.wrap_titles = !board_config.wrap_titles;
886 if board_config.wrap_titles {
887 "Wrap titles: on".to_string()
888 } else {
889 "Wrap titles: off".to_string()
890 }
891 }
892 1 => {
893 board_config.title_lines = board_config.title_lines % 5 + 1;
894 format!("Title lines: {}", board_config.title_lines)
895 }
896 2 => {
897 board_config.order_by = board_config.order_by.next();
898 format!("Order by: {}", board_config.order_by.as_str())
899 }
900 _ => return Ok(Action::None),
901 }
902 };
903 if !self.persist_config() {
904 return Ok(Action::None);
905 }
906 self.rebuild_board();
907 self.status_msg = Some(action_msg);
908 Ok(Action::None)
909 }
910
911 fn settings_commit_add_column(&mut self, name: String) {
912 let name = name.trim().to_string();
913 if name.is_empty() {
914 self.status_msg = Some("Column name is required".to_string());
915 return;
916 }
917 let board_name = self.active_board_name().to_string();
918 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
919 let Some(board_config) = board_config else {
920 self.status_msg = Some("board not found".to_string());
921 return;
922 };
923 if board_config.columns.iter().any(|c| c == &name) {
924 self.status_msg = Some(format!("Column '{name}' already exists"));
925 return;
926 }
927 board_config.columns.push(name.clone());
928 if !self.persist_config() {
929 return;
930 }
931 self.rebuild_board();
932 let last = self.active_board_config().columns.len().saturating_sub(1);
933 if let Some(ref mut m) = self.settings {
934 m.prompt = None;
935 m.selected = last;
936 }
937 self.status_msg = Some(format!("Added column '{name}'"));
938 }
939
940 fn settings_commit_new_board(&mut self, name: String) {
941 let name = name.trim().to_string();
942 if name.is_empty() {
943 self.status_msg = Some("Board name is required".to_string());
944 return;
945 }
946 if self.store.config.kanban.configurations.contains_key(&name) {
947 self.status_msg = Some(format!("Board '{name}' already exists"));
948 return;
949 }
950 self.store
951 .config
952 .kanban
953 .configurations
954 .insert(name.clone(), crate::config::BoardConfiguration::default());
955 if !self.persist_config() {
956 return;
957 }
958 self.reload_tabs();
959 let idx = self
960 .store
961 .config
962 .configs()
963 .position(|(n, _)| n == name.as_str())
964 .unwrap_or(0);
965 self.active_config = idx;
966 self.tabs.selected = idx;
967 self.switch_config();
968 if let Some(ref mut m) = self.settings {
969 m.prompt = None;
970 m.selected = 0;
971 }
972 self.status_msg = Some(format!("Added board '{name}'"));
973 }
974
975 fn settings_commit_delete_board(&mut self, name: String) {
976 if self.store.config.kanban.configurations.len() <= 1 {
977 self.status_msg = Some("Cannot delete the last board".to_string());
978 return;
979 }
980 let idx = self.active_config;
981 let removed = self.store.config.kanban.configurations.shift_remove(&name);
982 if removed.is_none() {
983 self.status_msg = Some(format!("Board '{name}' not found"));
984 return;
985 }
986 if !self.persist_config() {
987 return;
988 }
989 self.reload_tabs();
990 let count = self.store.config.kanban.configurations.len();
991 let target = idx.min(count.saturating_sub(1));
992 self.active_config = target;
993 self.tabs.selected = target;
994 self.switch_config();
995 if let Some(ref mut m) = self.settings {
996 m.prompt = None;
997 m.selected = 0;
998 }
999 self.status_msg = Some(format!("Deleted board '{name}'"));
1000 }
1001
1002 fn persist_config(&mut self) -> bool {
1003 match self.store.config_path() {
1004 Ok(path) => match self.store.config.save(&path) {
1005 Ok(()) => true,
1006 Err(e) => {
1007 self.status_msg = Some(format!("settings save: {e}"));
1008 false
1009 }
1010 },
1011 Err(e) => {
1012 self.status_msg = Some(format!("settings path: {e}"));
1013 false
1014 }
1015 }
1016 }
1017
1018 fn reload_tabs(&mut self) {
1019 let names: Vec<String> = self
1020 .store
1021 .config
1022 .configs()
1023 .map(|(name, _)| name.to_string())
1024 .collect();
1025 self.tabs = BoardTabs::new(names);
1026 }
1027
1028 fn active_board_name(&self) -> &str {
1029 self.store
1030 .config
1031 .configs()
1032 .nth(self.active_config)
1033 .map(|(name, _)| name)
1034 .unwrap_or("default")
1035 }
1036
1037 fn submit_comment(&mut self) -> Result<Action> {
1038 let buffer = match &self.mode {
1039 Mode::Comment { buffer } => buffer.clone(),
1040 _ => return Ok(Action::None),
1041 };
1042 let text = buffer.trim().to_string();
1043 if text.is_empty() {
1044 self.status_msg = Some("Comment discarded (empty)".to_string());
1045 self.mode = Mode::Detail;
1046 return Ok(Action::None);
1047 }
1048 let filename = match self.detail.as_ref() {
1049 Some(d) => d.issue.filename.clone(),
1050 None => {
1051 self.mode = Mode::Detail;
1052 return Ok(Action::None);
1053 }
1054 };
1055 let author = crate::assignee::git_identity()
1056 .map(|id| id.to_string())
1057 .unwrap_or_else(|| "anonymous".to_string());
1058 let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
1059 issue.add_comment(&author, &text);
1060 self.store.update(&filename, &issue)?;
1061 if let Some(ref mut d) = self.detail {
1062 d.issue = issue;
1063 d.scroll_offset = 0;
1064 }
1065 self.refresh_corpus()?;
1066 self.status_msg = Some(format!("Commented by {author}"));
1067 self.mode = Mode::Detail;
1068 Ok(Action::None)
1069 }
1070
1071 fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
1072 match key.code {
1073 KeyCode::Esc => {
1074 self.mode = Mode::Detail;
1075 Ok(Action::None)
1076 }
1077 KeyCode::Enter => self.submit_asset(),
1078 KeyCode::Char(c) => {
1079 if let Mode::Asset { ref mut buffer } = self.mode {
1080 buffer.push(c);
1081 }
1082 Ok(Action::None)
1083 }
1084 KeyCode::Backspace => {
1085 if let Mode::Asset { ref mut buffer } = self.mode {
1086 buffer.pop();
1087 }
1088 Ok(Action::None)
1089 }
1090 _ => Ok(Action::None),
1091 }
1092 }
1093
1094 fn submit_asset(&mut self) -> Result<Action> {
1095 let buffer = match &self.mode {
1096 Mode::Asset { buffer } => buffer.clone(),
1097 _ => return Ok(Action::None),
1098 };
1099 let path = std::path::PathBuf::from(buffer.trim());
1100 let filename = match self.detail.as_ref() {
1101 Some(d) => d.issue.filename.clone(),
1102 None => {
1103 self.mode = Mode::Detail;
1104 return Ok(Action::None);
1105 }
1106 };
1107 match self.store.add_asset(&filename, &path) {
1108 Ok(added) => {
1109 if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
1110 && let Some(ref mut d) = self.detail
1111 {
1112 d.issue = issue;
1113 d.scroll_offset = 0;
1114 }
1115 self.status_msg = Some(format!("Asset added: {}", added.link));
1116 self.refresh_corpus()?;
1117 self.mode = Mode::Detail;
1118 Ok(Action::None)
1119 }
1120 Err(e) => {
1121 self.status_msg = Some(format!("asset: {e}"));
1122 Ok(Action::None)
1123 }
1124 }
1125 }
1126
1127 fn toggle_column_visibility(&mut self) {
1130 let col_name = self.board.active_column_name().to_string();
1131 let config_name = self.tabs.names[self.active_config].clone();
1132 let board_config = self
1133 .store
1134 .config
1135 .kanban
1136 .configurations
1137 .get_mut(&config_name);
1138 let board_config = match board_config {
1139 Some(bc) => bc,
1140 None => {
1141 self.status_msg = Some("toggle: config not found".into());
1142 return;
1143 }
1144 };
1145 let now_hidden = if let Some(pos) = board_config
1146 .hidden_columns
1147 .iter()
1148 .position(|h| h == &col_name)
1149 {
1150 board_config.hidden_columns.remove(pos);
1151 false
1152 } else {
1153 board_config.hidden_columns.push(col_name.clone());
1154 true
1155 };
1156 match self.store.config_path() {
1157 Ok(path) => {
1158 if let Err(e) = self.store.config.save(&path) {
1159 self.status_msg = Some(format!("toggle save: {e}"));
1160 return;
1161 }
1162 }
1163 Err(e) => {
1164 self.status_msg = Some(format!("toggle path: {e}"));
1165 return;
1166 }
1167 }
1168 self.rebuild_board();
1169 let visible = self.board.columns.len();
1173 self.board.active_col = if now_hidden {
1174 visible
1175 + self
1176 .board
1177 .hidden
1178 .iter()
1179 .position(|c| c.name == col_name)
1180 .unwrap_or_default()
1181 } else {
1182 self.board
1183 .columns
1184 .iter()
1185 .position(|c| c.name == col_name)
1186 .unwrap_or_default()
1187 };
1188 self.board.clamp_selection();
1189 self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
1190 }
1191
1192 fn switch_config(&mut self) {
1197 let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
1198 self.rebuild_board();
1199 self.board.active_col = 0;
1200 if let Some(filename) = selected_filename
1201 && self
1202 .board
1203 .column_cards(0)
1204 .iter()
1205 .any(|c| c.filename == filename)
1206 {
1207 self.board.selected = self
1208 .board
1209 .column_cards(0)
1210 .iter()
1211 .position(|c| c.filename == filename)
1212 .unwrap_or(0);
1213 } else {
1214 self.board.selected = 0;
1215 }
1216 self.board.clamp_selection();
1217 self.status_msg = Some("Switched board".to_string());
1218 }
1219
1220 fn rebuild_board(&mut self) {
1223 let active_col = self.board.active_col;
1224 let selected = self.board.selected;
1225 let board_config = self.active_board_config().clone();
1226 let active_filter = self.active_structured_filter().clone();
1227 let q = self.filter.clone().unwrap_or_default();
1228 if !q.is_empty() || active_filter.has_active_criteria() {
1229 self.board =
1230 BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
1231 } else {
1232 self.board = BoardState::build(&self.store, &board_config);
1233 }
1234 self.board.active_col = active_col;
1235 self.board.selected = selected;
1236 self.board.clamp_selection();
1237 }
1238
1239 fn select_card_by_filename(&mut self, filename: &str) {
1240 if let Some((col_idx, card_idx)) =
1241 self.board
1242 .columns
1243 .iter()
1244 .enumerate()
1245 .find_map(|(col_idx, col)| {
1246 col.cards
1247 .iter()
1248 .position(|card| card.filename == filename)
1249 .map(|card_idx| (col_idx, card_idx))
1250 })
1251 {
1252 self.board.active_col = col_idx;
1253 self.board.selected = card_idx;
1254 }
1255 }
1256
1257 fn apply_filter(&mut self, buffer: &str) {
1261 if buffer.trim().is_empty() {
1262 self.filter = None;
1263 } else {
1264 self.filter = Some(buffer.trim().to_string());
1265 }
1266 let board_config = self.active_board_config().clone();
1267 let active_filter = self.active_structured_filter().clone();
1268 let q = self.filter.clone().unwrap_or_default();
1269 if !q.is_empty() || active_filter.has_active_criteria() {
1270 self.board =
1271 BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
1272 self.status_msg = if self.board.is_empty() {
1273 Some("No matches for current filter".to_string())
1274 } else {
1275 None
1276 };
1277 } else {
1278 self.board = BoardState::build(&self.store, &board_config);
1279 self.status_msg = None;
1280 }
1281 }
1282
1283 fn refresh_corpus(&mut self) -> Result<()> {
1286 let c = Corpus::build(&self.store)?;
1287 self.corpus = c;
1288 self.rebuild_board();
1289 Ok(())
1290 }
1291
1292 pub fn refresh_from_disk(&mut self) -> Result<()> {
1297 self.refresh_corpus()
1298 }
1299
1300 pub fn after_editor(&mut self, filename: &str) {
1301 match self
1302 .store
1303 .get(IssueSelector::Filename(filename.to_string()))
1304 {
1305 Ok(mut issue) => {
1306 issue.bump_updated();
1307 let location = match issue.assignee {
1311 Some(ref a) => match self.store.reassign(filename, Some(a)) {
1312 Ok(moved) => moved.to_string_lossy().to_string(),
1313 Err(e) => {
1314 self.status_msg = Some(format!("editor: {e}"));
1315 return;
1316 }
1317 },
1318 None => filename.to_string(),
1319 };
1320 if let Err(e) = self.store.update(&location, &issue) {
1321 self.status_msg = Some(format!("editor: {e}"));
1322 } else {
1323 if let Some(ref mut d) = self.detail
1324 && (d.issue.filename == filename || d.issue.filename == location)
1325 {
1326 d.issue = issue;
1327 d.scroll_offset = 0;
1328 }
1329 let _ = self.refresh_corpus();
1330 if let Err(e) = self.warn_if_linted_issue_invalid(&location) {
1331 self.status_msg = Some(format!("editor: {e}"));
1332 } else {
1333 self.status_msg = Some("Edited".to_string());
1334 }
1335 }
1336 }
1337 Err(e) => {
1338 self.status_msg = Some(format!("editor: {e}"));
1339 }
1340 }
1341 }
1342
1343 fn warn_if_linted_issue_invalid(&mut self, filename: &str) -> Result<()> {
1344 let findings =
1345 crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
1346 if let Some(finding) = findings.into_iter().next() {
1347 let message = format!("{}: {}", finding.kind, finding.message);
1348 self.mode = Mode::EditLintWarning {
1349 filename: filename.to_string(),
1350 message,
1351 };
1352 }
1353 Ok(())
1354 }
1355
1356 fn handle_edit_lint_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
1357 let (filename, _) = match &self.mode {
1358 Mode::EditLintWarning { filename, message } => (filename.clone(), message.clone()),
1359 _ => return Ok(Action::None),
1360 };
1361 match key.code {
1362 KeyCode::Char('r') | KeyCode::Char('R') => {
1363 self.mode = Mode::Board;
1364 Ok(Action::OpenEditor(filename))
1365 }
1366 KeyCode::Char('i') | KeyCode::Char('I') | KeyCode::Enter | KeyCode::Esc => {
1367 self.mode = Mode::Board;
1368 Ok(Action::None)
1369 }
1370 _ => Ok(Action::None),
1371 }
1372 }
1373}
1374
1375const HINT_HEIGHT: u16 = 1;
1376const TAB_HEIGHT: u16 = 2;
1380const FILTER_HEIGHT: u16 = 1;
1383const HOVER_HEIGHT: u16 = 1;
1386
1387pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
1388 match &app.mode {
1389 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
1390 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
1391 Mode::Form => {
1392 if let Some(ref fr) = app.form {
1393 render_board(app, f, area, commandbar::MODE_BOARD);
1394 let rect = crate::ui::modal::modal_rect(area);
1395 f.render_widget(Clear, rect);
1396 form::render(fr, f, rect);
1397 }
1398 }
1399 Mode::ConfirmDelete => {
1400 if area.height < 2 {
1401 return;
1402 }
1403 board::render(
1404 &app.board,
1405 f,
1406 area,
1407 &None,
1408 board::BoardDisplay::from(app.active_board_config()),
1409 );
1410 let title = app.confirm_delete_target.as_deref().unwrap_or("?");
1411 let msg = format!("Delete '{title}'? [y/N]");
1412 let prompt_w = 40.min(area.width);
1413 let prompt_h = 3;
1414 let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
1415 let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
1416 let prompt_area = Rect::new(prompt_x, prompt_y, prompt_w, prompt_h);
1417 let prompt = Paragraph::new(Line::from(Span::styled(
1418 msg,
1419 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1420 )))
1421 .block(
1422 Block::default()
1423 .borders(Borders::ALL)
1424 .title("Confirm")
1425 .style(Style::default().fg(Color::Red)),
1426 );
1427 f.render_widget(prompt, prompt_area);
1428 }
1429 Mode::MoveIntoDone { buffer, .. } => {
1430 if area.height < 3 {
1431 return;
1432 }
1433 let prompt_area = Rect {
1434 y: area.y + area.height - 1,
1435 height: 1,
1436 ..area
1437 };
1438 let block_area = Rect {
1439 height: area.height - 1,
1440 ..area
1441 };
1442 board::render(
1443 &app.board,
1444 f,
1445 block_area,
1446 &app.status_msg,
1447 board::BoardDisplay::from(app.active_board_config()),
1448 );
1449 let prompt_text = format!("Commit hash: {buffer}_");
1450 let prompt = Paragraph::new(Line::from(Span::styled(
1451 prompt_text,
1452 Style::default().fg(Color::Yellow),
1453 )));
1454 f.render_widget(prompt, prompt_area);
1455 }
1456 Mode::Help { return_to } => render_help(app, f, area, return_to),
1457 Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
1458 Mode::FilterModal => {
1459 render_board(app, f, area, commandbar::MODE_BOARD);
1460 if let Some(m) = app.filter_modal.as_ref() {
1461 m.render(f, area);
1462 }
1463 }
1464 Mode::Settings => {
1465 render_board(app, f, area, commandbar::MODE_BOARD);
1466 if let Some(m) = app.settings.as_ref() {
1467 let board = app.active_board_config();
1468 m.render(f, area, app.active_board_name(), board);
1469 }
1470 }
1471 Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
1472 Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
1473 Mode::EditLintWarning { filename, message } => {
1474 render_edit_lint_warning(app, f, area, filename, message)
1475 }
1476 }
1477}
1478
1479fn render_edit_lint_warning(
1480 app: &App,
1481 f: &mut ratatui::Frame,
1482 area: Rect,
1483 filename: &str,
1484 message: &str,
1485) {
1486 render_board(app, f, area, commandbar::MODE_BOARD);
1487 let rect = crate::ui::modal::modal_rect(area);
1488 f.render_widget(Clear, rect);
1489 let lines = vec![
1490 Line::from(Span::styled("Lint failed", Style::default().fg(Color::Red))),
1491 Line::from(Span::raw(filename.to_string())),
1492 Line::from(Span::styled(
1493 message.to_string(),
1494 Style::default().fg(Color::Yellow),
1495 )),
1496 Line::from(""),
1497 Line::from(Span::styled(
1498 "r: re-edit · i/Enter/Esc: ignore",
1499 Style::default().fg(Color::DarkGray),
1500 )),
1501 ];
1502 let para = Paragraph::new(lines).block(
1503 Block::default()
1504 .borders(Borders::ALL)
1505 .title("Edit Warning")
1506 .style(Style::default().fg(Color::Red)),
1507 );
1508 f.render_widget(para, rect);
1509}
1510
1511fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
1516 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
1517 let hover_area = Rect {
1518 y: area.y + tab_h,
1519 height: HOVER_HEIGHT,
1520 ..area
1521 };
1522 let line = match app.board.get_selected_card() {
1523 Some(card) => Line::from(vec![
1524 Span::styled(
1525 format!("#{}", card.number),
1526 Style::default().fg(Color::Cyan),
1527 ),
1528 Span::raw(" "),
1529 Span::styled(card.title.clone(), Style::default()),
1530 ]),
1531 None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
1532 };
1533 f.render_widget(Paragraph::new(line), hover_area);
1534}
1535
1536fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
1541 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
1542 let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
1543 FILTER_HEIGHT
1544 } else {
1545 0
1546 };
1547 if area.height <= HINT_HEIGHT + tab_h + HOVER_HEIGHT + filter_h {
1548 return;
1549 }
1550 let hint_area = Rect {
1551 y: area.y + area.height - HINT_HEIGHT,
1552 height: HINT_HEIGHT,
1553 ..area
1554 };
1555 let tabs_area = Rect {
1556 y: area.y,
1557 height: tab_h,
1558 ..area
1559 };
1560 let filter_area = Rect {
1561 y: area.y + tab_h + HOVER_HEIGHT,
1562 height: filter_h,
1563 ..area
1564 };
1565 let content = Rect {
1566 y: area.y + tab_h + HOVER_HEIGHT + filter_h,
1567 height: area.height - tab_h - HOVER_HEIGHT - filter_h - HINT_HEIGHT,
1568 ..area
1569 };
1570 if tab_h > 0 {
1571 app.tabs.render(f, tabs_area);
1572 }
1573 render_hover_line(app, f, area);
1574 if filter_h > 0 {
1575 filter_row(app).render(f, filter_area);
1576 }
1577 board::render(
1578 &app.board,
1579 f,
1580 content,
1581 &app.status_msg,
1582 board::BoardDisplay::from(app.active_board_config()),
1583 );
1584 render_hint(f, hint_area, tag);
1585}
1586
1587fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
1589 let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
1590 if area.height <= HINT_HEIGHT + status_h {
1591 return;
1592 }
1593 let hint_area = Rect {
1594 y: area.y + area.height - HINT_HEIGHT,
1595 height: HINT_HEIGHT,
1596 ..area
1597 };
1598 let content = Rect {
1599 height: area.height - status_h - HINT_HEIGHT,
1600 ..area
1601 };
1602 if let Some(ref d) = app.detail {
1603 detail::render(d, f, content);
1604 }
1605 if let Some(msg) = &app.status_msg {
1606 let status_area = Rect {
1607 y: area.y + area.height - status_h - HINT_HEIGHT,
1608 height: status_h,
1609 ..area
1610 };
1611 let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
1612 .style(Style::default().fg(Color::Yellow));
1613 f.render_widget(para, status_area);
1614 }
1615 render_hint(f, hint_area, tag);
1616}
1617
1618fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1622 let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
1623 if area.height < tab_h + HOVER_HEIGHT + 1 {
1624 return;
1625 }
1626 let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
1627 FILTER_HEIGHT
1628 } else {
1629 0
1630 };
1631 let input_area = Rect {
1632 y: area.y + area.height - 1,
1633 height: 1,
1634 ..area
1635 };
1636 let tabs_area = Rect {
1637 y: area.y,
1638 height: tab_h,
1639 ..area
1640 };
1641 let filter_area = Rect {
1642 y: area.y + tab_h + HOVER_HEIGHT,
1643 height: filter_h,
1644 ..area
1645 };
1646 let content = Rect {
1647 y: area.y + tab_h + HOVER_HEIGHT + filter_h,
1648 height: area
1649 .height
1650 .saturating_sub(tab_h + HOVER_HEIGHT + filter_h + 1),
1651 ..area
1652 };
1653 if tab_h > 0 {
1654 app.tabs.render(f, tabs_area);
1655 }
1656 render_hover_line(app, f, area);
1657 if filter_h > 0 {
1658 filter_row(app).render(f, filter_area);
1659 }
1660 board::render(
1661 &app.board,
1662 f,
1663 content,
1664 &app.status_msg,
1665 board::BoardDisplay::from(app.active_board_config()),
1666 );
1667 render_input_line(f, input_area, label, buffer);
1668}
1669
1670fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1672 if area.height < 2 {
1673 return;
1674 }
1675 let input_area = Rect {
1676 y: area.y + area.height - 1,
1677 height: 1,
1678 ..area
1679 };
1680 let content = Rect {
1681 height: area.height - 1,
1682 ..area
1683 };
1684 if let Some(ref d) = app.detail {
1685 detail::render(d, f, content);
1686 }
1687 render_input_line(f, input_area, label, buffer);
1688}
1689
1690fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1691 let text = format!("{label}: {buffer}_");
1692 let prompt = Paragraph::new(Line::from(Span::styled(
1693 text,
1694 Style::default().fg(Color::Yellow),
1695 )));
1696 f.render_widget(prompt, area);
1697}
1698
1699fn filter_row(app: &App) -> crate::ui::filter_row::FilterRow {
1700 crate::ui::filter_row::FilterRow {
1701 query: app.filter.clone(),
1702 structured: app.structured.clone(),
1703 }
1704}
1705
1706fn render_hint(f: &mut ratatui::Frame, area: Rect, tag: &str) {
1707 let bar = commandbar::hint_bar(&[tag]);
1708 let para = Paragraph::new(Line::from(Span::styled(
1709 bar,
1710 Style::default().fg(Color::Gray),
1711 )));
1712 f.render_widget(para, area);
1713}
1714
1715fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
1719 match return_to {
1720 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
1721 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
1722 _ => render_board(app, f, area, commandbar::MODE_BOARD),
1723 }
1724
1725 let help_entries = commandbar::help_overlay_list();
1726 let rows: Vec<Line> = help_entries
1727 .iter()
1728 .map(|(key, desc, scope)| {
1729 Line::from(vec![
1730 Span::styled(format!("{key:<10}"), Style::default().fg(Color::Cyan)),
1731 Span::styled(format!("{desc:<10}"), Style::default().fg(Color::White)),
1732 Span::styled(*scope, Style::default().fg(Color::DarkGray)),
1733 ])
1734 })
1735 .collect();
1736 let block = Block::default()
1737 .borders(Borders::ALL)
1738 .title("Help [? close]")
1739 .style(Style::default().fg(Color::Cyan));
1740 let para = Paragraph::new(rows).block(block);
1741
1742 let modal_w = 42.min(area.width).max(30);
1743 let modal_h = (help_entries.len() as u16 + 2).min(area.height).max(4);
1744 let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
1745 let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
1746 let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
1747
1748 if area.height >= modal_h && area.width >= modal_w {
1749 f.render_widget(Clear, modal_area);
1750 f.render_widget(para, modal_area);
1751 }
1752}
1753
1754pub fn run(config: &Config) -> Result<()> {
1755 let mut app = App::new(config)?;
1756 let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
1757 enable_raw_mode().map_err(io_err)?;
1758 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
1759 let guard = RestoreGuard;
1760 let backend = CrosstermBackend::new(io::stdout());
1761 let mut terminal = Terminal::new(backend).map_err(io_err)?;
1762
1763 let result = loop {
1764 terminal
1765 .draw(|f| render(&app, f, f.area()))
1766 .map_err(io_err)?;
1767
1768 if event::poll(Duration::from_millis(200)).map_err(io_err)?
1769 && let Event::Key(key) = event::read().map_err(io_err)?
1770 {
1771 match app.handle_key(key)? {
1772 Action::None => {}
1773 Action::Quit => break Ok(()),
1774 Action::OpenEditor(filename) => {
1775 drop(terminal);
1776 let _ = disable_raw_mode();
1777 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1778
1779 let edit_result = (|| -> Result<()> {
1780 let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
1781 let original = app.store.get(IssueSelector::Filename(filename.clone()))?;
1782 let temp = std::env::temp_dir().join(format!(
1783 "tix-edit-{}-{}.md",
1784 std::process::id(),
1785 original.number
1786 ));
1787 std::fs::write(&temp, original.to_file_contents()).map_err(TixError::Io)?;
1788 editor::run_editor(&name, &temp, &app.store.config)?;
1789 let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
1790 let mut parsed = Issue::parse(&edited)?;
1791 let _ = std::fs::remove_file(&temp);
1792 if parsed.title.trim().is_empty() {
1793 app.mode = Mode::EditLintWarning {
1794 filename: filename.clone(),
1795 message: "title is required".to_string(),
1796 };
1797 app.status_msg = Some("editor: title is required".to_string());
1798 return Ok(());
1799 }
1800 parsed.bump_updated();
1801 let location = match parsed.assignee {
1802 Some(ref a) => match app.store.reassign(&original.filename, Some(a)) {
1803 Ok(moved) => moved.to_string_lossy().to_string(),
1804 Err(e) => {
1805 app.status_msg = Some(format!("editor: {e}"));
1806 return Ok(());
1807 }
1808 },
1809 None => original.filename.clone(),
1810 };
1811 app.store.update(&location, &parsed)?;
1812 if let Some(ref mut d) = app.detail
1813 && (d.issue.filename == original.filename
1814 || d.issue.filename == location)
1815 {
1816 d.issue = parsed;
1817 d.scroll_offset = 0;
1818 }
1819 app.refresh_corpus()?;
1820 app.status_msg = Some("Edited".to_string());
1821 Ok(())
1822 })();
1823 if let Err(e) = edit_result {
1824 app.status_msg = Some(format!("editor: {e}"));
1825 }
1826
1827 enable_raw_mode().map_err(io_err)?;
1828 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
1829 let backend = CrosstermBackend::new(io::stdout());
1830 terminal = Terminal::new(backend).map_err(io_err)?;
1831 }
1832 Action::OpenAgent(filename) => {
1833 drop(terminal);
1834 let _ = disable_raw_mode();
1835 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1836
1837 let edit_result = (|| -> Result<()> {
1838 let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
1839 let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
1840 let temp = std::env::temp_dir().join(format!(
1841 "tix-agent-{}-{}.md",
1842 std::process::id(),
1843 issue.number
1844 ));
1845 std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
1846 let result = std::process::Command::new(&agent)
1847 .arg(&temp)
1848 .status()
1849 .map_err(|e| {
1850 crate::error::TixError::Editor(format!(
1851 "failed to launch {agent}: {e}"
1852 ))
1853 });
1854 let _ = std::fs::remove_file(&temp);
1855 result?;
1856 Ok(())
1857 })();
1858 if let Err(e) = edit_result {
1859 app.status_msg = Some(format!("agent: {e}"));
1860 }
1861
1862 enable_raw_mode().map_err(io_err)?;
1863 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
1864 let backend = CrosstermBackend::new(io::stdout());
1865 terminal = Terminal::new(backend).map_err(io_err)?;
1866 }
1867 }
1868 }
1869
1870 if watcher.poll()
1874 && let Err(e) = app.refresh_from_disk()
1875 {
1876 app.status_msg = Some(format!("watch: {e}"));
1877 }
1878 };
1879 drop(guard);
1880 result
1881}
1882
1883struct RestoreGuard;
1884
1885impl Drop for RestoreGuard {
1886 fn drop(&mut self) {
1887 let _ = disable_raw_mode();
1888 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1889 }
1890}
1891
1892fn io_err<E: std::fmt::Display>(e: E) -> TixError {
1893 TixError::Io(io::Error::other(e.to_string()))
1894}
1895
1896#[cfg(test)]
1897mod tests {
1898 use super::*;
1899 use std::fs;
1900 use tempfile::TempDir;
1901
1902 fn make_app() -> (TempDir, App) {
1903 let tmp = TempDir::new().unwrap();
1904 let dir = tmp.path().join("issues");
1905 fs::create_dir_all(&dir).unwrap();
1906 let config_path = tmp.path().join("tix.toml");
1907 let config = Config {
1908 issues_dir: dir.clone(),
1909 ..Config::default()
1910 };
1911 config.save(&config_path).unwrap();
1912 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
1913 let app = App::new(&config).unwrap();
1914 (tmp, app)
1915 }
1916
1917 fn write_issue(
1918 dir: &std::path::Path,
1919 name: &str,
1920 number: u32,
1921 title: &str,
1922 status: &str,
1923 commit: Option<&str>,
1924 ) {
1925 let commit_line = match commit {
1926 Some(h) => format!("commit: \"{h}\""),
1927 None => String::new(),
1928 };
1929 let contents = format!(
1930 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
1931 );
1932 fs::write(dir.join(name), contents).unwrap();
1933 }
1934
1935 fn press(code: KeyCode) -> KeyEvent {
1936 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
1937 }
1938
1939 #[test]
1940 fn quit_on_q() {
1941 let (_tmp, mut app) = make_app();
1942 let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
1943 assert!(app.quit);
1944 assert!(matches!(action, Action::Quit));
1945 }
1946
1947 #[test]
1948 fn quit_on_esc() {
1949 let (_tmp, mut app) = make_app();
1950 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
1951 assert!(app.quit);
1952 }
1953
1954 #[test]
1955 fn cursor_clamp_empty_column() {
1956 let (_tmp, mut app) = make_app();
1957 app.board.move_left();
1958 assert_eq!(app.board.active_col, app.board.total_cols() - 1);
1959 app.board.move_right();
1960 assert_eq!(app.board.active_col, 0);
1961 }
1962
1963 #[test]
1964 fn cursor_clamp_single_card() {
1965 let (tmp, mut app) = make_app();
1966 let dir = tmp.path().join("issues");
1967 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1968 let config = app.store.config.clone();
1969 app.board = BoardState::build(&app.store, config.default_config());
1970 assert_eq!(app.board.column_cards(0).len(), 1);
1971 app.board.move_down();
1972 assert_eq!(app.board.selected, 0);
1973 app.board.move_up();
1974 assert_eq!(app.board.selected, 0);
1975 }
1976
1977 #[test]
1978 fn wrap_around_columns() {
1979 let (_tmp, mut app) = make_app();
1980 let total = app.board.total_cols();
1981 for _ in 0..total + 2 {
1982 app.board.move_right();
1983 }
1984 assert_eq!(app.board.active_col, 2 % total);
1985 for _ in 0..total + 2 {
1986 app.board.move_left();
1987 }
1988 }
1989
1990 #[test]
1991 fn refresh_from_disk_picks_up_new_file() {
1992 let (tmp, mut app) = make_app();
1993 let dir = tmp.path().join("issues");
1994 assert_eq!(app.board.column_cards(0).len(), 0);
1995 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1996 app.refresh_from_disk().unwrap();
1997 assert_eq!(app.board.column_cards(0).len(), 1);
1998 }
1999
2000 #[test]
2001 fn refresh_from_disk_reflects_external_status_move() {
2002 let (tmp, mut app) = make_app();
2003 let dir = tmp.path().join("issues");
2004 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2005 app.refresh_from_disk().unwrap();
2006 assert_eq!(app.board.column_cards(0).len(), 1);
2007
2008 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
2009 app.refresh_from_disk().unwrap();
2010 assert_eq!(app.board.column_cards(0).len(), 0);
2011 assert_eq!(app.board.column_cards(3).len(), 1);
2012 }
2013
2014 #[test]
2015 fn refresh_from_disk_keeps_board_on_bad_file() {
2016 let (tmp, mut app) = make_app();
2017 let dir = tmp.path().join("issues");
2018 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2019 app.refresh_from_disk().unwrap();
2020 assert_eq!(app.board.column_cards(0).len(), 1);
2021
2022 fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
2023 assert!(app.refresh_from_disk().is_err());
2024 assert_eq!(
2025 app.board.column_cards(0).len(),
2026 1,
2027 "a malformed file must not wipe the board"
2028 );
2029 }
2030
2031 #[test]
2032 fn enter_opens_detail() {
2033 let (tmp, mut app) = make_app();
2034 let dir = tmp.path().join("issues");
2035 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2036 let config = app.store.config.clone();
2037 app.board = BoardState::build(&app.store, config.default_config());
2038 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2039 assert!(matches!(app.mode, Mode::Detail));
2040 assert!(app.detail.is_some());
2041 }
2042
2043 #[test]
2044 fn n_opens_form() {
2045 let (_tmp, mut app) = make_app();
2046 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2047 assert!(matches!(app.mode, Mode::Form));
2048 assert!(app.form.is_some());
2049 }
2050
2051 fn filter_modal_focus(app: &App) -> crate::search::FilterField {
2052 app.filter_modal
2053 .as_ref()
2054 .expect("filter modal open")
2055 .focus()
2056 }
2057
2058 #[test]
2059 fn filter_modal_arrows_navigate_rows() {
2060 let (_tmp, mut app) = make_app();
2061 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
2062 assert!(matches!(app.mode, Mode::FilterModal));
2063 use crate::search::FilterField::*;
2064 assert_eq!(filter_modal_focus(&app), Assignee);
2065 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2066 assert_eq!(filter_modal_focus(&app), Label);
2067 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
2068 assert_eq!(filter_modal_focus(&app), Assignee);
2069 for _ in 0..4 {
2070 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2071 }
2072 assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
2073 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
2074 assert_eq!(filter_modal_focus(&app), Priority, "Up wraps backward");
2075 }
2076
2077 #[test]
2078 fn filter_modal_arrow_press_keeps_criterion_value() {
2079 let (_tmp, mut app) = make_app();
2080 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
2081 for c in "alice".chars() {
2082 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2083 }
2084 let before = app
2085 .filter_modal
2086 .as_ref()
2087 .unwrap()
2088 .active_filter()
2089 .assignee
2090 .clone();
2091 assert_eq!(before.as_deref(), Some("alice"));
2092 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2093 let after = app
2094 .filter_modal
2095 .as_ref()
2096 .unwrap()
2097 .active_filter()
2098 .assignee
2099 .clone();
2100 assert_eq!(after, before, "arrow press must not alter the value");
2101 use crate::search::FilterField::*;
2102 assert_eq!(filter_modal_focus(&app), Label);
2103 }
2104
2105 #[test]
2106 fn filter_modal_j_and_k_type_their_letters() {
2107 let (_tmp, mut app) = make_app();
2108 let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
2109 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
2110 let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
2111 let modal = app.filter_modal.as_ref().unwrap();
2112 assert_eq!(
2113 modal.active_filter().assignee.as_deref(),
2114 Some("jk"),
2115 "j and k must type into the focused criterion, not navigate"
2116 );
2117 use crate::search::FilterField::*;
2118 assert_eq!(modal.focus(), Assignee);
2119 }
2120
2121 #[test]
2122 fn d_opens_confirm_delete() {
2123 let (tmp, mut app) = make_app();
2124 let dir = tmp.path().join("issues");
2125 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2126 let config = app.store.config.clone();
2127 app.board = BoardState::build(&app.store, config.default_config());
2128 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
2129 assert!(matches!(app.mode, Mode::ConfirmDelete));
2130 assert!(app.confirm_delete_target.is_some());
2131 }
2132
2133 #[test]
2134 fn move_to_done_triggers_commit_prompt() {
2135 let (tmp, mut app) = make_app();
2136 let dir = tmp.path().join("issues");
2137 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
2139 let config = app.store.config.clone();
2140 app.board = BoardState::build(&app.store, config.default_config());
2141 app.board.select_column(2);
2142 app.board.selected = 0;
2143
2144 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
2145 assert!(
2147 matches!(
2148 &app.mode,
2149 Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
2150 ),
2151 "expected MoveIntoDone, got {:?}",
2152 app.mode
2153 );
2154
2155 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2157 assert!(matches!(app.mode, Mode::Board));
2158 let issue = app
2159 .store
2160 .get(crate::storage::IssueSelector::Filename(
2161 "2026-01-01-a.md".into(),
2162 ))
2163 .unwrap();
2164 assert_eq!(issue.status.display(), "in review");
2165 assert!(issue.commit.is_none());
2166 }
2167
2168 #[test]
2169 fn move_into_done_with_commit_applies() {
2170 let (tmp, mut app) = make_app();
2171 let dir = tmp.path().join("issues");
2172 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
2173 let config = app.store.config.clone();
2174 app.board = BoardState::build(&app.store, config.default_config());
2175 app.board.select_column(2);
2176 app.board.selected = 0;
2177
2178 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
2179 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
2180
2181 for c in "abc123".chars() {
2182 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2183 }
2184 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2185
2186 assert!(matches!(app.mode, Mode::Board));
2187 let issue = app
2188 .store
2189 .get(crate::storage::IssueSelector::Filename(
2190 "2026-01-01-a.md".into(),
2191 ))
2192 .unwrap();
2193 assert_eq!(issue.status.display(), "done");
2194 assert_eq!(issue.commit.as_deref(), Some("abc123"));
2195 }
2196
2197 #[test]
2198 fn move_into_done_with_empty_commit_moves_without_hash() {
2199 let (tmp, mut app) = make_app();
2200 let dir = tmp.path().join("issues");
2201 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
2202 let config = app.store.config.clone();
2203 app.board = BoardState::build(&app.store, config.default_config());
2204 app.board.select_column(2);
2205 app.board.selected = 0;
2206
2207 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
2208 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
2209 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2210
2211 assert!(matches!(app.mode, Mode::Board));
2212 let issue = app
2213 .store
2214 .get(crate::storage::IssueSelector::Filename(
2215 "2026-01-01-a.md".into(),
2216 ))
2217 .unwrap();
2218 assert_eq!(issue.status.display(), "done");
2219 assert!(
2220 issue.commit.is_none(),
2221 "no commit recorded when moving to done without a hash"
2222 );
2223 }
2224
2225 #[test]
2226 fn move_out_of_done_clears_commit() {
2227 let (tmp, mut app) = make_app();
2228 let dir = tmp.path().join("issues");
2229 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
2230 let config = app.store.config.clone();
2231 app.board = BoardState::build(&app.store, config.default_config());
2232 app.board.select_column(3);
2233 app.board.selected = 0;
2234
2235 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
2236 assert!(matches!(app.mode, Mode::Board));
2237 let issue = app
2238 .store
2239 .get(crate::storage::IssueSelector::Filename(
2240 "2026-01-01-a.md".into(),
2241 ))
2242 .unwrap();
2243 assert_eq!(issue.status.display(), "in review");
2244 assert!(issue.commit.is_none());
2245 }
2246
2247 #[test]
2248 fn move_right_wraps_across_real_columns_skipping_mismatch() {
2249 let (tmp, mut app) = make_app();
2250 let dir = tmp.path().join("issues");
2251 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
2252 let config = app.store.config.clone();
2253 app.board = BoardState::build(&app.store, config.default_config());
2254 app.board.select_column(3); app.board.selected = 0;
2256 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
2257 assert_eq!(app.board.active_col, 0);
2258 let issue = app
2259 .store
2260 .get(crate::storage::IssueSelector::Filename(
2261 "2026-01-01-a.md".into(),
2262 ))
2263 .unwrap();
2264 assert_eq!(issue.status.display(), "todo");
2265 }
2266
2267 #[test]
2268 fn move_left_wraps_across_real_columns_skipping_mismatch() {
2269 let (tmp, mut app) = make_app();
2270 let dir = tmp.path().join("issues");
2271 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
2272 let config = app.store.config.clone();
2273 app.board = BoardState::build(&app.store, config.default_config());
2274 app.board.select_column(0);
2275 app.board.selected = 0;
2276 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
2277 assert_eq!(app.board.active_col, 3);
2278 let issue = app
2279 .store
2280 .get(crate::storage::IssueSelector::Filename(
2281 "2026-01-01-a.md".into(),
2282 ))
2283 .unwrap();
2284 assert_eq!(issue.status.display(), "done");
2285 }
2286
2287 #[test]
2288 fn move_left_right_board() {
2289 let (_tmp, mut app) = make_app();
2290 let initial = app.board.active_col;
2291 let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
2292 assert_eq!(app.board.active_col, initial + 1);
2293 let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
2294 assert_eq!(app.board.active_col, initial);
2295 }
2296
2297 #[test]
2298 fn refresh_rebuilds_board() {
2299 let (tmp, mut app) = make_app();
2300 let dir = tmp.path().join("issues");
2301 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2302 let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
2303 assert_eq!(app.board.column_cards(0).len(), 1);
2304 assert_eq!(app.status_msg, Some("Refreshed".to_string()));
2305 }
2306
2307 #[test]
2308 fn detail_esc_back_to_board() {
2309 let (tmp, mut app) = make_app();
2310 let dir = tmp.path().join("issues");
2311 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2312 let config = app.store.config.clone();
2313 app.board = BoardState::build(&app.store, config.default_config());
2314 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2315 assert!(matches!(app.mode, Mode::Detail));
2316 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2317 assert!(matches!(app.mode, Mode::Board));
2318 assert!(app.detail.is_none());
2319 }
2320
2321 #[test]
2322 fn confirm_delete_y_removes_issue() {
2323 let (tmp, mut app) = make_app();
2324 let dir = tmp.path().join("issues");
2325 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2326 let config = app.store.config.clone();
2327 app.board = BoardState::build(&app.store, config.default_config());
2328 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
2329 assert!(matches!(app.mode, Mode::ConfirmDelete));
2330 let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
2331 assert!(matches!(app.mode, Mode::Board));
2332 let config = app.store.config.clone();
2333 app.board = BoardState::build(&app.store, config.default_config());
2334 assert_eq!(app.board.column_cards(0).len(), 0);
2335 }
2336
2337 #[test]
2338 fn confirm_delete_n_cancels() {
2339 let (tmp, mut app) = make_app();
2340 let dir = tmp.path().join("issues");
2341 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2342 let config = app.store.config.clone();
2343 app.board = BoardState::build(&app.store, config.default_config());
2344 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
2345 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2346 assert!(matches!(app.mode, Mode::Board));
2347 assert!(app.confirm_delete_target.is_none());
2348 let config = app.store.config.clone();
2349 app.board = BoardState::build(&app.store, config.default_config());
2350 assert_eq!(app.board.column_cards(0).len(), 1);
2351 }
2352
2353 #[test]
2354 fn form_submit_creates_issue() {
2355 let (_tmp, mut app) = make_app();
2356 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2357 if let Mode::Form = &app.mode {
2358 for c in "My Issue".chars() {
2360 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2361 }
2362 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2364 }
2365 assert!(matches!(app.mode, Mode::Board));
2366 assert!(
2367 app.status_msg
2368 .as_deref()
2369 .is_some_and(|m| m.starts_with("Created ")),
2370 "got {:?}",
2371 app.status_msg
2372 );
2373 }
2374
2375 #[test]
2376 fn form_submit_selects_created_issue() {
2377 let (_tmp, mut app) = make_app();
2378 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2379 if let Mode::Form = &app.mode {
2380 for c in "Selected Issue".chars() {
2381 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2382 }
2383 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2384 }
2385 assert!(matches!(app.mode, Mode::Board));
2386 let card = app.board.get_selected_card().expect("selected card");
2387 assert_eq!(card.title, "Selected Issue");
2388 assert_eq!(app.board.active_col, 0);
2389 }
2390
2391 #[test]
2392 fn editor_return_warns_on_invalid_issue() {
2393 let (_tmp, mut app) = make_app();
2394 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2395 if let Mode::Form = &app.mode {
2396 for c in "Lint Warn".chars() {
2397 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2398 }
2399 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2400 }
2401 let issue = app
2402 .board
2403 .get_selected_card()
2404 .cloned()
2405 .expect("selected issue");
2406 let path = app.store.issues_dir.join(&issue.filename);
2407 let mut text = std::fs::read_to_string(&path).unwrap();
2408 text = text.replace("priority: medium", "priority: strange");
2409 std::fs::write(&path, text).unwrap();
2410
2411 app.after_editor(&issue.filename);
2412
2413 assert!(matches!(app.mode, Mode::EditLintWarning { .. }));
2414 }
2415
2416 #[test]
2417 fn form_empty_title_rejected() {
2418 let (_tmp, mut app) = make_app();
2419 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2420 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2421 assert!(matches!(app.mode, Mode::Form));
2422 assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
2423 }
2424
2425 #[test]
2426 fn form_esc_cancels() {
2427 let (_tmp, mut app) = make_app();
2428 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2429 assert!(matches!(app.mode, Mode::Form));
2430 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2431 assert!(matches!(app.mode, Mode::Board));
2432 }
2433
2434 fn form_focus(app: &App) -> crate::ui::form::FormFocus {
2435 app.form.as_ref().expect("form open").focus
2436 }
2437
2438 #[test]
2439 fn form_arrows_navigate_fields() {
2440 let (_tmp, mut app) = make_app();
2441 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2442 use crate::ui::form::FormFocus::*;
2443 assert_eq!(form_focus(&app), Title);
2444 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2445 assert_eq!(form_focus(&app), Status);
2446 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
2447 assert_eq!(form_focus(&app), Title);
2448 for _ in 0..6 {
2449 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2450 }
2451 assert_eq!(form_focus(&app), Title, "Down wraps forward");
2452 let _ = app.handle_key(press(KeyCode::Up)).unwrap();
2453 assert_eq!(form_focus(&app), Estimate, "Up wraps backward");
2454 }
2455
2456 #[test]
2457 fn form_j_and_k_type_their_letters() {
2458 let (_tmp, mut app) = make_app();
2459 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2460 let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
2461 let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
2462 let form = app.form.as_ref().unwrap();
2463 assert_eq!(form.title, "jk", "j and k must type, not navigate");
2464 assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
2465 }
2466
2467 #[test]
2468 fn form_arrow_navigated_status_creates_in_that_column() {
2469 let (_tmp, mut app) = make_app();
2470 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
2471 for c in "Arrow Status".chars() {
2472 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2473 }
2474 let _ = app.handle_key(press(KeyCode::Down)).unwrap();
2475 let prefilled = app.store.config.default_config().columns[0].len();
2476 for _ in 0..prefilled {
2477 let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
2478 }
2479 for c in "in progress".chars() {
2480 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2481 }
2482 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2483 assert!(matches!(app.mode, Mode::Board));
2484 let names: Vec<String> = (0..app.board.total_cols())
2485 .map(|i| app.board.column_name(i).to_string())
2486 .collect();
2487 let idx = names
2488 .iter()
2489 .position(|n| n == "in progress")
2490 .expect("in progress column exists");
2491 assert_eq!(
2492 app.board.column_cards(idx).len(),
2493 1,
2494 "arrow-navigated status must land the issue in 'in progress'"
2495 );
2496 }
2497
2498 #[test]
2499 fn key_release_ignored() {
2500 let (_tmp, mut app) = make_app();
2501 let key = KeyEvent {
2502 code: KeyCode::Char('q'),
2503 modifiers: crossterm::event::KeyModifiers::NONE,
2504 kind: KeyEventKind::Release,
2505 state: crossterm::event::KeyEventState::NONE,
2506 };
2507 let _ = app.handle_key(key).unwrap();
2508 assert!(!app.quit);
2509 }
2510
2511 fn open_detail(app: &mut App) {
2516 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2517 assert!(matches!(app.mode, Mode::Detail));
2518 assert!(app.detail.is_some());
2519 }
2520
2521 #[test]
2522 fn help_opens_from_board_and_closes() {
2523 let (_tmp, mut app) = make_app();
2524 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
2525 assert!(
2526 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
2527 "got {:?}",
2528 app.mode
2529 );
2530 let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
2531 assert!(matches!(app.mode, Mode::Board));
2532 }
2533
2534 #[test]
2535 fn help_from_detail_returns_to_detail() {
2536 let (tmp, mut app) = make_app();
2537 let dir = tmp.path().join("issues");
2538 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2539 let config = app.store.config.clone();
2540 app.board = BoardState::build(&app.store, config.default_config());
2541 open_detail(&mut app);
2542 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
2543 assert!(
2544 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
2545 "got {:?}",
2546 app.mode
2547 );
2548 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2549 assert!(matches!(app.mode, Mode::Detail));
2550 }
2551
2552 #[test]
2553 fn filter_narrows_board_live() {
2554 let (tmp, mut app) = make_app();
2555 let dir = tmp.path().join("issues");
2556 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2557 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
2558 let config = app.store.config.clone();
2559 app.board = BoardState::build(&app.store, config.default_config());
2560 app.refresh_corpus().unwrap();
2561 assert_eq!(app.board.column_cards(0).len(), 2);
2562
2563 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2564 assert!(matches!(app.mode, Mode::Filter { .. }));
2565 for c in "arget".chars() {
2566 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2567 }
2568 assert_eq!(app.filter, Some("arget".into()));
2569 assert_eq!(app.board.column_cards(0).len(), 1);
2570 assert_eq!(app.board.column_cards(0)[0].title, "Target");
2571 assert!(matches!(app.mode, Mode::Filter { .. }));
2572 }
2573
2574 #[test]
2575 fn filter_esc_clears_and_returns_to_board() {
2576 let (tmp, mut app) = make_app();
2577 let dir = tmp.path().join("issues");
2578 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2579 let config = app.store.config.clone();
2580 app.board = BoardState::build(&app.store, config.default_config());
2581 app.refresh_corpus().unwrap();
2582 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2583 for c in "zzw".chars() {
2584 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2585 }
2586 assert_eq!(app.board.column_cards(0).len(), 0);
2587 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2588 assert!(matches!(app.mode, Mode::Board));
2589 assert_eq!(app.filter, None);
2590 assert_eq!(app.board.column_cards(0).len(), 1);
2591 }
2592
2593 #[test]
2594 fn filter_enter_commits_and_stays_filtered() {
2595 let (tmp, mut app) = make_app();
2596 let dir = tmp.path().join("issues");
2597 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2598 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
2599 let config = app.store.config.clone();
2600 app.board = BoardState::build(&app.store, config.default_config());
2601 app.refresh_corpus().unwrap();
2602 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2603 for c in "arget".chars() {
2604 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2605 }
2606 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2607 assert!(matches!(app.mode, Mode::Board));
2608 assert_eq!(app.filter, Some("arget".into()));
2609 assert_eq!(app.board.column_cards(0).len(), 1);
2610 }
2611
2612 #[test]
2613 fn filter_zero_match_reports_status() {
2614 let (tmp, mut app) = make_app();
2615 let dir = tmp.path().join("issues");
2616 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2617 let config = app.store.config.clone();
2618 app.board = BoardState::build(&app.store, config.default_config());
2619 app.refresh_corpus().unwrap();
2620 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2621 for c in "zzw".chars() {
2622 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2623 }
2624 assert_eq!(app.board.column_cards(0).len(), 0);
2625 assert!(
2626 app.status_msg
2627 .as_deref()
2628 .is_some_and(|m| m.contains("No matches")),
2629 "got {:?}",
2630 app.status_msg
2631 );
2632 }
2633
2634 #[test]
2635 fn filter_zero_match_number_reports_status() {
2636 let (tmp, mut app) = make_app();
2637 let dir = tmp.path().join("issues");
2638 write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
2639 let config = app.store.config.clone();
2643 app.board = BoardState::build(&app.store, config.default_config());
2644 app.refresh_corpus().unwrap();
2645 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2646 for c in "999".chars() {
2647 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2648 }
2649 assert_eq!(app.filter, Some("999".into()));
2650 assert_eq!(app.board.column_cards(0).len(), 0);
2651 assert!(
2652 app.status_msg
2653 .as_deref()
2654 .is_some_and(|m| m.contains("No matches")),
2655 "got {:?}",
2656 app.status_msg
2657 );
2658 }
2659
2660 #[test]
2661 fn comment_round_trip_via_detail() {
2662 let (tmp, mut app) = make_app();
2663 let dir = tmp.path().join("issues");
2664 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2665 let config = app.store.config.clone();
2666 app.board = BoardState::build(&app.store, config.default_config());
2667 open_detail(&mut app);
2668
2669 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2670 assert!(matches!(app.mode, Mode::Comment { .. }));
2671 for c in "hello world".chars() {
2672 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2673 }
2674 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2675 assert!(matches!(app.mode, Mode::Detail));
2676
2677 let issue = app
2678 .store
2679 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2680 .unwrap();
2681 let comments = issue.comments();
2682 assert_eq!(comments.len(), 1);
2683 assert_eq!(comments[0].text.trim(), "hello world");
2684 }
2685
2686 #[test]
2687 fn comment_esc_cancels() {
2688 let (tmp, mut app) = make_app();
2689 let dir = tmp.path().join("issues");
2690 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2691 let config = app.store.config.clone();
2692 app.board = BoardState::build(&app.store, config.default_config());
2693 open_detail(&mut app);
2694 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2695 for c in "partial".chars() {
2696 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2697 }
2698 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2699 assert!(matches!(app.mode, Mode::Detail));
2700 let issue = app
2701 .store
2702 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2703 .unwrap();
2704 assert!(issue.comments().is_empty());
2705 }
2706
2707 #[test]
2708 fn comment_empty_discarded() {
2709 let (tmp, mut app) = make_app();
2710 let dir = tmp.path().join("issues");
2711 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2712 let config = app.store.config.clone();
2713 app.board = BoardState::build(&app.store, config.default_config());
2714 open_detail(&mut app);
2715 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2716 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2717 assert!(matches!(app.mode, Mode::Detail));
2718 let issue = app
2719 .store
2720 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2721 .unwrap();
2722 assert!(issue.comments().is_empty());
2723 assert!(
2724 app.status_msg
2725 .as_deref()
2726 .is_some_and(|m| m.contains("empty"))
2727 );
2728 }
2729
2730 #[test]
2731 fn asset_round_trip_via_detail() {
2732 let (tmp, mut app) = make_app();
2733 let dir = tmp.path().join("issues");
2734 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2735 let config = app.store.config.clone();
2736 app.board = BoardState::build(&app.store, config.default_config());
2737 open_detail(&mut app);
2738
2739 let asset = tmp.path().join("pic.png");
2740 fs::write(&asset, b"fake-png").unwrap();
2741
2742 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
2743 assert!(matches!(app.mode, Mode::Asset { .. }));
2744 for c in asset.to_string_lossy().chars() {
2745 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2746 }
2747 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2748 assert!(matches!(app.mode, Mode::Detail));
2749
2750 assert!(
2751 dir.join("a-assets").join("pic.png").is_file(),
2752 "asset must be copied into the issue asset dir"
2753 );
2754 let issue = app
2755 .store
2756 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2757 .unwrap();
2758 assert!(
2759 issue.body.contains("pic.png"),
2760 "body must reference the asset, got {:?}",
2761 issue.body
2762 );
2763 }
2764
2765 #[test]
2766 fn asset_error_stays_in_mode() {
2767 let (tmp, mut app) = make_app();
2768 let dir = tmp.path().join("issues");
2769 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2770 let config = app.store.config.clone();
2771 app.board = BoardState::build(&app.store, config.default_config());
2772 open_detail(&mut app);
2773 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
2774 for c in "/no/such/file.png".chars() {
2775 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2776 }
2777 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2778 assert!(
2779 matches!(app.mode, Mode::Asset { .. }),
2780 "asset error must stay in entry mode, got {:?}",
2781 app.mode
2782 );
2783 assert!(app.status_msg.is_some());
2784 }
2785
2786 fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
2787 let backend = ratatui::backend::TestBackend::new(w, h);
2788 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2789 terminal.draw(|f| render(app, f, f.area())).unwrap();
2790 let buffer = terminal.backend().buffer();
2791 buffer
2792 .content
2793 .iter()
2794 .map(|c| c.symbol().to_string())
2795 .collect()
2796 }
2797
2798 fn flush_line(cells: &[String], width: u16, row: u16) -> String {
2799 let start = (row as usize) * (width as usize);
2800 cells[start..start + width as usize].concat()
2801 }
2802
2803 #[test]
2804 fn render_board_unfiltered_has_no_filter_row() {
2805 let (_tmp, app) = make_app();
2806 let cells = draw_board(&app, 80, 24);
2807 assert!(
2808 !cells.iter().any(|s| s.contains("Filters:")),
2809 "no filter row expected"
2810 );
2811 }
2812
2813 #[test]
2814 fn render_board_filtered_shows_filter_row_beneath_tabs() {
2815 let (_tmp, mut app) = make_app();
2816 app.filter = Some("55".into());
2817 app.board = BoardState::build(&app.store, app.store.config.default_config());
2818 let cells = draw_board(&app, 80, 24);
2819 assert!(
2822 flush_line(&cells, 80, 1).contains("Filters: #55"),
2823 "row 1 should read the filter, got {}",
2824 flush_line(&cells, 80, 1)
2825 );
2826 }
2827
2828 #[test]
2829 fn render_board_structured_filter_shows_row() {
2830 let (_tmp, mut app) = make_app();
2831 app.filter = None;
2832 app.structured = search::StructuredFilter {
2833 status: Some("todo".into()),
2834 ..Default::default()
2835 };
2836 app.board = BoardState::build(&app.store, app.store.config.default_config());
2837 let cells = draw_board(&app, 80, 24);
2838 assert!(
2839 flush_line(&cells, 80, 1).contains("Filters:"),
2840 "got {}",
2841 flush_line(&cells, 80, 1)
2842 );
2843 }
2844
2845 #[test]
2846 fn render_board_filter_input_draws_row_and_prompt() {
2847 let (_tmp, mut app) = make_app();
2848 app.mode = Mode::Filter {
2849 buffer: "55".to_string(),
2850 };
2851 app.filter = Some("55".into());
2852 app.board = BoardState::build(&app.store, app.store.config.default_config());
2853 let cells = draw_board(&app, 80, 24);
2854 assert!(
2855 flush_line(&cells, 80, 1).contains("Filters: #55"),
2856 "row 1 should read the filter while typing"
2857 );
2858 assert!(
2859 flush_line(&cells, 80, 23).contains("Filter: 55"),
2860 "bottom row should hold the input prompt"
2861 );
2862 }
2863
2864 #[test]
2865 fn render_board_tiny_terminal_filtered_does_not_panic() {
2866 let (_tmp, mut app) = make_app();
2867 app.filter = Some("55".into());
2868 app.board = BoardState::build(&app.store, app.store.config.default_config());
2869 app.mode = Mode::Filter {
2870 buffer: "55".to_string(),
2871 };
2872 for h in [2_u16, 3, 4, 5] {
2873 let _ = draw_board(&app, 80, h);
2874 }
2875 }
2876
2877 #[test]
2878 fn filter_esc_clears_row_and_resolves_to_board() {
2879 let (tmp, mut app) = make_app();
2880 let dir = tmp.path().join("issues");
2881 write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
2882 let config = app.store.config.clone();
2883 app.board = BoardState::build(&app.store, config.default_config());
2884 app.refresh_corpus().unwrap();
2885 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2886 for c in "55".chars() {
2887 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2888 }
2889 let cells = draw_board(&app, 80, 24);
2890 assert!(
2891 cells.concat().contains("Filters:"),
2892 "filtered board must show the row; filter={:?} cells_row1={}",
2893 app.filter,
2894 flush_line(&cells, 80, 1)
2895 );
2896 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2897 assert!(matches!(app.mode, Mode::Board));
2898 assert_eq!(app.filter, None);
2899 let after = draw_board(&app, 80, 24).concat();
2900 assert!(
2901 !after.contains("Filters:"),
2902 "esc cleared the filter, row must disappear, got {after:?}"
2903 );
2904 }
2905}