use crate::diff::{self, FileDiff, Kind, Line as DLine};
use crate::gh::{self, Anchor, Pr, Thread};
use crate::highlight::{Colors, Highlighter};
use crate::theme::{Settings, Theme, Ui};
use crate::lsp::{self, Loc, Lsp};
use anyhow::{bail, Context, Result};
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
DefaultTerminal, Frame,
};
use std::collections::{HashMap, HashSet};
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod agent;
mod keys;
mod motion;
mod ra;
mod render;
mod review;
mod select;
#[cfg(test)]
mod tests;
const HELP: &str = "
MOVE
h j k l, arrows move (counts: 5j)
w b e W B E word motions (select)
gh gl gs line start / end / first non-blank
f t F T <c> find char
gw jump labels
mm matching bracket
gg ge top / end
Ctrl-d Ctrl-u half page down / up
Ctrl-e Ctrl-y scroll 5 lines
zz zt zb cursor line to center / top / bottom
Ctrl-o jump back
DIFF
]g [g next / prev hunk (change)
]f [f, Space f next / prev file, file picker
]c [c next / prev comment thread
]a [a next / prev Claude comment (wraps around)
/ n N * search, next / prev match, search selection
SELECT & COPY
x select line (repeat to extend)
v ; Esc select mode / collapse / exit select mode
y (or Space y) copy text
Y copy as patch
Space c copy a ref: file:line, or a comment's / the PR's link
VIEW
+ = - hunk context +10 / -10 lines
Z whole file
Tab fold file / PR info / thread
Shift-Tab fold / unfold all files
Ctrl-w v toggle split (side-by-side) view
Ctrl-w h Ctrl-w l split view: go to the old / new side (or Ctrl-h Ctrl-l)
Enter open hx at cursor (on a link: open it)
gf open the link under the cursor in the browser
RUST-ANALYZER
gd gr gy gi definition / references / type / impl
Space k hover; on an issue/PR link: preview it
ON SELECTED LINES
c comment, in your pending review (C: post it now)
s suggest a change, in your pending review
ON A REVIEW THREAD
r reply, in your pending review (C: post it now)
Space r resolve / unresolve
Space e Space d edit / delete your comment (delete asks first)
ON CLAUDE'S COMMENT
r reply to Claude (Claude answers right away, in the background)
c copy it into your pending review (C: post it now)
Space r Space d resolve (Claude skips it) / dismiss
ON PR TEXT
C quote it in a new conversation comment
REVIEW
S submit (comment / approve / request changes), or discard
Space p Space t pick from your pending comments / all threads
Space v mark the file viewed (folds it, moves on)
Space a ask Claude Code to review, answer your replies, or make the changes
Space A pick from Claude's comments ([waiting]: unanswered)
GENERAL
? this help (/ to search it)
R refresh (git diff + GitHub)
q, Ctrl-c quit
";
const DEFAULT_CONTEXT: u32 = 3;
const CTRL_W: char = '\u{17}';
const HL_MAX_BYTES: usize = 1 << 20;
const INFO: usize = usize::MAX - 1;
const SUMMARY: usize = usize::MAX;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pos {
row: usize,
col: usize,
}
struct Row {
file: usize,
line: DLine,
thread: Option<usize>,
syn: Vec<Option<Style>>,
links: Vec<(usize, usize, String)>,
info: Option<usize>,
edge: bool,
}
impl Row {
fn new(file: usize, line: DLine) -> Row {
Row { file, line, thread: None, syn: vec![], links: vec![], info: None, edge: false }
}
fn text(file: usize, kind: Kind, text: String) -> Row {
Row::new(file, DLine { kind, old: None, new: None, text })
}
}
struct CursorKey {
line: Option<(String, Option<u32>, Option<u32>)>,
thread: Option<u64>,
info: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum BoxPart {
Top,
Divider,
Body,
Bottom,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum DRow {
Full(usize),
Pair(Option<usize>, Option<usize>),
}
enum Action {
Open(String),
Comment { anchor: Anchor, review: bool, prefill: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, quote: String },
ClaudeReply(String),
ClaudeWith(agent::Ask),
Converse { quote: String },
Edit { comment: String, body: String, author: String },
Submit(&'static str),
}
enum Job {
Refresh,
Resolve(String, bool),
Viewed(String, bool),
Discard(String),
DeleteComment(String),
Preview(String),
Post(Post),
}
enum Post {
Thread { anchor: Anchor, review: bool, body: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, body: String },
Converse(String),
Edit { comment: String, body: String },
Submit(&'static str, String),
}
enum Target {
Row(usize),
Loc(Loc),
Submit(&'static str),
Discard,
DiscardYes,
DeleteComment(String),
Comment(usize, usize),
Claude(agent::Ask),
Nothing,
}
struct Item {
label: String,
detail: Vec<String>,
target: Target,
}
impl Item {
fn new(label: impl Into<String>, target: Target) -> Item {
Item { label: label.into(), detail: vec![], target }
}
}
struct Picker {
title: &'static str,
items: Vec<Item>,
query: String,
sel: usize,
}
enum Req {
Init,
Goto,
Hover,
}
pub struct App {
base: String,
files: Vec<FileDiff>,
extra: HashMap<(String, usize), i64>,
whole: HashSet<String>,
collapsed: HashSet<String>,
rows: Vec<Row>,
anchor: Pos,
head: Pos,
want_col: usize,
select: bool,
count: usize,
pending: Option<char>,
prompt: Option<String>,
picker: Option<Picker>,
search: String,
msg: String,
top: usize,
left: usize,
height: usize,
width: u16,
action: Option<Action>,
job: Option<Job>,
review: Option<String>,
viewed: HashMap<String, String>,
split: bool,
right: bool,
drows: Vec<DRow>,
d_of: Vec<usize>,
emph: HashMap<usize, Vec<(usize, usize)>>,
settings: Settings,
quit: bool,
pr: Option<Pr>,
threads: Vec<Thread>,
info: Vec<gh::InfoLine>,
info_collapsed: bool,
hl: Highlighter,
ui: Ui,
hl_cache: HashMap<(String, bool), Colors>,
md_cache: std::cell::RefCell<HashMap<String, Vec<crate::md::MdLine>>>,
wrap_w: usize,
toggled: HashSet<u64>,
claude_path: Option<PathBuf>,
claude_mtime: Option<std::time::SystemTime>,
claude_summary: String,
claude_job: Option<(std::process::Child, PathBuf, &'static str)>,
claude_session: Option<String>,
claude_again: bool,
root: PathBuf,
lsp: Option<Lsp>,
lsp_ready: bool,
inflight: HashMap<u64, Req>,
progress: HashMap<String, (String, String)>,
popup: Option<(String, Vec<Line<'static>>, u16)>,
help: Option<(String, bool)>,
jumps: Vec<Pos>,
alphabet: Vec<char>,
labels: Vec<(Pos, Pos, [char; 2])>,
label_first: Option<char>,
}
impl App {
pub fn new(base: String, pr: Option<Pr>) -> Result<Self> {
crate::progress(Some("loading the diff"));
let files = diff::load(&base, DEFAULT_CONTEXT)?;
let mut app = Self::with(base, files);
let theme = Theme::load();
(app.hl, app.ui) = (Highlighter::new(&theme), theme.ui());
app.settings = crate::theme::settings();
app.claude_path = crate::claude::path(pr.as_ref().map(|p| p.number)).ok();
if pr.is_some() {
crate::progress(Some("loading comments and PR info"));
}
app.pr = pr;
app.load_threads();
app.build();
match Lsp::start(&app.root) {
Ok((l, id)) => {
app.lsp = Some(l);
app.inflight.insert(id, Req::Init);
}
Err(e) => app.msg = format!("{e:#}"),
}
Ok(app)
}
fn with(base: String, files: Vec<FileDiff>) -> Self {
let mut app = App {
base,
files,
extra: HashMap::new(),
whole: HashSet::new(),
collapsed: HashSet::new(),
rows: vec![],
anchor: Pos::default(),
head: Pos::default(),
want_col: 0,
select: false,
count: 0,
pending: None,
prompt: None,
picker: None,
search: String::new(),
msg: String::new(),
top: 0,
left: 0,
height: 1,
width: 80,
action: None,
job: None,
review: None,
viewed: HashMap::new(),
split: false,
right: true,
drows: vec![],
d_of: vec![],
emph: HashMap::new(),
settings: Settings::default(),
quit: false,
pr: None,
threads: vec![],
info: vec![],
info_collapsed: false,
wrap_w: 100,
hl: Highlighter::new(&Theme::ansi()),
ui: Theme::ansi().ui(),
hl_cache: HashMap::new(),
md_cache: Default::default(),
toggled: HashSet::new(),
claude_path: None,
claude_mtime: None,
claude_summary: String::new(),
claude_job: None,
claude_session: None,
claude_again: false,
root: std::env::current_dir().unwrap_or_default(),
lsp: None,
lsp_ready: false,
inflight: HashMap::new(),
progress: HashMap::new(),
popup: None,
help: None,
jumps: vec![],
alphabet: jump_alphabet(),
labels: vec![],
label_first: None,
};
app.shape();
app.build();
app
}
pub fn run(&mut self, term: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
term.draw(|f| self.draw(f))?;
if let Some(job) = self.job.take() {
self.run_job(job);
continue;
}
if event::poll(Duration::from_millis(100))?
&& let Event::Key(k) = event::read()?
&& k.kind == KeyEventKind::Press {
self.key(k);
}
self.lsp_poll();
self.watch_claude();
self.poll_claude();
if let Some(a) = self.action.take() {
let floating = in_zellij();
if !floating {
ratatui::restore();
}
let res = self.perform(a);
self.hl_cache.retain(|k, _| !k.1); if !floating {
*term = ratatui::init();
}
if let Err(e) = self.reload(false) {
self.msg = e.to_string();
}
match res {
Ok(m) => self.msg = m,
Err(e) => self.msg = format!("{e:#}"),
}
}
}
Ok(())
}
fn perform(&mut self, a: Action) -> Result<String> {
match a {
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) => {
let p = self.claude_path.clone().context("no review file")?;
let Some(body) = compose_with("reply to Claude", false, "", false)? else { return Ok("reply cancelled".into()) };
keep_on_error(crate::claude::reply(&p, &k, &body), &body)?;
Ok(self.answer_now())
}
Action::ClaudeWith(ask) => {
let what = match ask {
agent::Ask::Review => "anything for Claude to look at or keep in mind in its review (optional)",
_ => "instructions for Claude, on top of its comments (optional)",
};
let Some(extra) = compose(what, true)? else { return Ok("Claude not asked".into()) };
self.run_claude(ask, &extra);
Ok(std::mem::take(&mut self.msg))
}
Action::Converse { quote } => {
self.pr.as_ref().context("no PR")?;
let Some(body) = compose_with("comment on the PR conversation", false, "e, false)? 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 Some(body) = compose(&format!("review summary ({})", event.to_lowercase().replace('_', " ")), true)?
else {
return Ok("review not submitted".into());
};
self.post_later(Post::Submit(event, body))
}
}
}
fn reload(&mut self, threads: bool) -> Result<()> {
let keep = self.cursor_key();
self.files = diff::load(&self.base, DEFAULT_CONTEXT)?;
match threads {
true => self.load_threads(),
false => self.load_claude(),
}
self.shape();
self.rebuild(keep);
Ok(())
}
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);
}
}
fn cursor_key(&self) -> CursorKey {
let r = &self.rows[self.head.row];
CursorKey {
line: self.files.get(r.file).map(|f| (f.path.clone(), r.line.old, r.line.new)),
thread: r.thread.and_then(|t| self.root_id(t)),
info: (r.file == INFO).then_some(self.head.row),
}
}
fn rebuild(&mut self, keep: CursorKey) {
self.build();
let row = keep
.thread
.and_then(|id| self.rows.iter().position(|r| r.thread.and_then(|t| self.root_id(t)) == Some(id)))
.or_else(|| keep.line.map(|(p, old, new)| self.locate(&p, old, new)))
.or(keep.info.filter(|&r| self.rows.get(r).is_some_and(|r| r.file == INFO)))
.unwrap_or(0);
self.select = false;
self.goto(row, self.head.col);
}
fn build(&mut self) {
self.jumps.clear();
let mut rows = vec![];
let title = match &self.pr {
Some(pr) => Some(format!("PR #{} · {}", pr.number, pr.title)),
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();
rows.push(Row { links, ..mk(Kind::File, title) });
if !self.info_collapsed {
for (ii, l) in self.info.iter().enumerate() {
match (l.heading, l.md) {
(true, _) => {
let body = self.info.get(ii + 1).filter(|n| n.md).map(|_| ii + 1);
rows.push(Row { info: body, ..mk(Kind::Thread, l.text.clone()) });
}
(false, true) if l.text.trim().is_empty() => rows.extend(self.md_rows("_(no description)_", mk)),
(false, true) => {
rows.extend(self.md_rows(&l.text, mk).into_iter().map(|r| Row { info: Some(ii), ..r }));
rows.push(Row { info: Some(ii), edge: true, ..mk(Kind::Comment, String::new()) });
}
(false, false) => rows.push(self.info_row(l, mk(Kind::Comment, l.text.clone()))),
}
}
if !self.claude_summary.is_empty() {
let summary = |r: Row| Row { info: Some(SUMMARY), ..r };
rows.push(summary(mk(Kind::Thread, "claude's review".into())));
rows.extend(self.md_rows(&self.claude_summary, mk).into_iter().map(summary));
rows.push(Row { edge: true, ..summary(mk(Kind::Comment, String::new())) });
}
}
}
for (i, f) in self.files.iter().enumerate() {
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 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)),
];
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)
.map(|t| (t, anchor_line(&self.threads[t], &f.lines)))
.collect();
for &(t, _) in placed.iter().filter(|p| p.1.is_none()) {
rows.extend(self.thread_rows(t, i));
}
for (li, l) in f.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.emph = self.word_spans();
self.layout();
}
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
}
fn layout(&mut self) {
let n = self.rows.len();
let kind = |i: usize| self.rows[i].line.kind;
let mut d = vec![];
let mut i = 0;
while i < n {
match kind(i) {
_ if !self.split => d.push(DRow::Full(i)),
Kind::Ctx => d.push(DRow::Pair(Some(i), Some(i))),
Kind::Del | Kind::Add => {
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.max(adds) {
d.push(DRow::Pair((k < dels).then_some(i + k), (k < adds).then_some(i + dels + k)));
}
i += dels + adds;
continue;
}
_ => d.push(DRow::Full(i)),
}
i += 1;
}
let mut d_of = vec![0; n];
for (di, dr) in d.iter().enumerate() {
match *dr {
DRow::Full(r) => d_of[r] = di,
DRow::Pair(l, r) => l.into_iter().chain(r).for_each(|x| d_of[x] = di),
}
}
(self.drows, self.d_of) = (d, d_of);
}
fn pick(&self, d: usize) -> usize {
match self.drows[d] {
DRow::Full(r) => r,
DRow::Pair(l, r) => if self.right { r.or(l) } else { l.or(r) }.unwrap_or(0),
}
}
fn toggle_split(&mut self) {
self.split = !self.split;
self.layout();
self.msg = if self.split { "split view (Ctrl-h / Ctrl-l switch sides)" } else { "unified view" }.into();
}
fn hd(&self) -> usize {
self.d_of[self.head.row]
}
fn md_rows(&self, text: &str, mk: impl Fn(Kind, String) -> Row) -> Vec<Row> {
let key = format!("{}\0{text}", self.wrap_w);
let lines = self
.md_cache
.borrow_mut()
.entry(key)
.or_insert_with(|| crate::md::render(text, self.wrap_w, &self.ui.md, &self.hl, self.repo_url()))
.clone();
lines.into_iter().map(|l| Row { syn: l.styles, links: l.links, ..mk(Kind::Comment, l.text) }).collect()
}
fn info_row(&self, l: &gh::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 {
gh::Mark::Chip(hex) => chip(hex),
gh::Mark::Pass => ui.plus,
gh::Mark::Fail => ui.error,
gh::Mark::Pending => ui.warning,
gh::Mark::Dim => ui.stale,
gh::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
}
fn viewed_tag(&self, file: usize) -> Option<(&'static str, Style)> {
match self.viewed.get(&self.files.get(file)?.path)?.as_str() {
"VIEWED" => Some(("✓ Viewed ", self.ui.plus)),
_ => Some(("↻ changed since viewed ", self.ui.warning)),
}
}
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()
}
fn repo_url(&self) -> Option<&str> {
self.pr.as_ref().and_then(|p| p.url.rsplit_once("/pull/")).map(|(r, _)| r)
}
fn root_id(&self, t: usize) -> Option<u64> {
Some(self.threads.get(t)?.comments.first()?.id)
}
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 tags: Vec<(&str, Style)> = [
(first_pending, "[pending] ", self.ui.pending),
(t.outdated, "[outdated] ", self.ui.warning),
(t.resolved, "[resolved] ", self.ui.resolved),
]
.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 author_syn = |c: &gh::Comment| vec![by_claude(c).then_some(self.ui.key); c.author.chars().count()];
let when = |c: &gh::Comment| match gh::ago(&c.created) {
a if a.is_empty() => String::new(),
a => format!(" · {a}"),
};
let mut out = vec![];
for (k, c) in t.comments.iter().enumerate() {
if k == 0 {
let mut head = format!("{tag_text}{}{}{range}", c.author, when(c));
if !open {
head += &match t.comments.len() {
1 => " · 1 comment".to_owned(),
n => format!(" · {n} comments"),
};
}
let syn = [tag_syn.clone(), author_syn(c)].concat();
out.push(Row { syn, ..mk(Kind::Thread, head) });
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();
out.push(Row { syn, ..mk(Kind::Thread, format!("{tag}{}{}", c.author, when(c))) });
}
let rows = self.md_rows(&c.body, 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 => 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,
})
}
fn thread_open(&self, ti: usize) -> bool {
self.threads[ti].stale() == self.root_id(ti).is_some_and(|id| self.toggled.contains(&id))
}
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));
}
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);
}
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)
}
fn file_path(&self) -> Option<&str> {
Some(&self.files.get(self.rows[self.head.row].file)?.path)
}
fn len(&self, r: usize) -> usize {
self.rows[r].line.text.chars().count()
}
fn chars(&self, r: usize) -> Vec<char> {
self.rows[r].line.text.chars().collect()
}
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 e = |a| *self.extra.get(&(path.clone(), a)).unwrap_or(&0);
let ws = 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 };
for a in hunks {
let x = self.extra.entry((path.clone(), a)).or_insert(0);
*x = (*x + d).max(-(DEFAULT_CONTEXT as i64));
}
self.reshape();
}
fn toggle_whole(&mut self) {
let Some(p) = self.file_path().map(str::to_owned) else { return };
flip(&mut self.whole, p);
self.reshape();
}
fn reshape(&mut self) {
let keep = self.cursor_key();
self.shape();
self.rebuild(keep);
}
fn toggle_collapse(&mut self) {
let fi = self.rows[self.head.row].file;
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);
}
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);
}
fn open_target(&self) -> Option<String> {
let row = &self.rows[self.head.row];
let f = self.files.get(row.file)?;
let line = self.rows[self.head.row..]
.iter()
.take_while(|r| r.file == row.file)
.find_map(|r| r.line.new)
.unwrap_or(1);
let col = if row.line.new.is_some() { self.head.col + 1 } else { 1 };
Some(format!("{}:{line}:{col}", f.path))
}
fn visible_rows(&self) -> Vec<usize> {
let mut out = vec![];
for d in &self.drows[self.top.min(self.drows.len())..(self.top + self.height).min(self.drows.len())] {
match *d {
DRow::Full(r) => out.push(r),
DRow::Pair(l, r) => out.extend(l.into_iter().chain(r.filter(|&r| Some(r) != l))),
}
}
out
}
fn hl_key(&self, r: usize) -> Option<(String, bool)> {
let row = &self.rows[r];
let f = self.files.get(row.file).filter(|f| f.meta != "binary")?;
match row.line.kind {
Kind::Del => Some((f.old_path.clone(), false)),
Kind::Add | Kind::Ctx => Some((f.path.clone(), true)),
_ => None,
}
}
}
fn compose(what: &str, allow_empty: bool) -> Result<Option<String>> {
compose_with(what, allow_empty, "", false)
}
fn in_zellij() -> bool {
std::env::var_os("ZELLIJ").is_some()
}
fn interactive(prog: &str, args: &[String], name: &str) -> std::io::Result<std::process::ExitStatus> {
if !in_zellij() {
return std::process::Command::new(prog).args(args).status();
}
let cwd = std::env::current_dir()?;
std::process::Command::new("zellij")
.args(["run", "--floating", "--close-on-exit", "--block-until-exit", "--name", name])
.args(["--width", "90%", "--height", "90%", "-x", "5%", "-y", "5%"])
.arg("--cwd")
.arg(cwd)
.arg("--")
.arg(prog)
.args(args)
.status()
}
fn compose_with(what: &str, allow_empty: bool, prefill: &str, untouched_ok: bool) -> Result<Option<String>> {
const MARK: &str = "<!-- dv:";
let path = std::env::temp_dir().join(format!("dv-comment-{}.md", std::process::id()));
let hint = if allow_empty { "leave empty for none; :cq to cancel" } else { "leave empty to cancel" };
std::fs::write(&path, format!("{prefill}\n{MARK} {what}. Save and quit to post; {hint}. -->\n"))?;
let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "hx".into());
let mut parts = editor.split_whitespace();
let prog = parts.next().unwrap_or("hx");
let line = prefill.lines().position(str::is_empty).unwrap_or(prefill.lines().count()) + 1;
let arg = match Path::new(prog).file_name().is_some_and(|n| n == "hx") {
true => format!("{}:{line}", path.display()),
false => path.display().to_string(),
};
let args: Vec<String> = parts.map(str::to_owned).chain([arg]).collect();
let st = interactive(prog, &args, &format!("dv: {what}"))?;
let text = std::fs::read_to_string(&path).unwrap_or_default();
_ = std::fs::remove_file(&path);
let body = clean_body(&text, MARK, if untouched_ok { "" } else { prefill });
Ok((st.success() && (allow_empty || !body.is_empty())).then_some(body))
}
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 }
}
fn keep_on_error(res: Result<()>, body: &str) -> Result<()> {
res.map_err(|e| {
let saved = std::env::temp_dir().join(format!("dv-unsent-{}.md", std::process::id()));
match std::fs::write(&saved, body) {
Ok(()) if !body.is_empty() => e.context(format!("text saved to {}", saved.display())),
_ => e,
}
})
}
fn posted_msg(review: bool) -> &'static str {
if review { "added to pending review (S to submit)" } else { "posted" }
}
fn wrap(text: &str, w: usize) -> Vec<String> {
let mut out = vec![];
for line in text.lines() {
if line.chars().count() <= w {
out.push(line.to_owned());
continue;
}
let mut cur = String::new();
for word in line.split(' ') {
if !cur.is_empty() && cur.chars().count() + 1 + word.chars().count() > w {
out.push(std::mem::take(&mut cur));
} else if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
out.push(cur);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn flip<T: std::hash::Hash + Eq>(s: &mut HashSet<T>, x: T) {
if !s.remove(&x) {
s.insert(x);
}
}
fn quote(text: &str) -> String {
let q: Vec<String> = text.lines().map(|l| format!("> {l}").trim_end().to_owned()).collect();
format!("{}\n\n", q.join("\n"))
}
pub(crate) fn chip(hex: &str) -> Style {
let n = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0x6e7681);
let (r, g, b) = ((n >> 16) as u8, (n >> 8) as u8, n as u8);
let light = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32 > 150.0;
let fg = if light { Color::Black } else { Color::White };
Style::new().bg(Color::Rgb(r, g, b)).fg(fg).add_modifier(ratatui::style::Modifier::BOLD)
}
fn jump_alphabet() -> Vec<char> {
let cfg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|_| std::env::var("HOME").map(|h| Path::new(&h).join(".config")))
.map(|d| std::fs::read_to_string(d.join("helix/config.toml")).unwrap_or_default())
.unwrap_or_default();
cfg.lines()
.filter_map(|l| l.trim().strip_prefix("jump-label-alphabet")?.trim().strip_prefix('=')?.trim().strip_prefix('"')?.strip_suffix('"').map(str::to_owned))
.next()
.filter(|a| !a.is_empty())
.unwrap_or_else(|| "abcdefghijklmnopqrstuvwxyz".into())
.chars()
.collect()
}
fn disk_line(cache: &mut HashMap<PathBuf, String>, p: &Path, n: u32) -> String {
let text = cache.entry(p.to_owned()).or_insert_with(|| std::fs::read_to_string(p).unwrap_or_default());
text.lines().nth(n as usize).unwrap_or("").to_owned()
}
const OUTDATED_SLACK: i64 = 10;
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))
}
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))
}
fn clipboard(t: &str) {
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", base64(t.as_bytes()));
let _ = out.flush();
}
fn base64(b: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut o = String::new();
for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
o.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
}
}
o
}