Skip to main content

tix/
app.rs

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    /// The active named board configuration index into `Config::configs()`
65    /// (session state only — never persisted). `rebuild_board`/`apply_filter`
66    /// must not reset it.
67    pub active_config: usize,
68    /// Persistent tabs bar of all named boards (DM-01M2MH8J01DZHV3JMT76XX38KX).
69    pub tabs: BoardTabs,
70    filter: Option<String>,
71    corpus: Corpus,
72    pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
73    /// Settings modal (board/column configuration via `s`).
74    pub settings: Option<SettingsModal>,
75    /// Committed structured filter (kept when the modal is closed). The live
76    /// in-progress criteria come from `filter_modal` while it is open.
77    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    /// The active `BoardConfiguration` indexed by [`App::active_config`].
106    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                // FR-028: pre-fill the assignee with the git identity (best
241                // effort — empty when git is absent, never an error).
242                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    /// The structured filter to apply right now: the live in-progress criteria
603    /// while the modal is open, otherwise the committed one.
604    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    /// Open the structured filter modal seeded from the committed filter.
612    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    /// FilterModal keys: Tab/Shift+Tab navigate criterion rows; `r` resets all
620    /// criteria (FR-014); Esc cancels; Enter normalizes + commits. Free-text
621    /// typing edits the focused field and re-renders live (FR-013). All
622    /// criteria run over the corpus in the same single pass as the query
623    /// (NFR-001) via [`BoardState::build_filtered`].
624    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    /// Open the settings modal (`s`), optionally seeding an inline prompt
685    /// (e.g. `b` starts a new-board prompt).
686    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    /// Settings modal keys: `j/k`/arrows move the column cursor; Space/Enter
695    /// toggles column visibility; `a` adds a column, `n` creates a board, `x`
696    /// deletes the current board (y/N confirm); Tab switches board; Inline
697    /// prompts accept text + Enter, Esc cancels. Every mutation persists to
698    /// `tix.toml` and rebuilds the board.
699    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    /// Browsing state: no inline prompt is active.
748    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    /// Toggle/cycle one of the display rows (after the column checkboxes).
874    /// Row 0 flips title wrapping, row 1 cycles the line limit through 1-5,
875    /// row 2 cycles the ordering; every change persists and rebuilds the board.
876    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    /// Toggle column visibility on the active column (FR-005/FR-006/NFR-004/
1128    /// SC-003). Updates the in-memory config and persists atomically.
1129    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        // Keep the cursor logically on the toggled column so a second `c`
1170        // press re-shows it: visible index when shown, hidden-region index
1171        // while hidden. Navigation keys still skip hidden columns (T014).
1172        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    /// Switch the active board configuration (Tab/BackTab). Rebuilds the
1193    /// board through the same path as `rebuild_board` so filters re-apply to
1194    /// the new config's visible columns, resetting `active_col` to the first
1195    /// visible column while keeping the selected card if it is still visible.
1196    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    /// Re-read the store and rebuild the board while preserving the current
1221    /// filter, active column, and selection.
1222    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    /// Live filter: update the applied `filter` and re-render the board from
1258    /// the corpus. An empty buffer clears the query (FR-013: an active
1259    /// structured filter alone still filters).
1260    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    /// Re-read the corpus after comment/asset writes so the active filter
1284    /// stays fresh, then rebuilds the board.
1285    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    /// Re-read issues from disk and rebuild the board, so edits made outside
1293    /// `tix` appear without a manual keypress (issue #10, driven by
1294    /// [`crate::watcher::FsWatcher`]). A read error (e.g. a partially written
1295    /// file) leaves the current board in place.
1296    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                // FR-031: when the editor assigned the issue, physically move
1308                // it into the new assignee segment before writing content.
1309                // An unassigned edit keeps the current file location.
1310                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;
1376/// Two rows: one for the tab labels, one for the tab bar's bottom border
1377/// (`BoardTabs` renders a `Block` with `Borders::BOTTOM`, which consumes the
1378/// last row). With height 1 the labels would never render.
1379const TAB_HEIGHT: u16 = 2;
1380/// Single-row active-filter indicator shown directly beneath the tab bar
1381/// (board-filtering FR-01); zero rows when no filter is active (D-05).
1382const FILTER_HEIGHT: u16 = 1;
1383/// Full-width row directly beneath the tab bar showing the currently hovered
1384/// issue's title (hovered-issue title line FR).
1385const 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
1511/// Full-width row that always shows the title of the currently hovered
1512/// (selected) issue — `#<number> <title>` — or a dim placeholder when no card
1513/// is selected. Renders regardless of filter state. Sits directly beneath the
1514/// tab bar, or at the very top when a single board makes the bar redundant.
1515fn 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
1536/// Board view: a persistent tabs bar at the top (omitted when only one board
1537/// is configured), the hovered-issue title line beneath it, then the board
1538/// (with its own status line), and the hint bar at the very bottom
1539/// (FR-009/FR-010).
1540fn 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
1587/// Detail view: detail content, optional status line, then the hint bar.
1588fn 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
1618/// Board-underneath input modes (filter): the tab bar is omitted for a single
1619/// board; otherwise the layout is tab bar, hovered-issue title line, optional
1620/// filter row, board, and a one-line prompt at the bottom.
1621fn 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
1670/// Detail-underneath input modes (comment, asset): detail content + prompt.
1671fn 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
1715/// Help as a centered modal (FR-010): the screen the user came from stays
1716/// visible underneath (rendered per `return_to`), and the command list floats
1717/// above it in a bordered box. `?`/`q`/Esc close it (see `handle_help_key`).
1718fn 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        // Issue #10: reflect edits made outside `tix` (another editor, `tix`
1871        // CLI, git checkout) by rebuilding the board when the issues tree
1872        // changes. A transient read error keeps the current board.
1873        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        // issue sits in "in review" (0-indexed column 2), target is "done" (3)
2138        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        // Moving to done without a commit opens the hash prompt
2146        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        // Esc cancels the prompt and keeps the status unchanged
2156        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); // done (last real column)
2255        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            // Type title
2359            for c in "My Issue".chars() {
2360                let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
2361            }
2362            // Submit
2363            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    // ------------------------------------------------------------------
2512    // WP05 — help overlay, `/` filter, comment and asset entry
2513    // ------------------------------------------------------------------
2514
2515    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        // Issue 7's title contains the digit sequence "999"? No: a miss still
2640        // must surface the no-matches status because the bare number gate
2641        // bypasses fuzzy text matching.
2642        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        // Single board: no tab bar. Row 0 = hovered-issue title line; row 1 =
2820        // the filter indicator.
2821        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}