use super::*;
#[derive(Clone)]
pub(super) enum Action {
Shell,
Open(String),
Comment { anchor: Anchor, review: bool, prefill: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, quote: String },
ClaudeReply(String, &'static str, String),
ClaudeReplyAll(Vec<String>, &'static str),
AskClaude { anchor: Anchor, code: Vec<String>, about: Option<(u64, String)>, want: &'static str },
AskConversation { url: String, prefill: String, want: &'static str },
ClaudeWith(agent::Ask),
Replace(Vec<u64>, Option<(&'static str, String, &'static str)>),
Publish(bool),
CiFixPr,
NewPr(String),
ClosePr,
CloseOriginals,
Converse { quote: String, as_is: bool },
Edit { comment: String, body: String, author: String },
Submit(&'static str),
Commit,
CommitWith(String),
}
pub(super) enum Post {
Thread { anchor: Anchor, review: bool, body: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, body: String },
Converse(String),
Summary(String),
Edit { comment: String, body: String },
Submit(&'static str, String),
}
pub(super) const HINT: &str = "<!-- dv:";
pub(super) fn clean_body(text: &str, mark: &str, prefill: &str) -> String {
let body: Vec<&str> = text.lines().filter(|l| !l.starts_with(mark)).collect();
let body = body.join("\n").trim().to_owned();
if !prefill.is_empty() && body == prefill.trim() { String::new() } else { body }
}
pub(super) fn keep_on_error(res: Result<()>, body: &str) -> Result<()> {
res.map_err(|e| {
let saved = term::unsent_path();
match !body.is_empty() && std::fs::write(&saved, body).is_ok() {
true => anyhow!("{e:#} (text saved to {})", saved.display()),
_ => e,
}
})
}
pub(super) fn ack(event: &str, pr: Option<&Pr>, local: &str) -> String {
let Some(p) = pr.filter(|p| event == "APPROVE" && !p.head_oid.is_empty()) else { return String::new() };
let short = |h: &str| h.chars().take(8).collect::<String>();
match p.head_oid == local || local.is_empty() {
true => format!("ACK {}", p.head_oid),
false => format!(
"{HINT} your checkout is at {}, not the PR's head {}: check this ACK names what you reviewed -->\nACK {}",
short(local),
short(&p.head_oid),
p.head_oid
),
}
}
pub(super) fn posted_msg(review: bool) -> &'static str {
if review { "added to pending review (S to submit)" } else { "posted" }
}
impl App {
fn new_pr(&self, what: &str, prefill: &str, untouched_ok: bool) -> Result<Option<u64>> {
let Some(task) = compose_with(what, false, prefill, untouched_ok)?.filter(|t| !t.trim().is_empty()) else { return Ok(None) };
let id = crate::replace::create_new(&task, &self.root)?;
crate::replace::spawn(&[id])?;
Ok(Some(id))
}
pub(super) fn perform(&mut self, a: Action) -> Result<String> {
match a {
Action::Shell => {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
let root = self.root.display().to_string();
if in_zellij() {
let name = format!("shell: {}", self.root.file_name().map_or(root.clone(), |n| n.to_string_lossy().into_owned()));
let st = std::process::Command::new("zellij")
.args(["run", "--floating", "--close-on-exit", "--name", &name, "--cwd", &root, "--", &shell])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
anyhow::ensure!(st.success(), "zellij run failed");
return Ok(format!("a shell in {root}, floating (exit closes it)"));
}
std::process::Command::new(&shell).current_dir(&self.root).status().context("running your shell")?;
Ok(String::new())
}
Action::Open(target) if in_zellij() => self.open_in_hx_pane(&target),
Action::Open(target) => {
interactive("hx", std::slice::from_ref(&target), &format!("hx {target}")).context("running hx")?;
Ok(String::new())
}
Action::Comment { anchor, review, prefill, claude } => {
let range = match anchor.start {
Some((s, _)) => format!("{s}-{}", anchor.line),
None => anchor.line.to_string(),
};
let kind = match (&claude, prefill.is_empty()) {
(Some(_), _) => "Claude's comment",
(None, true) => "comment",
(None, false) => "suggestion",
};
let what = format!("{kind} on {}:{range}", anchor.path);
self.pr.as_ref().context("no PR")?; let Some(body) = compose_with(&what, false, &prefill, claude.is_some())? else {
return Ok(format!("{kind} cancelled"));
};
self.post_later(Post::Thread { anchor, review, body, claude })
}
Action::Reply { thread, comment, review, quote } => {
self.pr.as_ref().context("no PR")?;
let Some(body) = compose_with("reply", false, "e, false)? else { return Ok("reply cancelled".into()) };
self.post_later(Post::Reply { thread, comment, review, body })
}
Action::ClaudeReply(k, want, prefill) => {
let p = self.claude.path.clone().context("no review file")?;
let what = format!("reply to Claude{}", ask::want_hint(want));
let Some(body) = compose_change(&what, want, &prefill)? else { return Ok("reply cancelled".into()) };
keep_on_error(crate::claude::reply(&p, &k, &body, want), &body)?;
Ok(self.answer_now())
}
Action::ClaudeReplyAll(keys, want) => {
let p = self.claude.path.clone().context("no review file")?;
let what = format!("one reply to Claude's {} comments{}", keys.len(), ask::want_hint(want));
let Some(body) = compose_change(&what, want, "")? else { return Ok("reply cancelled".into()) };
let mut errs: Vec<(String, anyhow::Error)> = keys.iter().filter_map(|k| crate::claude::reply(&p, k, &body, want).err().map(|e| (k.clone(), e))).collect();
if errs.len() == keys.len() {
keep_on_error(Err::<(), _>(errs.remove(0).1), &body)?;
}
let failed: Vec<String> = errs.into_iter().map(|(k, e)| format!("{k}: {e:#}")).collect();
let said = self.answer_now();
Ok(match failed.is_empty() {
true => said,
false => format!("{said} (not replied to: {})", failed.join("; ")),
})
}
Action::AskClaude { anchor, code, about, want } => {
let p = self.claude.path.clone().context("no review file")?;
let range = anchor.start.map_or(anchor.line.to_string(), |(s, _)| format!("{s}-{}", anchor.line));
let what = match about {
Some(_) => format!("ask Claude about this review comment on {}:{range} (it answers right away; nothing goes to GitHub)", anchor.path),
None => format!("a comment for Claude on {}:{range}{}", anchor.path, ask::want_hint(want)),
};
let prefill = about.as_ref().map_or(String::new(), |(_, q)| q.clone());
let Some(body) = compose_with(&what, false, &prefill, false)? else { return Ok("comment cancelled".into()) };
keep_on_error(crate::claude::add_comment(&p, &anchor, code, &body, about.map(|a| a.0), want).map(drop), &body)?;
Ok(self.answer_now())
}
Action::AskConversation { url, prefill, want } => {
let p = self.claude.path.clone().context("no review file")?;
let what = match (url == ON_SUMMARY, commit_of(&url)) {
(true, _) => "a question for Claude about the PR, or a reply to its review (it answers in the Claude tab; nothing goes to GitHub)".to_owned(),
(_, Some(sha)) => format!("for Claude, about commit {}{}", self.commit_short(sha), ask::want_hint(want)),
_ => "ask Claude about this conversation comment (it answers under it; nothing goes to GitHub)".to_owned(),
};
let Some(body) = compose_with(&what, false, &prefill, false)? else { return Ok("question cancelled".into()) };
keep_on_error(crate::claude::add_conversation(&p, &url, &body, want).map(drop), &body)?;
Ok(self.answer_now())
}
Action::ClaudeWith(ask) => {
let alone = matches!(ask, agent::Ask::Fix | agent::Ask::Edit) && self.change_requests() == 0 || matches!(ask, agent::Ask::Describe);
let what = match (ask, alone) {
(agent::Ask::Review, _) => "anything for Claude to look at or keep in mind in its review (optional)",
(agent::Ask::Describe, _) if !self.claude.description.is_empty() => "what else to change in Claude's proposed description",
(agent::Ask::Describe, _) => "what to change in the PR's description (Claude proposes a new one; you approve it)",
(agent::Ask::Close, _) if !self.claude.closing.is_empty() => "what to change in Claude's closing comment",
(agent::Ask::Close, _) => "why close it, and anything the comment should say (optional: Claude drafts it; you edit it before it goes)",
(_, true) => "what Claude should change (there are no open comments to act on)",
_ => "instructions for Claude, on top of the open comments (optional)",
};
let review = matches!(ask, agent::Ask::Review);
let last = if review { crate::claude::instructions("review") } else { String::new() };
let Some(extra) = compose_with(what, !alone, last.trim(), review)? else { return Ok("Claude not asked".into()) };
if review {
_ = crate::claude::save_instructions("review", &extra);
}
if self.run_claude(ask, &extra) || extra.trim().is_empty() {
return Ok(std::mem::take(&mut self.msg));
}
let why = std::mem::take(&mut self.msg);
Ok(keep_on_error(Err(anyhow::anyhow!(why)), &extra).err().map_or(String::new(), |e| format!("{e:#}")))
}
Action::Replace(prs, about) => {
let here = self.replacement.as_ref().is_some_and(|r| r.id == prs[0]) && (self.claude.job.is_some() || self.claude.pane_busy);
let drafting = || here || crate::replace::load(prs[0]).is_ok_and(|r| r.drafting_now());
let which = prs.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
if crate::replace::drafted(prs[0]) || crate::replace::is_new(prs[0]) {
let (what, (on, prefill, want)) = match about {
Some(q @ (_, _, "ask")) => (format!("ask Claude about the replacement of {which}, under the quote (it answers; it changes nothing)"), q),
Some(q) => (format!("ask Claude to redraft the replacement of {which}: what to change, under the quote"), q),
None => (format!("what else Claude should do in its redraft of {which} (optional)"), ("", String::new(), "")),
};
let asking = !prefill.is_empty();
let Some(ask) = compose_with(&what, !asking, prefill.trim_end(), !asking)? else { return Ok("not asked".into()) };
if !ask.trim().is_empty() {
crate::replace::ask(prs[0], &ask, on, want)?;
}
if drafting() {
return Ok(match ask.trim() {
"" => format!("Claude is already drafting the replacement of #{}", prs[0]),
_ => "kept with the drafts, for Claude's redraft: shown as it takes it in (else Space a → Redraft once it's done)".into(),
});
}
} else {
if drafting() {
return Ok(format!("Claude is already drafting the replacement of #{}", prs[0]));
}
let what = format!("instructions for Claude's replacement of {which} (optional)");
let Some(extra) = compose_with(&what, true, crate::claude::instructions("replace").trim_end(), true)? else { return Ok("not asked".into()) };
crate::claude::save_instructions("replace", &extra)?; if drafting() {
return Ok(format!("Claude started drafting the replacement of #{} meanwhile", prs[0]));
}
}
crate::replace::spawn(&prs)?;
Ok(format!("Claude is drafting a replacement for {which} in the background: dv list's replacements tab shows it"))
}
Action::Publish(close) => {
anyhow::ensure!(!self.claude_busy(), "Claude is still at work here: publish once it's done");
let mut r = self.replacement.clone().context("not a replacement")?;
if crate::replace::remote().is_none() {
return Ok(self.ask_replace_remote(close));
}
let prefill = format!("{}\n\n{}", r.title.trim(), r.body.trim());
let kind = "draft PR";
let what = format!("{kind} for {}: its title (first line), then its description", crate::replace::branch(r.id));
let Some(text) = compose_with(&what, false, &prefill, true)? else { return Ok("not published".into()) };
let (title, body) = text.split_once('\n').map_or((text.as_str(), ""), |(t, b)| (t, b));
let (title, body) = (title.trim(), body.trim());
if title.is_empty() {
return Ok("not published: it needs a title (the first line)".into());
}
let res = crate::replace::publish(&mut r, &self.root, title, body);
keep_on_error(res.as_ref().map(drop).map_err(|e| anyhow!("{e:#}")), &text)?;
let originals = !r.replaces.is_empty(); self.show_replacement(r);
if let Ok(n) = res {
self.become_pr(n);
}
if close {
self.action = Some(Action::CloseOriginals); }
let then = if originals { ": S closes what it replaces, with the note" } else { "" };
Ok(format!("published as {kind} #{}{then} (ready for review once CI passes: Space g)", res.unwrap_or(0)))
}
Action::ClosePr => self.close_pr(),
Action::CiFixPr => {
let n = self.pr.as_ref().map_or(0, |p| p.number);
let c = self.ci_note(n).context("no fix in mind for CI any more")?;
let id = self.new_pr("a new PR fixing CI, for Claude to draft: what it should do; unpublished until you publish it", &c.prefill(n), true)?;
if id.is_some() {
crate::claude::ci_fix_drafted(n); }
Ok(drafting(id))
}
Action::NewPr(prefill) => Ok(drafting(self.new_pr("a new PR for Claude to draft: what it should do; unpublished until you publish it", &prefill, false)?)),
Action::CloseOriginals => {
let mut r = self.replacement.clone().context("not a replacement")?;
let which: Vec<String> = r.replaces.iter().filter(|o| !r.closed.contains(&o.number)).map(|o| format!("#{}", o.number)).collect();
let what = format!("note closing {} (each gets it as a comment, then is closed)", which.join(", "));
let Some(note) = compose_with(&what, false, &crate::replace::note(&r), true)? else { return Ok("nothing closed".into()) };
let res = crate::replace::close_originals(&mut r, ¬e);
self.show_replacement(r);
let done = keep_on_error(res.as_ref().map(drop).map_err(|e| anyhow!("{e:#}")), ¬e).map(|_| res.unwrap_or_default())?;
let done: Vec<String> = done.iter().map(|n| format!("#{n}")).collect();
Ok(format!("closed {}, with the note", done.join(", ")))
}
Action::Converse { quote, as_is } => {
self.pr.as_ref().context("no PR")?;
let Some(body) = compose_with("comment on the PR conversation", false, "e, as_is)? else {
return Ok("comment cancelled".into());
};
self.post_later(Post::Converse(body))
}
Action::Edit { comment, body: old, author } => {
let what = format!("editing @{author}'s comment");
let Some(body) = compose_with(&what, false, &old, false)? else { return Ok("edit cancelled".into()) };
self.post_later(Post::Edit { comment, body })
}
Action::Submit(event) => {
let what = match event {
review::CLOSE => "closing comment (empty: just close the PR)".to_owned(),
e => format!("review summary ({})", e.to_lowercase().replace('_', " ")),
};
let head = diff::git(&["rev-parse", "HEAD"]).unwrap_or_default();
let mut prefill = ack(event, self.pr.as_ref(), head.trim());
if !self.summary_draft.is_empty() {
prefill = format!("{}\n\n{prefill}", self.summary_draft.trim_end());
}
if event == "APPROVE" {
let notes = self.submit_notes().into_iter().filter(|n| !n.starts_with("your checkout"));
let notes: String = notes.map(|n| format!("{HINT} still open: {n} -->\n")).collect();
prefill = notes + &prefill;
}
let Some(body) = compose_with(&what, true, &prefill, true)? else {
return Ok("review not submitted".into());
};
self.summary_draft.clear();
self.post_later(Post::Submit(event, body))
}
Action::Commit => {
anyhow::ensure!(!self.claude_busy(), "Claude is still at work here: commit once it's done (its changes would be half made)");
match self.claude.path.is_some() {
true => self.commit_ask(),
false => {
let draft = self.commit_draft();
self.commit(&draft)
}
}
}
Action::CommitWith(msg) => self.commit(&msg),
}
}
pub(super) fn open_in_hx_pane(&mut self, target: &str) -> Result<String> {
if let Some(id) = self.hx.as_ref().map(|h| h.pane.clone()).filter(|id| crate::pane::alive(id)) {
let mut parts = target.rsplitn(3, ':');
let (_col, line, path) = (parts.next(), parts.next().unwrap_or("1"), parts.next().unwrap_or(target));
crate::pane::action(&["write-chars", "-p", &id, "\x1b"])?;
crate::pane::action(&["write-chars", "-p", &id, &format!(":open {path}\r:goto {line}\r")])?;
crate::pane::action(&["focus-pane-id", &id])?;
return Ok("opened in hx (its pane below); dv reloads as you save".into());
}
let (st, id) = term::zellij_pane("hx", &[target.to_owned()], "hx", &self.root, false)?;
anyhow::ensure!(st.success(), "zellij run failed ({st})");
self.hx = id.map(|pane| HxPane { pane });
Ok("hx is open below; dv reloads as you save, and Enter opens the next file there".into())
}
}
impl App {
pub(super) fn act(&mut self, term: &mut DefaultTerminal) {
let (mut given, mut kept) = (None, None); if self.action.is_none()
&& let Some(text) = self.writing.as_mut().and_then(|w| w.1.done())
{
let (a, w) = self.writing.take().unwrap();
(self.action, given, kept) = (Some(a), Some(text), w.kept);
focus_dv();
}
if let Some(a) = self.action.take() {
let in_pane = in_zellij();
if !in_pane {
restore_terminal();
}
let again = a.clone();
let (res, left) = term::writing_beside(self.writing.is_some(), given.take(), || self.perform(a));
if res.as_ref().is_err_and(|e| e.is::<term::Writing>()) {
let w = res.unwrap_err().downcast().unwrap();
self.writing = Some((again, w));
self.msg = "writing in the editor's pane: dv's yours meanwhile (save and quit there to send; :cq cancels)".into();
return;
}
let res = match left.flatten().filter(|b| !b.trim().is_empty()) {
Some(body) => {
let why = res.as_ref().map_or_else(|e| format!("{e:#}"), String::clone);
Err(keep_on_error(Err(anyhow!("{why}: what you wrote wasn't sent")), &body).unwrap_err())
}
None => res,
};
if !in_pane {
*term = init_terminal();
}
self.reloaded(res, false);
if let Some(k) = kept.take() {
self.msg = format!("{}: its pane closed; what it had saved is kept in {}", self.msg, k.display());
}
}
}
}
fn compose_change(what: &str, want: &str, prefill: &str) -> Result<Option<String>> {
if !ask::edits_code(want) {
return compose_with(what, false, prefill, false);
}
let what = format!("{what}; left empty: what Claude's comment says");
Ok(compose_with(&what, true, prefill, true)?.map(|b| match b.trim().is_empty() || b.trim() == prefill.trim() {
true => format!("{prefill}Make the change your comment describes.").trim_start().to_owned(),
false => b,
}))
}
fn drafting(id: Option<u64>) -> String {
match id {
Some(id) => format!("Claude is drafting {} in the background: dv list's drafts tab shows it (Enter there reviews it, S publishes it)", crate::replace::label(id)),
None => "not asked".into(),
}
}