use crate::gh::{self, Listed};
use crate::theme::{Theme, Ui};
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
type Checks = anyhow::Result<HashMap<u64, (usize, usize, usize)>>;
const HELP: &str = "\
j k, Up Down move
gg ge first / last PR
Ctrl-d Ctrl-u half a page down / up
Tab open PRs / PRs with a local worktree (<repo>-N, as dv N -w makes)
Enter review the PR (with dv list's flags; from the worktrees tab, in its
worktree as it was made); q there comes back here
/ filter (Enter keeps it, Esc clears it); terms must all match:
text number, title, author, branch, labels
author: label: base: branch: part of that field
is:draft review:approved|changes|required|none
ci:pass|fail|pending|none -term excludes
Esc clear the filter
o O next sort order (updated, created, number, size) / reverse
R refresh
? this help
q, Ctrl-c quit";
pub struct List {
prs: Vec<Listed>,
ui: Ui,
flags: String,
sel: usize,
top: usize,
query: String,
filtering: bool,
pending_g: bool,
help: bool,
order: Order,
rev: bool,
checks: Option<Receiver<Checks>>,
local: HashMap<u64, (PathBuf, Option<String>)>,
worktrees: bool,
pub msg: String,
}
pub struct Pick {
pub number: u64,
pub worktree: Option<bool>,
}
impl List {
pub fn new(flags: String) -> List {
let ui = Theme::load().ui();
let (query, msg) = (String::new(), String::new());
List {
prs: vec![],
ui,
flags,
sel: 0,
top: 0,
query,
filtering: false,
pending_g: false,
help: false,
order: Order::Updated,
rev: false,
checks: None,
local: HashMap::new(),
worktrees: false,
msg,
}
}
pub fn run(&mut self, term: &mut DefaultTerminal) -> Result<Option<Pick>> {
if self.prs.is_empty() {
self.refresh(term)?;
}
loop {
term.draw(|f| self.draw(f))?;
if let Some(Ok(res)) = self.checks.as_ref().map(Receiver::try_recv) {
self.checks = None;
match res {
Ok(by_pr) => self.prs.iter_mut().for_each(|p| p.checks = by_pr.get(&p.number).copied()),
Err(e) => self.msg = format!("checks: {e:#}"),
}
}
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(k) = event::read()? else { continue };
if k.kind != KeyEventKind::Press {
continue;
}
match self.key(k) {
Some(Do::Quit) => return Ok(None),
Some(Do::Open(n)) => {
let worktree = self.worktrees.then(|| self.local.get(&n).is_some_and(|w| w.1.is_some()));
return Ok(Some(Pick { number: n, worktree }));
}
Some(Do::Refresh) => self.refresh(term)?,
None => {}
}
}
}
fn refresh(&mut self, term: &mut DefaultTerminal) -> Result<()> {
self.msg = "loading PRs…".into();
term.draw(|f| self.draw(f))?;
let keep = self.shown().get(self.sel).map(|p| p.number);
self.local = crate::pr_worktrees().into_iter().map(|(n, dir, b)| (n, (dir, b))).collect();
match gh::list() {
Ok(mut prs) => {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || tx.send(gh::list_checks()));
self.checks = Some(rx);
self.msg = if prs.is_empty() { "no open PRs".into() } else { String::new() };
let mut gone: Vec<u64> = self.local.keys().filter(|n| !prs.iter().any(|p| p.number == **n)).copied().collect();
gone.sort();
for n in gone {
let title = "(couldn't load this PR)".into();
prs.push(gh::listed_one(n).unwrap_or(Listed { number: n, title, ..Listed::default() }));
}
self.prs = prs;
self.sel = keep.and_then(|n| self.shown().iter().position(|p| p.number == n)).unwrap_or(0);
}
Err(e) => self.msg = format!("{e:#}"),
}
Ok(())
}
fn shown(&self) -> Vec<&Listed> {
let in_tab = |p: &&Listed| match self.worktrees {
true => self.local.contains_key(&p.number),
false => p.state == "OPEN" || p.state.is_empty(),
};
let mut prs: Vec<&Listed> =
self.prs.iter().filter(in_tab).filter(|p| self.query.split_whitespace().all(|t| term(p, t))).collect();
prs.sort_by(|a, b| {
let o = match self.order {
Order::Updated => b.updated.cmp(&a.updated), Order::Created => b.created.cmp(&a.created),
Order::Number => b.number.cmp(&a.number),
Order::Size => (b.additions + b.deletions).cmp(&(a.additions + a.deletions)),
};
if self.rev { o.reverse() } else { o }
});
prs
}
fn sort(&mut self, order: Order, rev: bool) {
let keep = self.shown().get(self.sel).map(|p| p.number);
(self.order, self.rev) = (order, rev);
self.sel = keep.and_then(|n| self.shown().iter().position(|p| p.number == n)).unwrap_or(0);
}
fn key(&mut self, k: KeyEvent) -> Option<Do> {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && k.code == KeyCode::Char('c') {
return Some(Do::Quit);
}
let n = self.shown().len();
self.msg.clear();
if std::mem::take(&mut self.help) {
return None; }
if self.filtering {
match k.code {
KeyCode::Esc => (self.query.clear(), self.filtering = false).1,
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => _ = self.query.pop(),
KeyCode::Char(c) => self.query.push(c),
_ => {}
}
self.sel = 0;
return None;
}
let half = (self.height() / 2).max(1);
let g = std::mem::take(&mut self.pending_g);
match k.code {
KeyCode::Enter => return self.shown().get(self.sel).map(|p| Do::Open(p.number)),
KeyCode::Esc if !self.query.is_empty() => (self.query.clear(), self.sel = 0).1,
KeyCode::Char('q') | KeyCode::Esc => return Some(Do::Quit),
KeyCode::Char('R') => return Some(Do::Refresh),
KeyCode::Char('?') => self.help = true,
KeyCode::Tab | KeyCode::BackTab => (self.worktrees, self.sel, self.top) = (!self.worktrees, 0, 0),
KeyCode::Char('/') => self.filtering = true, KeyCode::Char('o') => self.sort(self.order.next(), self.rev),
KeyCode::Char('O') => self.sort(self.order, !self.rev),
KeyCode::Char('d') if ctrl => self.sel = (self.sel + half).min(n.saturating_sub(1)),
KeyCode::Char('u') if ctrl => self.sel = self.sel.saturating_sub(half),
KeyCode::Char('j') | KeyCode::Down => self.sel = (self.sel + 1).min(n.saturating_sub(1)),
KeyCode::Char('k') | KeyCode::Up => self.sel = self.sel.saturating_sub(1),
KeyCode::Char('g') if g => self.sel = 0,
KeyCode::Char('e') if g => self.sel = n.saturating_sub(1),
KeyCode::Char('g') => self.pending_g = true,
_ => {}
}
None
}
fn height(&self) -> usize {
ratatui::crossterm::terminal::size().map_or(10, |(_, h)| h.saturating_sub(2) as usize / 2)
}
fn draw(&mut self, f: &mut Frame) {
let area = f.area();
let h = area.height.saturating_sub(2) as usize / 2;
let sel = self.sel.min(self.shown().len().saturating_sub(1));
(self.sel, self.top) = (sel, self.top.min(sel).max((sel + 1).saturating_sub(h)));
let (ui, shown) = (&self.ui, self.shown());
f.render_widget(Paragraph::new("").style(ui.base), area);
let head = Rect { height: 1.min(area.height), ..area };
let hs = ui.base.patch(ui.header);
let arrow = if self.rev { '↑' } else { '↓' };
let open = self.prs.iter().filter(|p| p.state == "OPEN" || p.state.is_empty()).count();
let tab = |name: String, on: bool| Span::styled(name, if on { hs.add_modifier(ratatui::style::Modifier::BOLD | ratatui::style::Modifier::UNDERLINED) } else { hs.patch(ui.stale) });
let tabs = Line::from(vec![
Span::styled(" ", hs),
tab(format!("open {open}"), !self.worktrees),
Span::styled(" ", hs),
tab(format!("worktrees {}", self.local.len()), self.worktrees),
Span::styled(format!(" by {} {arrow} (Tab switches)", self.order.name()), hs.patch(ui.stale)),
]);
f.render_widget(Paragraph::new(tabs).style(hs), head);
let pos = if shown.is_empty() { "0 / 0 ".into() } else { format!("{} / {} ", sel + 1, shown.len()) };
f.render_widget(Paragraph::new(pos).alignment(Alignment::Right).style(hs), head);
let mut bases: HashMap<&str, usize> = HashMap::new();
self.prs.iter().for_each(|p| *bases.entry(p.base.as_str()).or_default() += 1);
let usual = bases.into_iter().max_by_key(|b| b.1).map(|b| b.0.to_owned()).unwrap_or_default();
let cx = Cards {
ui,
num_w: shown.iter().map(|p| p.number.to_string().len()).max().unwrap_or(1) + 1,
width: area.width as usize,
checks_loading: self.checks.is_some(),
usual: &usual,
};
let lines: Vec<Line> = shown
.iter()
.enumerate()
.skip(self.top)
.take(h)
.flat_map(|(i, p)| card(p, &cx, i == sel, self.local.get(&p.number)))
.collect();
if shown.is_empty() && self.worktrees && self.query.is_empty() {
let none = " no PR worktrees here yet: `dv N -w` (or dv list -w, then Enter) makes one";
f.render_widget(Paragraph::new(none).style(ui.base.patch(ui.stale)), Rect { y: area.y + 2, height: 1, ..area });
}
f.render_widget(Paragraph::new(lines), Rect { y: area.y + 1, height: (h * 2) as u16, ..area });
let status = Rect { y: area.bottom().saturating_sub(1), height: 1.min(area.height), ..area };
let bar = ui.base.patch(ui.status);
let left = match (self.filtering, self.query.is_empty(), self.msg.is_empty()) {
(true, _, _) => format!(" /{}▏ author: label: base: branch: is:draft review: ci: -x negates", self.query),
(_, _, false) => format!(" {}", self.msg),
(_, false, _) => format!(" filter: {} (Esc clears)", self.query),
_ => String::new(), };
let flags = if self.flags.is_empty() { String::new() } else { format!("opens with {} ", self.flags) };
f.render_widget(Paragraph::new(left).style(bar), status);
f.render_widget(Paragraph::new(flags).alignment(Alignment::Right).style(bar), status);
if !self.help && !self.filtering {
let enter = match self.worktrees {
true => "review, in its worktree".to_owned(),
false if self.flags.is_empty() => "review".to_owned(),
false => format!("review (dv N {})", self.flags),
};
let tab = if self.worktrees { "open PRs" } else { "PRs with a worktree" };
let keys = [("Enter", enter.as_str()), ("Tab", tab), ("/", "filter"), ("o O", "sort / reverse"), ("R", "refresh"), ("?", "all keys"), ("q", "quit")];
let lines: Vec<Line> = keys
.iter()
.map(|(k, d)| Line::from(vec![Span::styled(format!("{k:<6}"), ui.key), Span::raw(d.to_string())]))
.collect();
let w = (lines.iter().map(Line::width).max().unwrap_or(10) as u16 + 4).min(area.width);
let h = (lines.len() as u16 + 2).min(area.height.saturating_sub(1));
let r = Rect { x: area.width - w, y: area.height.saturating_sub(1 + h), width: w, height: h };
f.render_widget(Clear, r);
f.render_widget(Block::new().style(ui.base.patch(ui.popup)), r);
f.render_widget(Paragraph::new(" keys").style(ui.base.patch(ui.header)), Rect { height: 1, ..r });
f.render_widget(Paragraph::new(lines), Rect { x: r.x + 1, y: r.y + 1, width: w.saturating_sub(2), height: h.saturating_sub(1) });
}
if self.help {
let lines: Vec<&str> = HELP.lines().collect();
let w = (lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16 + 4).min(area.width);
let h = (lines.len() as u16 + 3).min(area.height);
let r = Rect { x: (area.width - w) / 2, y: (area.height - h) / 2, width: w, height: h };
f.render_widget(Clear, r);
f.render_widget(Block::new().style(ui.base.patch(ui.popup)), r);
let hs = ui.base.patch(ui.header);
f.render_widget(Paragraph::new(" dv list keys").style(hs), Rect { height: 1, ..r });
f.render_widget(Paragraph::new("any key closes ").alignment(Alignment::Right).style(hs), Rect { height: 1, ..r });
let body: Vec<Line> = lines
.iter()
.map(|l| {
let (k, d) = l.split_at(l.char_indices().nth(23).map_or(l.len(), |(i, _)| i));
Line::from(vec![Span::styled(k.to_owned(), ui.key), Span::raw(d.to_owned())])
})
.collect();
f.render_widget(Paragraph::new(body), Rect { x: r.x + 1, y: r.y + 2, width: r.width.saturating_sub(2), height: h.saturating_sub(3) });
}
}
}
enum Do {
Quit,
Open(u64),
Refresh,
}
struct Cards<'a> {
ui: &'a Ui,
num_w: usize,
width: usize,
checks_loading: bool,
usual: &'a str,
}
fn card(p: &Listed, cx: &Cards, selected: bool, local: Option<&(PathBuf, Option<String>)>) -> [Line<'static>; 2] {
let (ui, num_w, width, usual) = (cx.ui, cx.num_w, cx.width, cx.usual);
let row = if selected { ui.base.patch(ui.menu_selected) } else { ui.base };
let dim = row.patch(ui.stale);
let sp = |t: String, st| Span::styled(t, st);
let age = format!("{} ", gh::ago(&p.updated));
let badge = match (p.state.as_str(), p.draft) {
("MERGED", _) => Some(("MERGED", "8957e5")),
("CLOSED", _) => Some(("CLOSED", "da3633")),
(_, true) => Some(("DRAFT", "6e7681")),
_ => None,
};
let room = width.saturating_sub(num_w + 3 + badge.map_or(0, |b| b.0.len() + 4) + age.len()).max(10);
let mut top = vec![
sp(format!(" {:>num_w$} ", format!("#{}", p.number)), row.patch(ui.key)),
sp(cut(&p.title, room), row),
];
if let Some((text, color)) = badge {
top.extend([sp(" ".into(), row), sp(format!(" {text} "), crate::view::chip(color))]);
}
let used: usize = top.iter().map(Span::width).sum();
top.push(sp(format!("{age:>w$}", w = width.saturating_sub(used)), dim));
let sep = || sp(" · ".into(), dim);
let mut bottom = vec![sp(" ".repeat(num_w + 2), row), sp(format!("@{}", p.author), dim)];
match p.checks {
Some((0, 0, 0)) => {}
Some((pass, fail, pending)) => {
let counts = [(pass, '✓', ui.plus), (fail, '✗', ui.error), (pending, '●', ui.warning)];
for (k, &(n, mark, st)) in counts.iter().filter(|c| c.0 > 0).enumerate() {
bottom.extend([sp(if k == 0 { " · " } else { " " }.into(), dim), sp(format!("{mark}{n}"), row.patch(st))]);
}
}
None if cx.checks_loading => bottom.extend([sep(), sp("checks…".into(), dim)]),
None => {}
}
match p.decision.as_str() {
"APPROVED" => bottom.extend([sep(), sp("✓ approved".into(), row.patch(ui.plus))]),
"CHANGES_REQUESTED" => bottom.extend([sep(), sp("✗ changes requested".into(), row.patch(ui.error))]),
_ => {}
}
bottom.extend([
sep(),
sp(format!("+{}", p.additions), row.patch(ui.plus)),
sp(" ".into(), row),
sp(format!("−{}", p.deletions), row.patch(ui.minus)),
sp(format!(" in {} file{}", p.files, if p.files == 1 { "" } else { "s" }), dim),
]);
for (name, color) in &p.labels {
bottom.extend([sp(" ".into(), row), sp(format!(" {name} "), crate::view::chip(color))]);
}
let branch = match p.base == usual {
true => cut(&p.branch, 32),
false => format!("{} ← {}", p.base, cut(&p.branch, 32)),
};
bottom.extend([sep(), sp(branch, dim)]);
if let Some((dir, branch)) = local {
let name = dir.file_name().map_or(String::new(), |n| n.to_string_lossy().into_owned());
let on = branch.as_deref().map_or("detached".to_owned(), |b| format!("on {}", cut(b, 24)));
bottom.extend([sep(), sp(format!("⌂ {name} ({on})"), row.patch(ui.key))]);
}
[Line::from(top).style(row), Line::from(bottom).style(row)]
}
fn cut(s: &str, n: usize) -> String {
match s.chars().count() > n {
true => format!("{}…", s.chars().take(n.saturating_sub(1)).collect::<String>()),
false => s.to_owned(),
}
}
#[derive(Clone, Copy)]
enum Order {
Updated,
Created,
Number,
Size,
}
impl Order {
fn next(self) -> Order {
match self {
Order::Updated => Order::Created,
Order::Created => Order::Number,
Order::Number => Order::Size,
Order::Size => Order::Updated,
}
}
fn name(self) -> &'static str {
match self {
Order::Updated => "updated",
Order::Created => "created",
Order::Number => "number",
Order::Size => "size",
}
}
}
fn term(p: &Listed, t: &str) -> bool {
let (neg, t) = match t.strip_prefix('-') {
Some(rest) if !rest.is_empty() => (true, rest),
_ => (false, t),
};
let fold = |s: &str| if t.chars().any(char::is_uppercase) { s.to_owned() } else { s.to_lowercase() };
let has = |field: &str, v: &str| fold(field).contains(v);
let labels = || p.labels.iter().map(|l| l.0.as_str()).collect::<Vec<_>>().join(" ");
let hit = match t.split_once(':') {
Some(("author", v)) => has(&p.author, v),
Some(("label", v)) => p.labels.iter().any(|l| has(&l.0, v)),
Some(("base", v)) => has(&p.base, v),
Some(("branch", v)) => has(&p.branch, v),
Some(("is", "draft")) => p.draft,
Some(("review", v)) => match p.decision.as_str() {
"APPROVED" => "approved".starts_with(v),
"CHANGES_REQUESTED" => "changes".starts_with(v),
"REVIEW_REQUIRED" => "required".starts_with(v),
_ => v == "none",
},
Some(("ci", v)) => match p.checks {
Some((0, 0, 0)) | None => v == "none",
Some((pass, fail, pending)) => {
("pass".starts_with(v) && pass > 0 && fail == 0 && pending == 0)
|| ("fail".starts_with(v) && fail > 0)
|| ("pending".starts_with(v) && pending > 0)
}
},
_ => has(&format!("#{} {} @{} {} {}", p.number, p.title, p.author, p.branch, labels()), t),
};
hit != neg
}
#[test]
fn filter_and_keys() {
let pr = |number, title: &str, author: &str, updated: &str| Listed {
number,
title: title.into(),
author: author.into(),
base: "master".into(),
branch: "b".into(),
state: "OPEN".into(),
updated: updated.into(),
additions: number,
files: 1,
..Listed::default()
};
let mut l = List::new("-wb".into());
l.prs = vec![pr(1, "Fix CI", "ann", "2026-03"), pr(22, "Add docs", "bo", "2026-01"), pr(333, "fix fees", "cy", "2026-02")];
(l.prs[1].draft, l.prs[1].decision, l.prs[1].labels) = (true, "APPROVED".into(), vec![("docs".into(), "0075ca".into())]);
l.prs[2].checks = Some((3, 1, 0));
let k = |l: &mut List, c| l.key(KeyEvent::new(c, KeyModifiers::NONE));
let shown = |l: &List| l.shown().iter().map(|p| p.number).collect::<Vec<_>>();
let filter = |l: &mut List, q: &str| (l.query = q.into(), shown(l)).1;
assert_eq!(shown(&l), [1, 333, 22]); assert_eq!(filter(&mut l, "fix"), [1, 333]); assert_eq!(filter(&mut l, "Fix"), [1]);
assert_eq!(filter(&mut l, "is:draft"), [22]);
assert_eq!(filter(&mut l, "-is:draft review:none"), [1, 333]);
assert_eq!(filter(&mut l, "review:appr label:doc"), [22]);
assert_eq!(filter(&mut l, "ci:fail"), [333]);
assert_eq!(filter(&mut l, "ci:none -author:an"), [22]);
assert_eq!(filter(&mut l, "docs"), [22]); l.query.clear();
k(&mut l, KeyCode::Char('/'));
"fix".chars().for_each(|c| _ = k(&mut l, KeyCode::Char(c)));
k(&mut l, KeyCode::Enter);
assert_eq!(shown(&l), [1, 333]);
k(&mut l, KeyCode::Char('j'));
assert!(matches!(k(&mut l, KeyCode::Enter), Some(Do::Open(333))));
k(&mut l, KeyCode::Esc); assert_eq!(shown(&l), [1, 333, 22]);
k(&mut l, KeyCode::Char('j')); k(&mut l, KeyCode::Char('o')); k(&mut l, KeyCode::Char('o')); assert_eq!(shown(&l), [333, 22, 1]);
k(&mut l, KeyCode::Char('O'));
assert_eq!(shown(&l), [1, 22, 333]);
assert_eq!(l.shown()[l.sel].number, 333);
k(&mut l, KeyCode::Char('?'));
assert!(l.help);
assert!(k(&mut l, KeyCode::Char('q')).is_none() && !l.help);
l.prs.push(Listed { number: 9, state: "MERGED".into(), ..pr(9, "old", "dee", "2025-01") });
l.local.insert(9, ("/x/r-9".into(), None));
l.local.insert(22, ("/x/r-22".into(), Some("feat".into())));
assert_eq!(shown(&l), [1, 22, 333]); k(&mut l, KeyCode::Tab);
assert_eq!(shown(&l), [9, 22]); k(&mut l, KeyCode::Char('j'));
assert!(matches!(k(&mut l, KeyCode::Enter), Some(Do::Open(22))));
assert!(matches!(k(&mut l, KeyCode::Char('q')), Some(Do::Quit)));
}