use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState};
use super::{
Action, Context, Result, View, new_task::NewTaskView, review::ReviewView, state_style,
task::TaskView,
};
use crate::domain::{Task, TaskState};
use crate::store::Transition;
pub struct TasksView {
tasks: Vec<Task>,
table: TableState,
hide_done: bool,
loaded: bool,
}
impl Default for TasksView {
fn default() -> Self {
Self::new()
}
}
impl TasksView {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
table: TableState::default().with_selected(Some(0)),
hide_done: true,
loaded: false,
}
}
fn reload(&mut self, ctx: &mut Context) -> Result<()> {
let mut tasks = ctx.store.list_tasks()?;
if self.hide_done {
tasks.retain(|t| !matches!(t.state, TaskState::Committed | TaskState::Cancelled));
}
tasks.reverse();
let selected_id = self.selected().map(|t| t.id);
self.tasks = tasks;
let index = selected_id
.and_then(|id| self.tasks.iter().position(|t| t.id == id))
.unwrap_or_else(|| self.table.selected().unwrap_or(0));
self.table
.select(Some(index.min(self.tasks.len().saturating_sub(1))));
self.loaded = true;
Ok(())
}
pub fn selected(&self) -> Option<&Task> {
self.table.selected().and_then(|i| self.tasks.get(i))
}
fn move_by(&mut self, delta: isize) {
if self.tasks.is_empty() {
return;
}
let current = self.table.selected().unwrap_or(0) as isize;
let last = self.tasks.len() as isize - 1;
self.table
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
}
}
impl View for TasksView {
fn title(&self) -> String {
let shown = self.tasks.len();
if self.hide_done {
format!("Tasks ({shown} open)")
} else {
format!("Tasks ({shown})")
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
let _ = self.reload(ctx);
}
if self.tasks.is_empty() {
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(" Nothing queued."),
Line::from(""),
Line::from(Span::styled(
" Press n to describe a task.",
Style::default().add_modifier(Modifier::DIM),
)),
]),
area,
);
return;
}
let rows: Vec<Row> = self
.tasks
.iter()
.map(|task| {
Row::new(vec![
Cell::from(format!("{}", task.id)),
Cell::from(Span::styled(
task.state.as_str().to_string(),
state_style(task.state),
)),
Cell::from(task.title.clone()),
Cell::from(detail_for(task)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(16),
Constraint::Percentage(40),
Constraint::Min(10),
],
)
.header(
Row::new(vec!["id", "state", "title", "detail"])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::DIM)),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("");
frame.render_stateful_widget(table, area, &mut self.table);
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Ok(match key.code {
KeyCode::Char('c') => Action::Quit,
_ => Action::None,
});
}
match key.code {
KeyCode::Char('q') => return Ok(Action::Quit),
KeyCode::Char('n') => return Ok(Action::Push(Box::new(NewTaskView::new()))),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
KeyCode::Char('g') | KeyCode::Home => self.table.select(Some(0)),
KeyCode::Char('G') | KeyCode::End => {
self.table.select(Some(self.tasks.len().saturating_sub(1)));
}
KeyCode::Char('a') => {
self.hide_done = !self.hide_done;
self.reload(ctx)?;
}
KeyCode::Char('r') => {
self.reload(ctx)?;
ctx.say("refreshed");
}
KeyCode::Enter => {
if let Some(task) = self.selected() {
return Ok(Action::Push(Box::new(TaskView::new(task.id))));
}
}
KeyCode::Char('v') => {
if let Some(task) = self.selected() {
if task.state == TaskState::Queued {
let id = task.id;
ctx.say(format!("task {id} has not started yet"));
} else {
return Ok(Action::Push(Box::new(ReviewView::new(task.id))));
}
}
}
KeyCode::Char('c') => {
if let Some(task) = self.selected() {
let (id, state) = (task.id, task.state);
if state.can_transition_to(TaskState::Cancelled) {
ctx.store.transition(
id,
TaskState::Cancelled,
Transition::Plain,
chrono::Utc::now(),
)?;
ctx.say(format!("cancelled task {id}"));
self.reload(ctx)?;
} else {
ctx.say(format!("task {id} is already {state}"));
}
}
}
_ => {}
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
self.reload(ctx)
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![
("n", "new"),
("↵", "open"),
("v", "review"),
("c", "cancel"),
("a", "all"),
("r", "refresh"),
("q", "quit"),
]
}
}
fn detail_for(task: &Task) -> String {
match task.state {
TaskState::Blocked => task
.blocked_reason
.clone()
.or_else(|| task.blocked_kind.map(|k| k.to_string()))
.unwrap_or_default(),
TaskState::Failed => task.failure_reason.clone().unwrap_or_default(),
TaskState::Running => task.session_name.clone().unwrap_or_default(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::BlockedKind;
use crate::store::{BlockedInfo, Store};
use crate::tui::testing::{press, render_view};
use chrono::{DateTime, Utc};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use std::path::Path;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn store_with(titles: &[&str]) -> (Store, Vec<i64>) {
let mut store = Store::open_in_memory().unwrap();
let ids = titles
.iter()
.map(|t| {
store
.create_task(t, "p", Path::new("/tmp/tasks"), &[], at(0))
.unwrap()
.id
})
.collect();
(store, ids)
}
#[test]
fn an_empty_list_says_what_to_do_next() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 60, 8);
assert!(
screen.iter().any(|l| l.contains("Press n")),
"an empty screen should not be a dead end: {screen:?}"
);
}
#[test]
fn tasks_are_listed_newest_first_with_their_state() {
let (mut store, _) = store_with(&["older", "newer"]);
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 70, 8);
let body: Vec<&String> = screen.iter().filter(|l| !l.is_empty()).collect();
assert!(body[1].contains("newer"), "{screen:?}");
assert!(body[2].contains("older"), "{screen:?}");
assert!(body[1].contains("queued"), "{screen:?}");
}
#[test]
fn a_blocked_task_shows_its_reason_in_the_list() {
let (mut store, ids) = store_with(&["fix auth"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(
ids[0],
TaskState::Blocked,
Transition::Blocked(BlockedInfo::with_reason(
BlockedKind::PermissionPrompt,
"edit main.rs",
)),
at(2),
)
.unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 80, 8);
assert!(
screen.iter().any(|l| l.contains("edit main.rs")),
"the reason is what makes the row actionable: {screen:?}"
);
}
#[test]
fn finished_tasks_are_hidden_until_asked_for() {
let (mut store, ids) = store_with(&["done", "live"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(ids[0], TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
store
.transition(ids[0], TaskState::Committed, Transition::Plain, at(3))
.unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 70, 8);
assert!(!screen.iter().any(|l| l.contains("done")), "{screen:?}");
press(&mut view, &mut store, key(KeyCode::Char('a')));
let all = render_view(&mut view, &mut store, 70, 8);
assert!(all.iter().any(|l| l.contains("done")), "{all:?}");
}
#[test]
fn the_cursor_stays_on_the_same_task_across_a_refresh() {
let (mut store, ids) = store_with(&["one", "two", "three"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('j')));
let before = view.selected().map(|t| t.id);
assert_eq!(before, Some(ids[1]));
store
.create_task("four", "p", Path::new("/tmp/tasks"), &[], at(4))
.unwrap();
press(&mut view, &mut store, key(KeyCode::Char('r')));
assert_eq!(
view.selected().map(|t| t.id),
before,
"a task appearing elsewhere must not move the selection"
);
}
#[test]
fn navigation_stops_at_the_ends() {
let (mut store, _) = store_with(&["one", "two"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
for _ in 0..5 {
press(&mut view, &mut store, key(KeyCode::Char('k')));
}
assert_eq!(view.table.selected(), Some(0));
for _ in 0..5 {
press(&mut view, &mut store, key(KeyCode::Char('j')));
}
assert_eq!(view.table.selected(), Some(1), "must not run off the end");
}
#[test]
fn cancelling_moves_the_task_and_removes_it_from_the_list() {
let (mut store, ids) = store_with(&["doomed"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('c')));
assert_eq!(store.get_task(ids[0]).unwrap().state, TaskState::Cancelled);
assert!(view.tasks.is_empty(), "cancelled tasks are not open work");
}
#[test]
fn ctrl_c_quits_instead_of_cancelling_the_highlighted_task() {
let (mut store, ids) = store_with(&["precious"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
let chord = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(matches!(press(&mut view, &mut store, chord), Action::Quit));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"ctrl-c must not cancel anything"
);
}
#[test]
fn other_modified_letters_do_nothing() {
let (mut store, _) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
for code in [KeyCode::Char('n'), KeyCode::Char('a'), KeyCode::Char('q')] {
let chord = KeyEvent::new(code, KeyModifiers::CONTROL);
assert!(
matches!(press(&mut view, &mut store, chord), Action::None),
"ctrl+{code:?} is not the {code:?} binding"
);
}
}
#[test]
fn cancelling_a_finished_task_is_refused_rather_than_erroring() {
let (mut store, ids) = store_with(&["done"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(ids[0], TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
store
.transition(ids[0], TaskState::Committed, Transition::Plain, at(3))
.unwrap();
let mut view = TasksView::new();
view.hide_done = false;
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('c')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Committed,
"a committed task must stay committed"
);
}
#[test]
fn n_opens_the_new_task_screen_and_enter_opens_a_task() {
let (mut store, _) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Char('n'))),
Action::Push(_)
));
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Enter)),
Action::Push(_)
));
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Char('q'))),
Action::Quit
));
}
#[test]
fn enter_on_an_empty_list_does_nothing() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Enter)),
Action::None
));
}
}