use super::*;
pub(super) const OUTDATED_SLACK: i64 = 10;
pub(super) fn view_windows(threads: &[&Thread], f: &FileDiff, extra: impl Fn(usize) -> i64, whole: bool) -> Vec<(usize, usize, Vec<usize>)> {
let mut spans: Vec<(usize, usize, usize)> = threads
.iter()
.filter(|t| t.path == f.path)
.filter_map(|t| {
let at = anchor_line(t, &f.full)?;
let more = match (t.start_line, t.line) {
(Some(s), Some(e)) if e > s => (e - s) as usize,
_ => 0,
};
let x = (DEFAULT_CONTEXT as i64 + extra(at)).max(0) as usize;
Some((at.saturating_sub(more + x), (at + x).min(f.full.len() - 1), at))
})
.collect();
if whole && !f.full.is_empty() {
return vec![(0, f.full.len() - 1, spans.into_iter().map(|s| s.2).collect())];
}
spans.sort();
let mut ws: Vec<(usize, usize, Vec<usize>)> = vec![];
for (lo, hi, at) in spans {
match ws.last_mut() {
Some(w) if lo <= w.1 + 1 => {
w.1 = w.1.max(hi);
w.2.push(at);
}
_ => ws.push((lo, hi, vec![at])),
}
}
ws
}
pub(super) fn anchor_line(t: &Thread, lines: &[DLine]) -> Option<usize> {
let side = |l: &DLine| match t.left {
true => matches!(l.kind, Kind::Ctx | Kind::Del),
false => matches!(l.kind, Kind::Ctx | Kind::Add),
};
let no = |l: &DLine| if t.left { l.old } else { l.new };
if !t.outdated {
let n = t.line?;
return lines.iter().position(|l| side(l) && no(l) == Some(n));
}
let want = t.original_line? as i64;
let dist = |i: usize| (no(&lines[i]).unwrap_or(0) as i64 - want).abs();
let target = t.hunk.last().and_then(|l| l.get(1..)).unwrap_or("").trim();
let distinctive = target.chars().filter(|c| c.is_alphanumeric()).count() >= 3;
let by_text = (0..lines.len())
.filter(|&i| distinctive && side(&lines[i]) && lines[i].text.trim() == target)
.min_by_key(|&i| dist(i));
by_text.or_else(|| (0..lines.len()).filter(|&i| side(&lines[i])).min_by_key(|&i| dist(i)).filter(|&i| dist(i) <= OUTDATED_SLACK))
}
pub(super) fn change_start(rows: &[Row], r: usize) -> bool {
let is = |r: usize| matches!(rows[r].line.kind, Kind::Add | Kind::Del);
is(r) && (r == 0 || !is(r - 1))
}
impl App {
pub(super) fn shape(&mut self) {
let (extra, whole) = (&self.extra, &self.whole);
for f in &mut self.files {
if f.full.is_empty() {
f.prepare(); }
let e = |a| *extra.get(&(f.path.clone(), a)).unwrap_or(&0);
let w = diff::windows(&f.full, DEFAULT_CONTEXT as usize, e, whole.contains(&f.path));
f.lines = diff::shown(&f.full, &w, &f.headers);
}
}
pub(super) fn cursor_key(&self) -> CursorKey {
self.key_at(self.head.row)
}
fn key_at(&self, row: usize) -> CursorKey {
let Some(r) = self.rows.get(row) else { return CursorKey { line: None, thread: None, info: None, section: None } };
CursorKey {
line: self.files.get(r.file).map(|f| (f.path.clone(), r.line.old, r.line.new)),
thread: r.thread.and_then(|t| {
let first = self.rows.iter().position(|r| r.thread == Some(t))?;
Some((self.root_id(t)?, row - first))
}),
info: (r.file == INFO && r.main.is_none()).then(|| {
let pr = |x: &&Row| x.file == INFO && x.main.is_none();
let before = || self.rows[..row].iter().filter(pr);
let id = self.info_id(r.info);
(id.clone(), before().filter(|x| id.is_some() && self.info_id(x.info) == id).count(), before().count())
}),
section: r.main.and_then(|m| Some((m, row - self.rows.iter().position(|x| x.main == Some(m))?))),
}
}
fn info_id(&self, i: Option<usize>) -> Option<String> {
let i = i?;
Some(self.info.get(i).map_or_else(|| format!("#{i}"), |l| if l.key.is_empty() { format!("~{}", l.text) } else { l.key.clone() }))
}
fn find_key(&self, key: CursorKey) -> Option<usize> {
key.thread
.and_then(|(id, off)| {
let first = self.rows.iter().position(|r| r.thread.and_then(|t| self.root_id(t)) == Some(id))?;
let t = self.rows[first].thread;
Some((first..=first + off).take_while(|&r| self.rows.get(r).is_some_and(|r| r.thread == t)).last().unwrap_or(first))
})
.or_else(|| {
let (m, off) = key.section?;
let first = self.rows.iter().position(|r| r.main == Some(m))?;
Some((first..=first + off).take_while(|&r| self.rows.get(r).is_some_and(|r| r.main == Some(m))).last().unwrap_or(first))
})
.or_else(|| key.line.map(|(p, old, new)| self.locate(&p, old, new)))
.or_else(|| {
let (info, off, k) = key.info?;
let pr: Vec<usize> = (0..self.rows.len()).filter(|&r| self.rows[r].file == INFO && self.rows[r].main.is_none()).collect();
let same: Vec<usize> = pr.iter().copied().filter(|&r| info.is_some() && self.info_id(self.rows[r].info) == info).collect();
same.get(off).or(same.last()).or(pr.get(k)).copied()
})
}
pub(super) fn rebuild(&mut self, keep: CursorKey) {
self.build();
let row = self.find_key(keep).unwrap_or(0);
self.select = false;
self.goto(row, self.head.col);
}
pub(super) fn build(&mut self) {
let code = self.key_at(self.code_head.row);
self.jumps.clear();
let mut rows = vec![];
let title = match (&self.pr, &self.replacement) {
(Some(pr), _) => Some(format!("PR #{} · {}", pr.number, pr.title)),
(None, Some(r)) => {
let title = if r.title.is_empty() { "(not drafted yet)" } else { r.title.as_str() };
Some(match crate::replace::is_new(r.id) {
true => format!("{} · {title}", crate::replace::label(r.id).replacen("new PR", "New PR", 1)),
false => format!("Replacement of #{} · {title}", r.id),
})
}
(None, None) => (!self.claude.summary.is_empty()).then(|| "Claude's review".to_owned()),
};
if let Some(title) = title {
let mk = |kind, text| Row::text(INFO, kind, text);
let url = self.pr.as_ref().map(|p| p.url.clone()).filter(|u| !u.is_empty());
let links = url.map(|u| vec![(0, title.chars().count(), u)]).unwrap_or_default();
let by = self.pr.as_ref().filter(|p| !p.author.is_empty()).map(|p| {
let head = if p.cross_repo { format!("{}:{}", p.head_owner, p.head_branch) } else { p.head_branch.clone() };
format!(" @{} · {} ← {head}", p.author, p.base)
});
let n = title.chars().count();
let by = by.unwrap_or_default();
let syn = [vec![None; n], vec![Some(self.ui.linenr); by.chars().count()]].concat();
rows.push(Row { links, syn, ..mk(Kind::File, title + &by) });
if self.view == View::Pending && !self.info_collapsed {
if let Some(l) = self.info.first().filter(|l| !l.heading && !l.md) {
rows.push(Row { attached: true, ..self.info_row(l, mk(Kind::Comment, l.text.clone())) });
}
rows.extend(self.draft_rows().into_iter().map(|r| Row { attached: true, ..r }));
}
}
let mut attached: Vec<Row> = match self.view {
_ if self.sections_folded.contains(&DESCRIPTION) => vec![],
_ if self.unpublished() => self.claude_attached(),
View::All if self.info_collapsed => vec![],
View::All => {
let latest = (!self.conversation_all).then_some(LATEST);
[self.pr_facts(), self.conversation(latest), self.no_pr_summary(), self.draft_rows()].into_iter().flatten().collect()
}
View::Claude => self.claude_attached(),
_ => vec![],
};
attached.iter_mut().for_each(|r| r.attached = true);
if self.view == View::All && !self.claude.closing.trim().is_empty() {
let what = match self.pr_state.is_some_and(|s| matches!(s.0, "OPEN" | "DRAFT")) {
true => "closing comment · Claude's draft (Space a: close the PR with it, more changes, discard)",
false => "closing comment · Claude's draft (the PR isn't open: Space a discards it)",
};
rows.extend(self.section(CLOSING_PR, what.into(), &self.claude.closing, None));
}
if matches!(self.view, View::All | View::Claude)
&& let Some(h) = (0..self.info.len()).find(|&i| self.is_description(i))
{
let text = self.info.get(h + 1).filter(|l| l.md).map_or("", |l| l.text.as_str());
match self.claude.description.is_empty() || self.pr.is_none() {
true => {
let text = match (text.trim().is_empty(), self.replacement.is_some()) {
(true, true) => "_(not drafted yet)_",
(true, false) => "_(no description)_",
_ => text,
};
let mut s = self.section(DESCRIPTION, self.info[h].text.clone(), text, Some(h + 1));
if let Some(l) = self.info.first().filter(|_| self.unpublished() && s.len() > 1) {
let mk = |text| Row { main: Some(DESCRIPTION), info: Some(h + 1), ..Row::text(INFO, Kind::Comment, text) };
s.splice(1..1, [self.info_row(l, mk(l.text.clone())), mk(String::new())]);
}
rows.extend(self.attach(s, &mut attached));
}
false => {
let mk = |kind, text| Row { main: Some(DESCRIPTION), info: Some(h + 1), ..Row::text(INFO, kind, text) };
let mut s = vec![mk(Kind::File, "description · Claude's proposal (Space a: approve, more changes, discard)".into())];
if !self.sections_folded.contains(&DESCRIPTION) {
let w = self.wrap_w.saturating_sub(2).max(20);
let lines = |t: &str| t.replace("\r\n", "\n").lines().map(str::to_owned).collect::<Vec<_>>();
for (k, line) in line_diff(&lines(text), &lines(&self.claude.description)) {
s.extend(wrap(&line, w).into_iter().map(|t| mk(k, t)));
}
}
rows.extend(self.attach(s, &mut attached));
}
}
}
rows.append(&mut attached);
if self.view == View::All {
if let Some(h) = (0..self.info.len()).find(|&i| self.is_closing_note(i)) {
let text = self.info.get(h + 1).filter(|l| l.md).map_or("", |l| l.text.as_str());
let text = if text.trim().is_empty() { "_(not drafted yet)_" } else { text };
rows.extend(self.section(CLOSING_NOTE, self.info[h].text.clone(), text, Some(h + 1)));
}
if let Some(r) = self.replacement.as_ref().filter(|r| !r.asks.is_empty()) {
let drafting = r.drafting_now();
let asks: Vec<String> = r.asks.iter().enumerate().map(|(i, (text, done))| match (done, drafting && i < r.taking) {
(true, _) => text.clone(),
(false, true) => format!("{text}\n\n_(Claude is redrafting with it)_"),
(false, false) => format!("{text}\n\n_(not redrafted with it yet)_"),
}).collect();
rows.extend(self.section(ASKED, "what you asked of the drafts".into(), &asks.join("\n\n---\n\n"), None));
}
if let Some((a, _)) = self.commit_shown {
for (k, msg) in self.commit_msgs.iter().enumerate() {
let Some(c) = self.commits.get(a + k) else { break };
rows.extend(self.section(1 + a + k, format!("commit {}", c.short), msg, None));
}
}
}
let unplaced = |th: &Thread| th.conversation != ON_SUMMARY && (self.view != View::All || self.conversation_item(&th.conversation).is_none());
let mut gone: Vec<usize> = (0..self.threads.len())
.filter(|&t| {
let th = &self.threads[t];
self.in_view(th) && !self.has_file(&th.path) && (self.on_code(th) || self.gone_listed() && unplaced(th))
})
.collect();
let label = |t: usize| {
let th = &self.threads[t];
match (th.path.is_empty(), commit_of(&th.conversation)) {
(false, _) => th.path.clone(),
(true, Some(sha)) => format!("about commit {}", self.commit_short(sha)),
(true, None) => "questions about the PR's conversation".into(),
}
};
gone.sort_by_cached_key(|&t| (self.threads[t].path.is_empty(), label(t)));
let gone_at = rows.len();
if !gone.is_empty() {
let heading = |text: String| Row { attached: true, ..Row::text(INFO, Kind::Thread, text) };
let on_files = gone.iter().filter(|&&t| !self.threads[t].path.is_empty()).count();
if on_files > 0 {
rows.push(heading(format!("comments on files no longer in the diff ({on_files})")));
}
let mut last = None;
for &t in &gone {
let l = label(t);
if last.as_ref() != Some(&l) {
rows.push(heading(l.clone()));
last = Some(l);
}
rows.extend(self.thread_rows(t, INFO).into_iter().map(|r| Row { attached: true, ..r }));
}
}
let shown: Vec<&Thread> = self.threads.iter().filter(|t| self.in_view(t)).collect();
for (i, f) in self.files.iter().enumerate() {
let around;
let lines = match self.view != View::All {
true if !shown.iter().any(|t| t.path == f.path) => continue,
true => {
let e = |a| *self.view_extra.get(&(f.path.clone(), a)).unwrap_or(&0);
around = diff::shown(&f.full, &view_windows(&shown, f, e, self.whole.contains(&f.path)), &f.headers);
&around
}
false => &f.lines,
};
let count = |k| f.lines.iter().filter(|l| l.kind == k).count();
let (dir, name) = f.path.rsplit_once('/').map_or(("", f.path.as_str()), |(d, n)| (d, n));
let dir = if dir.is_empty() { String::new() } else { format!("{dir}/") };
let meta = if f.meta.is_empty() { String::new() } else { format!(" ({})", f.meta) };
let dim = Some(self.ui.stale);
let open = self.threads.iter().filter(|t| t.path == f.path && !t.stale() && self.in_view(t)).count();
let comments = match open {
0 => String::new(),
1 => " · 1 comment".into(),
n => format!(" · {n} comments"),
};
let parts = [
(dir, dim),
(name.to_owned(), None),
(meta, dim),
(" ".into(), None),
(format!("+{}", count(Kind::Add)), Some(self.ui.plus)),
(" ".into(), None),
(format!("−{}", count(Kind::Del)), Some(self.ui.minus)),
(comments, Some(self.ui.thread)),
];
let text: String = parts.iter().map(|p| p.0.as_str()).collect();
let syn = parts.iter().flat_map(|(t, st)| t.chars().map(move |_| *st)).collect();
rows.push(Row { syn, ..Row::text(i, Kind::File, text) });
if self.collapsed.contains(&f.path) {
continue;
}
let placed: Vec<(usize, Option<usize>)> = (0..self.threads.len())
.filter(|&t| self.threads[t].path == f.path && self.in_view(&self.threads[t]))
.map(|t| (t, anchor_line(&self.threads[t], lines)))
.collect();
for &(t, _) in placed.iter().filter(|p| p.1.is_none()) {
rows.extend(self.thread_rows(t, i));
}
for (li, l) in lines.iter().enumerate() {
rows.push(Row::new(i, l.clone()));
for &(t, _) in placed.iter().filter(|p| p.1 == Some(li)) {
rows.extend(self.thread_rows(t, i));
}
}
}
if rows.is_empty() {
rows.push(Row::text(usize::MAX, Kind::Hunk, "(no changes)".into()));
}
self.rows = rows;
self.gone_at = gone_at;
self.code_head.row = self.find_key(code).unwrap_or(0);
self.emph = self.word_spans();
self.layout();
}
pub(super) fn word_spans(&self) -> HashMap<usize, Vec<(usize, usize)>> {
let (n, kind) = (self.rows.len(), |i: usize| self.rows[i].line.kind);
let mut out = HashMap::new();
let mut i = 0;
while i < n {
if kind(i) != Kind::Del {
i += 1;
continue;
}
let dels = (i..n).take_while(|&j| kind(j) == Kind::Del).count();
let adds = (i + dels..n).take_while(|&j| kind(j) == Kind::Add).count();
for k in 0..dels.min(adds) {
let (d, a) = (i + k, i + dels + k);
if let Some((x, y)) = diff::word_diff(&self.rows[d].line.text, &self.rows[a].line.text) {
out.insert(d, x);
out.insert(a, y);
}
}
i += dels + adds;
}
out
}
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()
}
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()
}
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 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 ("));
}
fn section(&self, id: usize, title: String, text: &str, info: Option<usize>) -> 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(self.md_rows_at(text, self.wrap_w, mk).into_iter().map(|r| Row { main: Some(id), info, ..r }));
}
rows
}
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
}
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
}
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();
if !summary && !questions {
return vec![];
}
let mk = |kind, text| Row::text(INFO, kind, text);
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
}
pub(super) fn on_code(&self, t: &Thread) -> bool {
commit_of(&t.conversation).is_some() || !t.path.is_empty() && (self.gone_listed() || self.has_file(&t.path))
}
fn gone_listed(&self) -> bool {
!(self.uncommitted() || self.commit_shown.is_some())
}
fn has_file(&self, path: &str) -> bool {
self.files.iter().any(|f| f.path == path)
}
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])
}
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
}
fn no_pr_summary(&self) -> Vec<Row> {
match self.pr.is_none() && self.replacement.is_none() {
true => self.claude_attached(),
false => vec![],
}
}
fn attach(&self, mut s: Vec<Row>, attached: &mut Vec<Row>) -> Vec<Row> {
let at = if self.margin_active { 1 } else { s.len() };
s.splice(at..at, std::mem::take(attached));
s
}
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 section_of(&self, r: usize) -> Option<usize> {
self.rows.get(r)?.main
}
pub(super) fn info_w(&self) -> usize {
if self.margin_active { self.margin_w - self.pane_gw() - 2 - self.pane_pad() } else { self.wrap_w }
}
pub(super) fn pane_pad(&self) -> usize {
usize::from(!self.margin_float)
}
pub(super) fn pane_gw(&self) -> usize {
pane_gw(self.margin_float)
}
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 header_tag(&self, file: usize) -> Option<(String, Style)> {
if file == INFO {
return self.pr_state.map(|(s, color)| (format!(" {s} "), chip(color)));
}
let (t, st) = match self.viewed.get(&self.files.get(file)?.path)?.as_str() {
"VIEWED" => ("✓ Viewed ", self.ui.plus),
_ => ("↻ changed since viewed ", self.ui.warning),
};
Some((t.into(), st))
}
pub(super) fn snippet(&self, body: &str, n: usize) -> Vec<String> {
let w = (self.width as usize * 3 / 4).saturating_sub(6).max(20);
let lines = crate::md::render(body, w, &self.ui.md, &self.hl, None);
let text = lines.into_iter().map(|l| l.text.trim_end().to_owned());
text.skip_while(|l| l.is_empty() || l.starts_with('▎')).filter(|l| !l.is_empty()).take(n).collect()
}
pub(super) fn root_id(&self, t: usize) -> Option<u64> {
Some(self.threads.get(t)?.comments.first()?.id)
}
pub(super) fn thread_rows(&self, ti: usize, file: usize) -> Vec<Row> {
let t = &self.threads[ti];
let open = self.thread_open(ti);
let range = match (t.start_line, t.line.or(t.original_line)) {
(Some(s), Some(e)) if s != e => format!(" · L{s}-{e}"),
(_, Some(e)) if t.outdated => format!(" · was L{e}"),
(_, Some(e)) => format!(" · L{e}"),
_ => String::new(),
};
let first_pending = t.comments.first().is_some_and(|c| c.pending);
let severity = format!("[{}] ", t.severity);
let serious = matches!(t.severity.as_str(), "bug" | "security" | "major" | "blocker");
let turn = agent::turn(t);
let want = agent::want_tag(t);
let tags: Vec<(&str, Style)> = [
(self.is_new(t), "[new] ", self.ui.key),
(first_pending, "[pending] ", self.ui.pending),
(t.outdated, "[outdated] ", self.ui.warning),
(t.resolved, "[resolved] ", self.ui.resolved),
(!t.severity.is_empty(), severity.as_str(), if serious { self.ui.warning } else { self.ui.resolved }),
(turn.is_some(), turn.unwrap_or(""), self.ui.pending),
(want.is_some(), want.unwrap_or(""), self.ui.pending),
]
.into_iter()
.filter_map(|(on, s, c)| on.then_some((s, c)))
.collect();
let tag_text: String = tags.iter().map(|t| t.0).collect();
let tag_syn: Vec<Option<Style>> =
tags.iter().flat_map(|&(s, st)| s.chars().map(move |_| Some(st))).collect();
let mk = |kind, text| Row { thread: Some(ti), ..Row::text(file, kind, text) };
let by_claude = |c: &gh::Comment| t.claude.is_some() && c.author == crate::claude::AUTHOR;
let name = |c: &gh::Comment| if by_claude(c) { format!("◆ {}", c.author) } else { c.author.clone() };
let author_syn = |c: &gh::Comment| vec![by_claude(c).then_some(self.ui.key); name(c).chars().count()];
let re = t
.about
.and_then(|id| self.threads.iter().flat_map(|t| &t.comments).find(|c| c.id == id))
.map_or(String::new(), |c| format!(" · re @{}", c.author));
let when = |c: &gh::Comment| match gh::ago(&c.created) {
a if a.is_empty() => String::new(),
a => format!(" · {a}"),
};
let reacts = |c: &gh::Comment| -> (String, Vec<Option<Style>>) {
let (mut text, mut syn) = (String::new(), vec![]);
for (i, (t, mine)) in reaction_parts(&c.reactions).into_iter().enumerate() {
let gap = if i == 0 { " · " } else { " " };
syn.extend(vec![None; gap.chars().count()]);
syn.extend(vec![mine.then_some(self.ui.key); t.chars().count()]);
text += gap;
text += &t;
}
(text, syn)
};
let with = |mut syn: Vec<Option<Style>>, at: usize, r: Vec<Option<Style>>| {
syn.resize(at, None);
syn.extend(r);
syn
};
let mut out = vec![];
let dim_note = |mut syn: Vec<Option<Style>>, a: usize, b: usize| {
if syn.len() < b {
syn.resize(b, None);
}
syn[a..b].fill(Some(self.ui.stale));
syn
};
for (k, c) in t.comments.iter().enumerate() {
let note = self.hidden_note(&c.hidden, &c.node_id);
if k == 0 {
let tags_below = self.margin_active && !tag_text.is_empty();
let lead = if tags_below { String::new() } else { tag_text.clone() };
let (rt, rs) = reacts(c);
let mut head = format!("{lead}{}", name(c));
let ns = head.chars().count();
head += note.as_deref().unwrap_or("");
let ne = head.chars().count();
head += &format!("{re}{}{range}", when(c));
let at = head.chars().count();
head += &rt;
if !open {
head += &match t.comments.len() {
1 => " · 1 comment".to_owned(),
n => format!(" · {n} comments"),
};
}
let syn = match tags_below {
true => author_syn(c),
false => [tag_syn.clone(), author_syn(c)].concat(),
};
let syn = if rs.is_empty() { syn } else { with(syn, at, rs) };
out.push(Row { syn: dim_note(syn, ns, ne), ..mk(Kind::Thread, head) });
if tags_below {
out.push(Row { syn: tag_syn.clone(), ..mk(Kind::Comment, tag_text.trim_end().to_owned()) });
}
if !open {
break;
}
if t.outdated {
let dim = Some(self.ui.stale);
out.extend(t.hunk.iter().map(|h| Row { syn: vec![dim; h.chars().count()], ..mk(Kind::Comment, h.clone()) }));
}
} else {
let tag = if c.pending { "[pending] " } else { "" };
let syn = [vec![Some(self.ui.pending); tag.chars().count()], author_syn(c)].concat();
let (rt, rs) = reacts(c);
let who = format!("{tag}{}", name(c));
let note = note.as_deref().unwrap_or("");
let (ns, ne) = (who.chars().count(), who.chars().count() + note.chars().count());
let head = format!("{who}{note}{}", when(c));
let syn = if rs.is_empty() { syn } else { with(syn, head.chars().count(), rs) };
out.push(Row { syn: dim_note(syn, ns, ne), ..mk(Kind::Thread, format!("{head}{rt}")) });
}
if note.is_some() {
continue;
}
let w = self.info_w();
let rows = self.md_rows_at(&c.body, w, mk);
match t.stale() {
true => out.extend(rows.into_iter().map(|mut r| {
r.syn = vec![Some(self.ui.stale); r.line.text.chars().count()];
r
})),
false => out.extend(rows),
}
}
if open {
out.push(Row { edge: true, ..mk(Kind::Comment, String::new()) });
}
out
}
pub(super) fn box_part(&self, r: usize) -> Option<BoxPart> {
let key = |r: &Row| match (r.thread, r.info) {
(Some(t), _) => Some((Some(t), None)),
(None, Some(i)) if r.file == INFO && r.main.is_none() && i != MORE_CONVERSATION && !self.hidden_row(r) && !self.info.get(i).is_some_and(|l| l.event) => Some((None, Some(i))),
_ => None,
};
let row = &self.rows[r];
let k = key(row)?;
if row.thread.is_some_and(|t| !self.thread_open(t)) {
return None; }
let first = r == 0 || key(&self.rows[r - 1]) != Some(k);
Some(match (row.edge, row.line.kind, first) {
(true, ..) => BoxPart::Bottom,
(_, _, true) => BoxPart::Top,
(_, Kind::Thread, false) => BoxPart::Divider, _ => BoxPart::Body,
})
}
pub(super) fn thread_open(&self, ti: usize) -> bool {
self.threads[ti].stale() == self.root_id(ti).is_some_and(|id| self.toggled.contains(&id))
}
pub(super) fn toggle_thread(&mut self) {
let Some(ti) = self.rows[self.head.row].thread else { return };
let Some(id) = self.root_id(ti) else { return };
flip(&mut self.toggled, id);
self.rebuild_at(|r| r.thread == Some(ti));
}
pub(super) fn rebuild_at(&mut self, at: impl Fn(&Row) -> bool) {
self.build();
self.select = false;
let r = self.rows.iter().position(at).unwrap_or(0);
self.goto(r, 0);
}
pub(super) fn locate(&self, path: &str, old: Option<u32>, new: Option<u32>) -> usize {
let Some(fi) = self.files.iter().position(|f| f.path == path) else { return 0 };
let in_file = |r: &&Row| r.file == fi;
let hit = match (new, old) {
(Some(n), _) => self.rows.iter().position(|r| in_file(&r) && r.line.new.is_some_and(|x| x >= n)),
(None, Some(o)) => self.rows.iter().position(|r| in_file(&r) && r.line.old.is_some_and(|x| x >= o)),
_ => None,
};
hit.or_else(|| self.rows.iter().position(|r| r.file == fi)).unwrap_or(0)
}
pub(super) fn expand(&mut self, d: i64) {
let row = &self.rows[self.head.row];
let Some(f) = self.files.get(row.file) else { return };
let path = f.path.clone();
if d < 0 && self.whole.remove(&path) {
return self.reshape();
}
let code = self.rows[self.head.row..]
.iter()
.take_while(|r| r.file == row.file)
.find(|r| matches!(r.line.kind, Kind::Ctx | Kind::Add | Kind::Del));
let Some(idx) = code.and_then(|c| f.full.iter().position(|x| x.old == c.line.old && x.new == c.line.new)) else {
return;
};
let view = self.view != View::All;
let extra = if view { &self.view_extra } else { &self.extra };
let e = |a| *extra.get(&(path.clone(), a)).unwrap_or(&0);
let ws = match view {
true => view_windows(&self.threads.iter().filter(|t| self.in_view(t)).collect::<Vec<_>>(), f, e, false),
false => diff::windows(&f.full, DEFAULT_CONTEXT as usize, e, self.whole.contains(&path)),
};
let Some((_, _, hunks)) = ws.into_iter().find(|w| w.0 <= idx && idx <= w.1) else { return };
let extra = if view { &mut self.view_extra } else { &mut self.extra };
for a in hunks {
let x = extra.entry((path.clone(), a)).or_insert(0);
*x = (*x + d).max(-(DEFAULT_CONTEXT as i64));
}
self.reshape();
}
pub(super) fn toggle_whole(&mut self) {
let Some(p) = self.file_path().map(str::to_owned) else { return };
flip(&mut self.whole, p);
self.reshape();
}
pub(super) fn reshape(&mut self) {
let keep = self.cursor_key();
self.shape();
self.rebuild(keep);
}
pub(super) fn toggle_collapse(&mut self) {
let fi = self.rows[self.head.row].file;
let attached = self.rows[self.head.row].attached;
if attached && self.head.row >= self.gone_at {
return self.msg = "nothing to fold here".into();
}
if attached && self.view != View::Pending {
if !self.rows.iter().any(|r| r.main == Some(DESCRIPTION) && r.line.kind == Kind::File) {
return self.msg = "nothing to fold here".into();
}
flip(&mut self.sections_folded, DESCRIPTION);
return self.rebuild_at(|r| r.main == Some(DESCRIPTION) && r.line.kind == Kind::File);
}
if let Some(id) = self.section_of(self.head.row) {
if !self.sections_folded.remove(&id) {
self.sections_folded.insert(id);
}
return self.rebuild_at(move |r| r.main == Some(id) && r.line.kind == Kind::File);
}
if fi == INFO && self.view == View::Claude {
return self.msg = "nothing to fold here".into();
}
if fi == INFO {
self.info_collapsed = !self.info_collapsed;
return self.rebuild_at(|_| true);
}
let Some(p) = self.files.get(fi).map(|f| f.path.clone()) else { return };
flip(&mut self.collapsed, p);
self.rebuild_at(|r| r.file == fi);
}
pub(super) fn toggle_collapse_all(&mut self) {
let fi = self.rows[self.head.row].file;
if self.files.iter().any(|f| !self.collapsed.contains(&f.path)) {
self.collapsed = self.files.iter().map(|f| f.path.clone()).collect();
} else {
self.collapsed.clear();
}
self.rebuild_at(|r| r.file == fi);
}
}
pub(super) fn pane_gw(float: bool) -> usize {
if float { 3 } else { 1 }
}
fn line_diff(old: &[String], new: &[String]) -> Vec<(Kind, String)> {
let (n, m) = (old.len(), new.len());
if n * m > 250_000 {
return old.iter().map(|l| (Kind::Del, l.clone())).chain(new.iter().map(|l| (Kind::Add, l.clone()))).collect();
}
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
lcs[i][j] = if old[i] == new[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
}
}
let (mut i, mut j, mut out) = (0, 0, vec![]);
while i < n || j < m {
if i < n && j < m && old[i] == new[j] {
out.push((Kind::Ctx, old[i].clone()));
(i, j) = (i + 1, j + 1);
} else if i < n && (j == m || lcs[i + 1][j] >= lcs[i][j + 1]) {
out.push((Kind::Del, old[i].clone())); i += 1;
} else {
out.push((Kind::Add, new[j].clone()));
j += 1;
}
}
out
}
#[test]
fn description_diff() {
let l = |s: &str| s.lines().map(str::to_owned).collect::<Vec<_>>();
let d = line_diff(&l("a\nb\nc"), &l("a\nB\nc\nd"));
let k: Vec<Kind> = d.iter().map(|x| x.0).collect();
assert_eq!(k, [Kind::Ctx, Kind::Del, Kind::Add, Kind::Ctx, Kind::Add]);
assert_eq!(super::wrap(" - one two", 8), [" - one", " two"]);
}