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}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Mode {
40 Board,
41 Detail,
42 Form,
43 ConfirmDelete,
44 MoveIntoDone { target_col: usize, buffer: String },
45 Help { return_to: Box<Mode> },
46 Filter { buffer: String },
47 FilterModal,
48 Settings,
49 Comment { buffer: String },
50 Asset { buffer: String },
51}
52
53pub struct App {
54 pub mode: Mode,
55 pub board: BoardState,
56 pub detail: Option<DetailState>,
57 pub form: Option<FormState>,
58 pub confirm_delete_target: Option<String>,
59 pub store: Store,
60 pub status_msg: Option<String>,
61 pub quit: bool,
62 pub active_config: usize,
66 pub tabs: BoardTabs,
68 filter: Option<String>,
69 corpus: Corpus,
70 pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
71 pub settings: Option<SettingsModal>,
73 structured: search::StructuredFilter,
76}
77
78impl App {
79 pub fn new(config: &Config) -> Result<Self> {
80 let store = Store::open(config)?;
81 let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
82 let board = BoardState::build(&store, config.default_config());
83 let corpus = Corpus::build(&store)?;
84 Ok(App {
85 mode: Mode::Board,
86 board,
87 detail: None,
88 form: None,
89 confirm_delete_target: None,
90 store,
91 status_msg: None,
92 quit: false,
93 active_config: 0,
94 tabs,
95 filter: None,
96 corpus,
97 filter_modal: None,
98 settings: None,
99 structured: search::StructuredFilter::default(),
100 })
101 }
102
103 fn active_board_config(&self) -> &BoardConfiguration {
105 self.store
106 .config
107 .configs()
108 .nth(self.active_config)
109 .map(|(_, board)| board)
110 .unwrap_or_else(|| self.store.config.default_config())
111 }
112
113 pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
114 if key.kind != KeyEventKind::Press {
115 return Ok(Action::None);
116 }
117 match self.mode.clone() {
118 Mode::Board => self.handle_board_key(key),
119 Mode::Detail => self.handle_detail_key(key),
120 Mode::Form => self.handle_form_key(key),
121 Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
122 Mode::MoveIntoDone { target_col, .. } => {
123 self.handle_move_into_done_key(key, target_col)
124 }
125 Mode::Help { .. } => self.handle_help_key(key),
126 Mode::Filter { .. } => self.handle_filter_key(key),
127 Mode::FilterModal => self.handle_filter_modal_key(key),
128 Mode::Settings => self.handle_settings_key(key),
129 Mode::Comment { .. } => self.handle_comment_key(key),
130 Mode::Asset { .. } => self.handle_asset_key(key),
131 }
132 }
133
134 fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
135 match key.code {
136 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
137 self.quit = true;
138 Ok(Action::Quit)
139 }
140 KeyCode::Tab => {
141 self.tabs.next();
142 self.active_config = self.tabs.selected;
143 self.switch_config();
144 Ok(Action::None)
145 }
146 KeyCode::BackTab => {
147 self.tabs.previous();
148 self.active_config = self.tabs.selected;
149 self.switch_config();
150 Ok(Action::None)
151 }
152 KeyCode::Left | KeyCode::Char('h') => {
153 self.board.move_left();
154 Ok(Action::None)
155 }
156 KeyCode::Right | KeyCode::Char('l') => {
157 self.board.move_right();
158 Ok(Action::None)
159 }
160 KeyCode::Up | KeyCode::Char('k') => {
161 self.board.move_up();
162 Ok(Action::None)
163 }
164 KeyCode::Down | KeyCode::Char('j') => {
165 self.board.move_down();
166 Ok(Action::None)
167 }
168 KeyCode::Enter => {
169 if let Some(card) = self.board.get_selected_card().cloned() {
170 let issue = self.store.get(IssueSelector::Filename(card.filename))?;
171 self.detail = Some(DetailState {
172 issue,
173 scroll_offset: 0,
174 });
175 self.mode = Mode::Detail;
176 }
177 Ok(Action::None)
178 }
179 KeyCode::Char('e') => {
180 if let Some(card) = self.board.get_selected_card() {
181 Ok(Action::OpenEditor(card.filename.clone()))
182 } else {
183 Ok(Action::None)
184 }
185 }
186 KeyCode::Char('c') => {
187 self.toggle_column_visibility();
188 Ok(Action::None)
189 }
190 KeyCode::Char('f') => {
191 self.open_filter_modal();
192 Ok(Action::None)
193 }
194 KeyCode::Char('<') => {
195 let real_cols = self.board.real_cols();
196 if real_cols > 0 {
197 let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
198 {
199 real_cols - 1
200 } else {
201 self.board.active_col - 1
202 };
203 self.move_selected_to(target)
204 } else {
205 Ok(Action::None)
206 }
207 }
208 KeyCode::Char('>') => {
209 let real_cols = self.board.real_cols();
210 if real_cols > 0 {
211 let target = if self.board.active_col >= real_cols {
212 0
213 } else {
214 (self.board.active_col + 1) % real_cols
215 };
216 self.move_selected_to(target)
217 } else {
218 Ok(Action::None)
219 }
220 }
221 KeyCode::Char('n') => {
222 let default_status = self
223 .store
224 .config
225 .default_config()
226 .columns
227 .first()
228 .cloned()
229 .unwrap_or_else(|| "todo".to_string());
230 let default_assignee = crate::assignee::git_identity()
233 .map(|id| id.to_string())
234 .unwrap_or_default();
235 self.form = Some(FormState::with_default_assignee(
236 &default_status,
237 &default_assignee,
238 ));
239 self.mode = Mode::Form;
240 Ok(Action::None)
241 }
242 KeyCode::Char('d') => {
243 if let Some(card) = self.board.get_selected_card().cloned() {
244 self.confirm_delete_target = Some(card.filename);
245 self.mode = Mode::ConfirmDelete;
246 }
247 Ok(Action::None)
248 }
249 KeyCode::Char('r') => {
250 self.rebuild_board();
251 self.status_msg = Some("Refreshed".to_string());
252 Ok(Action::None)
253 }
254 KeyCode::Char('s') => {
255 self.open_settings(None);
256 Ok(Action::None)
257 }
258 KeyCode::Char('b') => {
259 self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
260 Ok(Action::None)
261 }
262 KeyCode::Char('?') => {
263 self.mode = Mode::Help {
264 return_to: Box::new(Mode::Board),
265 };
266 Ok(Action::None)
267 }
268 KeyCode::Char('/') => {
269 let buffer = self.filter.clone().unwrap_or_default();
270 self.mode = Mode::Filter { buffer };
271 Ok(Action::None)
272 }
273 _ => Ok(Action::None),
274 }
275 }
276
277 fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
278 let card = match self.board.get_selected_card().cloned() {
279 Some(c) => c,
280 None => return Ok(Action::None),
281 };
282
283 let target_name = self.board.column_name(target_col).to_string();
284 let done_col = self.store.config.default_config().effective_done_column();
285 let is_done_target = done_col == Some(&target_name);
286 let current_col_name = self.board.column_name(self.board.active_col);
287 let was_done = done_col == Some(current_col_name);
288
289 if is_done_target && card.commit.is_none() {
290 self.mode = Mode::MoveIntoDone {
291 target_col,
292 buffer: String::new(),
293 };
294 return Ok(Action::None);
295 }
296
297 let mut issue = self
298 .store
299 .get(IssueSelector::Filename(card.filename.clone()))?;
300 issue.status = Status::new(target_name.clone());
301 if was_done {
302 issue.commit = None;
303 }
304 issue.bump_updated();
305 self.store.update(&issue.filename, &issue)?;
306
307 self.rebuild_board();
308 self.board.select_column(target_col);
309 self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
310 Ok(Action::None)
311 }
312
313 fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
314 match key.code {
315 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
316 self.mode = Mode::Board;
317 self.detail = None;
318 Ok(Action::None)
319 }
320 KeyCode::Char('e') => {
321 if let Some(ref d) = self.detail {
322 let filename = d.issue.filename.clone();
323 Ok(Action::OpenEditor(filename))
324 } else {
325 Ok(Action::None)
326 }
327 }
328 KeyCode::PageUp => {
329 if let Some(ref mut d) = self.detail {
330 d.scroll_offset = d.scroll_offset.saturating_sub(5);
331 }
332 Ok(Action::None)
333 }
334 KeyCode::PageDown => {
335 if let Some(ref mut d) = self.detail {
336 d.scroll_offset = d.scroll_offset.saturating_add(5);
337 }
338 Ok(Action::None)
339 }
340 KeyCode::Char('?') => {
341 self.mode = Mode::Help {
342 return_to: Box::new(Mode::Detail),
343 };
344 Ok(Action::None)
345 }
346 KeyCode::Char('c') => {
347 self.mode = Mode::Comment {
348 buffer: String::new(),
349 };
350 Ok(Action::None)
351 }
352 KeyCode::Char('a') => {
353 self.mode = Mode::Asset {
354 buffer: String::new(),
355 };
356 Ok(Action::None)
357 }
358 _ => Ok(Action::None),
359 }
360 }
361
362 fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
363 let form = match self.form.as_mut() {
364 Some(f) => f,
365 None => return Ok(Action::None),
366 };
367 match key.code {
368 KeyCode::Esc => {
369 self.form = None;
370 self.mode = Mode::Board;
371 Ok(Action::None)
372 }
373 KeyCode::Enter => {
374 if form.title.trim().is_empty() {
375 self.status_msg = Some("Title is required".to_string());
376 return Ok(Action::None);
377 }
378 let title = form.title.trim().to_string();
379 let status_str = if form.status.trim().is_empty() {
380 self.store
381 .config
382 .default_config()
383 .columns
384 .first()
385 .cloned()
386 .unwrap_or_else(|| "todo".to_string())
387 } else {
388 form.status.trim().to_string()
389 };
390 let priority: Priority = form.priority.parse().unwrap_or_default();
391 let identity = git_identity();
392 let assignee = if form.assignee.trim().is_empty() {
393 None
394 } else {
395 resolve_me(form.assignee.trim(), identity.as_ref())
396 };
397 let labels: Vec<String> = form
398 .labels
399 .split(',')
400 .map(|s| s.trim().to_string())
401 .filter(|s| !s.is_empty())
402 .collect();
403 let estimate = if form.estimate.trim().is_empty() {
404 None
405 } else {
406 Some(form.estimate.trim().to_string())
407 };
408
409 let mut issue = Issue {
410 number: 0,
411 title,
412 status: Status::new(status_str),
413 labels,
414 priority,
415 assignee,
416 estimate,
417 commit: None,
418 gitlab_id: None,
419 linear_id: None,
420 mission: None,
421 task: None,
422 created: today(),
423 updated: today(),
424 filename: String::new(),
425 body: String::new(),
426 };
427 let path = self.store.create(&mut issue, "", None)?;
428 let name = path.file_name().unwrap().to_string_lossy().to_string();
429 self.status_msg = Some(format!("Created {name}"));
430 self.form = None;
431 self.mode = Mode::Board;
432 self.rebuild_board();
433 Ok(Action::None)
434 }
435 KeyCode::Tab => {
436 form.move_focus_next();
437 Ok(Action::None)
438 }
439 KeyCode::BackTab => {
440 form.move_focus_prev();
441 Ok(Action::None)
442 }
443 KeyCode::Char(c) => {
444 form.insert_char(c);
445 Ok(Action::None)
446 }
447 KeyCode::Backspace => {
448 form.delete_char();
449 Ok(Action::None)
450 }
451 _ => Ok(Action::None),
452 }
453 }
454
455 fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
456 match key.code {
457 KeyCode::Char('y') | KeyCode::Char('Y') => {
458 if let Some(filename) = self.confirm_delete_target.take() {
459 self.store.delete(&filename)?;
460 self.status_msg = Some(format!("Deleted {filename}"));
461 }
462 self.mode = Mode::Board;
463 self.rebuild_board();
464 Ok(Action::None)
465 }
466 _ => {
467 self.confirm_delete_target = None;
468 self.mode = Mode::Board;
469 Ok(Action::None)
470 }
471 }
472 }
473
474 fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
475 match key.code {
476 KeyCode::Enter => {
477 let buffer = match &self.mode {
478 Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
479 _ => unreachable!(),
480 };
481
482 let card = match self.board.get_selected_card().cloned() {
483 Some(c) => c,
484 None => {
485 self.mode = Mode::Board;
486 return Ok(Action::None);
487 }
488 };
489
490 let mut issue = self
491 .store
492 .get(IssueSelector::Filename(card.filename.clone()))?;
493 let target_name = self.board.column_name(target_col).to_string();
494 issue.status = Status::new(target_name.clone());
495 let trimmed = buffer.trim();
496 if trimmed.is_empty() {
497 issue.commit = None;
498 } else {
499 issue.commit = Some(trimmed.to_string());
500 }
501 issue.bump_updated();
502 self.store.update(&issue.filename, &issue)?;
503
504 self.rebuild_board();
505 self.board.select_column(target_col);
506 self.status_msg = Some(match trimmed {
507 "" => format!(
508 "Moved #{} to {target_name} (no commit recorded)",
509 card.number
510 ),
511 _ => format!(
512 "Moved #{} to {target_name} (commit: {})",
513 card.number, trimmed
514 ),
515 });
516 self.mode = Mode::Board;
517 Ok(Action::None)
518 }
519 KeyCode::Esc => {
520 self.mode = Mode::Board;
521 Ok(Action::None)
522 }
523 KeyCode::Char(c) => {
524 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
525 buffer.push(c);
526 }
527 Ok(Action::None)
528 }
529 KeyCode::Backspace => {
530 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
531 buffer.pop();
532 }
533 Ok(Action::None)
534 }
535 _ => Ok(Action::None),
536 }
537 }
538
539 fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
540 match key.code {
541 KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
542 if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
543 self.mode = *return_to;
544 }
545 }
546 _ => {}
547 }
548 Ok(Action::None)
549 }
550
551 fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
552 match key.code {
553 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
554 self.filter = None;
555 self.board = BoardState::build(&self.store, self.active_board_config());
556 self.status_msg = None;
557 self.mode = Mode::Board;
558 }
559 KeyCode::Enter => {
560 self.mode = Mode::Board;
561 }
562 KeyCode::Char(c) => {
563 let mut next = match &self.mode {
564 Mode::Filter { buffer } => buffer.clone(),
565 _ => String::new(),
566 };
567 next.push(c);
568 self.mode = Mode::Filter {
569 buffer: next.clone(),
570 };
571 self.apply_filter(&next);
572 }
573 KeyCode::Backspace => {
574 let mut next = match &self.mode {
575 Mode::Filter { buffer } => buffer.clone(),
576 _ => String::new(),
577 };
578 next.pop();
579 self.mode = Mode::Filter {
580 buffer: next.clone(),
581 };
582 self.apply_filter(&next);
583 }
584 _ => {}
585 }
586 Ok(Action::None)
587 }
588
589 fn active_structured_filter(&self) -> &search::StructuredFilter {
592 self.filter_modal
593 .as_ref()
594 .map(|m| m.active_filter())
595 .unwrap_or(&self.structured)
596 }
597
598 fn open_filter_modal(&mut self) {
600 self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
601 self.structured.clone(),
602 ));
603 self.mode = Mode::FilterModal;
604 }
605
606 fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
612 let Some(modal) = self.filter_modal.as_mut() else {
613 self.mode = Mode::Board;
614 return Ok(Action::None);
615 };
616 match key.code {
617 KeyCode::Esc => {
618 self.filter_modal = None;
619 self.mode = Mode::Board;
620 self.rebuild_board();
621 }
622 KeyCode::Tab => modal.focus_next(),
623 KeyCode::BackTab => modal.focus_prev(),
624 KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
625 KeyCode::Enter => {
626 let mut filter = modal.active_filter().clone();
627 filter.normalize();
628 self.structured = filter;
629 self.filter_modal = None;
630 self.mode = Mode::Board;
631 self.rebuild_board();
632 }
633 KeyCode::Backspace => {
634 let field = modal.focus();
635 field.value_mut(modal.active_filter_mut()).pop();
636 self.rebuild_board();
637 }
638 KeyCode::Char(c) => {
639 let field = modal.focus();
640 field.value_mut(modal.active_filter_mut()).push(c);
641 self.rebuild_board();
642 }
643 _ => {}
644 }
645 Ok(Action::None)
646 }
647
648 fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
649 match key.code {
650 KeyCode::Esc => {
651 self.mode = Mode::Detail;
652 Ok(Action::None)
653 }
654 KeyCode::Enter => self.submit_comment(),
655 KeyCode::Char(c) => {
656 if let Mode::Comment { ref mut buffer } = self.mode {
657 buffer.push(c);
658 }
659 Ok(Action::None)
660 }
661 KeyCode::Backspace => {
662 if let Mode::Comment { ref mut buffer } = self.mode {
663 buffer.pop();
664 }
665 Ok(Action::None)
666 }
667 _ => Ok(Action::None),
668 }
669 }
670
671 fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
674 self.settings = Some(SettingsModal {
675 selected: 0,
676 prompt,
677 });
678 self.mode = Mode::Settings;
679 }
680
681 fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
687 if self.settings.is_none() {
688 self.mode = Mode::Board;
689 return Ok(Action::None);
690 }
691 let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
692 let Some(p) = prompt_active else {
693 return self.handle_settings_browse(key);
694 };
695 match key.code {
696 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
697 if let Some(ref mut m) = self.settings {
698 m.prompt = None;
699 }
700 Ok(Action::None)
701 }
702 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
703 match p {
704 SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
705 SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
706 SettingsPrompt::ConfirmDeleteBoard(name) => {
707 self.settings_commit_delete_board(name)
708 }
709 }
710 Ok(Action::None)
711 }
712 KeyCode::Char(c) => {
713 if let Some(ref mut m) = self.settings
714 && let Some(SettingsPrompt::AddColumn(ref mut b))
715 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
716 {
717 b.push(c);
718 }
719 Ok(Action::None)
720 }
721 KeyCode::Backspace => {
722 if let Some(ref mut m) = self.settings
723 && let Some(SettingsPrompt::AddColumn(ref mut b))
724 | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
725 {
726 b.pop();
727 }
728 Ok(Action::None)
729 }
730 _ => Ok(Action::None),
731 }
732 }
733
734 fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
736 match key.code {
737 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
738 self.settings = None;
739 self.mode = Mode::Board;
740 self.rebuild_board();
741 Ok(Action::None)
742 }
743 KeyCode::Up | KeyCode::Char('k') => {
744 let count = self.active_board_config().columns.len();
745 if let Some(ref mut m) = self.settings {
746 m.move_selected(-1, count);
747 }
748 Ok(Action::None)
749 }
750 KeyCode::Down | KeyCode::Char('j') => {
751 let count = self.active_board_config().columns.len();
752 if let Some(ref mut m) = self.settings {
753 m.move_selected(1, count);
754 }
755 Ok(Action::None)
756 }
757 KeyCode::Tab => {
758 self.settings_next_board();
759 Ok(Action::None)
760 }
761 KeyCode::BackTab => {
762 self.settings_prev_board();
763 Ok(Action::None)
764 }
765 KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
766 KeyCode::Char('a') => {
767 if let Some(ref mut m) = self.settings {
768 m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
769 }
770 Ok(Action::None)
771 }
772 KeyCode::Char('n') => {
773 if let Some(ref mut m) = self.settings {
774 m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
775 }
776 Ok(Action::None)
777 }
778 KeyCode::Char('x') | KeyCode::Char('X') => {
779 if self.store.config.configs().count() <= 1 {
780 self.status_msg = Some("Cannot delete the last board".to_string());
781 return Ok(Action::None);
782 }
783 let name = self.active_board_name().to_string();
784 if let Some(ref mut m) = self.settings {
785 m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
786 }
787 Ok(Action::None)
788 }
789 _ => Ok(Action::None),
790 }
791 }
792
793 fn settings_next_board(&mut self) {
794 self.tabs.next();
795 self.active_config = self.tabs.selected;
796 self.switch_config();
797 if let Some(ref mut m) = self.settings {
798 m.selected = 0;
799 }
800 }
801
802 fn settings_prev_board(&mut self) {
803 self.tabs.previous();
804 self.active_config = self.tabs.selected;
805 self.switch_config();
806 if let Some(ref mut m) = self.settings {
807 m.selected = 0;
808 }
809 }
810
811 fn settings_toggle_selected(&mut self) -> Result<Action> {
812 let col = self
813 .settings
814 .as_ref()
815 .and_then(|m| self.active_board_config().columns.get(m.selected))
816 .cloned();
817 let Some(col) = col else {
818 return Ok(Action::None);
819 };
820 let board_name = self.active_board_name().to_string();
821 let now_hidden = {
822 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
823 match board_config {
824 Some(bc) => {
825 if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
826 bc.hidden_columns.remove(pos);
827 false
828 } else {
829 bc.hidden_columns.push(col.clone());
830 true
831 }
832 }
833 None => return Ok(Action::None),
834 }
835 };
836 if !self.persist_config() {
837 return Ok(Action::None);
838 }
839 self.rebuild_board();
840 let last = self.active_board_config().columns.len().saturating_sub(1);
841 if let Some(ref mut m) = self.settings
842 && m.selected > last
843 {
844 m.selected = last;
845 }
846 self.status_msg = Some(match now_hidden {
847 true => format!("'{col}' hidden"),
848 false => format!("'{col}' shown"),
849 });
850 Ok(Action::None)
851 }
852
853 fn settings_commit_add_column(&mut self, name: String) {
854 let name = name.trim().to_string();
855 if name.is_empty() {
856 self.status_msg = Some("Column name is required".to_string());
857 return;
858 }
859 let board_name = self.active_board_name().to_string();
860 let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
861 let Some(board_config) = board_config else {
862 self.status_msg = Some("board not found".to_string());
863 return;
864 };
865 if board_config.columns.iter().any(|c| c == &name) {
866 self.status_msg = Some(format!("Column '{name}' already exists"));
867 return;
868 }
869 board_config.columns.push(name.clone());
870 if !self.persist_config() {
871 return;
872 }
873 self.rebuild_board();
874 let last = self.active_board_config().columns.len().saturating_sub(1);
875 if let Some(ref mut m) = self.settings {
876 m.prompt = None;
877 m.selected = last;
878 }
879 self.status_msg = Some(format!("Added column '{name}'"));
880 }
881
882 fn settings_commit_new_board(&mut self, name: String) {
883 let name = name.trim().to_string();
884 if name.is_empty() {
885 self.status_msg = Some("Board name is required".to_string());
886 return;
887 }
888 if self.store.config.kanban.configurations.contains_key(&name) {
889 self.status_msg = Some(format!("Board '{name}' already exists"));
890 return;
891 }
892 self.store
893 .config
894 .kanban
895 .configurations
896 .insert(name.clone(), crate::config::BoardConfiguration::default());
897 if !self.persist_config() {
898 return;
899 }
900 self.reload_tabs();
901 let idx = self
902 .store
903 .config
904 .configs()
905 .position(|(n, _)| n == name.as_str())
906 .unwrap_or(0);
907 self.active_config = idx;
908 self.tabs.selected = idx;
909 self.switch_config();
910 if let Some(ref mut m) = self.settings {
911 m.prompt = None;
912 m.selected = 0;
913 }
914 self.status_msg = Some(format!("Added board '{name}'"));
915 }
916
917 fn settings_commit_delete_board(&mut self, name: String) {
918 if self.store.config.kanban.configurations.len() <= 1 {
919 self.status_msg = Some("Cannot delete the last board".to_string());
920 return;
921 }
922 let idx = self.active_config;
923 let removed = self.store.config.kanban.configurations.shift_remove(&name);
924 if removed.is_none() {
925 self.status_msg = Some(format!("Board '{name}' not found"));
926 return;
927 }
928 if !self.persist_config() {
929 return;
930 }
931 self.reload_tabs();
932 let count = self.store.config.kanban.configurations.len();
933 let target = idx.min(count.saturating_sub(1));
934 self.active_config = target;
935 self.tabs.selected = target;
936 self.switch_config();
937 if let Some(ref mut m) = self.settings {
938 m.prompt = None;
939 m.selected = 0;
940 }
941 self.status_msg = Some(format!("Deleted board '{name}'"));
942 }
943
944 fn persist_config(&mut self) -> bool {
945 match self.store.config_path() {
946 Ok(path) => match self.store.config.save(&path) {
947 Ok(()) => true,
948 Err(e) => {
949 self.status_msg = Some(format!("settings save: {e}"));
950 false
951 }
952 },
953 Err(e) => {
954 self.status_msg = Some(format!("settings path: {e}"));
955 false
956 }
957 }
958 }
959
960 fn reload_tabs(&mut self) {
961 let names: Vec<String> = self
962 .store
963 .config
964 .configs()
965 .map(|(name, _)| name.to_string())
966 .collect();
967 self.tabs = BoardTabs::new(names);
968 }
969
970 fn active_board_name(&self) -> &str {
971 self.store
972 .config
973 .configs()
974 .nth(self.active_config)
975 .map(|(name, _)| name)
976 .unwrap_or("default")
977 }
978
979 fn submit_comment(&mut self) -> Result<Action> {
980 let buffer = match &self.mode {
981 Mode::Comment { buffer } => buffer.clone(),
982 _ => return Ok(Action::None),
983 };
984 let text = buffer.trim().to_string();
985 if text.is_empty() {
986 self.status_msg = Some("Comment discarded (empty)".to_string());
987 self.mode = Mode::Detail;
988 return Ok(Action::None);
989 }
990 let filename = match self.detail.as_ref() {
991 Some(d) => d.issue.filename.clone(),
992 None => {
993 self.mode = Mode::Detail;
994 return Ok(Action::None);
995 }
996 };
997 let author = crate::assignee::git_identity()
998 .map(|id| id.to_string())
999 .unwrap_or_else(|| "anonymous".to_string());
1000 let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
1001 issue.add_comment(&author, &text);
1002 self.store.update(&filename, &issue)?;
1003 if let Some(ref mut d) = self.detail {
1004 d.issue = issue;
1005 d.scroll_offset = 0;
1006 }
1007 self.refresh_corpus()?;
1008 self.status_msg = Some(format!("Commented by {author}"));
1009 self.mode = Mode::Detail;
1010 Ok(Action::None)
1011 }
1012
1013 fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
1014 match key.code {
1015 KeyCode::Esc => {
1016 self.mode = Mode::Detail;
1017 Ok(Action::None)
1018 }
1019 KeyCode::Enter => self.submit_asset(),
1020 KeyCode::Char(c) => {
1021 if let Mode::Asset { ref mut buffer } = self.mode {
1022 buffer.push(c);
1023 }
1024 Ok(Action::None)
1025 }
1026 KeyCode::Backspace => {
1027 if let Mode::Asset { ref mut buffer } = self.mode {
1028 buffer.pop();
1029 }
1030 Ok(Action::None)
1031 }
1032 _ => Ok(Action::None),
1033 }
1034 }
1035
1036 fn submit_asset(&mut self) -> Result<Action> {
1037 let buffer = match &self.mode {
1038 Mode::Asset { buffer } => buffer.clone(),
1039 _ => return Ok(Action::None),
1040 };
1041 let path = std::path::PathBuf::from(buffer.trim());
1042 let filename = match self.detail.as_ref() {
1043 Some(d) => d.issue.filename.clone(),
1044 None => {
1045 self.mode = Mode::Detail;
1046 return Ok(Action::None);
1047 }
1048 };
1049 match self.store.add_asset(&filename, &path) {
1050 Ok(added) => {
1051 if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
1052 && let Some(ref mut d) = self.detail
1053 {
1054 d.issue = issue;
1055 d.scroll_offset = 0;
1056 }
1057 self.status_msg = Some(format!("Asset added: {}", added.link));
1058 self.refresh_corpus()?;
1059 self.mode = Mode::Detail;
1060 Ok(Action::None)
1061 }
1062 Err(e) => {
1063 self.status_msg = Some(format!("asset: {e}"));
1064 Ok(Action::None)
1065 }
1066 }
1067 }
1068
1069 fn toggle_column_visibility(&mut self) {
1072 let col_name = self.board.active_column_name().to_string();
1073 let config_name = self.tabs.names[self.active_config].clone();
1074 let board_config = self
1075 .store
1076 .config
1077 .kanban
1078 .configurations
1079 .get_mut(&config_name);
1080 let board_config = match board_config {
1081 Some(bc) => bc,
1082 None => {
1083 self.status_msg = Some("toggle: config not found".into());
1084 return;
1085 }
1086 };
1087 let now_hidden = if let Some(pos) = board_config
1088 .hidden_columns
1089 .iter()
1090 .position(|h| h == &col_name)
1091 {
1092 board_config.hidden_columns.remove(pos);
1093 false
1094 } else {
1095 board_config.hidden_columns.push(col_name.clone());
1096 true
1097 };
1098 match self.store.config_path() {
1099 Ok(path) => {
1100 if let Err(e) = self.store.config.save(&path) {
1101 self.status_msg = Some(format!("toggle save: {e}"));
1102 return;
1103 }
1104 }
1105 Err(e) => {
1106 self.status_msg = Some(format!("toggle path: {e}"));
1107 return;
1108 }
1109 }
1110 self.rebuild_board();
1111 let visible = self.board.columns.len();
1115 self.board.active_col = if now_hidden {
1116 visible
1117 + self
1118 .board
1119 .hidden
1120 .iter()
1121 .position(|c| c.name == col_name)
1122 .unwrap_or_default()
1123 } else {
1124 self.board
1125 .columns
1126 .iter()
1127 .position(|c| c.name == col_name)
1128 .unwrap_or_default()
1129 };
1130 self.board.clamp_selection();
1131 self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
1132 }
1133
1134 fn switch_config(&mut self) {
1139 let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
1140 self.rebuild_board();
1141 self.board.active_col = 0;
1142 if let Some(filename) = selected_filename
1143 && self
1144 .board
1145 .column_cards(0)
1146 .iter()
1147 .any(|c| c.filename == filename)
1148 {
1149 self.board.selected = self
1150 .board
1151 .column_cards(0)
1152 .iter()
1153 .position(|c| c.filename == filename)
1154 .unwrap_or(0);
1155 } else {
1156 self.board.selected = 0;
1157 }
1158 self.board.clamp_selection();
1159 self.status_msg = Some("Switched board".to_string());
1160 }
1161
1162 fn rebuild_board(&mut self) {
1165 let active_col = self.board.active_col;
1166 let selected = self.board.selected;
1167 let board_config = self.active_board_config().clone();
1168 let active_filter = self.active_structured_filter().clone();
1169 let q = self.filter.clone().unwrap_or_default();
1170 if !q.is_empty() || active_filter.has_active_criteria() {
1171 self.board =
1172 BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
1173 } else {
1174 self.board = BoardState::build(&self.store, &board_config);
1175 }
1176 self.board.active_col = active_col;
1177 self.board.selected = selected;
1178 self.board.clamp_selection();
1179 }
1180
1181 fn apply_filter(&mut self, buffer: &str) {
1185 if buffer.trim().is_empty() {
1186 self.filter = None;
1187 } else {
1188 self.filter = Some(buffer.trim().to_string());
1189 }
1190 let board_config = self.active_board_config().clone();
1191 let active_filter = self.active_structured_filter().clone();
1192 let q = self.filter.clone().unwrap_or_default();
1193 if !q.is_empty() || active_filter.has_active_criteria() {
1194 self.board =
1195 BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
1196 self.status_msg = if self.board.is_empty() {
1197 Some("No matches for current filter".to_string())
1198 } else {
1199 None
1200 };
1201 } else {
1202 self.board = BoardState::build(&self.store, &board_config);
1203 self.status_msg = None;
1204 }
1205 }
1206
1207 fn refresh_corpus(&mut self) -> Result<()> {
1210 let c = Corpus::build(&self.store)?;
1211 self.corpus = c;
1212 self.rebuild_board();
1213 Ok(())
1214 }
1215
1216 pub fn refresh_from_disk(&mut self) -> Result<()> {
1221 self.refresh_corpus()
1222 }
1223
1224 pub fn after_editor(&mut self, filename: &str) {
1225 match self
1226 .store
1227 .get(IssueSelector::Filename(filename.to_string()))
1228 {
1229 Ok(mut issue) => {
1230 issue.bump_updated();
1231 let location = match issue.assignee {
1235 Some(ref a) => match self.store.reassign(filename, Some(a)) {
1236 Ok(moved) => moved.to_string_lossy().to_string(),
1237 Err(e) => {
1238 self.status_msg = Some(format!("editor: {e}"));
1239 return;
1240 }
1241 },
1242 None => filename.to_string(),
1243 };
1244 if let Err(e) = self.store.update(&location, &issue) {
1245 self.status_msg = Some(format!("editor: {e}"));
1246 } else {
1247 if let Some(ref mut d) = self.detail
1248 && (d.issue.filename == filename || d.issue.filename == location)
1249 {
1250 d.issue = issue;
1251 d.scroll_offset = 0;
1252 }
1253 let _ = self.refresh_corpus();
1254 self.status_msg = Some("Edited".to_string());
1255 }
1256 }
1257 Err(e) => {
1258 self.status_msg = Some(format!("editor: {e}"));
1259 }
1260 }
1261 }
1262}
1263
1264const HINT_HEIGHT: u16 = 1;
1265const TAB_HEIGHT: u16 = 2;
1269
1270pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
1271 match &app.mode {
1272 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
1273 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
1274 Mode::Form => {
1275 if let Some(ref fr) = app.form {
1276 render_board(app, f, area, commandbar::MODE_BOARD);
1277 let rect = crate::ui::modal::modal_rect(area);
1278 f.render_widget(Clear, rect);
1279 form::render(fr, f, rect);
1280 }
1281 }
1282 Mode::ConfirmDelete => {
1283 if area.height < 2 {
1284 return;
1285 }
1286 board::render(&app.board, f, area, &None);
1287 let title = app.confirm_delete_target.as_deref().unwrap_or("?");
1288 let msg = format!("Delete '{title}'? [y/N]");
1289 let prompt_w = 40.min(area.width);
1290 let prompt_h = 3;
1291 let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
1292 let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
1293 let prompt_area = Rect::new(prompt_x, prompt_y, prompt_w, prompt_h);
1294 let prompt = Paragraph::new(Line::from(Span::styled(
1295 msg,
1296 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1297 )))
1298 .block(
1299 Block::default()
1300 .borders(Borders::ALL)
1301 .title("Confirm")
1302 .style(Style::default().fg(Color::Red)),
1303 );
1304 f.render_widget(prompt, prompt_area);
1305 }
1306 Mode::MoveIntoDone { buffer, .. } => {
1307 if area.height < 3 {
1308 return;
1309 }
1310 let prompt_area = Rect {
1311 y: area.y + area.height - 1,
1312 height: 1,
1313 ..area
1314 };
1315 let block_area = Rect {
1316 height: area.height - 1,
1317 ..area
1318 };
1319 board::render(&app.board, f, block_area, &app.status_msg);
1320 let prompt_text = format!("Commit hash: {buffer}_");
1321 let prompt = Paragraph::new(Line::from(Span::styled(
1322 prompt_text,
1323 Style::default().fg(Color::Yellow),
1324 )));
1325 f.render_widget(prompt, prompt_area);
1326 }
1327 Mode::Help { return_to } => render_help(app, f, area, return_to),
1328 Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
1329 Mode::FilterModal => {
1330 render_board(app, f, area, commandbar::MODE_BOARD);
1331 if let Some(m) = app.filter_modal.as_ref() {
1332 m.render(f, area);
1333 }
1334 }
1335 Mode::Settings => {
1336 render_board(app, f, area, commandbar::MODE_BOARD);
1337 if let Some(m) = app.settings.as_ref() {
1338 let board = app.active_board_config();
1339 m.render(
1340 f,
1341 area,
1342 app.active_board_name(),
1343 &board.columns,
1344 &board.hidden_columns,
1345 );
1346 }
1347 }
1348 Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
1349 Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
1350 }
1351}
1352
1353fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
1356 if area.height <= HINT_HEIGHT + TAB_HEIGHT {
1357 return;
1358 }
1359 let hint_area = Rect {
1360 y: area.y + area.height - HINT_HEIGHT,
1361 height: HINT_HEIGHT,
1362 ..area
1363 };
1364 let tabs_area = Rect {
1365 y: area.y,
1366 height: TAB_HEIGHT,
1367 ..area
1368 };
1369 let content = Rect {
1370 y: area.y + TAB_HEIGHT,
1371 height: area.height - TAB_HEIGHT - HINT_HEIGHT,
1372 ..area
1373 };
1374 app.tabs.render(f, tabs_area);
1375 board::render(&app.board, f, content, &app.status_msg);
1376 render_hint(f, hint_area, tag);
1377}
1378
1379fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
1381 let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
1382 if area.height <= HINT_HEIGHT + status_h {
1383 return;
1384 }
1385 let hint_area = Rect {
1386 y: area.y + area.height - HINT_HEIGHT,
1387 height: HINT_HEIGHT,
1388 ..area
1389 };
1390 let content = Rect {
1391 height: area.height - status_h - HINT_HEIGHT,
1392 ..area
1393 };
1394 if let Some(ref d) = app.detail {
1395 detail::render(d, f, content);
1396 }
1397 if let Some(msg) = &app.status_msg {
1398 let status_area = Rect {
1399 y: area.y + area.height - status_h - HINT_HEIGHT,
1400 height: status_h,
1401 ..area
1402 };
1403 let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
1404 .style(Style::default().fg(Color::Yellow));
1405 f.render_widget(para, status_area);
1406 }
1407 render_hint(f, hint_area, tag);
1408}
1409
1410fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1413 if area.height < 2 {
1414 return;
1415 }
1416 let input_area = Rect {
1417 y: area.y + area.height - 1,
1418 height: 1,
1419 ..area
1420 };
1421 let content = Rect {
1422 height: area.height - 1,
1423 ..area
1424 };
1425 board::render(&app.board, f, content, &app.status_msg);
1426 render_input_line(f, input_area, label, buffer);
1427}
1428
1429fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1431 if area.height < 2 {
1432 return;
1433 }
1434 let input_area = Rect {
1435 y: area.y + area.height - 1,
1436 height: 1,
1437 ..area
1438 };
1439 let content = Rect {
1440 height: area.height - 1,
1441 ..area
1442 };
1443 if let Some(ref d) = app.detail {
1444 detail::render(d, f, content);
1445 }
1446 render_input_line(f, input_area, label, buffer);
1447}
1448
1449fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
1450 let text = format!("{label}: {buffer}_");
1451 let prompt = Paragraph::new(Line::from(Span::styled(
1452 text,
1453 Style::default().fg(Color::Yellow),
1454 )));
1455 f.render_widget(prompt, area);
1456}
1457
1458fn render_hint(f: &mut ratatui::Frame, area: Rect, tag: &str) {
1459 let bar = commandbar::hint_bar(&[tag]);
1460 let para = Paragraph::new(Line::from(Span::styled(
1461 bar,
1462 Style::default().fg(Color::Gray),
1463 )));
1464 f.render_widget(para, area);
1465}
1466
1467fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
1471 match return_to {
1472 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
1473 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
1474 _ => render_board(app, f, area, commandbar::MODE_BOARD),
1475 }
1476
1477 let help_entries = commandbar::help_overlay_list();
1478 let rows: Vec<Line> = help_entries
1479 .iter()
1480 .map(|(key, desc, scope)| {
1481 Line::from(vec![
1482 Span::styled(format!("{key:<10}"), Style::default().fg(Color::Cyan)),
1483 Span::styled(format!("{desc:<10}"), Style::default().fg(Color::White)),
1484 Span::styled(*scope, Style::default().fg(Color::DarkGray)),
1485 ])
1486 })
1487 .collect();
1488 let block = Block::default()
1489 .borders(Borders::ALL)
1490 .title("Help [? close]")
1491 .style(Style::default().fg(Color::Cyan));
1492 let para = Paragraph::new(rows).block(block);
1493
1494 let modal_w = 42.min(area.width).max(30);
1495 let modal_h = (help_entries.len() as u16 + 2).min(area.height).max(4);
1496 let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
1497 let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
1498 let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
1499
1500 if area.height >= modal_h && area.width >= modal_w {
1501 f.render_widget(Clear, modal_area);
1502 f.render_widget(para, modal_area);
1503 }
1504}
1505
1506pub fn run(config: &Config) -> Result<()> {
1507 let mut app = App::new(config)?;
1508 let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
1509 enable_raw_mode().map_err(io_err)?;
1510 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
1511 let guard = RestoreGuard;
1512 let backend = CrosstermBackend::new(io::stdout());
1513 let mut terminal = Terminal::new(backend).map_err(io_err)?;
1514
1515 let result = loop {
1516 terminal
1517 .draw(|f| render(&app, f, f.area()))
1518 .map_err(io_err)?;
1519
1520 if event::poll(Duration::from_millis(200)).map_err(io_err)?
1521 && let Event::Key(key) = event::read().map_err(io_err)?
1522 {
1523 match app.handle_key(key)? {
1524 Action::None => {}
1525 Action::Quit => break Ok(()),
1526 Action::OpenEditor(filename) => {
1527 drop(terminal);
1528 let _ = disable_raw_mode();
1529 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1530
1531 let edit_result = (|| -> Result<()> {
1532 let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
1533 let path = app.store.issues_dir.join(&filename);
1534 editor::run_editor(&name, &path, &app.store.config)?;
1535 app.after_editor(&filename);
1536 Ok(())
1537 })();
1538 if let Err(e) = edit_result {
1539 app.status_msg = Some(format!("editor: {e}"));
1540 }
1541
1542 enable_raw_mode().map_err(io_err)?;
1543 execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
1544 let backend = CrosstermBackend::new(io::stdout());
1545 terminal = Terminal::new(backend).map_err(io_err)?;
1546 }
1547 }
1548 }
1549
1550 if watcher.poll()
1554 && let Err(e) = app.refresh_from_disk()
1555 {
1556 app.status_msg = Some(format!("watch: {e}"));
1557 }
1558 };
1559 drop(guard);
1560 result
1561}
1562
1563struct RestoreGuard;
1564
1565impl Drop for RestoreGuard {
1566 fn drop(&mut self) {
1567 let _ = disable_raw_mode();
1568 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1569 }
1570}
1571
1572fn io_err<E: std::fmt::Display>(e: E) -> TixError {
1573 TixError::Io(io::Error::other(e.to_string()))
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use std::fs;
1580 use tempfile::TempDir;
1581
1582 fn make_app() -> (TempDir, App) {
1583 let tmp = TempDir::new().unwrap();
1584 let dir = tmp.path().join("issues");
1585 fs::create_dir_all(&dir).unwrap();
1586 let config_path = tmp.path().join("tix.toml");
1587 let config = Config {
1588 issues_dir: dir.clone(),
1589 ..Config::default()
1590 };
1591 config.save(&config_path).unwrap();
1592 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
1593 let app = App::new(&config).unwrap();
1594 (tmp, app)
1595 }
1596
1597 fn write_issue(
1598 dir: &std::path::Path,
1599 name: &str,
1600 number: u32,
1601 title: &str,
1602 status: &str,
1603 commit: Option<&str>,
1604 ) {
1605 let commit_line = match commit {
1606 Some(h) => format!("commit: \"{h}\""),
1607 None => String::new(),
1608 };
1609 let contents = format!(
1610 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
1611 );
1612 fs::write(dir.join(name), contents).unwrap();
1613 }
1614
1615 fn press(code: KeyCode) -> KeyEvent {
1616 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
1617 }
1618
1619 #[test]
1620 fn quit_on_q() {
1621 let (_tmp, mut app) = make_app();
1622 let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
1623 assert!(app.quit);
1624 assert!(matches!(action, Action::Quit));
1625 }
1626
1627 #[test]
1628 fn quit_on_esc() {
1629 let (_tmp, mut app) = make_app();
1630 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
1631 assert!(app.quit);
1632 }
1633
1634 #[test]
1635 fn cursor_clamp_empty_column() {
1636 let (_tmp, mut app) = make_app();
1637 app.board.move_left();
1638 assert_eq!(app.board.active_col, app.board.total_cols() - 1);
1639 app.board.move_right();
1640 assert_eq!(app.board.active_col, 0);
1641 }
1642
1643 #[test]
1644 fn cursor_clamp_single_card() {
1645 let (tmp, mut app) = make_app();
1646 let dir = tmp.path().join("issues");
1647 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1648 let config = app.store.config.clone();
1649 app.board = BoardState::build(&app.store, config.default_config());
1650 assert_eq!(app.board.column_cards(0).len(), 1);
1651 app.board.move_down();
1652 assert_eq!(app.board.selected, 0);
1653 app.board.move_up();
1654 assert_eq!(app.board.selected, 0);
1655 }
1656
1657 #[test]
1658 fn wrap_around_columns() {
1659 let (_tmp, mut app) = make_app();
1660 let total = app.board.total_cols();
1661 for _ in 0..total + 2 {
1662 app.board.move_right();
1663 }
1664 assert_eq!(app.board.active_col, 2 % total);
1665 for _ in 0..total + 2 {
1666 app.board.move_left();
1667 }
1668 }
1669
1670 #[test]
1671 fn refresh_from_disk_picks_up_new_file() {
1672 let (tmp, mut app) = make_app();
1673 let dir = tmp.path().join("issues");
1674 assert_eq!(app.board.column_cards(0).len(), 0);
1675 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1676 app.refresh_from_disk().unwrap();
1677 assert_eq!(app.board.column_cards(0).len(), 1);
1678 }
1679
1680 #[test]
1681 fn refresh_from_disk_reflects_external_status_move() {
1682 let (tmp, mut app) = make_app();
1683 let dir = tmp.path().join("issues");
1684 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1685 app.refresh_from_disk().unwrap();
1686 assert_eq!(app.board.column_cards(0).len(), 1);
1687
1688 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
1689 app.refresh_from_disk().unwrap();
1690 assert_eq!(app.board.column_cards(0).len(), 0);
1691 assert_eq!(app.board.column_cards(3).len(), 1);
1692 }
1693
1694 #[test]
1695 fn refresh_from_disk_keeps_board_on_bad_file() {
1696 let (tmp, mut app) = make_app();
1697 let dir = tmp.path().join("issues");
1698 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1699 app.refresh_from_disk().unwrap();
1700 assert_eq!(app.board.column_cards(0).len(), 1);
1701
1702 fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
1703 assert!(app.refresh_from_disk().is_err());
1704 assert_eq!(
1705 app.board.column_cards(0).len(),
1706 1,
1707 "a malformed file must not wipe the board"
1708 );
1709 }
1710
1711 #[test]
1712 fn enter_opens_detail() {
1713 let (tmp, mut app) = make_app();
1714 let dir = tmp.path().join("issues");
1715 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1716 let config = app.store.config.clone();
1717 app.board = BoardState::build(&app.store, config.default_config());
1718 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1719 assert!(matches!(app.mode, Mode::Detail));
1720 assert!(app.detail.is_some());
1721 }
1722
1723 #[test]
1724 fn n_opens_form() {
1725 let (_tmp, mut app) = make_app();
1726 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
1727 assert!(matches!(app.mode, Mode::Form));
1728 assert!(app.form.is_some());
1729 }
1730
1731 #[test]
1732 fn d_opens_confirm_delete() {
1733 let (tmp, mut app) = make_app();
1734 let dir = tmp.path().join("issues");
1735 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1736 let config = app.store.config.clone();
1737 app.board = BoardState::build(&app.store, config.default_config());
1738 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
1739 assert!(matches!(app.mode, Mode::ConfirmDelete));
1740 assert!(app.confirm_delete_target.is_some());
1741 }
1742
1743 #[test]
1744 fn move_to_done_triggers_commit_prompt() {
1745 let (tmp, mut app) = make_app();
1746 let dir = tmp.path().join("issues");
1747 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
1749 let config = app.store.config.clone();
1750 app.board = BoardState::build(&app.store, config.default_config());
1751 app.board.select_column(2);
1752 app.board.selected = 0;
1753
1754 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
1755 assert!(
1757 matches!(
1758 &app.mode,
1759 Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
1760 ),
1761 "expected MoveIntoDone, got {:?}",
1762 app.mode
1763 );
1764
1765 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
1767 assert!(matches!(app.mode, Mode::Board));
1768 let issue = app
1769 .store
1770 .get(crate::storage::IssueSelector::Filename(
1771 "2026-01-01-a.md".into(),
1772 ))
1773 .unwrap();
1774 assert_eq!(issue.status.display(), "in review");
1775 assert!(issue.commit.is_none());
1776 }
1777
1778 #[test]
1779 fn move_into_done_with_commit_applies() {
1780 let (tmp, mut app) = make_app();
1781 let dir = tmp.path().join("issues");
1782 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
1783 let config = app.store.config.clone();
1784 app.board = BoardState::build(&app.store, config.default_config());
1785 app.board.select_column(2);
1786 app.board.selected = 0;
1787
1788 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
1789 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
1790
1791 for c in "abc123".chars() {
1792 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
1793 }
1794 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1795
1796 assert!(matches!(app.mode, Mode::Board));
1797 let issue = app
1798 .store
1799 .get(crate::storage::IssueSelector::Filename(
1800 "2026-01-01-a.md".into(),
1801 ))
1802 .unwrap();
1803 assert_eq!(issue.status.display(), "done");
1804 assert_eq!(issue.commit.as_deref(), Some("abc123"));
1805 }
1806
1807 #[test]
1808 fn move_into_done_with_empty_commit_moves_without_hash() {
1809 let (tmp, mut app) = make_app();
1810 let dir = tmp.path().join("issues");
1811 write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
1812 let config = app.store.config.clone();
1813 app.board = BoardState::build(&app.store, config.default_config());
1814 app.board.select_column(2);
1815 app.board.selected = 0;
1816
1817 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
1818 assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
1819 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1820
1821 assert!(matches!(app.mode, Mode::Board));
1822 let issue = app
1823 .store
1824 .get(crate::storage::IssueSelector::Filename(
1825 "2026-01-01-a.md".into(),
1826 ))
1827 .unwrap();
1828 assert_eq!(issue.status.display(), "done");
1829 assert!(
1830 issue.commit.is_none(),
1831 "no commit recorded when moving to done without a hash"
1832 );
1833 }
1834
1835 #[test]
1836 fn move_out_of_done_clears_commit() {
1837 let (tmp, mut app) = make_app();
1838 let dir = tmp.path().join("issues");
1839 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
1840 let config = app.store.config.clone();
1841 app.board = BoardState::build(&app.store, config.default_config());
1842 app.board.select_column(3);
1843 app.board.selected = 0;
1844
1845 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
1846 assert!(matches!(app.mode, Mode::Board));
1847 let issue = app
1848 .store
1849 .get(crate::storage::IssueSelector::Filename(
1850 "2026-01-01-a.md".into(),
1851 ))
1852 .unwrap();
1853 assert_eq!(issue.status.display(), "in review");
1854 assert!(issue.commit.is_none());
1855 }
1856
1857 #[test]
1858 fn move_right_wraps_across_real_columns_skipping_mismatch() {
1859 let (tmp, mut app) = make_app();
1860 let dir = tmp.path().join("issues");
1861 write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
1862 let config = app.store.config.clone();
1863 app.board = BoardState::build(&app.store, config.default_config());
1864 app.board.select_column(3); app.board.selected = 0;
1866 let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
1867 assert_eq!(app.board.active_col, 0);
1868 let issue = app
1869 .store
1870 .get(crate::storage::IssueSelector::Filename(
1871 "2026-01-01-a.md".into(),
1872 ))
1873 .unwrap();
1874 assert_eq!(issue.status.display(), "todo");
1875 }
1876
1877 #[test]
1878 fn move_left_wraps_across_real_columns_skipping_mismatch() {
1879 let (tmp, mut app) = make_app();
1880 let dir = tmp.path().join("issues");
1881 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
1882 let config = app.store.config.clone();
1883 app.board = BoardState::build(&app.store, config.default_config());
1884 app.board.select_column(0);
1885 app.board.selected = 0;
1886 let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
1887 assert_eq!(app.board.active_col, 3);
1888 let issue = app
1889 .store
1890 .get(crate::storage::IssueSelector::Filename(
1891 "2026-01-01-a.md".into(),
1892 ))
1893 .unwrap();
1894 assert_eq!(issue.status.display(), "done");
1895 }
1896
1897 #[test]
1898 fn move_left_right_board() {
1899 let (_tmp, mut app) = make_app();
1900 let initial = app.board.active_col;
1901 let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
1902 assert_eq!(app.board.active_col, initial + 1);
1903 let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
1904 assert_eq!(app.board.active_col, initial);
1905 }
1906
1907 #[test]
1908 fn refresh_rebuilds_board() {
1909 let (tmp, mut app) = make_app();
1910 let dir = tmp.path().join("issues");
1911 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1912 let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
1913 assert_eq!(app.board.column_cards(0).len(), 1);
1914 assert_eq!(app.status_msg, Some("Refreshed".to_string()));
1915 }
1916
1917 #[test]
1918 fn detail_esc_back_to_board() {
1919 let (tmp, mut app) = make_app();
1920 let dir = tmp.path().join("issues");
1921 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1922 let config = app.store.config.clone();
1923 app.board = BoardState::build(&app.store, config.default_config());
1924 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1925 assert!(matches!(app.mode, Mode::Detail));
1926 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
1927 assert!(matches!(app.mode, Mode::Board));
1928 assert!(app.detail.is_none());
1929 }
1930
1931 #[test]
1932 fn confirm_delete_y_removes_issue() {
1933 let (tmp, mut app) = make_app();
1934 let dir = tmp.path().join("issues");
1935 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1936 let config = app.store.config.clone();
1937 app.board = BoardState::build(&app.store, config.default_config());
1938 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
1939 assert!(matches!(app.mode, Mode::ConfirmDelete));
1940 let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
1941 assert!(matches!(app.mode, Mode::Board));
1942 let config = app.store.config.clone();
1943 app.board = BoardState::build(&app.store, config.default_config());
1944 assert_eq!(app.board.column_cards(0).len(), 0);
1945 }
1946
1947 #[test]
1948 fn confirm_delete_n_cancels() {
1949 let (tmp, mut app) = make_app();
1950 let dir = tmp.path().join("issues");
1951 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
1952 let config = app.store.config.clone();
1953 app.board = BoardState::build(&app.store, config.default_config());
1954 let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
1955 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
1956 assert!(matches!(app.mode, Mode::Board));
1957 assert!(app.confirm_delete_target.is_none());
1958 let config = app.store.config.clone();
1959 app.board = BoardState::build(&app.store, config.default_config());
1960 assert_eq!(app.board.column_cards(0).len(), 1);
1961 }
1962
1963 #[test]
1964 fn form_submit_creates_issue() {
1965 let (_tmp, mut app) = make_app();
1966 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
1967 if let Mode::Form = &app.mode {
1968 for c in "My Issue".chars() {
1970 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
1971 }
1972 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1974 }
1975 assert!(matches!(app.mode, Mode::Board));
1976 assert!(
1977 app.status_msg
1978 .as_deref()
1979 .is_some_and(|m| m.starts_with("Created ")),
1980 "got {:?}",
1981 app.status_msg
1982 );
1983 }
1984
1985 #[test]
1986 fn form_empty_title_rejected() {
1987 let (_tmp, mut app) = make_app();
1988 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
1989 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
1990 assert!(matches!(app.mode, Mode::Form));
1991 assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
1992 }
1993
1994 #[test]
1995 fn form_esc_cancels() {
1996 let (_tmp, mut app) = make_app();
1997 let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
1998 assert!(matches!(app.mode, Mode::Form));
1999 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2000 assert!(matches!(app.mode, Mode::Board));
2001 }
2002
2003 #[test]
2004 fn key_release_ignored() {
2005 let (_tmp, mut app) = make_app();
2006 let key = KeyEvent {
2007 code: KeyCode::Char('q'),
2008 modifiers: crossterm::event::KeyModifiers::NONE,
2009 kind: KeyEventKind::Release,
2010 state: crossterm::event::KeyEventState::NONE,
2011 };
2012 let _ = app.handle_key(key).unwrap();
2013 assert!(!app.quit);
2014 }
2015
2016 fn open_detail(app: &mut App) {
2021 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2022 assert!(matches!(app.mode, Mode::Detail));
2023 assert!(app.detail.is_some());
2024 }
2025
2026 #[test]
2027 fn help_opens_from_board_and_closes() {
2028 let (_tmp, mut app) = make_app();
2029 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
2030 assert!(
2031 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
2032 "got {:?}",
2033 app.mode
2034 );
2035 let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
2036 assert!(matches!(app.mode, Mode::Board));
2037 }
2038
2039 #[test]
2040 fn help_from_detail_returns_to_detail() {
2041 let (tmp, mut app) = make_app();
2042 let dir = tmp.path().join("issues");
2043 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2044 let config = app.store.config.clone();
2045 app.board = BoardState::build(&app.store, config.default_config());
2046 open_detail(&mut app);
2047 let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
2048 assert!(
2049 matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
2050 "got {:?}",
2051 app.mode
2052 );
2053 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2054 assert!(matches!(app.mode, Mode::Detail));
2055 }
2056
2057 #[test]
2058 fn filter_narrows_board_live() {
2059 let (tmp, mut app) = make_app();
2060 let dir = tmp.path().join("issues");
2061 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2062 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
2063 let config = app.store.config.clone();
2064 app.board = BoardState::build(&app.store, config.default_config());
2065 app.refresh_corpus().unwrap();
2066 assert_eq!(app.board.column_cards(0).len(), 2);
2067
2068 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2069 assert!(matches!(app.mode, Mode::Filter { .. }));
2070 for c in "arget".chars() {
2071 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2072 }
2073 assert_eq!(app.filter, Some("arget".into()));
2074 assert_eq!(app.board.column_cards(0).len(), 1);
2075 assert_eq!(app.board.column_cards(0)[0].title, "Target");
2076 assert!(matches!(app.mode, Mode::Filter { .. }));
2077 }
2078
2079 #[test]
2080 fn filter_esc_clears_and_returns_to_board() {
2081 let (tmp, mut app) = make_app();
2082 let dir = tmp.path().join("issues");
2083 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2084 let config = app.store.config.clone();
2085 app.board = BoardState::build(&app.store, config.default_config());
2086 app.refresh_corpus().unwrap();
2087 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2088 for c in "zzw".chars() {
2089 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2090 }
2091 assert_eq!(app.board.column_cards(0).len(), 0);
2092 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2093 assert!(matches!(app.mode, Mode::Board));
2094 assert_eq!(app.filter, None);
2095 assert_eq!(app.board.column_cards(0).len(), 1);
2096 }
2097
2098 #[test]
2099 fn filter_enter_commits_and_stays_filtered() {
2100 let (tmp, mut app) = make_app();
2101 let dir = tmp.path().join("issues");
2102 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2103 write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
2104 let config = app.store.config.clone();
2105 app.board = BoardState::build(&app.store, config.default_config());
2106 app.refresh_corpus().unwrap();
2107 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2108 for c in "arget".chars() {
2109 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2110 }
2111 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2112 assert!(matches!(app.mode, Mode::Board));
2113 assert_eq!(app.filter, Some("arget".into()));
2114 assert_eq!(app.board.column_cards(0).len(), 1);
2115 }
2116
2117 #[test]
2118 fn filter_zero_match_reports_status() {
2119 let (tmp, mut app) = make_app();
2120 let dir = tmp.path().join("issues");
2121 write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
2122 let config = app.store.config.clone();
2123 app.board = BoardState::build(&app.store, config.default_config());
2124 app.refresh_corpus().unwrap();
2125 let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
2126 for c in "zzw".chars() {
2127 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2128 }
2129 assert_eq!(app.board.column_cards(0).len(), 0);
2130 assert!(
2131 app.status_msg
2132 .as_deref()
2133 .is_some_and(|m| m.contains("No matches")),
2134 "got {:?}",
2135 app.status_msg
2136 );
2137 }
2138
2139 #[test]
2140 fn comment_round_trip_via_detail() {
2141 let (tmp, mut app) = make_app();
2142 let dir = tmp.path().join("issues");
2143 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2144 let config = app.store.config.clone();
2145 app.board = BoardState::build(&app.store, config.default_config());
2146 open_detail(&mut app);
2147
2148 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2149 assert!(matches!(app.mode, Mode::Comment { .. }));
2150 for c in "hello world".chars() {
2151 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2152 }
2153 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2154 assert!(matches!(app.mode, Mode::Detail));
2155
2156 let issue = app
2157 .store
2158 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2159 .unwrap();
2160 let comments = issue.comments();
2161 assert_eq!(comments.len(), 1);
2162 assert_eq!(comments[0].text.trim(), "hello world");
2163 }
2164
2165 #[test]
2166 fn comment_esc_cancels() {
2167 let (tmp, mut app) = make_app();
2168 let dir = tmp.path().join("issues");
2169 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2170 let config = app.store.config.clone();
2171 app.board = BoardState::build(&app.store, config.default_config());
2172 open_detail(&mut app);
2173 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2174 for c in "partial".chars() {
2175 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2176 }
2177 let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
2178 assert!(matches!(app.mode, Mode::Detail));
2179 let issue = app
2180 .store
2181 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2182 .unwrap();
2183 assert!(issue.comments().is_empty());
2184 }
2185
2186 #[test]
2187 fn comment_empty_discarded() {
2188 let (tmp, mut app) = make_app();
2189 let dir = tmp.path().join("issues");
2190 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2191 let config = app.store.config.clone();
2192 app.board = BoardState::build(&app.store, config.default_config());
2193 open_detail(&mut app);
2194 let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
2195 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2196 assert!(matches!(app.mode, Mode::Detail));
2197 let issue = app
2198 .store
2199 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2200 .unwrap();
2201 assert!(issue.comments().is_empty());
2202 assert!(
2203 app.status_msg
2204 .as_deref()
2205 .is_some_and(|m| m.contains("empty"))
2206 );
2207 }
2208
2209 #[test]
2210 fn asset_round_trip_via_detail() {
2211 let (tmp, mut app) = make_app();
2212 let dir = tmp.path().join("issues");
2213 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2214 let config = app.store.config.clone();
2215 app.board = BoardState::build(&app.store, config.default_config());
2216 open_detail(&mut app);
2217
2218 let asset = tmp.path().join("pic.png");
2219 fs::write(&asset, b"fake-png").unwrap();
2220
2221 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
2222 assert!(matches!(app.mode, Mode::Asset { .. }));
2223 for c in asset.to_string_lossy().chars() {
2224 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2225 }
2226 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2227 assert!(matches!(app.mode, Mode::Detail));
2228
2229 assert!(
2230 dir.join("a-assets").join("pic.png").is_file(),
2231 "asset must be copied into the issue asset dir"
2232 );
2233 let issue = app
2234 .store
2235 .get(IssueSelector::Filename("2026-01-01-a.md".into()))
2236 .unwrap();
2237 assert!(
2238 issue.body.contains("pic.png"),
2239 "body must reference the asset, got {:?}",
2240 issue.body
2241 );
2242 }
2243
2244 #[test]
2245 fn asset_error_stays_in_mode() {
2246 let (tmp, mut app) = make_app();
2247 let dir = tmp.path().join("issues");
2248 write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
2249 let config = app.store.config.clone();
2250 app.board = BoardState::build(&app.store, config.default_config());
2251 open_detail(&mut app);
2252 let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
2253 for c in "/no/such/file.png".chars() {
2254 let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2255 }
2256 let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
2257 assert!(
2258 matches!(app.mode, Mode::Asset { .. }),
2259 "asset error must stay in entry mode, got {:?}",
2260 app.mode
2261 );
2262 assert!(app.status_msg.is_some());
2263 }
2264}