use std::collections::HashMap;
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, DETAIL_WORDS, Result, View, first_words, new_task::NewTaskView,
review::ReviewView, state_style, task::TaskView,
};
use crate::domain::{Task, TaskState};
use crate::store::Transition;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arrangement {
Recent,
Oldest,
Repo,
}
impl Arrangement {
pub const ALL: &'static [Arrangement] = &[Self::Recent, Self::Oldest, Self::Repo];
fn label(self) -> &'static str {
match self {
Self::Recent => "newest",
Self::Oldest => "oldest",
Self::Repo => "by repo",
}
}
fn next(self) -> Self {
match self {
Self::Recent => Self::Oldest,
Self::Oldest => Self::Repo,
Self::Repo => Self::Recent,
}
}
}
pub struct TasksView {
tasks: Vec<Task>,
repos: HashMap<i64, Vec<String>>,
table: TableState,
hide_done: bool,
arrangement: Arrangement,
loaded: bool,
}
impl Default for TasksView {
fn default() -> Self {
Self::new()
}
}
impl TasksView {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
repos: HashMap::new(),
table: TableState::default().with_selected(Some(0)),
hide_done: true,
arrangement: Arrangement::Recent,
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));
}
self.repos = ctx.store.repo_names_by_task().unwrap_or_default();
self.arrange(&mut tasks);
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(())
}
fn arrange(&self, tasks: &mut [Task]) {
match self.arrangement {
Arrangement::Oldest => {}
Arrangement::Recent => tasks.reverse(),
Arrangement::Repo => {
tasks.sort_by(|a, b| {
self.repo_label(a)
.cmp(&self.repo_label(b))
.then(b.id.cmp(&a.id))
});
}
}
}
fn repo_label(&self, task: &Task) -> (bool, String) {
match self.repos.get(&task.id) {
Some(names) if !names.is_empty() => (false, names.join(", ")),
_ => (true, String::new()),
}
}
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();
let counted = if self.hide_done {
format!("{shown} open")
} else {
shown.to_string()
};
format!("Tasks ({counted}) · {}", self.arrangement.label())
}
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 mut previous: Option<String> = None;
let rows: Vec<Row> = self
.tasks
.iter()
.map(|task| {
let repo = self.repo_label(task).1;
let style = if previous.as_deref() == Some(repo.as_str()) {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
};
previous = Some(repo.clone());
Row::new(vec![
Cell::from(format!("{}", task.id)),
Cell::from(Span::styled(
task.state.as_str().to_string(),
state_style(task.state),
)),
Cell::from(Span::styled(repo, style)),
Cell::from(task.title.clone()),
Cell::from(detail_for(task)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(16),
Constraint::Length(16),
Constraint::Percentage(35),
Constraint::Min(10),
],
)
.header(
Row::new(vec!["id", "state", "repo", "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') | KeyCode::Char('q') => Action::Quit,
_ => Action::None,
});
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => 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::Char('s') => {
self.arrangement = self.arrangement.next();
self.reload(ctx)?;
ctx.say(self.arrangement.label());
}
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('x') => {
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"),
("x", "cancel"),
("a", "all"),
("s", "sort"),
("r", "refresh"),
("esc", "quit"),
]
}
}
fn detail_for(task: &Task) -> String {
let full = 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(),
};
first_words(&full, DETAIL_WORDS)
}
#[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)
}
fn titles(view: &TasksView) -> Vec<String> {
view.tasks.iter().map(|t| t.title.clone()).collect()
}
fn store_with_repos(rows: &[(&str, &str)]) -> (Store, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
for (index, (title, repo_name)) in rows.iter().enumerate() {
let path = tmp.path().join(repo_name);
let repo = store.upsert_repo(&path, repo_name, at(0)).unwrap();
store
.create_task(
title,
"p",
Path::new("/tmp/tasks"),
&[repo.id],
at(index as i64),
)
.unwrap();
}
(store, tmp)
}
fn repo_column(screen: &[String]) -> Vec<String> {
screen
.iter()
.skip(1)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| line.split_whitespace().nth(2).map(str::to_string))
.collect()
}
#[test]
fn the_list_names_the_repo_each_task_is_for() {
let (mut store, _tmp) = store_with_repos(&[("first", "api"), ("second", "web")]);
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 100, 8);
assert!(screen[0].contains("repo"), "a column for it: {screen:?}");
assert!(screen.iter().any(|l| l.contains("api")), "{screen:?}");
assert!(screen.iter().any(|l| l.contains("web")), "{screen:?}");
}
#[test]
fn sorting_cycles_through_the_arrangements() {
let (mut store, _tmp) =
store_with_repos(&[("oldest", "web"), ("middle", "api"), ("newest", "web")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
assert_eq!(titles(&view), ["newest", "middle", "oldest"]);
assert!(view.title().contains("newest"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["oldest", "middle", "newest"]);
assert!(view.title().contains("oldest"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["middle", "newest", "oldest"]);
assert!(view.title().contains("by repo"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["newest", "middle", "oldest"], "it wraps");
}
#[test]
fn cycling_the_whole_set_comes_back_to_where_it_started() {
let (mut store, _tmp) = store_with_repos(&[("only", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 8);
let start = view.arrangement;
for _ in Arrangement::ALL {
press(&mut view, &mut store, key(KeyCode::Char('s')));
}
assert_eq!(view.arrangement, start);
}
#[test]
fn grouping_puts_a_repo_together() {
let (mut store, _tmp) =
store_with_repos(&[("a", "web"), ("b", "api"), ("c", "web"), ("d", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('s')));
press(&mut view, &mut store, key(KeyCode::Char('s')));
let screen = render_view(&mut view, &mut store, 100, 10);
let repos = repo_column(&screen);
let mut runs = repos.clone();
runs.dedup();
assert_eq!(
runs.len(),
2,
"each repo should appear as one run, not scattered: {repos:?}"
);
}
#[test]
fn the_cursor_stays_on_its_task_when_the_order_changes() {
let (mut store, _tmp) =
store_with_repos(&[("first", "api"), ("second", "web"), ("third", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('j')));
let held = view.selected().map(|t| t.id);
assert!(held.is_some());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(view.selected().map(|t| t.id), held, "the cursor followed");
}
#[test]
fn a_task_with_no_repo_sorts_last_rather_than_first() {
let (mut store, _tmp) = store_with_repos(&[("has one", "api")]);
store
.create_task("has none", "p", Path::new("/tmp/tasks"), &[], at(9))
.unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('s')));
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["has one", "has none"]);
}
#[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('x')));
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_and_bare_c_no_longer_destroys_anything() {
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"
);
press(&mut view, &mut store, key(KeyCode::Char('c')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"`c` is commit in review; here it must do nothing at all"
);
}
#[test]
fn esc_leaves_the_list_the_way_it_leaves_every_other_screen() {
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::Esc)),
Action::Quit
));
}
#[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('v')] {
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('x')));
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
));
}
}