use super::*;
pub(super) fn rep_card(r: &crate::replace::Record, root: &std::path::Path) -> Listed {
let branch = crate::replace::branch(r.id);
let git = |args: &[&str]| crate::diff::git(&[&["-C", &root.to_string_lossy()], args].concat()).unwrap_or_default();
let stat = git(&["diff", "--shortstat", &format!("{}...{branch}", crate::diff::remote_branch(&r.base))]);
let count = |what: &str| -> u64 {
stat.split(',').find(|p| p.contains(what)).and_then(|p| p.split_whitespace().next()?.parse().ok()).unwrap_or(0)
};
let title = match r.title.is_empty() {
true if crate::replace::is_new(r.id) => format!("(not drafted yet: {})", r.task.lines().next().unwrap_or("")),
true => format!("(replacement of #{}, not drafted yet)", r.id),
false => r.title.clone(),
};
Listed {
number: r.id,
title,
base: r.base.clone(),
branch,
state: "OPEN".into(),
updated: git(&["log", "-1", "--format=%cI", &crate::replace::branch(r.id)]).trim().to_owned(),
files: count("file"),
additions: count("insertion"),
deletions: count("deletion"),
..Listed::default()
}
}
pub(super) struct Mark {
pub(super) new: bool,
pub(super) replaces: Option<String>,
pub(super) marked: bool,
pub(super) claude: Option<(String, bool)>,
pub(super) awaiting: usize,
pub(super) rank: Option<usize>,
pub(super) tags: Vec<String>,
}
impl List {
pub(super) fn claude_state(&self, n: u64) -> Option<(String, bool)> {
let id = self.rep_of.values().find(|r| r.replaces.iter().any(|o| o.number == n)).map(|r| r.id);
let replaced = id.or(self.drafting.contains_key(&n).then_some(n)).and_then(|id| self.draft_state(id, "replacement"));
match (self.review_state(n), replaced) {
(Some((a, x)), Some((b, y))) => Some((format!("{a} · {b}"), x && y)),
(a, b) => a.or(b),
}
}
pub(super) fn review_state(&self, n: u64) -> Option<(String, bool)> {
match self.batch.get(&n) {
Some(batch::Status::Queued) => Some(("claude: queued".into(), true)),
Some(batch::Status::Preparing) => Some(("claude: checking out…".into(), true)),
Some(batch::Status::Reviewing) => Some((format!("claude: {}…", self.batch_task.get(&n).map_or("reviewing", batch::Task::doing)), true)),
_ if self.running.contains_key(&n) => Some((format!("claude: {}…", self.running[&n]), true)),
Some(batch::Status::Failed(e)) => Some((format!("claude failed: {}", e.lines().map(str::trim).filter(|l| !l.is_empty()).collect::<Vec<_>>().join(" · ")), false)),
_ if self.reviewed.contains(&n) => Some(match self.claude_open.get(&n) {
Some(Some((0, _))) => ("claude: all addressed".into(), true),
Some(Some((k, false))) => (format!("claude: {k} to address"), false),
Some(Some((k, true))) => (format!("claude: {k} open, on an older head"), false),
Some(None) => ("claude: its review won't read".into(), false),
None => ("claude reviewed".into(), true),
}),
_ => None,
}
}
}
pub(super) struct Cards<'a> {
pub(super) ui: &'a Ui,
pub(super) num_w: usize,
pub(super) width: usize,
pub(super) checks_loading: bool,
pub(super) usual: &'a str,
}
pub(super) const TWO_ZONES: usize = 110;
pub(super) const RIGHT: usize = 30;
pub(super) const CARD_MAX: usize = 140;
pub(super) const MARK_W: usize = 3;
pub(super) fn card(p: &Listed, cx: &Cards, selected: bool, local: Option<&(PathBuf, Option<String>)>, mark: Mark) -> Vec<Line<'static>> {
let (ui, num_w, width, usual) = (cx.ui, cx.num_w, cx.width, cx.usual);
let row = card_row(ui, selected);
let tint = |st: Style| row.patch(Style { bg: None, ..st });
let dim = tint(ui.stale);
let sp = |t: String, st| Span::styled(t, st);
let gap = || sp(" ".into(), row);
let bar = || sp(if selected { "▌".into() } else { " ".into() }, tint(ui.key));
let indent = || sp(" ".repeat(MARK_W + num_w + 2), row);
let badge = match (p.state.as_str(), p.draft) {
("MERGED", _) => Some(("MERGED", "8957e5")),
("CLOSED", _) => Some(("CLOSED", "da3633")),
(_, true) => Some(("DRAFT", "6e7681")),
_ => None,
};
let lead = vec![
sp(if mark.marked { " ● ".into() } else { " ".into() }, tint(ui.key).add_modifier(ratatui::style::Modifier::BOLD)),
sp(format!("{:>w$} ", crate::replace::shown(p.number), w = num_w - 1), tint(ui.key)),
];
let lead_w: usize = lead.iter().map(Span::width).sum();
let title_in = |room: usize, tags: Vec<Span<'static>>| {
let mut v = lead.clone();
v.extend(cut_spans(vec![sp(p.title.clone(), row.add_modifier(ratatui::style::Modifier::BOLD))], room.saturating_sub(lead_w).max(8)));
v.extend(tags);
v
};
let mut tags = vec![];
if let Some((text, color)) = badge {
tags.extend([sp(" ".into(), row), sp(format!(" {text} "), crate::view::chip(color))]);
}
if mark.new {
tags.extend([sp(" ".into(), row), sp("[new]".into(), tint(ui.key).add_modifier(ratatui::style::Modifier::BOLD))]);
}
if !mark.tags.is_empty() {
tags.extend([sp(" ".into(), row), sp(format!("[{}]", mark.tags.join(" · ")), tint(ui.warning))]);
}
let age = sp(gh::ago(&p.updated), dim);
let who = mark.replaces.clone().unwrap_or_else(|| format!("@{}", p.author));
let first = match mark.rank {
Some(r) => sp(format!("{}{:>w$} ", " ".repeat(MARK_W), format!("{r}."), w = num_w - 1), tint(ui.key)),
None => indent(),
};
let mut people = vec![first, sp(who, dim), gap()];
people.extend([
sp(format!("+{}", p.additions), tint(ui.plus)),
sp(" ".into(), row),
sp(format!("−{}", p.deletions), tint(ui.minus)),
sp(format!(" in {} file{}", p.files, if p.files == 1 { "" } else { "s" }), dim),
]);
for (k, (name, color)) in p.labels.iter().enumerate() {
people.extend([sp(if k == 0 { " " } else { " " }.into(), row), sp(format!(" {name} "), crate::view::chip(color))]);
}
let branch = match p.base == usual {
true => p.branch.clone(),
false => format!("{} ← {}", p.base, p.branch),
};
let mut place = vec![indent(), 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)));
place.extend([gap(), sp(format!("⌂ {name} ({on})"), tint(ui.key))]);
}
let ci: Vec<Span<'static>> = match p.checks {
Some((0, 0, 0)) => vec![],
Some((pass, fail, pending)) => {
let mut v = vec![sp("CI ".into(), dim)];
let counts = [(pass, '✓', ui.plus), (fail, '✗', ui.error), (pending, '●', ui.warning)];
for (k, &(n, m, st)) in counts.iter().filter(|c| c.0 > 0).enumerate() {
v.extend([sp(if k == 0 { "" } else { " " }.into(), row), sp(format!("{m}{n}"), tint(st))]);
}
if p.optional_failing > 0 {
v.push(sp(format!(" · ✗{} optional", p.optional_failing), dim));
}
v
}
None if cx.checks_loading && mark.replaces.is_none() => vec![sp("CI …".into(), dim)],
None => vec![],
};
let ci = match mark.awaiting {
0 => ci,
n => [vec![sp(format!("⏸ {n} to approve"), tint(ui.warning))], if ci.is_empty() { vec![] } else { vec![sp(" ".into(), row)] }, ci].concat(),
};
let review = match p.decision.as_str() {
"APPROVED" => vec![sp("✓ approved".into(), tint(ui.plus))],
"CHANGES_REQUESTED" => vec![sp("✗ changes requested".into(), tint(ui.error))],
"REVIEW_REQUIRED" => vec![sp("review needed".into(), dim)],
_ => vec![],
};
let claude: Vec<Span<'static>> = mark.claude.map(|(text, ok)| sp(text, tint(if ok { ui.key } else { ui.error }))).into_iter().collect();
let full = |mut spans: Vec<Span<'static>>| {
let used: usize = spans.iter().map(Span::width).sum();
spans.push(sp(" ".repeat(width.saturating_sub(used)), row));
Line::from(spans).style(row)
};
if width >= TWO_ZONES {
let content = width.min(CARD_MAX);
let left_w = content - 1 - RIGHT; let zones = |left: Vec<Span<'static>>, right: Vec<Span<'static>>, run_on: bool| {
let mut v = vec![bar()];
v.extend(fit(left, left_w, row));
v.extend(fit(right, if run_on { width.saturating_sub(1 + left_w).max(RIGHT) } else { RIGHT }, row));
full(v)
};
let tags_w: usize = tags.iter().map(Span::width).sum();
let top = title_in(left_w.saturating_sub(tags_w), tags);
let age_w = age.width();
let mut stand = fit(ci, RIGHT.saturating_sub(age_w + 1), row);
stand.push(age);
return vec![zones(top, stand, false), zones(people, review, true), zones(place, claude, true)];
}
let line = |mut spans: Vec<Span<'static>>| {
spans.insert(0, bar());
full(fit(spans, width, row))
};
let age = sp(format!(" {} ", age.content), age.style);
let tags_w: usize = tags.iter().map(Span::width).sum();
let mut top = title_in(width.saturating_sub(1 + tags_w + age.width()), tags);
let used: usize = top.iter().map(Span::width).sum::<usize>() + 1;
top.push(sp(" ".repeat(width.saturating_sub(used + age.width())), row));
top.push(age);
let mut status = vec![indent()];
for (part, w) in [(ci, 16usize), (review, 26)] {
let used: usize = part.iter().map(Span::width).sum();
if used > 0 {
status.extend(part);
status.push(sp(" ".repeat(w.saturating_sub(used).max(3)), row));
}
}
status.extend(claude);
vec![line(top), line(people), line(status), line(place)]
}
pub(super) fn fit(spans: Vec<Span<'static>>, w: usize, pad: Style) -> Vec<Span<'static>> {
let mut out = cut_spans(spans, w);
let used: usize = out.iter().map(Span::width).sum();
if used < w {
out.push(Span::styled(" ".repeat(w - used), pad));
}
out
}
pub(super) fn cut_spans(spans: Vec<Span<'static>>, w: usize) -> Vec<Span<'static>> {
let mut out = vec![];
let mut used = 0;
for sp in spans {
let n = sp.width();
if used + n <= w {
used += n;
out.push(sp);
continue;
}
let room = w - used;
if room > 0 {
out.push(Span::styled(cut(&sp.content, room), sp.style));
}
break;
}
out
}
pub(super) fn close_line(why: &str, cx: &Cards, selected: bool) -> Line<'static> {
note_line("✕", why, cx, selected)
}
pub(super) fn note_line(mark: &str, why: &str, cx: &Cards, selected: bool) -> Line<'static> {
let row = card_row(cx.ui, selected);
let bar = Span::styled(if selected { "▌" } else { " " }, row.patch(Style { bg: None, ..cx.ui.key }));
let lead = format!("{}{:w$} {mark} ", " ".repeat(MARK_W), "", w = cx.num_w - 1);
let text: String = why.lines().next().unwrap_or("").chars().take(cx.width.saturating_sub(lead.chars().count() + 2)).collect();
Line::from(vec![bar, Span::styled(lead, row), Span::styled(text, row.patch(Style { bg: None, ..cx.ui.warning }))])
}
pub(super) fn todo_says(it: &crate::todo::Item) -> (Vec<String>, String) {
let (tags, core) = match &it.reason {
Some(r) => (it.tags(), r.clone()),
None => (vec![], it.says()),
};
let says = match &it.deferred {
_ if it.busy.is_some() => format!("Claude is {}: nothing for you till it's done", it.busy.as_deref().unwrap_or("at it")),
_ if it.waits.is_some() => {
let (c, who) = it.waits.as_ref().map(|(c, w)| (c.says(), w.clone())).unwrap_or_default();
let from = if who.is_empty() { "as Claude read the PR's conversation".to_owned() } else { format!("@{who} said, on the PR") };
format!("waiting until {c} ({from}) · {core}")
}
Some(d) => {
let left = || crate::todo::in_secs(d.until - crate::claude::now_secs());
let when = match &d.cond {
None => format!("in {}", left()),
Some(c) if d.until < i64::MAX => format!("until {}, or in {}", c.says(), left()),
Some(c) => format!("until {}", c.says()),
};
format!("deferred, back {when} (or sooner if it changes; s brings it back now) · {core}")
}
None if it.changed => format!("(placed since Claude ranked) {core}"),
None => core,
};
(tags, says)
}
const MSG_MIN: usize = 40;
pub(super) const MSG_ROWS: usize = 4;
pub(super) fn msg_column(width: usize) -> Option<usize> {
let content = width.checked_sub(3 + MSG_MIN)?.min(CARD_MAX);
(content >= TWO_ZONES).then_some(content)
}
pub(super) fn todo_line(rank: usize, it: &crate::todo::Item, cx: &Cards, selected: bool) -> Line<'static> {
let ui = cx.ui;
let row = card_row(ui, selected);
let st = row.patch(Style { bg: None, ..ui.key });
let bar = Span::styled(if selected { "▌" } else { " " }, st);
let lead = format!("{}{:>w$} → ", " ".repeat(MARK_W), format!("{rank}."), w = cx.num_w - 1);
let (tags, says) = todo_says(it);
let says = says.replace('\n', " — ");
let says = match tags.is_empty() {
true => says,
false => format!("[{}] {says}", tags.join(" · ")),
};
let says = cut(&says, cx.width.saturating_sub(lead.chars().count() + 1));
let pad = cx.width.saturating_sub(1 + lead.chars().count() + says.chars().count());
Line::from(vec![bar, Span::styled(lead, st), Span::styled(says, st), Span::styled(" ".repeat(pad), row)]).style(row)
}
pub(super) fn with_message(mut lines: Vec<Line<'static>>, says: &str, cx: &Cards, card_w: usize, width: usize, selected: bool) -> Vec<Line<'static>> {
let ui = cx.ui;
let row = card_row(ui, selected);
let st = |s: Style| row.patch(Style { bg: None, ..s });
let w = width.saturating_sub(card_w + 3);
let (why, todo) = match says.split_once('\n') {
Some((a, b)) => (a, Some(b)),
None => (says, None),
};
let mut todo = todo.map(|t| wrap(&format!("→ {t}"), w)).unwrap_or_default();
todo.truncate(2);
let mut why = wrap(why, w);
let room = MSG_ROWS - todo.len();
if why.len() > room {
let rest = why[room.saturating_sub(1)..].join(" ");
why.truncate(room.saturating_sub(1));
why.push(cut(&rest, w));
}
let text: Vec<(String, Style)> = why.into_iter().map(|l| (l, st(ui.base))).chain(todo.into_iter().map(|l| (l, st(ui.key)))).collect();
while lines.len() < MSG_ROWS {
let bar = Span::styled(if selected { "▌" } else { " " }, st(ui.key));
lines.push(Line::from(vec![bar, Span::styled(" ".repeat(card_w - 1), row)]).style(row));
}
for (k, line) in lines.iter_mut().enumerate() {
let (t, s) = text.get(k).cloned().unwrap_or_default();
let pad = w.saturating_sub(t.chars().count());
line.spans.extend([Span::styled(" │ ", st(ui.stale)), Span::styled(t, s), Span::styled(" ".repeat(pad), row)]);
}
lines
}
fn wrap(s: &str, w: usize) -> Vec<String> {
let mut out: Vec<String> = vec![];
let mut cur = String::new();
for word in s.split_whitespace() {
let word = cut(word, w.max(1));
if !cur.is_empty() && cur.chars().count() + 1 + word.chars().count() > w {
out.push(std::mem::take(&mut cur));
}
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(&word);
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub(super) fn card_row(ui: &Ui, selected: bool) -> Style {
match selected {
true => ui.base.bg(ui.cursorline.or(ui.menu_selected.bg).unwrap_or(ratatui::style::Color::DarkGray)),
false => ui.base,
}
}
pub(super) use crate::view::cut;