use super::*;
impl App {
pub(super) fn section(&self, id: usize, title: String, text: &str, info: Option<usize>) -> Vec<Row> {
self.section_with(id, title, info, |mk| self.md_rows_at(text, self.wrap_w, mk))
}
pub(super) fn section_with(&self, id: usize, title: String, info: Option<usize>, body: impl FnOnce(&dyn Fn(Kind, String) -> Row) -> Vec<Row>) -> Vec<Row> {
let mk = |kind, text| Row { main: Some(id), info, ..Row::text(INFO, kind, text) };
let mut rows = vec![mk(Kind::File, title)];
if !self.sections_folded.contains(&id) {
rows.extend(body(&mk));
}
rows
}
pub(super) fn pr_facts(&self) -> Vec<Row> {
let mk = |kind, text| Row::text(INFO, kind, text);
let facts = self.info.iter().take_while(|l| !l.heading && !l.md).count();
if facts == 0 || self.unpublished() {
return vec![];
}
let url = self.pr.as_ref().map(|p| p.url.clone()).filter(|u| !u.is_empty());
let fact = |r: Row| Row { info: Some(FACTS), ..r };
let title = if url.is_some() { "status · on GitHub ↗" } else { "status" };
let links = url.iter().map(|u| (9, title.chars().count(), u.clone())).collect();
let mut rows = vec![fact(Row { links, ..mk(Kind::Thread, title.into()) })];
rows.extend(self.info[..facts].iter().map(|l| fact(Row { nowrap: true, ..self.info_row(l, mk(Kind::Comment, l.text.clone())) })));
rows.push(Row { edge: true, ..fact(mk(Kind::Comment, String::new())) });
rows
}
pub(super) fn conversation(&self, latest: Option<usize>) -> Vec<Row> {
let mk = |kind, text| Row::text(INFO, kind, text);
let width = self.info_w();
let Some(start) = self.info.iter().position(|l| l.key == "activity") else { return vec![] };
let items: Vec<usize> = (start + 1..self.info.len()).filter(|&i| self.info[i].heading).collect();
let latest_shown: Vec<usize> = {
let n = latest.unwrap_or(LATEST);
let asked = |h: usize| !self.info[h].url.is_empty() && self.threads.iter().any(|t| t.conversation == self.info[h].url && self.in_view(t));
let me = format!("@{} ·", self.me);
let yours = |h: usize| !self.me.is_empty() && self.info[h].text.starts_with(&me);
let after = items.iter().rposition(|&h| yours(h)).map_or(0, |k| k + 1);
let new = |k: usize, h: usize| {
self.seen.path.is_some() && k >= after && !yours(h) && !self.info[h].node.is_empty() && !self.seen.before.contains(&self.info[h].node)
};
let said: Vec<usize> = (0..items.len()).filter(|&k| !self.info[items[k]].event).collect();
let from = said.len().checked_sub(n).map_or(said.first().copied().unwrap_or(0), |k| said[k]);
let recent = |k: usize| k >= from && (!self.info[items[k]].event || k >= items.len().saturating_sub(3 * n));
items.iter().enumerate().filter(|&(k, &h)| recent(k) || new(k, h) || asked(h)).map(|(_, &h)| h).collect()
};
let shown = if latest.is_some() { &latest_shown } else { &items };
let mut rows = vec![];
if !items.is_empty() || !self.info[start].heading {
rows.push(Row { info: None, ..mk(Kind::Thread, self.info[start].text.clone()) });
}
let more = items.len() - shown.len();
if more > 0 {
let what = if more == 1 { "1 earlier".to_owned() } else { format!("{more} earlier") };
rows.push(Row { info: Some(MORE_CONVERSATION), ..mk(Kind::Comment, format!("… {what}: Space o shows them")) });
} else if latest.is_none() && latest_shown.len() < items.len() {
rows.push(Row { info: Some(MORE_CONVERSATION), ..mk(Kind::Comment, "… all of it: Space o shows only the latest".into()) });
}
for h in shown.iter().copied() {
let body = h + 1;
if self.info[h].event {
let under = self.info[body..].iter().take_while(|l| l.event && !l.heading).count();
rows.extend((h..body + under).map(|i| Row { info: Some(i), ..self.info_row(&self.info[i], mk(Kind::Comment, self.info[i].text.clone())) }));
continue;
}
if let Some(note) = self.hidden_note(&self.info[h].hidden, &self.info[h].node) {
let l = &self.info[h];
let at = l.text.find(" · ").map_or(l.text.chars().count(), |i| l.text[..i].chars().count());
let n = note.chars().count();
let rest = l.text.chars().skip(at).collect::<String>();
let rest = if rest.trim() == "·" { String::new() } else { rest }; let text: String = l.text.chars().take(at).chain(note.chars()).chain(rest.chars()).collect();
let mut row = self.info_row(l, mk(Kind::Thread, text.clone()));
for link in &mut row.links {
if link.0 >= at {
(link.0, link.1) = (link.0 + n, link.1 + n);
}
}
rows.push(Row { info: self.info.get(body).filter(|n| n.md).map(|_| body), syn: vec![Some(self.ui.stale); text.chars().count()], ..row });
rows.extend(self.conversation_rows(body));
continue;
}
rows.push(Row { info: self.info.get(body).filter(|n| n.md).map(|_| body), ..self.info_row(&self.info[h], mk(Kind::Thread, self.info[h].text.clone())) });
let text = self.info.get(body).map_or("", |l| l.text.as_str());
let text = if text.trim().is_empty() { "_(no comment)_" } else { text };
rows.extend(self.md_rows_at(text, width, mk).into_iter().map(|r| Row { info: Some(body), ..r }));
rows.push(Row { info: Some(body), edge: true, ..mk(Kind::Comment, String::new()) });
rows.extend(self.conversation_rows(body));
}
rows
}
pub(super) fn conversation_rows(&self, body: usize) -> Vec<Row> {
let Some(url) = body.checked_sub(1).and_then(|h| self.info.get(h)).filter(|h| h.heading && !h.url.is_empty()).map(|h| &h.url) else { return vec![] };
(0..self.threads.len())
.filter(|&t| self.threads[t].conversation == *url && self.in_view(&self.threads[t]))
.flat_map(|t| self.thread_rows(t, INFO))
.collect()
}
pub(super) fn conversation_item(&self, url: &str) -> Option<usize> {
(0..self.info.len()).find(|&i| self.info[i].heading && self.info[i].url == url).map(|h| h + 1).filter(|&b| self.info.get(b).is_some_and(|l| l.md))
}
pub(super) fn claude_attached(&self) -> Vec<Row> {
let questions = self.threads.iter().any(|t| t.conversation == ON_SUMMARY && self.in_view(t));
let summary = !self.claude.summary.trim().is_empty();
let mk = |kind, text| Row::text(INFO, kind, text);
let mut next = vec![];
if let Some((says, at)) = &self.ranked_next {
next.push(mk(Kind::Thread, format!("next, as Claude ranked it ({} ago)", crate::gh::ago_secs(crate::claude::now_secs() - at))));
next.extend(self.md_rows_at(says, self.info_w(), mk));
next.push(mk(Kind::Comment, String::new()));
}
if !summary && !questions {
next.extend(self.claude_activity()); return next;
}
let mut rows = match summary {
false => vec![mk(Kind::Thread, "questions for Claude".into())],
true => {
let summary = |r: Row| Row { info: Some(SUMMARY), ..r };
let mut rows = vec![summary(mk(Kind::Thread, "claude's review".into()))];
rows.extend(self.md_rows_at(&self.claude.summary, self.info_w(), mk).into_iter().map(summary));
rows.push(Row { edge: true, ..summary(mk(Kind::Comment, String::new())) });
rows
}
};
rows.extend(self.summary_threads());
rows.extend(self.claude_activity());
next.extend(rows);
next
}
pub(super) fn claude_activity(&self) -> Vec<Row> {
let mut said: Vec<(&str, String, &str, (usize, usize))> = self
.threads
.iter()
.enumerate()
.filter(|(_, t)| t.claude.is_some())
.flat_map(|(ti, t)| {
let at = match (t.path.as_str(), t.line.or(t.original_line), t.conversation.as_str()) {
("", _, ON_SUMMARY) => "on its review".to_owned(),
("", _, c) if commit_of(c).is_some() => format!("on commit {}", self.commit_short(commit_of(c).unwrap_or(""))),
("", ..) => "on the conversation".to_owned(),
(p, Some(l), _) => format!("{}:{l}", p.rsplit('/').next().unwrap_or(p)),
(p, None, _) => p.rsplit('/').next().unwrap_or(p).to_owned(),
};
t.comments.iter().enumerate().filter(|(_, c)| c.author == crate::claude::AUTHOR).map(move |(ci, c)| (c.created.as_str(), at.clone(), c.body.as_str(), (ti, ci)))
})
.collect();
if said.is_empty() {
return vec![];
}
said.sort_by(|a, b| b.0.cmp(a.0)); let mk = |kind, text| Row { info: Some(CLAUDE_ACTIVITY), ..Row::text(INFO, kind, text) };
let mut rows = vec![mk(Kind::Thread, "claude's activity".into())];
let w = self.info_w();
let dim = Some(self.ui.linenr);
for (created, at, body, goes) in said.into_iter().take(CLAUDE_LATEST) {
let when = if created.is_empty() { String::new() } else { format!("{} · ", crate::gh::ago(created)) };
let head = super::cut(&format!("â—† {when}{at}"), w.saturating_sub(1));
rows.push(Row { goes: Some(goes), syn: vec![dim; head.chars().count()], ..mk(Kind::Comment, head) });
let lines: Vec<&str> = body.lines().map(str::trim).skip_while(|l| l.is_empty()).collect();
let para = lines.iter().take_while(|l| !l.is_empty()).copied().collect::<Vec<_>>().join(" ");
let tw = w.saturating_sub(3).max(1);
let wrapped = super::wrap(¶, tw);
let more = wrapped.len() > 2 || lines.iter().skip_while(|l| !l.is_empty()).any(|l| !l.is_empty());
let shown = wrapped.len().min(2);
for (k, l) in wrapped.iter().take(2).enumerate() {
let l = if more && k + 1 == shown { format!("{l} …") } else { l.clone() };
rows.push(Row { goes: Some(goes), ..mk(Kind::Comment, format!(" {}", super::cut(&l, tw))) });
}
}
rows.push(Row { edge: true, ..mk(Kind::Comment, String::new()) });
rows
}
pub(super) fn summary_threads(&self) -> Vec<Row> {
(0..self.threads.len())
.filter(|&t| self.threads[t].conversation == ON_SUMMARY && self.in_view(&self.threads[t]))
.flat_map(|t| self.thread_rows(t, INFO))
.collect()
}
pub(super) fn no_pr_summary(&self) -> Vec<Row> {
match self.pr.is_none() && self.replacement.is_none() {
true => self.claude_attached(),
false => vec![],
}
}
pub(super) fn request_rows(&self, on: &[&str], draft: usize) -> Vec<Row> {
let Some(r) = self.replacement.as_ref().filter(|_| self.unpublished()) else { return vec![] };
let mk = |kind, text| Row { info: Some(draft), ..Row::text(INFO, kind, text) };
let drafting = r.drafting_now();
let mut rows = vec![];
for (i, a) in r.asks.iter().enumerate().filter(|(_, a)| on.contains(&a.on.as_str())) {
let [done, doing, todo] = match a.want.as_str() {
"ask" => ["answered", "Claude is answering it", "not answered yet"],
_ => ["redrafted with it", "Claude is redrafting with it", "not redrafted with it yet"],
};
let state = match (a.done(), drafting && i < r.taking) {
(true, _) => done,
(false, true) => doing,
(false, false) => todo,
};
let want = match a.want.as_str() {
"ask" => " [ask]",
"change" => " [change]",
_ => "",
};
rows.push(mk(Kind::Thread, format!("you · asked of the drafts{want} · {state}")));
rows.extend(self.md_rows_at(&a.text, self.info_w(), mk));
match a.answer.trim() {
"" => {}
crate::replace::NO_ANSWER => rows.extend(self.md_rows_at(crate::replace::NO_ANSWER, self.info_w(), mk)),
ans => {
rows.push(mk(Kind::Thread, "â—† claude".into()));
rows.extend(self.md_rows_at(ans, self.info_w(), mk));
}
}
rows.push(Row { edge: true, ..mk(Kind::Comment, String::new()) });
}
rows
}
pub(super) fn draft_rows(&self) -> Vec<Row> {
if self.summary_draft.trim().is_empty() {
return vec![];
}
let mk = |kind, text| Row::text(INFO, kind, text);
let draft = |r: Row| Row { info: Some(DRAFT), ..r };
let mut rows = vec![draft(mk(Kind::Thread, "your review (pending)".into()))];
rows.extend(self.md_rows_at(&self.summary_draft, self.info_w(), mk).into_iter().map(draft));
rows.push(Row { edge: true, ..draft(mk(Kind::Comment, String::new())) });
rows
}
pub(super) fn attach(&self, mut s: Vec<Row>, attached: &mut Vec<Row>) -> Vec<Row> {
let at = if self.margin_active { 1 } else { s.len() }.min(s.len());
s.splice(at..at, std::mem::take(attached).into_iter().map(|r| Row { attached: true, ..r }));
s
}
pub(super) fn md_rows_at(&self, text: &str, width: usize, mk: impl Fn(Kind, String) -> Row) -> Vec<Row> {
let key = format!("{width}\0{text}");
let lines = self
.md_cache
.borrow_mut()
.entry(key)
.or_insert_with(|| crate::md::render(text, width, &self.ui.md, &self.hl, self.repo_url()))
.clone();
lines.into_iter().map(|l| Row { syn: l.styles, links: l.links, nowrap: l.nowrap, ..mk(Kind::Comment, l.text) }).collect()
}
pub(super) fn info_row(&self, l: &InfoLine, mut row: Row) -> Row {
let ui = &self.ui;
row.syn = vec![None; l.text.chars().count()];
for (a, b, m) in &l.marks {
let st = match m {
Mark::Chip(hex) => chip(hex),
Mark::Pass => ui.plus,
Mark::Fail => ui.error,
Mark::Pending => ui.warning,
Mark::Dim => ui.stale,
Mark::Mine => ui.key,
Mark::Link(url) => {
row.links.push((*a, *b, url.clone()));
ui.md.link
}
};
for s in row.syn.iter_mut().take(*b).skip(*a) {
*s = Some(st);
}
}
row
}
pub(super) fn hidden_note(&self, why: &str, node: &str) -> Option<String> {
self.unseen(why, node).then(|| format!(" · {}", hidden_words(why)))
}
pub(super) fn unseen(&self, hidden: &str, node: &str) -> bool {
!hidden.is_empty() && !self.revealed.contains(node)
}
pub(super) fn seen_body<'a>(&self, c: &'a gh::Comment) -> Option<&'a str> {
(!self.unseen(&c.hidden, &c.node_id)).then_some(c.body.as_str())
}
pub(super) fn seen_info(&self, i: usize) -> Option<&InfoLine> {
let head = i.checked_sub(1).and_then(|h| self.info.get(h)).filter(|h| h.heading);
self.info.get(i).filter(|_| !head.is_some_and(|h| self.unseen(&h.hidden, &h.node)))
}
pub(super) fn gist<'a>(&self, c: &'a gh::Comment) -> std::borrow::Cow<'a, str> {
self.seen_body(c).map_or_else(|| format!("_({})_", hidden_words(&c.hidden)).into(), Into::into)
}
pub(super) fn hidden_line(&self, r: usize) -> bool {
self.hidden_row(&self.rows[r])
}
pub(super) fn hidden_row(&self, row: &Row) -> bool {
row.file == INFO && row.thread.is_none() && row.line.kind == Kind::Thread && row.info.is_some_and(|i| i < self.info.len() && self.seen_info(i).is_none())
}
pub(super) fn hidden_here(&self) -> Option<String> {
let r = self.head.row;
match self.rows[r].thread {
Some(t) if !self.thread_open(t) => None,
Some(t) => {
let c = self.comment_under_cursor(t).filter(|c| !c.hidden.is_empty())?;
let first = r == 0 || self.rows[r - 1].thread != Some(t);
(!(first && self.hid_again.contains(&c.node_id))).then(|| c.node_id.clone())
}
None => self.conversation_node().filter(|n| self.info.iter().any(|l| l.node == *n && !l.hidden.is_empty())),
}
}
pub(super) fn toggle_hidden(&mut self) -> bool {
let r = self.head.row;
let first = self.rows[r].thread.filter(|&t| r == 0 || self.rows[r - 1].thread != Some(t)).and_then(|t| self.comment_under_cursor(t)).map(|c| c.node_id.clone());
let Some(node) = self.hidden_here() else {
if let Some(n) = first {
self.hid_again.remove(&n);
}
return false;
};
if first.as_ref() == Some(&node) && self.revealed.contains(&node) {
self.hid_again.insert(node.clone());
}
flip(&mut self.revealed, node.clone());
let at = self.rows[self.head.row].thread.map(|t| (self.root_id(t), self.comment_index_under_cursor(t)));
let (keep, col) = (self.cursor_key(), self.head.col);
self.rebuild(keep);
let row = match at {
Some((id, k)) => {
let t = (0..self.threads.len()).find(|&t| self.root_id(t) == id);
self.rows.iter().enumerate().filter(|(_, r)| r.thread.is_some() && r.thread == t && r.line.kind == Kind::Thread).nth(k).map(|(i, _)| i)
}
None => self.rows.iter().position(|r| {
let head = r.info.and_then(|b| self.info.get(b.checked_sub(1)?));
r.file == INFO && r.line.kind == Kind::Thread && r.thread.is_none() && head.is_some_and(|l| l.heading && l.node == node)
}),
};
if let Some(row) = row {
self.goto(row, col);
}
true
}
pub(super) fn is_description(&self, i: usize) -> bool {
self.info.get(i).is_some_and(|l| l.heading && (l.text == "description" || self.replacement.is_some() && l.text.starts_with("description")))
}
pub(super) fn is_closing_note(&self, i: usize) -> bool {
self.replacement.is_some() && self.info.get(i).is_some_and(|l| l.heading && l.text.starts_with("closing note"))
}
pub(super) fn toggle_conversation(&mut self) {
if self.pr.is_none() {
return self.msg = "no PR here".into();
}
let start = self.info.iter().position(|l| l.key == "activity");
if let Some(s) = start.filter(|&s| !self.info[s].heading) {
return self.msg = self.info[s].text.clone();
}
if !start.is_some_and(|s| self.info[s + 1..].iter().any(|l| l.heading)) {
return self.msg = "no activity on this PR yet".into();
}
self.conversation_all = self.view != View::All || !self.conversation_all;
if self.view != View::All {
(self.view, self.msg) = (View::All, "the whole diff, with all of the PR's activity".into());
}
self.sections_folded.remove(&DESCRIPTION);
self.info_collapsed = false;
self.rebuild_at(|r| r.attached && r.line.text.starts_with("activity ("));
}
}