use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::assignee::{git_identity, resolve_me};
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority, Status, today};
use crate::search::{self, Corpus};
use crate::storage::{IssueSelector, Store};
use crate::editor;
use crate::ui::board::{self, BoardState};
use crate::ui::commandbar;
use crate::ui::detail::{self, DetailState};
use crate::ui::form::{self, FormState};
use crate::ui::settings::{SettingsModal, SettingsPrompt};
use crate::ui::tabs::BoardTabs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Quit,
OpenEditor(String),
OpenAgent(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Board,
Detail,
Form,
ConfirmDelete,
MoveIntoDone { target_col: usize, buffer: String },
Help { return_to: Box<Mode> },
Filter { buffer: String },
FilterModal,
Settings,
Comment { buffer: String },
Asset { buffer: String },
EditLintWarning { filename: String, message: String },
}
pub struct App {
pub mode: Mode,
pub board: BoardState,
pub detail: Option<DetailState>,
pub form: Option<FormState>,
pub confirm_delete_target: Option<String>,
pub store: Store,
pub status_msg: Option<String>,
pub quit: bool,
pub active_config: usize,
pub tabs: BoardTabs,
filter: Option<String>,
corpus: Corpus,
pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
pub settings: Option<SettingsModal>,
structured: search::StructuredFilter,
}
impl App {
pub fn new(config: &Config) -> Result<Self> {
let store = Store::open(config)?;
let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
let board = BoardState::build(&store, config.default_config());
let corpus = Corpus::build(&store)?;
Ok(App {
mode: Mode::Board,
board,
detail: None,
form: None,
confirm_delete_target: None,
store,
status_msg: None,
quit: false,
active_config: 0,
tabs,
filter: None,
corpus,
filter_modal: None,
settings: None,
structured: search::StructuredFilter::default(),
})
}
fn active_board_config(&self) -> &BoardConfiguration {
self.store
.config
.configs()
.nth(self.active_config)
.map(|(_, board)| board)
.unwrap_or_else(|| self.store.config.default_config())
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
if key.kind != KeyEventKind::Press {
return Ok(Action::None);
}
match self.mode.clone() {
Mode::Board => self.handle_board_key(key),
Mode::Detail => self.handle_detail_key(key),
Mode::Form => self.handle_form_key(key),
Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
Mode::MoveIntoDone { target_col, .. } => {
self.handle_move_into_done_key(key, target_col)
}
Mode::Help { .. } => self.handle_help_key(key),
Mode::Filter { .. } => self.handle_filter_key(key),
Mode::FilterModal => self.handle_filter_modal_key(key),
Mode::Settings => self.handle_settings_key(key),
Mode::Comment { .. } => self.handle_comment_key(key),
Mode::Asset { .. } => self.handle_asset_key(key),
Mode::EditLintWarning { .. } => self.handle_edit_lint_warning_key(key),
}
}
fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.quit = true;
Ok(Action::Quit)
}
KeyCode::Tab => {
self.tabs.next();
self.active_config = self.tabs.selected;
self.switch_config();
Ok(Action::None)
}
KeyCode::BackTab => {
self.tabs.previous();
self.active_config = self.tabs.selected;
self.switch_config();
Ok(Action::None)
}
KeyCode::Left | KeyCode::Char('h') => {
self.board.move_left();
Ok(Action::None)
}
KeyCode::Right | KeyCode::Char('l') => {
self.board.move_right();
Ok(Action::None)
}
KeyCode::Up | KeyCode::Char('k') => {
self.board.move_up();
Ok(Action::None)
}
KeyCode::Down | KeyCode::Char('j') => {
self.board.move_down();
Ok(Action::None)
}
KeyCode::Enter => {
if let Some(card) = self.board.get_selected_card().cloned() {
let issue = self.store.get(IssueSelector::Filename(card.filename))?;
self.detail = Some(DetailState {
issue,
scroll_offset: 0,
});
self.mode = Mode::Detail;
}
Ok(Action::None)
}
KeyCode::Char('e') => {
if let Some(card) = self.board.get_selected_card() {
Ok(Action::OpenEditor(card.filename.clone()))
} else {
Ok(Action::None)
}
}
KeyCode::Char('A') => {
if let Some(card) = self.board.get_selected_card() {
Ok(Action::OpenAgent(card.filename.clone()))
} else {
Ok(Action::None)
}
}
KeyCode::Char('c') => {
self.toggle_column_visibility();
Ok(Action::None)
}
KeyCode::Char('f') => {
self.open_filter_modal();
Ok(Action::None)
}
KeyCode::Char('<') => {
let real_cols = self.board.real_cols();
if real_cols > 0 {
let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
{
real_cols - 1
} else {
self.board.active_col - 1
};
self.move_selected_to(target)
} else {
Ok(Action::None)
}
}
KeyCode::Char('>') => {
let real_cols = self.board.real_cols();
if real_cols > 0 {
let target = if self.board.active_col >= real_cols {
0
} else {
(self.board.active_col + 1) % real_cols
};
self.move_selected_to(target)
} else {
Ok(Action::None)
}
}
KeyCode::Char('n') => {
let default_status = self
.store
.config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string());
let default_assignee = crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_default();
self.form = Some(FormState::with_default_assignee(
&default_status,
&default_assignee,
));
self.mode = Mode::Form;
Ok(Action::None)
}
KeyCode::Char('d') => {
if let Some(card) = self.board.get_selected_card().cloned() {
self.confirm_delete_target = Some(card.filename);
self.mode = Mode::ConfirmDelete;
}
Ok(Action::None)
}
KeyCode::Char('r') => {
self.rebuild_board();
self.status_msg = Some("Refreshed".to_string());
Ok(Action::None)
}
KeyCode::Char('s') => {
self.open_settings(None);
Ok(Action::None)
}
KeyCode::Char('b') => {
self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
Ok(Action::None)
}
KeyCode::Char('?') => {
self.mode = Mode::Help {
return_to: Box::new(Mode::Board),
};
Ok(Action::None)
}
KeyCode::Char('/') => {
let buffer = self.filter.clone().unwrap_or_default();
self.mode = Mode::Filter { buffer };
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
let card = match self.board.get_selected_card().cloned() {
Some(c) => c,
None => return Ok(Action::None),
};
let target_name = self.board.column_name(target_col).to_string();
let done_col = self.store.config.default_config().effective_done_column();
let is_done_target = done_col == Some(&target_name);
let current_col_name = self.board.column_name(self.board.active_col);
let was_done = done_col == Some(current_col_name);
if is_done_target && card.commit.is_none() {
self.mode = Mode::MoveIntoDone {
target_col,
buffer: String::new(),
};
return Ok(Action::None);
}
let mut issue = self
.store
.get(IssueSelector::Filename(card.filename.clone()))?;
issue.status = Status::new(target_name.clone());
if was_done {
issue.commit = None;
}
issue.bump_updated();
self.store.update(&issue.filename, &issue)?;
self.rebuild_board();
self.board.select_column(target_col);
self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
Ok(Action::None)
}
fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.mode = Mode::Board;
self.detail = None;
Ok(Action::None)
}
KeyCode::Char('e') => {
if let Some(ref d) = self.detail {
let filename = d.issue.filename.clone();
Ok(Action::OpenEditor(filename))
} else {
Ok(Action::None)
}
}
KeyCode::PageUp => {
if let Some(ref mut d) = self.detail {
d.scroll_offset = d.scroll_offset.saturating_sub(5);
}
Ok(Action::None)
}
KeyCode::PageDown => {
if let Some(ref mut d) = self.detail {
d.scroll_offset = d.scroll_offset.saturating_add(5);
}
Ok(Action::None)
}
KeyCode::Char('?') => {
self.mode = Mode::Help {
return_to: Box::new(Mode::Detail),
};
Ok(Action::None)
}
KeyCode::Char('c') => {
self.mode = Mode::Comment {
buffer: String::new(),
};
Ok(Action::None)
}
KeyCode::Char('a') => {
self.mode = Mode::Asset {
buffer: String::new(),
};
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
let form = match self.form.as_mut() {
Some(f) => f,
None => return Ok(Action::None),
};
match key.code {
KeyCode::Esc => {
self.form = None;
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Enter => {
if form.title.trim().is_empty() {
self.status_msg = Some("Title is required".to_string());
return Ok(Action::None);
}
let title = form.title.trim().to_string();
let status_str = if form.status.trim().is_empty() {
self.store
.config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string())
} else {
form.status.trim().to_string()
};
let priority: Priority = form.priority.parse().unwrap_or_default();
let identity = git_identity();
let assignee = if form.assignee.trim().is_empty() {
None
} else {
resolve_me(form.assignee.trim(), identity.as_ref())
};
let labels: Vec<String> = form
.labels
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let estimate = if form.estimate.trim().is_empty() {
None
} else {
Some(form.estimate.trim().to_string())
};
let mut issue = Issue {
number: 0,
title,
status: Status::new(status_str),
labels,
priority,
assignee,
estimate,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: today(),
updated: today(),
filename: String::new(),
body: String::new(),
};
let path = self.store.create(&mut issue, "", None)?;
let name = path.file_name().unwrap().to_string_lossy().to_string();
self.status_msg = Some(format!("Created {name}"));
self.form = None;
self.mode = Mode::Board;
self.rebuild_board();
self.select_card_by_filename(&name);
Ok(Action::None)
}
KeyCode::Tab | KeyCode::Down => {
form.move_focus_next();
Ok(Action::None)
}
KeyCode::BackTab | KeyCode::Up => {
form.move_focus_prev();
Ok(Action::None)
}
KeyCode::Char(c) => {
form.insert_char(c);
Ok(Action::None)
}
KeyCode::Backspace => {
form.delete_char();
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(filename) = self.confirm_delete_target.take() {
self.store.delete(&filename)?;
self.status_msg = Some(format!("Deleted {filename}"));
}
self.mode = Mode::Board;
self.rebuild_board();
Ok(Action::None)
}
_ => {
self.confirm_delete_target = None;
self.mode = Mode::Board;
Ok(Action::None)
}
}
}
fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
match key.code {
KeyCode::Enter => {
let buffer = match &self.mode {
Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
_ => unreachable!(),
};
let card = match self.board.get_selected_card().cloned() {
Some(c) => c,
None => {
self.mode = Mode::Board;
return Ok(Action::None);
}
};
let mut issue = self
.store
.get(IssueSelector::Filename(card.filename.clone()))?;
let target_name = self.board.column_name(target_col).to_string();
issue.status = Status::new(target_name.clone());
let trimmed = buffer.trim();
if trimmed.is_empty() {
issue.commit = None;
} else {
issue.commit = Some(trimmed.to_string());
}
issue.bump_updated();
self.store.update(&issue.filename, &issue)?;
self.rebuild_board();
self.board.select_column(target_col);
self.status_msg = Some(match trimmed {
"" => format!(
"Moved #{} to {target_name} (no commit recorded)",
card.number
),
_ => format!(
"Moved #{} to {target_name} (commit: {})",
card.number, trimmed
),
});
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Esc => {
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
self.mode = *return_to;
}
}
_ => {}
}
Ok(Action::None)
}
fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.filter = None;
self.board = BoardState::build(&self.store, self.active_board_config());
self.status_msg = None;
self.mode = Mode::Board;
}
KeyCode::Enter => {
self.mode = Mode::Board;
}
KeyCode::Char(c) => {
let mut next = match &self.mode {
Mode::Filter { buffer } => buffer.clone(),
_ => String::new(),
};
next.push(c);
self.mode = Mode::Filter {
buffer: next.clone(),
};
self.apply_filter(&next);
}
KeyCode::Backspace => {
let mut next = match &self.mode {
Mode::Filter { buffer } => buffer.clone(),
_ => String::new(),
};
next.pop();
self.mode = Mode::Filter {
buffer: next.clone(),
};
self.apply_filter(&next);
}
_ => {}
}
Ok(Action::None)
}
fn active_structured_filter(&self) -> &search::StructuredFilter {
self.filter_modal
.as_ref()
.map(|m| m.active_filter())
.unwrap_or(&self.structured)
}
fn open_filter_modal(&mut self) {
self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
self.structured.clone(),
));
self.mode = Mode::FilterModal;
}
fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
let Some(modal) = self.filter_modal.as_mut() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
match key.code {
KeyCode::Esc => {
self.filter_modal = None;
self.mode = Mode::Board;
self.rebuild_board();
}
KeyCode::Tab | KeyCode::Down => modal.focus_next(),
KeyCode::BackTab | KeyCode::Up => modal.focus_prev(),
KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
KeyCode::Enter => {
let mut filter = modal.active_filter().clone();
filter.normalize();
self.structured = filter;
self.filter_modal = None;
self.mode = Mode::Board;
self.rebuild_board();
}
KeyCode::Backspace => {
let field = modal.focus();
field.value_mut(modal.active_filter_mut()).pop();
self.rebuild_board();
}
KeyCode::Char(c) => {
let field = modal.focus();
field.value_mut(modal.active_filter_mut()).push(c);
self.rebuild_board();
}
_ => {}
}
Ok(Action::None)
}
fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_comment(),
KeyCode::Char(c) => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
self.settings = Some(SettingsModal {
selected: 0,
prompt,
});
self.mode = Mode::Settings;
}
fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.settings.is_none() {
self.mode = Mode::Board;
return Ok(Action::None);
}
let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
let Some(p) = prompt_active else {
return self.handle_settings_browse(key);
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
if let Some(ref mut m) = self.settings {
m.prompt = None;
}
Ok(Action::None)
}
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
match p {
SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
SettingsPrompt::ConfirmDeleteBoard(name) => {
self.settings_commit_delete_board(name)
}
}
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Some(ref mut m) = self.settings
&& let Some(SettingsPrompt::AddColumn(ref mut b))
| Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
{
b.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Some(ref mut m) = self.settings
&& let Some(SettingsPrompt::AddColumn(ref mut b))
| Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
{
b.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.settings = None;
self.mode = Mode::Board;
self.rebuild_board();
Ok(Action::None)
}
KeyCode::Up | KeyCode::Char('k') => {
let count =
self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
if let Some(ref mut m) = self.settings {
m.move_selected(-1, count);
}
Ok(Action::None)
}
KeyCode::Down | KeyCode::Char('j') => {
let count =
self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
if let Some(ref mut m) = self.settings {
m.move_selected(1, count);
}
Ok(Action::None)
}
KeyCode::Tab => {
self.settings_next_board();
Ok(Action::None)
}
KeyCode::BackTab => {
self.settings_prev_board();
Ok(Action::None)
}
KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
KeyCode::Char('a') => {
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
}
Ok(Action::None)
}
KeyCode::Char('n') => {
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
}
Ok(Action::None)
}
KeyCode::Char('x') | KeyCode::Char('X') => {
if self.store.config.configs().count() <= 1 {
self.status_msg = Some("Cannot delete the last board".to_string());
return Ok(Action::None);
}
let name = self.active_board_name().to_string();
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn settings_next_board(&mut self) {
self.tabs.next();
self.active_config = self.tabs.selected;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.selected = 0;
}
}
fn settings_prev_board(&mut self) {
self.tabs.previous();
self.active_config = self.tabs.selected;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.selected = 0;
}
}
fn settings_toggle_selected(&mut self) -> Result<Action> {
let col_count = self.active_board_config().columns.len();
let selected = self.settings.as_ref().map(|m| m.selected).unwrap_or(0);
if selected >= col_count {
return self.settings_toggle_display_row(selected - col_count);
}
let col = self
.settings
.as_ref()
.and_then(|m| self.active_board_config().columns.get(m.selected))
.cloned();
let Some(col) = col else {
return Ok(Action::None);
};
let board_name = self.active_board_name().to_string();
let now_hidden = {
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
match board_config {
Some(bc) => {
if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
bc.hidden_columns.remove(pos);
false
} else {
bc.hidden_columns.push(col.clone());
true
}
}
None => return Ok(Action::None),
}
};
if !self.persist_config() {
return Ok(Action::None);
}
self.rebuild_board();
let last = self.active_board_config().columns.len().saturating_sub(1);
if let Some(ref mut m) = self.settings
&& m.selected > last
{
m.selected = last;
}
self.status_msg = Some(match now_hidden {
true => format!("'{col}' hidden"),
false => format!("'{col}' shown"),
});
Ok(Action::None)
}
fn settings_toggle_display_row(&mut self, row: usize) -> Result<Action> {
let board_name = self.active_board_name().to_string();
let action_msg = {
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
let Some(board_config) = board_config else {
return Ok(Action::None);
};
match row {
0 => {
board_config.wrap_titles = !board_config.wrap_titles;
if board_config.wrap_titles {
"Wrap titles: on".to_string()
} else {
"Wrap titles: off".to_string()
}
}
1 => {
board_config.title_lines = board_config.title_lines % 5 + 1;
format!("Title lines: {}", board_config.title_lines)
}
2 => {
board_config.order_by = board_config.order_by.next();
format!("Order by: {}", board_config.order_by.as_str())
}
_ => return Ok(Action::None),
}
};
if !self.persist_config() {
return Ok(Action::None);
}
self.rebuild_board();
self.status_msg = Some(action_msg);
Ok(Action::None)
}
fn settings_commit_add_column(&mut self, name: String) {
let name = name.trim().to_string();
if name.is_empty() {
self.status_msg = Some("Column name is required".to_string());
return;
}
let board_name = self.active_board_name().to_string();
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
let Some(board_config) = board_config else {
self.status_msg = Some("board not found".to_string());
return;
};
if board_config.columns.iter().any(|c| c == &name) {
self.status_msg = Some(format!("Column '{name}' already exists"));
return;
}
board_config.columns.push(name.clone());
if !self.persist_config() {
return;
}
self.rebuild_board();
let last = self.active_board_config().columns.len().saturating_sub(1);
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = last;
}
self.status_msg = Some(format!("Added column '{name}'"));
}
fn settings_commit_new_board(&mut self, name: String) {
let name = name.trim().to_string();
if name.is_empty() {
self.status_msg = Some("Board name is required".to_string());
return;
}
if self.store.config.kanban.configurations.contains_key(&name) {
self.status_msg = Some(format!("Board '{name}' already exists"));
return;
}
self.store
.config
.kanban
.configurations
.insert(name.clone(), crate::config::BoardConfiguration::default());
if !self.persist_config() {
return;
}
self.reload_tabs();
let idx = self
.store
.config
.configs()
.position(|(n, _)| n == name.as_str())
.unwrap_or(0);
self.active_config = idx;
self.tabs.selected = idx;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = 0;
}
self.status_msg = Some(format!("Added board '{name}'"));
}
fn settings_commit_delete_board(&mut self, name: String) {
if self.store.config.kanban.configurations.len() <= 1 {
self.status_msg = Some("Cannot delete the last board".to_string());
return;
}
let idx = self.active_config;
let removed = self.store.config.kanban.configurations.shift_remove(&name);
if removed.is_none() {
self.status_msg = Some(format!("Board '{name}' not found"));
return;
}
if !self.persist_config() {
return;
}
self.reload_tabs();
let count = self.store.config.kanban.configurations.len();
let target = idx.min(count.saturating_sub(1));
self.active_config = target;
self.tabs.selected = target;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = 0;
}
self.status_msg = Some(format!("Deleted board '{name}'"));
}
fn persist_config(&mut self) -> bool {
match self.store.config_path() {
Ok(path) => match self.store.config.save(&path) {
Ok(()) => true,
Err(e) => {
self.status_msg = Some(format!("settings save: {e}"));
false
}
},
Err(e) => {
self.status_msg = Some(format!("settings path: {e}"));
false
}
}
}
fn reload_tabs(&mut self) {
let names: Vec<String> = self
.store
.config
.configs()
.map(|(name, _)| name.to_string())
.collect();
self.tabs = BoardTabs::new(names);
}
fn active_board_name(&self) -> &str {
self.store
.config
.configs()
.nth(self.active_config)
.map(|(name, _)| name)
.unwrap_or("default")
}
fn submit_comment(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Comment { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let text = buffer.trim().to_string();
if text.is_empty() {
self.status_msg = Some("Comment discarded (empty)".to_string());
self.mode = Mode::Detail;
return Ok(Action::None);
}
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
let author = crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_else(|| "anonymous".to_string());
let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
issue.add_comment(&author, &text);
self.store.update(&filename, &issue)?;
if let Some(ref mut d) = self.detail {
d.issue = issue;
d.scroll_offset = 0;
}
self.refresh_corpus()?;
self.status_msg = Some(format!("Commented by {author}"));
self.mode = Mode::Detail;
Ok(Action::None)
}
fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_asset(),
KeyCode::Char(c) => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn submit_asset(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Asset { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let path = std::path::PathBuf::from(buffer.trim());
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
match self.store.add_asset(&filename, &path) {
Ok(added) => {
if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
&& let Some(ref mut d) = self.detail
{
d.issue = issue;
d.scroll_offset = 0;
}
self.status_msg = Some(format!("Asset added: {}", added.link));
self.refresh_corpus()?;
self.mode = Mode::Detail;
Ok(Action::None)
}
Err(e) => {
self.status_msg = Some(format!("asset: {e}"));
Ok(Action::None)
}
}
}
fn toggle_column_visibility(&mut self) {
let col_name = self.board.active_column_name().to_string();
let config_name = self.tabs.names[self.active_config].clone();
let board_config = self
.store
.config
.kanban
.configurations
.get_mut(&config_name);
let board_config = match board_config {
Some(bc) => bc,
None => {
self.status_msg = Some("toggle: config not found".into());
return;
}
};
let now_hidden = if let Some(pos) = board_config
.hidden_columns
.iter()
.position(|h| h == &col_name)
{
board_config.hidden_columns.remove(pos);
false
} else {
board_config.hidden_columns.push(col_name.clone());
true
};
match self.store.config_path() {
Ok(path) => {
if let Err(e) = self.store.config.save(&path) {
self.status_msg = Some(format!("toggle save: {e}"));
return;
}
}
Err(e) => {
self.status_msg = Some(format!("toggle path: {e}"));
return;
}
}
self.rebuild_board();
let visible = self.board.columns.len();
self.board.active_col = if now_hidden {
visible
+ self
.board
.hidden
.iter()
.position(|c| c.name == col_name)
.unwrap_or_default()
} else {
self.board
.columns
.iter()
.position(|c| c.name == col_name)
.unwrap_or_default()
};
self.board.clamp_selection();
self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
}
fn switch_config(&mut self) {
let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
self.rebuild_board();
self.board.active_col = 0;
if let Some(filename) = selected_filename
&& self
.board
.column_cards(0)
.iter()
.any(|c| c.filename == filename)
{
self.board.selected = self
.board
.column_cards(0)
.iter()
.position(|c| c.filename == filename)
.unwrap_or(0);
} else {
self.board.selected = 0;
}
self.board.clamp_selection();
self.status_msg = Some("Switched board".to_string());
}
fn rebuild_board(&mut self) {
let active_col = self.board.active_col;
let selected = self.board.selected;
let board_config = self.active_board_config().clone();
let active_filter = self.active_structured_filter().clone();
let q = self.filter.clone().unwrap_or_default();
if !q.is_empty() || active_filter.has_active_criteria() {
self.board =
BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
} else {
self.board = BoardState::build(&self.store, &board_config);
}
self.board.active_col = active_col;
self.board.selected = selected;
self.board.clamp_selection();
}
fn select_card_by_filename(&mut self, filename: &str) {
if let Some((col_idx, card_idx)) =
self.board
.columns
.iter()
.enumerate()
.find_map(|(col_idx, col)| {
col.cards
.iter()
.position(|card| card.filename == filename)
.map(|card_idx| (col_idx, card_idx))
})
{
self.board.active_col = col_idx;
self.board.selected = card_idx;
}
}
fn apply_filter(&mut self, buffer: &str) {
if buffer.trim().is_empty() {
self.filter = None;
} else {
self.filter = Some(buffer.trim().to_string());
}
let board_config = self.active_board_config().clone();
let active_filter = self.active_structured_filter().clone();
let q = self.filter.clone().unwrap_or_default();
if !q.is_empty() || active_filter.has_active_criteria() {
self.board =
BoardState::build_filtered(&board_config, &self.corpus, &q, &active_filter);
self.status_msg = if self.board.is_empty() {
Some("No matches for current filter".to_string())
} else {
None
};
} else {
self.board = BoardState::build(&self.store, &board_config);
self.status_msg = None;
}
}
fn refresh_corpus(&mut self) -> Result<()> {
let c = Corpus::build(&self.store)?;
self.corpus = c;
self.rebuild_board();
Ok(())
}
pub fn refresh_from_disk(&mut self) -> Result<()> {
self.refresh_corpus()
}
pub fn after_editor(&mut self, filename: &str) {
match self
.store
.get(IssueSelector::Filename(filename.to_string()))
{
Ok(mut issue) => {
issue.bump_updated();
let location = match issue.assignee {
Some(ref a) => match self.store.reassign(filename, Some(a)) {
Ok(moved) => moved.to_string_lossy().to_string(),
Err(e) => {
self.status_msg = Some(format!("editor: {e}"));
return;
}
},
None => filename.to_string(),
};
if let Err(e) = self.store.update(&location, &issue) {
self.status_msg = Some(format!("editor: {e}"));
} else {
if let Some(ref mut d) = self.detail
&& (d.issue.filename == filename || d.issue.filename == location)
{
d.issue = issue;
d.scroll_offset = 0;
}
let _ = self.refresh_corpus();
if let Err(e) = self.warn_if_linted_issue_invalid(&location) {
self.status_msg = Some(format!("editor: {e}"));
} else {
self.status_msg = Some("Edited".to_string());
}
}
}
Err(e) => {
self.status_msg = Some(format!("editor: {e}"));
}
}
}
fn warn_if_linted_issue_invalid(&mut self, filename: &str) -> Result<()> {
let findings =
crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
if let Some(finding) = findings.into_iter().next() {
let message = format!("{}: {}", finding.kind, finding.message);
self.mode = Mode::EditLintWarning {
filename: filename.to_string(),
message,
};
}
Ok(())
}
fn handle_edit_lint_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
let (filename, _) = match &self.mode {
Mode::EditLintWarning { filename, message } => (filename.clone(), message.clone()),
_ => return Ok(Action::None),
};
match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => {
self.mode = Mode::Board;
Ok(Action::OpenEditor(filename))
}
KeyCode::Char('i') | KeyCode::Char('I') | KeyCode::Enter | KeyCode::Esc => {
self.mode = Mode::Board;
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
}
const HINT_HEIGHT: u16 = 1;
const TAB_HEIGHT: u16 = 2;
const FILTER_HEIGHT: u16 = 1;
const HOVER_HEIGHT: u16 = 1;
pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
match &app.mode {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
Mode::Form => {
if let Some(ref fr) = app.form {
render_board(app, f, area, commandbar::MODE_BOARD);
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
form::render(fr, f, rect);
}
}
Mode::ConfirmDelete => {
if area.height < 2 {
return;
}
board::render(
&app.board,
f,
area,
&None,
board::BoardDisplay::from(app.active_board_config()),
);
let title = app.confirm_delete_target.as_deref().unwrap_or("?");
let msg = format!("Delete '{title}'? [y/N]");
let prompt_w = 40.min(area.width);
let prompt_h = 3;
let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
let prompt_area = Rect::new(prompt_x, prompt_y, prompt_w, prompt_h);
let prompt = Paragraph::new(Line::from(Span::styled(
msg,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)))
.block(
Block::default()
.borders(Borders::ALL)
.title("Confirm")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(prompt, prompt_area);
}
Mode::MoveIntoDone { buffer, .. } => {
if area.height < 3 {
return;
}
let prompt_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let block_area = Rect {
height: area.height - 1,
..area
};
board::render(
&app.board,
f,
block_area,
&app.status_msg,
board::BoardDisplay::from(app.active_board_config()),
);
let prompt_text = format!("Commit hash: {buffer}_");
let prompt = Paragraph::new(Line::from(Span::styled(
prompt_text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, prompt_area);
}
Mode::Help { return_to } => render_help(app, f, area, return_to),
Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
Mode::FilterModal => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.filter_modal.as_ref() {
m.render(f, area);
}
}
Mode::Settings => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.settings.as_ref() {
let board = app.active_board_config();
m.render(f, area, app.active_board_name(), board);
}
}
Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
Mode::EditLintWarning { filename, message } => {
render_edit_lint_warning(app, f, area, filename, message)
}
}
}
fn render_edit_lint_warning(
app: &App,
f: &mut ratatui::Frame,
area: Rect,
filename: &str,
message: &str,
) {
render_board(app, f, area, commandbar::MODE_BOARD);
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let lines = vec![
Line::from(Span::styled("Lint failed", Style::default().fg(Color::Red))),
Line::from(Span::raw(filename.to_string())),
Line::from(Span::styled(
message.to_string(),
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from(Span::styled(
"r: re-edit · i/Enter/Esc: ignore",
Style::default().fg(Color::DarkGray),
)),
];
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Edit Warning")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(para, rect);
}
fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
let hover_area = Rect {
y: area.y + tab_h,
height: HOVER_HEIGHT,
..area
};
let line = match app.board.get_selected_card() {
Some(card) => Line::from(vec![
Span::styled(
format!("#{}", card.number),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(card.title.clone(), Style::default()),
]),
None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
};
f.render_widget(Paragraph::new(line), hover_area);
}
fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
FILTER_HEIGHT
} else {
0
};
if area.height <= HINT_HEIGHT + tab_h + HOVER_HEIGHT + filter_h {
return;
}
let hint_area = Rect {
y: area.y + area.height - HINT_HEIGHT,
height: HINT_HEIGHT,
..area
};
let tabs_area = Rect {
y: area.y,
height: tab_h,
..area
};
let filter_area = Rect {
y: area.y + tab_h + HOVER_HEIGHT,
height: filter_h,
..area
};
let content = Rect {
y: area.y + tab_h + HOVER_HEIGHT + filter_h,
height: area.height - tab_h - HOVER_HEIGHT - filter_h - HINT_HEIGHT,
..area
};
if tab_h > 0 {
app.tabs.render(f, tabs_area);
}
render_hover_line(app, f, area);
if filter_h > 0 {
filter_row(app).render(f, filter_area);
}
board::render(
&app.board,
f,
content,
&app.status_msg,
board::BoardDisplay::from(app.active_board_config()),
);
render_hint(f, hint_area, tag);
}
fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
if area.height <= HINT_HEIGHT + status_h {
return;
}
let hint_area = Rect {
y: area.y + area.height - HINT_HEIGHT,
height: HINT_HEIGHT,
..area
};
let content = Rect {
height: area.height - status_h - HINT_HEIGHT,
..area
};
if let Some(ref d) = app.detail {
detail::render(d, f, content);
}
if let Some(msg) = &app.status_msg {
let status_area = Rect {
y: area.y + area.height - status_h - HINT_HEIGHT,
height: status_h,
..area
};
let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
.style(Style::default().fg(Color::Yellow));
f.render_widget(para, status_area);
}
render_hint(f, hint_area, tag);
}
fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
if area.height < tab_h + HOVER_HEIGHT + 1 {
return;
}
let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
FILTER_HEIGHT
} else {
0
};
let input_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let tabs_area = Rect {
y: area.y,
height: tab_h,
..area
};
let filter_area = Rect {
y: area.y + tab_h + HOVER_HEIGHT,
height: filter_h,
..area
};
let content = Rect {
y: area.y + tab_h + HOVER_HEIGHT + filter_h,
height: area
.height
.saturating_sub(tab_h + HOVER_HEIGHT + filter_h + 1),
..area
};
if tab_h > 0 {
app.tabs.render(f, tabs_area);
}
render_hover_line(app, f, area);
if filter_h > 0 {
filter_row(app).render(f, filter_area);
}
board::render(
&app.board,
f,
content,
&app.status_msg,
board::BoardDisplay::from(app.active_board_config()),
);
render_input_line(f, input_area, label, buffer);
}
fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
if area.height < 2 {
return;
}
let input_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let content = Rect {
height: area.height - 1,
..area
};
if let Some(ref d) = app.detail {
detail::render(d, f, content);
}
render_input_line(f, input_area, label, buffer);
}
fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
let text = format!("{label}: {buffer}_");
let prompt = Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, area);
}
fn filter_row(app: &App) -> crate::ui::filter_row::FilterRow {
crate::ui::filter_row::FilterRow {
query: app.filter.clone(),
structured: app.structured.clone(),
}
}
fn render_hint(f: &mut ratatui::Frame, area: Rect, tag: &str) {
let bar = commandbar::hint_bar(&[tag]);
let para = Paragraph::new(Line::from(Span::styled(
bar,
Style::default().fg(Color::Gray),
)));
f.render_widget(para, area);
}
fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
match return_to {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
_ => render_board(app, f, area, commandbar::MODE_BOARD),
}
let help_entries = commandbar::help_overlay_list();
let rows: Vec<Line> = help_entries
.iter()
.map(|(key, desc, scope)| {
Line::from(vec![
Span::styled(format!("{key:<10}"), Style::default().fg(Color::Cyan)),
Span::styled(format!("{desc:<10}"), Style::default().fg(Color::White)),
Span::styled(*scope, Style::default().fg(Color::DarkGray)),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title("Help [? close]")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(rows).block(block);
let modal_w = 42.min(area.width).max(30);
let modal_h = (help_entries.len() as u16 + 2).min(area.height).max(4);
let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
if area.height >= modal_h && area.width >= modal_w {
f.render_widget(Clear, modal_area);
f.render_widget(para, modal_area);
}
}
pub fn run(config: &Config) -> Result<()> {
let mut app = App::new(config)?;
let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
let guard = RestoreGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).map_err(io_err)?;
let result = loop {
terminal
.draw(|f| render(&app, f, f.area()))
.map_err(io_err)?;
if event::poll(Duration::from_millis(200)).map_err(io_err)?
&& let Event::Key(key) = event::read().map_err(io_err)?
{
match app.handle_key(key)? {
Action::None => {}
Action::Quit => break Ok(()),
Action::OpenEditor(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let edit_result = (|| -> Result<()> {
let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
let original = app.store.get(IssueSelector::Filename(filename.clone()))?;
let temp = std::env::temp_dir().join(format!(
"tix-edit-{}-{}.md",
std::process::id(),
original.number
));
std::fs::write(&temp, original.to_file_contents()).map_err(TixError::Io)?;
editor::run_editor(&name, &temp, &app.store.config)?;
let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
let mut parsed = Issue::parse(&edited)?;
let _ = std::fs::remove_file(&temp);
if parsed.title.trim().is_empty() {
app.mode = Mode::EditLintWarning {
filename: filename.clone(),
message: "title is required".to_string(),
};
app.status_msg = Some("editor: title is required".to_string());
return Ok(());
}
parsed.bump_updated();
let location = match parsed.assignee {
Some(ref a) => match app.store.reassign(&original.filename, Some(a)) {
Ok(moved) => moved.to_string_lossy().to_string(),
Err(e) => {
app.status_msg = Some(format!("editor: {e}"));
return Ok(());
}
},
None => original.filename.clone(),
};
app.store.update(&location, &parsed)?;
if let Some(ref mut d) = app.detail
&& (d.issue.filename == original.filename
|| d.issue.filename == location)
{
d.issue = parsed;
d.scroll_offset = 0;
}
app.refresh_corpus()?;
app.status_msg = Some("Edited".to_string());
Ok(())
})();
if let Err(e) = edit_result {
app.status_msg = Some(format!("editor: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenAgent(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let edit_result = (|| -> Result<()> {
let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
let temp = std::env::temp_dir().join(format!(
"tix-agent-{}-{}.md",
std::process::id(),
issue.number
));
std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
let result = std::process::Command::new(&agent)
.arg(&temp)
.status()
.map_err(|e| {
crate::error::TixError::Editor(format!(
"failed to launch {agent}: {e}"
))
});
let _ = std::fs::remove_file(&temp);
result?;
Ok(())
})();
if let Err(e) = edit_result {
app.status_msg = Some(format!("agent: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
}
}
if watcher.poll()
&& let Err(e) = app.refresh_from_disk()
{
app.status_msg = Some(format!("watch: {e}"));
}
};
drop(guard);
result
}
struct RestoreGuard;
impl Drop for RestoreGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
fn io_err<E: std::fmt::Display>(e: E) -> TixError {
TixError::Io(io::Error::other(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
fn write_issue(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
commit: Option<&str>,
) {
let commit_line = match commit {
Some(h) => format!("commit: \"{h}\""),
None => String::new(),
};
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
#[test]
fn quit_on_q() {
let (_tmp, mut app) = make_app();
let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(app.quit);
assert!(matches!(action, Action::Quit));
}
#[test]
fn quit_on_esc() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.quit);
}
#[test]
fn cursor_clamp_empty_column() {
let (_tmp, mut app) = make_app();
app.board.move_left();
assert_eq!(app.board.active_col, app.board.total_cols() - 1);
app.board.move_right();
assert_eq!(app.board.active_col, 0);
}
#[test]
fn cursor_clamp_single_card() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 1);
app.board.move_down();
assert_eq!(app.board.selected, 0);
app.board.move_up();
assert_eq!(app.board.selected, 0);
}
#[test]
fn wrap_around_columns() {
let (_tmp, mut app) = make_app();
let total = app.board.total_cols();
for _ in 0..total + 2 {
app.board.move_right();
}
assert_eq!(app.board.active_col, 2 % total);
for _ in 0..total + 2 {
app.board.move_left();
}
}
#[test]
fn refresh_from_disk_picks_up_new_file() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
assert_eq!(app.board.column_cards(0).len(), 0);
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn refresh_from_disk_reflects_external_status_move() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 0);
assert_eq!(app.board.column_cards(3).len(), 1);
}
#[test]
fn refresh_from_disk_keeps_board_on_bad_file() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
assert!(app.refresh_from_disk().is_err());
assert_eq!(
app.board.column_cards(0).len(),
1,
"a malformed file must not wipe the board"
);
}
#[test]
fn enter_opens_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
#[test]
fn n_opens_form() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert!(app.form.is_some());
}
fn filter_modal_focus(app: &App) -> crate::search::FilterField {
app.filter_modal
.as_ref()
.expect("filter modal open")
.focus()
}
#[test]
fn filter_modal_arrows_navigate_rows() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Assignee);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(filter_modal_focus(&app), Label);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(filter_modal_focus(&app), Assignee);
for _ in 0..4 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(filter_modal_focus(&app), Priority, "Up wraps backward");
}
#[test]
fn filter_modal_arrow_press_keeps_criterion_value() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
for c in "alice".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let before = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.assignee
.clone();
assert_eq!(before.as_deref(), Some("alice"));
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let after = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.assignee
.clone();
assert_eq!(after, before, "arrow press must not alter the value");
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Label);
}
#[test]
fn filter_modal_j_and_k_type_their_letters() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(
modal.active_filter().assignee.as_deref(),
Some("jk"),
"j and k must type into the focused criterion, not navigate"
);
use crate::search::FilterField::*;
assert_eq!(modal.focus(), Assignee);
}
#[test]
fn d_opens_confirm_delete() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
assert!(app.confirm_delete_target.is_some());
}
#[test]
fn move_to_done_triggers_commit_prompt() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(
matches!(
&app.mode,
Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
),
"expected MoveIntoDone, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_into_done_with_commit_applies() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
for c in "abc123".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert_eq!(issue.commit.as_deref(), Some("abc123"));
}
#[test]
fn move_into_done_with_empty_commit_moves_without_hash() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert!(
issue.commit.is_none(),
"no commit recorded when moving to done without a hash"
);
}
#[test]
fn move_out_of_done_clears_commit() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_right_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3); app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert_eq!(app.board.active_col, 0);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "todo");
}
#[test]
fn move_left_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(0);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert_eq!(app.board.active_col, 3);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
}
#[test]
fn move_left_right_board() {
let (_tmp, mut app) = make_app();
let initial = app.board.active_col;
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.board.active_col, initial + 1);
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(app.board.active_col, initial);
}
#[test]
fn refresh_rebuilds_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
assert_eq!(app.status_msg, Some("Refreshed".to_string()));
}
#[test]
fn detail_esc_back_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.detail.is_none());
}
#[test]
fn confirm_delete_y_removes_issue() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 0);
}
#[test]
fn confirm_delete_n_cancels() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.confirm_delete_target.is_none());
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn form_submit_creates_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "My Issue".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
assert!(matches!(app.mode, Mode::Board));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.starts_with("Created ")),
"got {:?}",
app.status_msg
);
}
#[test]
fn form_submit_selects_created_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "Selected Issue".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
assert!(matches!(app.mode, Mode::Board));
let card = app.board.get_selected_card().expect("selected card");
assert_eq!(card.title, "Selected Issue");
assert_eq!(app.board.active_col, 0);
}
#[test]
fn editor_return_warns_on_invalid_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "Lint Warn".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
let issue = app
.board
.get_selected_card()
.cloned()
.expect("selected issue");
let path = app.store.issues_dir.join(&issue.filename);
let mut text = std::fs::read_to_string(&path).unwrap();
text = text.replace("priority: medium", "priority: strange");
std::fs::write(&path, text).unwrap();
app.after_editor(&issue.filename);
assert!(matches!(app.mode, Mode::EditLintWarning { .. }));
}
#[test]
fn form_empty_title_rejected() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
}
#[test]
fn form_esc_cancels() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Form));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
fn form_focus(app: &App) -> crate::ui::form::FormFocus {
app.form.as_ref().expect("form open").focus
}
#[test]
fn form_arrows_navigate_fields() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
use crate::ui::form::FormFocus::*;
assert_eq!(form_focus(&app), Title);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(form_focus(&app), Status);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(form_focus(&app), Title);
for _ in 0..6 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(form_focus(&app), Title, "Down wraps forward");
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(form_focus(&app), Estimate, "Up wraps backward");
}
#[test]
fn form_j_and_k_type_their_letters() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
let form = app.form.as_ref().unwrap();
assert_eq!(form.title, "jk", "j and k must type, not navigate");
assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
}
#[test]
fn form_arrow_navigated_status_creates_in_that_column() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Arrow Status".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let prefilled = app.store.config.default_config().columns[0].len();
for _ in 0..prefilled {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "in progress".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let names: Vec<String> = (0..app.board.total_cols())
.map(|i| app.board.column_name(i).to_string())
.collect();
let idx = names
.iter()
.position(|n| n == "in progress")
.expect("in progress column exists");
assert_eq!(
app.board.column_cards(idx).len(),
1,
"arrow-navigated status must land the issue in 'in progress'"
);
}
#[test]
fn key_release_ignored() {
let (_tmp, mut app) = make_app();
let key = KeyEvent {
code: KeyCode::Char('q'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: crossterm::event::KeyEventState::NONE,
};
let _ = app.handle_key(key).unwrap();
assert!(!app.quit);
}
fn open_detail(app: &mut App) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
#[test]
fn help_opens_from_board_and_closes() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
assert!(
matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
"got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn help_from_detail_returns_to_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
assert!(
matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
"got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
}
#[test]
fn filter_narrows_board_live() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
assert_eq!(app.board.column_cards(0).len(), 2);
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
assert!(matches!(app.mode, Mode::Filter { .. }));
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
assert_eq!(app.board.column_cards(0)[0].title, "Target");
assert!(matches!(app.mode, Mode::Filter { .. }));
}
#[test]
fn filter_esc_clears_and_returns_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_enter_commits_and_stays_filtered() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_zero_match_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn filter_zero_match_number_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "999".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("999".into()));
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn comment_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert!(matches!(app.mode, Mode::Comment { .. }));
for c in "hello world".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
let comments = issue.comments();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].text.trim(), "hello world");
}
#[test]
fn comment_esc_cancels() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
for c in "partial".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(issue.comments().is_empty());
}
#[test]
fn comment_empty_discarded() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(issue.comments().is_empty());
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("empty"))
);
}
#[test]
fn asset_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let asset = tmp.path().join("pic.png");
fs::write(&asset, b"fake-png").unwrap();
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Asset { .. }));
for c in asset.to_string_lossy().chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(
dir.join("a-assets").join("pic.png").is_file(),
"asset must be copied into the issue asset dir"
);
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(
issue.body.contains("pic.png"),
"body must reference the asset, got {:?}",
issue.body
);
}
#[test]
fn asset_error_stays_in_mode() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
for c in "/no/such/file.png".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Asset { .. }),
"asset error must stay in entry mode, got {:?}",
app.mode
);
assert!(app.status_msg.is_some());
}
fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
let backend = ratatui::backend::TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render(app, f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
buffer
.content
.iter()
.map(|c| c.symbol().to_string())
.collect()
}
fn flush_line(cells: &[String], width: u16, row: u16) -> String {
let start = (row as usize) * (width as usize);
cells[start..start + width as usize].concat()
}
#[test]
fn render_board_unfiltered_has_no_filter_row() {
let (_tmp, app) = make_app();
let cells = draw_board(&app, 80, 24);
assert!(
!cells.iter().any(|s| s.contains("Filters:")),
"no filter row expected"
);
}
#[test]
fn render_board_filtered_shows_filter_row_beneath_tabs() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters: #55"),
"row 1 should read the filter, got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_structured_filter_shows_row() {
let (_tmp, mut app) = make_app();
app.filter = None;
app.structured = search::StructuredFilter {
status: Some("todo".into()),
..Default::default()
};
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters:"),
"got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_filter_input_draws_row_and_prompt() {
let (_tmp, mut app) = make_app();
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters: #55"),
"row 1 should read the filter while typing"
);
assert!(
flush_line(&cells, 80, 23).contains("Filter: 55"),
"bottom row should hold the input prompt"
);
}
#[test]
fn render_board_tiny_terminal_filtered_does_not_panic() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
for h in [2_u16, 3, 4, 5] {
let _ = draw_board(&app, 80, h);
}
}
#[test]
fn filter_esc_clears_row_and_resolves_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "55".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let cells = draw_board(&app, 80, 24);
assert!(
cells.concat().contains("Filters:"),
"filtered board must show the row; filter={:?} cells_row1={}",
app.filter,
flush_line(&cells, 80, 1)
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
let after = draw_board(&app, 80, 24).concat();
assert!(
!after.contains("Filters:"),
"esc cleared the filter, row must disappear, got {after:?}"
);
}
}