use super::*;
impl List {
fn conflicts(&self, n: u64) -> bool {
self.extra.get(&n).is_some_and(|e| e.mergeable == "CONFLICTING")
}
pub(super) fn height(&self) -> usize {
ratatui::crossterm::terminal::size().map_or(10, |(_, h)| h.saturating_sub(2) as usize / self.card_rows())
}
pub(super) fn card_rows(&self) -> usize {
let lines = if self.wide { 3 } else { 4 };
let more = match self.tab {
Tab::Todo | Tab::CanClose | Tab::Stalled => 1,
_ => 0,
};
lines + more + 1
}
pub(super) fn draw(&mut self, f: &mut Frame) {
let area = f.area();
self.wide = area.width as usize >= TWO_ZONES;
let msg_col = msg_column(area.width as usize);
let rows = self.card_rows();
let h = area.height.saturating_sub(2) as usize / rows;
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 arrow = if self.rev { '↑' } else { '↓' };
let open = self.prs.iter().filter(|p| p.open()).count();
let names = [
(Tab::Todo, format!("todo {}", self.todo_now())),
(Tab::Open, format!("open {open}{}", if self.prs.len() >= github::LIST_MAX { "+" } else { "" })),
(Tab::CanClose, format!("close {}", self.prs.iter().filter(|p| p.open() && self.closable.contains_key(&p.number)).count())),
(Tab::Stalled, format!("stalled {}", self.prs.iter().filter(|p| p.open() && self.stalled(p).is_some()).count())),
(Tab::Worktrees, format!("worktrees {}", self.local.len())),
(Tab::Replacements, format!("drafts {}", self.reps.len())),
];
let tabs: Vec<(String, bool)> = names.iter().map(|(t, n)| (n.clone(), self.tab == *t)).collect();
let hits = top_bar(f, ui, "PRs", &tabs, "I I the issues", sel, shown.len());
let hits: Vec<_> = hits.into_iter().zip(names.iter().map(|n| n.0)).collect();
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| crate::replace::shown(p.number).chars().count()).max().unwrap_or(2),
width: match msg_col.filter(|_| self.tab == Tab::Todo) {
Some(w) => w,
None => 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)| {
let item = (self.tab == Tab::Todo).then(|| self.todo_shown().into_iter().find(|(c, _)| std::ptr::eq(*c, *p)).map(|x| x.1)).flatten();
let rep_card = self.tab == Tab::Replacements || item.is_some_and(|it| it.is_rep());
let says = item.map(todo_says);
let mark = match rep_card {
true => {
let replaces = self.rep_of.get(&p.number).map(|r| match r.replaces.is_empty() {
true => format!("asked: {}", r.task.lines().next().unwrap_or("")),
false => {
let each: Vec<String> = r.replaces.iter().map(|o| format!("#{} (@{})", o.number, o.author)).collect();
format!("replaces {}", each.join(", "))
}
});
let published = self.rep_of.get(&p.number).and_then(|r| r.published);
let tags = published.filter(|n| self.conflicts(*n)).map(|_| vec!["conflicts".to_owned()]).unwrap_or_default();
Mark { marked: false, claude: self.draft_state(p.number, "claude"), new: false, replaces, awaiting: 0, rank: None, tags }
}
false => {
let could = item.and_then(|it| it.claude.as_ref()).filter(|_| !self.batch.get(&p.number).is_some_and(batch::Status::running)).map(batch::Task::could);
let claude = match (self.claude_state(p.number), could) {
(Some((s, ok)), Some(c)) => Some((format!("{s} · {c}"), ok)),
(None, Some(c)) => Some((format!("claude {c}"), true)),
(s, None) => s,
};
let awaiting = self.extra.get(&p.number).map_or(0, |e| e.awaiting);
let (rank, mut tags) = match says.as_ref().filter(|_| msg_col.is_some()) {
Some((tags, _)) => (Some(i + 1), tags.clone()),
None => (None, vec![]),
};
if self.conflicts(p.number) && !tags.iter().any(|t| t == "conflicts") {
tags.push("conflicts".into());
}
Mark { marked: self.marked.contains(&p.number), claude, new: self.new.contains(&p.number), replaces: None, awaiting, rank, tags }
}
};
let local = if rep_card { None } else { self.local.get(&p.number) };
let mut lines = card(p, &cx, i == sel, local, mark);
match (item, msg_col) {
(Some(_), Some(card_w)) => lines = with_message(lines, says.as_ref().map_or("", |s| s.1.as_str()), &cx, card_w, area.width as usize, i == sel),
(Some(it), None) => {
lines.insert(1, todo_line(i + 1, it, &cx, i == sel)); }
(None, _) => {}
}
if let Some(why) = self.closable.get(&p.number).filter(|_| self.tab == Tab::CanClose) {
lines.insert(1, close_line(why, &cx, i == sel));
}
if let Some((days, who, _)) = self.stalled(p).filter(|_| self.tab == Tab::Stalled) {
let why = format!("@{} hasn't answered @{who} for {days} days (H asks whether they need help)", p.author);
lines.insert(1, note_line("⏸", &why, &cx, i == sel));
}
lines.push(Line::raw("")); lines
})
.collect();
let typed = self.query.trim().trim_start_matches('#').parse::<u64>().ok();
let none = match (self.tab, self.query.is_empty(), typed) {
(Tab::CanClose, true, _) => " nothing Claude would close: its reviews say when a PR's premise is wrong, or a competing PR does it better".into(),
(Tab::Stalled, true, _) => format!(" nothing stalled: no author has left a review or a maintainer's comment unanswered for {} days or more (git config dv.stalledDays)", self.stalled_days),
(Tab::Worktrees, true, _) => " no PR worktrees here yet: `dv N -w` (or dv list -w, then Enter) makes one".into(),
(Tab::Replacements, true, _) => " no replacements yet: A A on a PR (or marked ones) has Claude draft one".into(),
(_, _, Some(n)) => format!(" #{n} isn't listed: Enter opens it anyway"),
_ => String::new(),
};
if shown.is_empty() {
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 * rows) as u16, ..area });
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),
_ if self.tab == Tab::Todo && msg_col.is_some() => " ? keys".into(),
_ => String::new(), };
let order = match (self.tab, &self.rank_rx, &self.ranked) {
(Tab::Todo, Some(_), _) => "Claude is ranking…".to_owned(),
(Tab::Todo, None, Some(r)) => format!("ranked {} ago, {}{}", ago_secs(crate::claude::now_secs() - r.at), todo::cost_text(r.cost), self.since_text()),
(Tab::Todo, None, None) => "in the rules' order".to_owned(),
_ => format!("by {} {arrow}", self.order.name()),
};
let flags = if self.flags.is_empty() { String::new() } else { format!("opens with {}", self.flags) };
status_bar(f, ui, &left, &order, self.auto, &flags);
let column = self.tab == Tab::Todo && msg_col.is_some();
if !self.help && !self.filtering && !column {
let enter = match self.tab {
Tab::Todo => "see to it (opens the PR or replacement)".to_owned(),
Tab::Worktrees => "review, in its worktree".to_owned(),
Tab::CanClose => "review (Space a there drafts a closing comment)".to_owned(),
Tab::Stalled => "review (H asks its author whether they need help)".to_owned(),
Tab::Replacements => "review it, in its worktree".to_owned(),
Tab::Open if self.flags.is_empty() => "review".to_owned(),
Tab::Open => format!("review (dv N {})", self.flags),
};
let tab = match self.tab {
Tab::Todo => "open PRs",
Tab::Open => "what Claude would close",
Tab::CanClose => "stalled PRs",
Tab::Stalled => "PRs with a worktree",
Tab::Worktrees => "replacements",
Tab::Replacements => "what needs you",
};
let remove = if self.tab == Tab::Replacements { "remove replacement" } else { "remove worktree" };
let keys = [
("Enter", enter.as_str()),
("h l", tab),
("/", "filter"),
("o O", "sort / reverse"),
("R", "refresh (from GitHub)"),
("r r", "Claude ranks what needs you (a run)"),
("s", "defer it (or bring it back)"),
("p", "post what it waits on (a comment)"),
("m", "mark"),
("a a", if self.tab == Tab::Todo { "Claude does its part (marked)" } else { "Claude reviews marked" }),
("A A", "Claude drafts a replacement"),
("M M", "merge (github-merge.py)"),
("W W", "approve its workflow runs"),
("U U", "unmark all"),
("N N", "Claude drafts a new PR (unpublished)"),
("C", "auto on / off (CI, reviews, ranking)"),
("b b", "ask for a rebase (a comment)"),
("D D", remove),
("?", "all keys"),
("q", "quit"),
];
let reps = self.tab == Tab::Replacements;
let lines: Vec<(&str, &str)> = keys
.iter()
.filter(|(k, _)| match *k {
"D D" => !matches!(self.tab, Tab::Open | Tab::Todo),
"o O" => self.tab != Tab::Todo, "r r" | "s" | "p" => self.tab == Tab::Todo,
"m" | "a a" | "A A" | "M M" | "b b" | "W W" | "U U" => !reps,
_ => true,
})
.map(|(k, d)| (*k, *d))
.collect();
let card = Rect { y: 1 + (self.sel.saturating_sub(self.top) * rows) as u16, height: rows as u16, ..area };
draw_keys(f, ui, &lines, Some(card));
}
if self.help {
draw_help(f, ui, HELP, "dv list keys");
}
self.tab_hits = hits; }
}
pub(super) fn draw_help(f: &mut Frame, ui: &Ui, help: &str, title: &str) {
let area = f.area();
let body = crate::view::help_lines(help, ui, area.width.saturating_sub(4) as usize, "");
let w = (body.iter().map(Line::width).max().unwrap_or(0) as u16 + 4).min(area.width);
let h = (body.len() as u16 + 2).min(area.height);
let r = Rect { x: (area.width - w) / 2, y: (area.height - h) / 2, width: w, height: h };
let inner = crate::view::popup_frame(ui, f, r, title, "any key closes");
f.render_widget(Paragraph::new(body), inner);
}
pub(super) fn top_bar(f: &mut Frame, ui: &Ui, title: &str, tabs: &[(String, bool)], other: &str, sel: usize, n: usize) -> Vec<std::ops::Range<u16>> {
let area = f.area();
let head = Rect { height: 1.min(area.height), ..area };
let hs = ui.base.patch(Style { bg: ui.bar, ..ui.header });
let dim = hs.remove_modifier(ratatui::style::Modifier::BOLD).patch(Style { fg: ui.linenr.fg, ..Style::new() });
let mut spans = vec![Span::styled(format!(" {title} "), hs.add_modifier(ratatui::style::Modifier::BOLD)), Span::styled(" ", hs)];
let (mut hits, mut x) = (vec![], title.chars().count() as u16 + 3);
for (name, on) in tabs {
let s = Span::styled(format!(" {name} "), if *on { ui.menu_selected } else { dim });
let w = s.width() as u16;
hits.push(x..x + w);
spans.extend([s, Span::styled(" ", hs)]);
x += w + 1;
}
spans.push(Span::styled(format!(" {other}"), dim));
f.render_widget(Paragraph::new(Line::from(spans)).style(hs), head);
let pos = if n == 0 { "0 / 0 ".into() } else { format!("{} / {n} ", sel + 1) };
let w = (pos.chars().count() as u16).min(head.width);
f.render_widget(Paragraph::new(pos).style(hs), Rect { x: head.right() - w, width: w, ..head });
hits
}
pub(super) fn status_bar(f: &mut Frame, ui: &Ui, left: &str, order: &str, auto: bool, more: &str) {
let area = f.area();
let status = Rect { y: area.bottom().saturating_sub(1), height: 1.min(area.height), ..area };
let bar = ui.base.patch(ui.status);
let mut state = vec![
Span::styled(format!("{order} · "), bar),
match auto {
true => Span::styled("auto on", bar.patch(Style { fg: ui.warning.fg, ..Style::new() })),
false => Span::styled("auto off", bar),
},
];
state.push(Span::styled(if more.is_empty() { " ".to_owned() } else { format!(" · {more} ") }, bar));
let state = Line::from(state);
let (lw, sw) = (Line::from(left).width() as u16, state.width() as u16);
let right = if lw + 2 + sw <= status.width { sw } else { 0 };
if right > 0 {
f.render_widget(Paragraph::new(state).alignment(Alignment::Right).style(bar), status);
}
f.render_widget(Paragraph::new(left.to_owned()).style(bar), Rect { width: status.width - right, ..status });
}
pub(super) fn draw_keys(f: &mut Frame, ui: &Ui, keys: &[(&str, &str)], avoid: Option<Rect>) {
let area = f.area();
crate::view::draw_hints(f, ui, Rect { y: 1.min(area.height), height: area.height.saturating_sub(2), ..area }, "keys", keys, avoid);
}