use super::*;
impl App {
pub(super) fn ensure_review(&mut self) -> Result<String> {
if let Some(r) = &self.review {
return Ok(r.clone());
}
let pr = self.pr.as_ref().context("no PR")?;
let id = gh::start_review(&pr.node_id, &pr.head_oid)?;
self.review = Some(id.clone());
Ok(id)
}
pub(super) fn run_job(&mut self, job: Job) {
if let Job::Pull = job {
let res = self.pull();
self.hl_cache.retain(|k, _| !k.1); let reload = self.reload(true);
self.msg = match (res, reload) {
(Err(e), _) | (_, Err(e)) => format!("{e:#}"),
(Ok(m), Ok(())) => m,
};
return;
}
if let Job::Push = job {
let res = self.push();
let reload = self.reload(true); self.msg = match (res, reload) {
(Err(e), _) | (_, Err(e)) => format!("{e:#}"),
(Ok(m), Ok(())) => m,
};
return;
}
if let Job::Preview(url) = job {
match gh::issue_preview(&url) {
Ok((title, md)) => (self.msg.clear(), self.popup = Some((title, self.hover_lines(&md), 0))).1,
Err(e) => self.msg = format!("{e:#}"),
}
return;
}
let mut viewed = None;
let res = match job {
Job::Refresh => Ok("refreshed"),
Job::Resolve(t, on) => gh::set_resolved(&t, on).map(|_| if on { "thread resolved" } else { "thread unresolved" }),
Job::Discard(r) => gh::delete_review(&r).map(|_| {
self.review = None;
"pending review discarded"
}),
Job::DeleteComment(c) => gh::delete_comment(&c).map(|_| "comment deleted"),
Job::Preview(_) | Job::Pull | Job::Push => unreachable!("handled above"),
Job::Post(p) => self.post(p),
Job::Viewed(path, on) => {
let pr_id = self.pr.as_ref().map(|p| p.node_id.clone()).unwrap_or_default();
let res = gh::set_viewed(&pr_id, &path, on);
if res.is_ok() && !on {
self.collapsed.remove(&path);
}
viewed = Some((path, on));
res.map(|_| if on { "marked as viewed" } else { "unmarked as viewed" })
}
};
self.hl_cache.retain(|k, _| !k.1);
let reload = self.reload(true);
if let Some((path, on)) = viewed
&& let Some(fi) = self.files.iter().position(|f| f.path == path)
&& let Some(own) = self.rows.iter().position(|r| r.file == fi && r.line.kind == Kind::File)
{
let later = self.rows[own + 1..].iter().position(|r| r.line.kind == Kind::File).map(|i| own + 1 + i);
self.select = false;
self.goto(if on { later.unwrap_or(own) } else { own }, 0);
}
self.msg = match (res, reload) {
(Err(e), _) | (_, Err(e)) => format!("{e:#}"),
(Ok(m), Ok(())) => m.into(),
};
}
pub(super) fn post_later(&mut self, p: Post) -> Result<String> {
let msg = match &p {
Post::Thread { review: true, .. } | Post::Reply { review: true, .. } => "adding to your pending review…",
Post::Thread { .. } | Post::Reply { .. } | Post::Converse(_) => "posting…",
Post::Edit { .. } => "updating the comment…",
Post::Submit(..) => "submitting the review…",
};
self.job = Some(Job::Post(p));
Ok(msg.into())
}
fn post(&mut self, p: Post) -> Result<&'static str> {
let pr = self.pr.clone().context("no PR")?;
match p {
Post::Thread { anchor, review, body, claude } => {
let res = match review {
true => self.ensure_review().and_then(|r| gh::add_thread(&r, &anchor, &body)),
false => gh::comment(&pr, &anchor, &body),
};
keep_on_error(res, &body)?;
if let (Some(k), Some(p)) = (claude, &self.claude_path) {
crate::claude::dismiss(p, &k)?;
}
Ok(posted_msg(review))
}
Post::Reply { thread, comment, review, body } => {
let res = match review {
true => self.ensure_review().and_then(|r| gh::add_reply(&r, &thread, &body)),
false => gh::reply(&pr, comment, &body),
};
keep_on_error(res, &body).map(|_| posted_msg(review))
}
Post::Converse(body) => {
keep_on_error(gh::conversation_comment(&pr, &body), &body).map(|_| "comment posted to the conversation")
}
Post::Edit { comment, body } => keep_on_error(gh::edit_comment(&comment, &body), &body).map(|_| "comment updated"),
Post::Submit(event, body) => {
let close = event == CLOSE;
let event = if close { "COMMENT" } else { event };
if !close || self.review.is_some() || !body.trim().is_empty() {
let res = match &self.review {
Some(r) => gh::submit_review(r, event, &body),
None => gh::review_now(&pr.node_id, &pr.head_oid, event, &body),
};
keep_on_error(res, &body)?;
self.review = None;
}
if !close {
return Ok("review submitted");
}
gh::close(&pr).context("the review went in, but closing the PR failed")?;
Ok("PR closed")
}
}
}
fn pull(&self) -> Result<String> {
match &self.pr {
Some(p) => {
let detached = diff::git(&["branch", "--show-current"])?.trim().is_empty();
let note = crate::checkout_or_keep(p.number, false, detached)?;
Ok(note.unwrap_or_else(|| format!("up to date with PR #{}", p.number)))
}
None => {
diff::git(&["pull", "--ff-only"])?;
Ok("pulled".into())
}
}
}
pub(super) fn apply_viewed(&mut self, viewed: HashMap<String, String>) {
for (path, state) in &viewed {
if state == "VIEWED" && self.viewed.get(path).is_none_or(|old| old != "VIEWED") {
self.collapsed.insert(path.clone());
}
}
self.viewed = viewed;
}
pub(super) fn toggle_viewed(&mut self) {
let Some(path) = self.file_path().map(str::to_owned) else {
return self.msg = "not in a file".into();
};
if self.pr.is_none() {
return self.msg = "no PR for this branch".into();
}
if self.files.iter().any(|f| f.path == path && f.meta == "untracked") {
return self.msg = format!("{path} is untracked: not on GitHub, so it can't be marked viewed there");
}
let on = self.viewed.get(&path).is_none_or(|s| s != "VIEWED");
self.msg = if on { "marking as viewed…" } else { "unmarking…" }.into();
self.job = Some(Job::Viewed(path, on));
}
pub(super) fn pending_count(&self) -> usize {
self.threads.iter().flat_map(|t| &t.comments).filter(|c| c.pending).count()
}
pub(super) fn review_menu(&mut self) {
if self.pr.is_none() && self.replacement.is_some() {
return self.replacement_menu();
}
if self.pr.is_none() {
self.msg = "no PR for this branch".into();
return;
}
let item = |label: &str, target| Item::new(label, target);
let mut items = vec![
item("Comment", Target::Submit("COMMENT")),
item("Approve", Target::Submit("APPROVE")),
item("Request changes", Target::Submit("REQUEST_CHANGES")),
item("Comment and close the PR", Target::Submit(CLOSE)),
];
if self.review.is_some() {
let n = self.pending_count();
items.push(item(&format!("Discard pending review ({n} comment(s))"), Target::Discard));
}
items.extend(self.submit_notes().iter().map(|n| item(&format!(" ! {n}"), Target::Nothing)));
self.picker = Some(Picker { title: " submit review ", items, query: String::new(), sel: 0 });
}
fn replacement_menu(&mut self) {
let Some(r) = &self.replacement else { return };
let item = |label: String, target| Item::new(label, target);
let open: Vec<String> = r.replaces.iter().filter(|o| !r.closed.contains(&o.number)).map(|o| format!("#{}", o.number)).collect();
let dirty = !diff::git(&["-C", &self.root.to_string_lossy(), "status", "--porcelain"]).unwrap_or_default().trim().is_empty();
let to = crate::replace::remote().map_or("your remote: dv asks which".to_owned(), |r| format!("to {r}"));
let mut items = match r.published {
_ if r.drafting_now() => vec![item("Claude is drafting it: wait for it to finish".into(), Target::Nothing)],
None if r.title.is_empty() => vec![item("Claude hasn't drafted it yet (Space a → Redraft)".into(), Target::Nothing)],
None if dirty => vec![item("Commit the uncommitted changes first (they'd be left out)…".into(), Target::GitCommit)],
None => {
let mut v = vec![item(format!("Publish as a draft PR (rebased on origin/{}, pushed {to})…", r.base), Target::Publish(false))];
if !open.is_empty() {
v.push(item(format!("Publish, then close {} with the closing note…", open.join(", ")), Target::Publish(true)));
}
v
}
Some(n) => vec![item(format!("Published as draft PR #{n}"), Target::Nothing)],
};
match (r.published, open.is_empty()) {
(_, true) => items.push(item(" the PRs it replaces are closed".into(), Target::Nothing)),
(Some(_), false) => items.push(item(format!("Close {} with the closing note (now)…", open.join(", ")), Target::CloseOriginals)),
(None, false) => {}
}
self.picker = Some(Picker { title: " replacement ", items, query: String::new(), sel: 0 });
}
pub(super) fn ask_replace_remote(&mut self, close: bool) -> String {
let choices = crate::replace::remote_choices();
if choices.is_empty() {
return "replacement branches go to a remote of your own, not origin: add one (git remote add <name> <your fork's URL>), then S again".into();
}
let items = choices.into_iter().map(|(n, url)| Item::new(format!("{n} {url}"), Target::ReplaceRemote(n, close))).collect();
self.picker = Some(Picker { title: " which remote keeps replacement branches? ", items, query: String::new(), sel: 0 });
"dv remembers it (git config dv.replaceRemote)".into()
}
pub(super) fn watch_replacement(&mut self) {
let Some(id) = self.replacement.as_ref().map(|r| r.id) else { return };
let mtime = crate::replace::path(id).ok().and_then(|p| std::fs::metadata(p).ok()?.modified().ok());
if mtime == self.replacement_mtime {
return;
}
let first = self.replacement_mtime.is_none();
self.replacement_mtime = mtime;
if let (false, Ok(r)) = (first, crate::replace::load(id)) {
let keep = self.cursor_key();
self.show_replacement(r);
self.rebuild(keep);
}
}
pub(super) fn submit_notes(&self) -> Vec<String> {
let count = |f: &dyn Fn(&Thread) -> bool| self.threads.iter().filter(|t| f(t)).count();
let open = count(&|t| t.claude.is_some() && !t.resolved && !t.outdated);
let answered = count(&|t| agent::turn(t) == Some("[answered] "));
let github = count(&|t| t.claude.is_none() && !t.resolved && !t.outdated && !t.comments.iter().all(|c| c.pending));
let stale = count(&|t| t.outdated && t.comments.iter().any(|c| c.pending));
let head = diff::git(&["rev-parse", "HEAD"]).unwrap_or_default();
let behind = self.pr.as_ref().is_some_and(|p| !p.head_oid.is_empty() && p.head_oid != head.trim());
[
(open > 0, format!("{open} open comment(s) in Claude's review ({answered} answered, your turn)")),
(github > 0, format!("{github} unresolved thread(s) on GitHub")),
(stale > 0, format!("{stale} of your pending comments are on code that has changed since")),
(behind, "your checkout isn't the PR's head: what you reviewed isn't what you'd approve".into()),
]
.into_iter()
.filter_map(|(on, s)| on.then_some(s))
.collect()
}
pub(super) fn start_delete(&mut self) {
if let Some((_, k)) = self.claude_at_cursor() {
return self.edit_claude(&k, crate::claude::dismiss, "dismissed Claude's comment");
}
let Some(c) = self.rows[self.head.row].thread.and_then(|t| self.comment_under_cursor(t)) else {
self.msg = "move onto a comment to delete it".into();
return;
};
self.confirm_delete(c.clone());
}
pub(super) fn confirm_delete(&mut self, c: gh::Comment) {
if !c.can_delete {
self.msg = format!("you can't delete @{}'s comment", c.author);
return;
}
let preview: String = c.body.lines().next().unwrap_or("").chars().take(40).collect();
let items = vec![
Item::new("No, keep it", Target::Nothing),
Item::new(format!("Yes, delete @{}'s comment: \"{preview}\"", c.author), Target::DeleteComment(c.node_id.clone())),
];
self.picker = Some(Picker { title: " delete comment? ", items, query: String::new(), sel: 0 });
}
pub(super) fn start_edit(&mut self) {
if self.claude_at_cursor().is_some() {
return self.msg = "c copies Claude's comment into your review, to edit there; r replies to Claude".into();
}
let Some(c) = self.rows[self.head.row].thread.and_then(|t| self.comment_under_cursor(t)) else {
self.msg = "move onto a comment to edit it".into();
return;
};
match c.can_edit {
true => {
let (comment, body, author) = (c.node_id.clone(), c.body.clone(), c.author.clone());
self.action = Some(Action::Edit { comment, body, author });
}
false => self.msg = format!("you can't edit @{}'s comment", c.author),
}
}
pub(super) fn toggle_resolved(&mut self) {
if let Some((t, k)) = self.claude_at_cursor() {
return match self.threads[t].resolved {
true => self.edit_claude(&k, |p, k| crate::claude::set_resolved(p, k, false), "unresolved (Claude's comment)"),
false => self.edit_claude(&k, |p, k| crate::claude::set_resolved(p, k, true), "resolved (Claude's comment)"),
};
}
match self.rows[self.head.row].thread.map(|t| &self.threads[t]) {
Some(t) => {
self.msg = if t.resolved { "unresolving…" } else { "resolving…" }.into();
self.job = Some(Job::Resolve(t.id.clone(), !t.resolved));
}
None => self.msg = "move onto a comment thread".into(),
}
}
pub(super) fn load_threads(&mut self) {
self.load_github();
self.load_claude();
}
pub(super) fn load_claude(&mut self) {
self.threads.retain(|t| t.claude.is_none());
self.claude_summary.clear();
self.claude_head.clear();
let Some(p) = &self.claude_path else { return };
let loaded = crate::claude::load_with_ids(p);
self.claude_mtime = std::fs::metadata(p).and_then(|m| m.modified()).ok(); match loaded {
Ok(Some(mut r)) => {
let head = diff::git(&["rev-parse", "HEAD"]).unwrap_or_default();
let moved = !r.head.is_empty() && !head.trim().starts_with(&r.head) && !r.head.starts_with(head.trim());
if moved && r.threads.iter().any(|t| t.hunk.is_empty()) {
self.msg = format!(
"Claude reviewed {}; HEAD has moved since, so its line numbers may be off (`dv review-check` re-anchors)",
&r.head[..r.head.len().min(8)]
);
}
if !self.uncommitted() {
crate::claude::reanchor(&mut r.threads, &self.files);
}
(self.claude_summary, self.claude_head) = (r.summary, r.head);
self.threads.extend(r.threads);
}
Ok(None) => {}
Err(e) => self.msg = format!("Claude's review: {e:#}"),
}
}
pub(super) fn watch_claude(&mut self) {
let Some(p) = &self.claude_path else { return };
let mtime = std::fs::metadata(p).and_then(|m| m.modified()).ok();
if mtime != self.claude_mtime {
let keep = self.cursor_key();
self.msg.clear(); self.load_claude();
self.rebuild(keep);
if self.msg.is_empty() {
let n = self.threads.iter().filter(|t| agent::claude_wrote(t)).count();
self.msg = format!("Claude's review updated: {n} comment(s)");
}
}
}
pub(super) fn edit_claude(&mut self, key: &str, f: fn(&std::path::Path, &str) -> Result<()>, done: &str) {
let Some(p) = self.claude_path.clone() else { return };
let (keep, res) = (self.cursor_key(), f(&p, key));
self.load_claude();
self.rebuild(keep);
self.msg = match res {
Ok(()) => done.into(),
Err(e) => format!("{e:#}"),
};
}
pub(super) fn claude_at_cursor(&self) -> Option<(usize, String)> {
let t = self.rows[self.head.row].thread?;
Some((t, self.threads[t].claude.clone()?))
}
pub(super) fn start_promote(&mut self, t: usize, key: String, review: bool) {
let th = &self.threads[t];
if !agent::claude_wrote(th) {
return self.msg = "this is your comment for Claude (private): r replies, Space d removes it".into();
}
if th.outdated {
return self.msg = "this comment is outdated (its code changed): ask Claude to update it, or Space d".into();
}
let anchor = Anchor { path: th.path.clone(), line: th.line.unwrap_or(0), left: th.left, start: th.start_line.map(|s| (s, th.left)) };
let mut body = self.comment_under_cursor(t).map(|c| c.body.clone()).unwrap_or_default();
if !th.severity.is_empty() && !body.starts_with("**") && self.comment_index_under_cursor(t) == 0 {
body = format!("**{}:** {body}", th.severity);
}
match self.check_synced().and_then(|_| self.on_github(anchor)) {
Ok(anchor) => self.action = Some(Action::Comment { anchor, review, prefill: body, claude: Some(key) }),
Err(e) => self.msg = format!("{e:#}"),
}
}
fn load_replacement(&mut self) {
if let Some(r) = crate::replace::current().and_then(|id| crate::replace::load(id).ok()) {
self.show_replacement(r);
}
}
pub(super) fn show_replacement(&mut self, r: crate::replace::Record) {
let line = |heading, md, text: &str| gh::InfoLine { heading, md, text: text.into(), marks: vec![], url: String::new() };
let mut replaces = line(false, false, "replaces ");
for (i, o) in r.replaces.iter().enumerate() {
let sep = if i == 0 { "" } else { ", " };
replaces.text.push_str(sep);
let at = replaces.text.chars().count();
let num = format!("#{}", o.number);
replaces.text.push_str(&format!("{num} {} (@{})", o.title, o.author));
replaces.marks.push((at, at + num.chars().count(), gh::Mark::Link(o.url.clone())));
}
let mut status = vec![];
if let Some(n) = r.published {
let url = r.replaces.first().and_then(|o| Some(format!("{}/{n}", o.url.rsplit_once('/')?.0))).unwrap_or_default();
let mut l = line(false, false, &format!("published as draft PR #{n}"));
l.marks.push((l.text.chars().count() - format!("#{n}").len(), l.text.chars().count(), gh::Mark::Link(url)));
status.push(l);
}
if !r.closed.is_empty() {
let closed: Vec<String> = r.closed.iter().map(|n| format!("#{n}")).collect();
status.push(line(false, false, &format!("closed {}, with the note", closed.join(", "))));
}
self.info = vec![replaces];
self.info.extend(status);
self.info.extend([
line(true, false, "description (Claude's draft)"),
line(false, true, &r.body),
line(true, false, "closing note, for the PRs it replaces (Claude's draft)"),
line(false, true, &r.closing_note),
]);
self.pr_state = Some(if r.published.is_some() { ("PUBLISHED", "238636") } else { ("UNPUBLISHED", "6e7681") });
self.replacement = Some(r);
}
fn load_github(&mut self) {
if self.pr.is_none() {
return self.load_replacement();
}
let Some(n) = self.pr.as_ref().map(|p| p.number) else { return };
let (threads, info) = std::thread::scope(|s| {
let info = s.spawn(|| gh::info(n));
(gh::threads(n), info.join())
});
match threads {
Ok(r) => {
self.apply_viewed(r.viewed);
(self.threads, self.review) = (r.threads, r.pending);
if let Some(pr) = &mut self.pr {
pr.node_id = r.pr_id;
}
(self.theirs, self.me) = (!r.mine, r.me);
}
Err(e) => self.msg = format!("loading comments: {e:#}"),
}
match info {
Ok(Ok((lines, title, head, url, state))) => {
(self.info, self.pr_state) = (lines, Some(state));
if let Some(pr) = &mut self.pr {
(pr.title, pr.head_oid, pr.url) = (title, head, url);
}
}
Ok(Err(e)) => self.msg = format!("loading PR info: {e:#}"),
Err(_) => self.msg = "loading PR info panicked".into(),
}
}
pub(super) fn link_at_cursor(&self) -> Option<&str> {
let col = self.head.col;
self.rows[self.head.row].links.iter().find(|(a, b, _)| *a <= col && col < *b).map(|l| l.2.as_str())
}
pub(super) fn open_link(&mut self) {
let Some(url) = self.link_at_cursor().map(str::to_owned) else {
let text = &self.rows[self.head.row].line.text;
if let Some((path, line)) = file_ref(text, self.head.col) {
return self.goto_file_line(&path, line);
}
let raw = self.ui.md.raw.fg;
let in_code = raw.is_some() && self.rows[self.head.row].syn.get(self.head.col).is_some_and(|s| s.and_then(|s| s.fg) == raw);
return match ident_at(text, self.head.col, in_code) {
Some(name) => self.goto_definition(&name),
None => self.msg = "no link, file:line or name under the cursor".into(),
};
};
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
let spawned = std::process::Command::new(opener)
.arg(&url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
self.msg = match spawned {
Ok(_) => format!("opened {url}"),
Err(e) => format!("{opener}: {e}"),
};
}
fn goto_file_line(&mut self, path: &str, line: u32) {
let path = path.trim_start_matches("./");
let ends = |p: &str| p == path || p.ends_with(&format!("/{path}"));
let found = match self.root.join(path).is_file() {
true => Some(path.to_owned()),
false => self.files.iter().map(|f| f.path.clone()).find(|p| ends(p)).or_else(|| {
let all = diff::git(&["ls-files"]).unwrap_or_default();
let mut hits = all.lines().filter(|p| ends(p));
match (hits.next(), hits.next()) {
(Some(p), None) => Some(p.to_owned()),
_ => None,
}
}),
};
let Some(rel) = found else { return self.msg = format!("no file {path} here") };
if self.collapsed.remove(&rel) {
let keep = self.cursor_key();
self.rebuild(keep);
}
self.jump_loc(&crate::lsp::Loc { path: self.root.join(&rel), line: line.saturating_sub(1), character: 0 });
}
pub(super) fn goto_definition(&mut self, name: &str) {
let pat = format!("(^|[^[:alnum:]_])({DEF_WORDS})[[:space:]]+{name}([^[:alnum:]_]|$)");
let root = self.root.display().to_string();
let out = diff::git(&["-C", &root, "grep", "-n", "-z", "-I", "--untracked", "-E", &pat]).unwrap_or_default();
let mut locs: Vec<crate::lsp::Loc> = out
.lines()
.filter_map(|l| {
let mut parts = l.splitn(3, '\0');
let (path, line, text) = (parts.next()?, parts.next()?.parse::<u32>().ok()?, parts.next()?);
let character = text.find(name).map_or(0, |i| text[..i].encode_utf16().count()) as u32;
Some(crate::lsp::Loc { path: self.root.join(path), line: line - 1, character })
})
.collect();
let in_diff: HashSet<PathBuf> = self.files.iter().map(|f| self.root.join(&f.path)).collect();
locs.sort_by_key(|l| !in_diff.contains(&l.path));
locs.truncate(50);
if locs.is_empty() {
return self.msg = format!("no definition of {name} found");
}
self.show_locations(locs);
}
pub(super) fn threads_picker(&mut self) {
self.threads_picker_of(" threads ", |_, _| true, "no review threads");
}
pub(super) fn claude_picker(&mut self) {
self.threads_picker_of(" Claude's comments ", |_, t| t.claude.is_some(), "no comments from Claude (Space a asks for a review)");
}
fn threads_picker_of(&mut self, title: &'static str, keep: impl Fn(usize, &Thread) -> bool, none: &str) {
let items: Vec<Item> = self
.threads
.iter()
.enumerate()
.filter(|&(ti, t)| keep(ti, t))
.filter_map(|(ti, t)| {
let c = t.comments.first()?;
let line = t.line.or(t.original_line).map_or(String::new(), |n| format!(":{n}"));
let severity = (!t.severity.is_empty()).then(|| format!("[{}] ", t.severity));
let tags: String = [
(self.is_new(t), "[new] "),
(t.comments.iter().any(|c| c.pending), "[pending] "),
(t.resolved, "[resolved] "),
(t.outdated, "[outdated] "),
]
.iter()
.filter_map(|&(on, s)| on.then_some(s))
.chain(severity.as_deref())
.chain(agent::turn(t))
.collect();
let replies = match t.comments.len() - 1 {
0 => String::new(),
1 => "1 reply · ".into(),
n => format!("{n} replies · "),
};
let label = format!("{}{line} {replies}{tags}@{}", t.path, c.author);
Some(Item { detail: self.snippet(&c.body, 2), ..Item::new(label, Target::Comment(ti, 0)) })
})
.collect();
match items.is_empty() {
true => self.msg = none.into(),
false => self.picker = Some(Picker { title, items, query: String::new(), sel: 0 }),
}
}
pub(super) fn new_picker(&mut self) {
let new: HashSet<usize> = (0..self.threads.len()).filter(|&t| self.is_new(&self.threads[t])).collect();
self.threads_picker_of(" new comments ", |ti, _| new.contains(&ti), "nothing new: you've seen every comment");
}
pub(super) fn pending_picker(&mut self) {
let mut items = vec![];
for (ti, t) in self.threads.iter().enumerate() {
for (ci, c) in t.comments.iter().enumerate().filter(|(_, c)| c.pending) {
let line = t.line.or(t.original_line).map_or(String::new(), |n| format!(":{n}"));
let kind = match (ci, c.body.contains("```suggestion")) {
(_, true) => "suggestion",
(0, _) => "comment",
_ => "reply",
};
let item = Item::new(format!("{}{line} {kind}", t.path), Target::Comment(ti, ci));
items.push(Item { detail: self.snippet(&c.body, 2), ..item });
}
}
match items.is_empty() {
true => self.msg = "no pending comments".into(),
false => self.picker = Some(Picker { title: " pending comments ", items, query: String::new(), sel: 0 }),
}
if self.picker.is_some() {
self.msg = "Enter jumps there · Ctrl-d deletes it".into();
}
}
pub(super) fn next_claude(&mut self, fwd: bool) {
let items = (0..self.threads.len()).filter(|&t| agent::claude_wrote(&self.threads[t])).map(|t| (t, 0)).collect();
self.next_comment(fwd, items, "no comments from Claude (Space a asks for a review)");
}
pub(super) fn next_new(&mut self, fwd: bool) {
let items = (0..self.threads.len()).filter(|&t| self.is_new(&self.threads[t])).map(|t| (t, 0)).collect();
self.next_comment(fwd, items, "nothing new: you've seen every comment");
}
pub(super) fn next_pending(&mut self, fwd: bool) {
let threads = &self.threads;
let items = (0..threads.len())
.flat_map(|t| (0..threads[t].comments.len()).filter(move |&c| threads[t].comments[c].pending).map(move |c| (t, c)))
.collect();
self.next_comment(fwd, items, "no pending comments");
}
fn next_comment(&mut self, fwd: bool, items: Vec<(usize, usize)>, none: &str) {
let file = |path: &str| self.files.iter().position(|f| f.path == path).map_or(Some(0), |i| Some(i + 1));
let key = |t: usize, c: usize| -> Option<(usize, u32, i64, usize)> {
let th = &self.threads[t];
Some((file(&th.path)?, th.line.or(th.original_line).unwrap_or(0), t as i64, c))
};
let any_here = items.iter().any(|&(t, _)| self.in_view(&self.threads[t]));
let mut order: Vec<_> = items.into_iter().filter(|&(t, _)| !any_here || self.in_view(&self.threads[t])).filter_map(|(t, c)| key(t, c)).collect();
if order.is_empty() {
return self.msg = none.into();
}
order.sort();
let row = &self.rows[self.head.row];
let here = match row.thread {
Some(t) => key(t, self.comment_index_under_cursor(t)),
None if row.file == INFO => None,
None => self.files.get(row.file).map(|_| (row.file + 1, row.line.new.or(row.line.old).unwrap_or(0), -1, 0)),
};
let pick = match (fwd, here) {
(true, Some(h)) => order.iter().find(|&&o| o > h).or(order.first()),
(false, Some(h)) => order.iter().rev().find(|&&o| o < h).or(order.last()),
(true, None) => order.first(),
(false, None) => order.last(),
};
if let Some(&(_, _, t, c)) = pick {
self.goto_comment(t as usize, c);
}
}
pub(super) fn goto_comment(&mut self, ti: usize, ci: usize) {
let Some(t) = self.threads.get(ti) else { return };
if !self.in_view(t) {
self.view = View::All; }
let t = &self.threads[ti];
self.collapsed.remove(&t.path.clone());
if !self.thread_open(ti)
&& let Some(id) = self.root_id(ti)
{
flip(&mut self.toggled, id);
}
self.build();
let row = self.rows.iter().enumerate().filter(|(_, r)| r.thread == Some(ti) && r.line.kind == Kind::Thread).nth(ci);
match row {
Some((r, _)) => self.jump(r, 0),
None => self.msg = format!("{} isn't in this diff", self.threads[ti].path), }
}
pub(super) fn selection_anchor(&self) -> Result<Anchor> {
let (s, e) = self.ordered();
let code: Vec<usize> = (s.row..=e.row)
.filter(|&r| matches!(self.rows[r].line.kind, Kind::Ctx | Kind::Add | Kind::Del))
.collect();
let (Some(&a), Some(&b)) = (code.first(), code.last()) else { bail!("select code lines to comment on") };
if self.rows[a].file != self.rows[b].file {
bail!("a comment can't span files");
}
let side = |r: usize| {
let l = &self.rows[r].line;
if l.kind == Kind::Del { (l.old, true) } else { (l.new, false) }
};
let ((Some(line), left), (Some(sl), sleft)) = (side(b), side(a)) else { bail!("no line number") };
Ok(Anchor {
path: self.files[self.rows[b].file].path.clone(),
line,
left,
start: (a != b).then_some((sl, sleft)),
})
}
pub(super) fn comment_or_reply(&mut self, review: bool) {
let row = &self.rows[self.head.row];
if row.info == Some(SUMMARY) {
return self.promote_summary(review);
}
let pr_text = row.file == INFO && row.thread.is_none();
if !review && self.review.is_some() && !pr_text {
let n = self.pending_count();
return self.msg = format!("you have a pending review ({n}): c adds to it, S submits it, then C posts");
}
if let Some((t, k)) = self.claude_at_cursor() {
return self.start_promote(t, k, review);
}
match (pr_text, self.rows[self.head.row].thread) {
(true, _) if review => self.msg = "select code lines to comment on".into(),
(true, _) => self.start_conversation_comment(),
(false, Some(_)) if review => self.msg = "r replies to this thread (c comments on selected code lines)".into(),
(false, Some(_)) => self.start_reply(false),
(false, None) => self.start_comment(review),
}
}
pub(super) fn check_synced(&self) -> Result<()> {
let pr = self.pr.as_ref().context("no PR for this branch")?;
if diff::git(&["rev-parse", "HEAD"])?.trim() != pr.head_oid {
bail!("HEAD isn't the PR head (pull, or `gh pr checkout {}`)", pr.number);
}
if !diff::git(&["status", "--porcelain", "--untracked-files=no"])?.trim().is_empty() {
bail!("working tree has local changes; stash them to comment");
}
Ok(())
}
pub(super) fn on_github(&self, a: Anchor) -> Result<Anchor> {
match self.files.iter().any(|f| f.path == a.path && f.meta == "untracked") {
true => bail!("{} is untracked: GitHub hasn't got it (commit and push it: Space g), or `a` asks Claude", a.path),
false => Ok(a),
}
}
pub(super) fn start_comment(&mut self, review: bool) {
match self.check_synced().and_then(|_| self.selection_anchor()).and_then(|a| self.on_github(a)) {
Ok(anchor) => self.action = Some(Action::Comment { anchor, review, prefill: String::new(), claude: None }),
Err(e) => self.msg = format!("{e:#}"),
}
}
pub(super) fn start_suggest(&mut self) {
match self.check_synced().and_then(|_| self.suggestion()).and_then(|(a, p)| Ok((self.on_github(a)?, p))) {
Ok((anchor, prefill)) => self.action = Some(Action::Comment { anchor, review: true, prefill, claude: None }),
Err(e) => self.msg = format!("{e:#}"),
}
}
pub(super) fn suggestion(&self) -> Result<(Anchor, String)> {
let (s, e) = self.ordered();
let rows: Vec<&Row> =
self.rows[s.row..=e.row].iter().filter(|r| matches!(r.line.kind, Kind::Ctx | Kind::Add | Kind::Del)).collect();
let (Some(first), Some(last)) = (rows.first(), rows.last()) else { bail!("select the lines to suggest a change for") };
if rows.iter().any(|r| r.line.kind == Kind::Del) {
bail!("suggestions replace lines in the new version; leave removed lines out of the selection");
}
if first.file != last.file {
bail!("a suggestion can't span files");
}
let (Some(a), Some(b)) = (first.line.new, last.line.new) else { bail!("no line number") };
let code: Vec<&str> = rows.iter().map(|r| r.line.text.as_str()).collect();
let code = code.join("\n");
let mut fence = String::from("```");
while code.contains(&fence) {
fence.push('`');
}
let anchor =
Anchor { path: self.files[first.file].path.clone(), line: b, left: false, start: (a != b).then_some((a, false)) };
Ok((anchor, format!("\n{fence}suggestion\n{code}\n{fence}\n")))
}
pub(super) fn start_conversation_comment(&mut self) {
if self.pr.is_none() {
return self.msg = "no PR for this branch".into();
}
let row = &self.rows[self.head.row];
let text = row.info.and_then(|i| self.info.get(i)).map(|l| l.text.as_str());
let quote = text.map_or(String::new(), quote);
self.action = Some(Action::Converse { quote, as_is: false });
}
fn promote_summary(&mut self, review: bool) {
if self.pr.is_none() {
return self.msg = "no PR for this branch".into();
}
let text = self.claude_summary.trim().to_owned();
match review {
true => {
self.summary_draft = text;
self.msg = "Claude's review is in your review summary: S submits it (edit it there)".into();
}
false => self.action = Some(Action::Converse { quote: format!("{text}\n"), as_is: true }),
}
}
pub(super) fn start_reply(&mut self, review: bool) {
if let Some((_, k)) = self.claude_at_cursor() {
return self.action = Some(Action::ClaudeReply(k));
}
let t = self.rows[self.head.row].thread;
match (t, t.and_then(|t| self.root_id(t))) {
(Some(t), Some(comment)) if self.pr.is_some() => {
let thread = self.threads[t].id.clone();
let quote = self.comment_under_cursor(t).map_or(String::new(), |c| quote(&c.body));
self.action = Some(Action::Reply { thread, comment, review, quote });
}
_ => self.msg = "move onto a comment thread to reply".into(),
}
}
pub(super) fn comment_under_cursor(&self, t: usize) -> Option<&gh::Comment> {
self.threads[t].comments.get(self.comment_index_under_cursor(t))
}
fn comment_index_under_cursor(&self, t: usize) -> usize {
let start = self.rows[..=self.head.row].iter().rposition(|r| r.thread != Some(t)).map_or(0, |i| i + 1);
let heads = self.rows[start..=self.head.row].iter().filter(|r| r.line.kind == Kind::Thread).count();
heads.saturating_sub(1)
}
}
const SEEN_AFTER: std::time::Duration = std::time::Duration::from_secs(3);
fn comment_key(t: &Thread, ci: usize) -> String {
match &t.claude {
Some(id) => format!("claude:{id}:{ci}"),
None => t.comments[ci].node_id.clone(),
}
}
impl App {
pub(super) fn load_seen(&mut self) {
self.seen_path = self.claude_path.as_ref().and_then(|p| Some(p.parent()?.parent()?.join("seen").join(p.file_name()?)));
let text = self.seen_path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();
self.seen = serde_json::from_str(&text).unwrap_or_default();
}
pub(super) fn is_new(&self, t: &Thread) -> bool {
self.seen_path.is_some()
&& t.comments.iter().enumerate().any(|(ci, c)| !self.wrote(c) && !self.seen.contains(&comment_key(t, ci)))
}
fn wrote(&self, c: &gh::Comment) -> bool {
c.pending || c.author == "you" || (!self.me.is_empty() && c.author == self.me)
}
pub(super) fn watch_seen(&mut self) {
if self.select {
return; }
let here = self.rows.get(self.head.row).and_then(|r| r.thread).map(|ti| {
let t = &self.threads[ti];
(0..t.comments.len()).map(|ci| comment_key(t, ci)).collect::<Vec<_>>()
});
let here = here.unwrap_or_default();
let seen = match here == self.seeing {
true if self.seeing_since.elapsed() >= SEEN_AFTER => self.seeing.clone(),
true => return,
false => {
self.seeing_since = std::time::Instant::now();
std::mem::replace(&mut self.seeing, here)
}
};
let before = self.seen.len();
self.seen.extend(seen.into_iter().filter(|k| !k.is_empty()));
if self.seen.len() != before
&& let Some(p) = &self.seen_path
{
let mut keys: Vec<&String> = self.seen.iter().collect();
keys.sort();
_ = std::fs::create_dir_all(p.parent().unwrap_or(p)).and_then(|_| std::fs::write(p, serde_json::to_string(&keys).unwrap_or_default()));
let keep = self.cursor_key();
self.rebuild(keep); }
}
}
pub(super) const CLOSE: &str = "CLOSE";
fn file_ref(text: &str, col: usize) -> Option<(String, u32)> {
let chars: Vec<char> = text.chars().collect();
let ok = |c: char| c.is_alphanumeric() || "_./-:#".contains(c);
if !chars.get(col).is_some_and(|&c| ok(c)) {
return None;
}
let a = (0..col).rev().take_while(|&i| ok(chars[i])).last().unwrap_or(col);
let b = (col..chars.len()).take_while(|&i| ok(chars[i])).last()? + 1;
let token: String = chars[a..b].iter().collect();
let token = token.trim_end_matches(['.', ',', ':', '-']);
let (path, rest) = token.split_once("#L").or_else(|| token.split_once(':'))?;
let line: u32 = rest.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()?;
let file = path.rsplit('/').next().is_some_and(|f| f.contains('.') && !f.starts_with('.') || path.contains('/'));
(file && line > 0 && !path.chars().all(|c| c.is_ascii_digit())).then(|| (path.to_owned(), line))
}
const DEF_WORDS: &str = "fn|struct|enum|trait|type|union|mod|macro_rules!|const|static|def|class|function|func|interface";
fn ident_at(text: &str, col: usize, in_code: bool) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
let ok = |c: char| c.is_alphanumeric() || c == '_';
if !chars.get(col).is_some_and(|&c| ok(c)) {
return None;
}
let a = (0..col).rev().take_while(|&i| ok(chars[i])).last().unwrap_or(col);
let b = (col..chars.len()).take_while(|&i| ok(chars[i])).last()? + 1;
let w: String = chars[a..b].iter().collect();
let code = in_code || w.contains('_') || w.chars().skip(1).any(char::is_uppercase) || w.chars().any(|c| c.is_ascii_digit());
(code && !w.starts_with(|c: char| c.is_ascii_digit())).then_some(w)
}
#[test]
fn idents() {
let at = |text: &str, needle: &str| ident_at(text, text.find(needle).unwrap(), false);
let t = "half of next_returns_none_after_error, so inline parseArgs or Foo::bar.";
assert_eq!(at(t, "none").as_deref(), Some("next_returns_none_after_error")); assert_eq!(at(t, "parse").as_deref(), Some("parseArgs"));
assert_eq!(at(t, "half"), None); assert_eq!(at(t, "Foo"), None); assert_eq!(at(t, " so"), None);
assert_eq!(ident_at(t, t.find("bar").unwrap(), true).as_deref(), Some("bar")); }
#[test]
fn file_refs() {
let at = |text: &str, needle: &str| file_ref(text, text.find(needle).unwrap());
let t = "see src/view/mod.rs:1213 and a.rs:12-15, also b.rs#L7.";
assert_eq!(at(t, "mod.rs"), Some(("src/view/mod.rs".into(), 1213)));
assert_eq!(at(t, "1213"), Some(("src/view/mod.rs".into(), 1213))); assert_eq!(at(t, "a.rs"), Some(("a.rs".into(), 12)));
assert_eq!(at(t, "b.rs"), Some(("b.rs".into(), 7)));
assert_eq!(at(t, "see"), None);
assert_eq!(at("at 12:30 today", "12"), None); assert_eq!(at("note: this", "note"), None);
assert_eq!(at("x.rs:12:5,", "x.rs"), Some(("x.rs".into(), 12)));
}