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),
}
#[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 },
}
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),
}
}
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('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,
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();
Ok(Action::None)
}
KeyCode::Tab => {
form.move_focus_next();
Ok(Action::None)
}
KeyCode::BackTab => {
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 => modal.focus_next(),
KeyCode::BackTab => 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();
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();
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 = 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_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 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();
self.status_msg = Some("Edited".to_string());
}
}
Err(e) => {
self.status_msg = Some(format!("editor: {e}"));
}
}
}
}
const HINT_HEIGHT: u16 = 1;
const TAB_HEIGHT: u16 = 2;
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);
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);
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.columns,
&board.hidden_columns,
);
}
}
Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
}
}
fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
if area.height <= HINT_HEIGHT + TAB_HEIGHT {
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_HEIGHT,
..area
};
let content = Rect {
y: area.y + TAB_HEIGHT,
height: area.height - TAB_HEIGHT - HINT_HEIGHT,
..area
};
app.tabs.render(f, tabs_area);
board::render(&app.board, f, content, &app.status_msg);
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) {
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
};
board::render(&app.board, f, content, &app.status_msg);
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 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 path = app.store.issues_dir.join(&filename);
editor::run_editor(&name, &path, &app.store.config)?;
app.after_editor(&filename);
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)?;
}
}
}
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());
}
#[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_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));
}
#[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 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());
}
}