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