use super::*;
const FILES_AUTO: usize = 150;
const FILES_MIN: usize = 16;
#[derive(Clone, Copy, Default, PartialEq, Debug)]
pub(super) struct Dirty {
pub(super) staged: usize,
pub(super) changed: usize,
pub(super) new: usize,
}
impl Dirty {
pub(super) fn of(status: &str) -> Dirty {
let mut d = Dirty::default();
for l in status.lines() {
let (x, y) = (l.chars().next().unwrap_or(' '), l.chars().nth(1).unwrap_or(' '));
match (x, y) {
('?', _) => d.new += 1,
_ => {
d.staged += usize::from(x != ' ');
d.changed += usize::from(y != ' ');
}
}
}
d
}
pub(super) fn has(&self) -> bool {
*self != Dirty::default()
}
pub(super) fn says(&self) -> String {
[(self.staged, "staged"), (self.changed, "changed"), (self.new, "new")].iter().filter(|k| k.0 > 0).map(|(n, k)| format!("{n} {k}")).collect::<Vec<_>>().join(", ")
}
}
#[derive(Clone, PartialEq, Debug)]
pub(super) struct Commit {
pub(super) sha: String,
pub(super) short: String,
pub(super) subject: String,
pub(super) at: String,
pub(super) signed: bool,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Hit {
Spot(Spot),
Commit(usize),
Nothing,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Spot {
Folder,
File(usize),
Section(usize),
}
pub(super) struct Entry {
pub(super) spot: Spot,
text: String,
}
impl App {
fn list_tints(&self) -> (Style, Style) {
let ui = &self.ui;
let line = |c: Option<Color>| ui.base.bg(c.or(ui.menu_selected.bg).unwrap_or(Color::DarkGray));
(line(ui.cursorline), line(ui.related.or(ui.cursorline)))
}
pub(super) fn files_width(&self, full: usize) -> usize {
let shown = self.files_pref.unwrap_or(full >= FILES_AUTO) && full >= 100 && !self.files.is_empty();
if !shown {
return 0;
}
let longest = self.files.iter().map(|f| f.path.rsplit('/').next().unwrap_or(&f.path).chars().count()).max().unwrap_or(10);
self.files_w_pref.unwrap_or((longest + 9).clamp(24, 36)).clamp(FILES_MIN, full / 3) + 1
}
pub(super) fn resize_files(&mut self, by: isize) {
let w = (by != 0).then(|| (self.files_w as isize - 1 + by).max(FILES_MIN as isize) as usize);
self.files_w_pref = w;
self.save_pane("files_width", w.map_or(Value::Null, Value::from));
}
pub(super) fn file_entries(&self) -> Vec<Entry> {
let mut out: Vec<Entry> = self
.rows
.iter()
.filter(|r| r.line.kind == Kind::File)
.filter_map(|r| {
let id = r.main?;
let text = match self.commits.get(id.wrapping_sub(1)) {
_ if id == DESCRIPTION => "description".to_owned(),
_ if id == CLOSING_NOTE => "closing note".to_owned(),
_ if id == CLOSING_PR => "closing comment (Claude's draft)".to_owned(),
Some(c) => format!("{} {}", c.short, c.subject),
None => r.line.text.clone(),
};
let text = if id == DESCRIPTION && !self.claude.description.is_empty() { format!("{text} (Claude's proposal)") } else { text };
Some(Entry { spot: Spot::Section(id), text })
})
.collect();
let mut dir = None;
for (i, f) in self.files.iter().enumerate() {
let (d, name) = f.path.rsplit_once('/').map_or(("", f.path.as_str()), |(d, n)| (d, n));
if dir != Some(d) {
if !d.is_empty() {
out.push(Entry { spot: Spot::Folder, text: format!("{d}/") });
}
dir = Some(d);
}
out.push(Entry { spot: Spot::File(i), text: name.to_owned() });
}
out
}
pub(super) fn current_spot(&self) -> Option<Spot> {
let at = |r: usize| {
let row = self.rows.get(r)?;
row.main.map(Spot::Section).or_else(|| (row.file < self.files.len()).then_some(Spot::File(row.file)))
};
at(self.head.row).or_else(|| {
let r = match *self.drows.get(self.top.max(self.pin as usize))? {
DRow::Full(r) => r,
DRow::Pair(l, r) | DRow::Margin(l, r, _) => l.or(r)?,
};
at(r)
})
}
pub(super) fn draw_files(&mut self, f: &mut Frame, area: Rect, keys: Option<(&str, &[(&str, &str)])>) {
let ui = &self.ui;
let w = area.width as usize - 1; let entries = self.file_entries();
let cur = self.current_spot();
let hints: Vec<Line<'static>> = keys.map_or(vec![], |(title, keys)| {
let kw = keys.iter().map(|k| k.0.chars().count()).max().unwrap_or(1);
let head = Line::from(Span::styled(format!("{:<w$}", format!(" KEYS: {title}")), Style { bg: ui.bar, ..ui.header }));
let rows = keys.iter().map(|(k, d)| {
Line::from(vec![Span::styled(format!(" {k:<kw$} "), ui.key), Span::raw(cut(d, w.saturating_sub(kw + 3)))])
});
std::iter::once(head).chain(rows).take(area.height as usize / 2).collect()
});
let avail = (area.height as usize).saturating_sub(hints.len());
let room = avail.saturating_sub(1);
let sel = self.files_sel.filter(|_| self.files_focus && !self.in_commits);
let (cursor, here) = self.list_tints();
let bar = Style { bg: ui.bar, ..ui.header };
let heading = Line::from(Span::styled(format!("{:<w$}", format!(" CONTENTS · {} files", self.files.len())), bar));
let dim = Style { fg: ui.stale.fg, ..Style::new() };
let mut rows: Vec<(Line<'static>, Hit)> = vec![];
for e in &entries {
let lit = |on: bool| match (sel == Some(e.spot), on) {
(true, _) => cursor,
(_, true) => here,
_ => ui.base,
};
let line = match e.spot {
Spot::Section(_) => {
let on = cur == Some(e.spot);
let row = lit(on);
let name = cut(&e.text, w.saturating_sub(3));
let pad = w.saturating_sub(3 + name.chars().count());
Line::from(vec![
Span::styled(if on { "▌" } else { " " }, row.patch(Style { fg: ui.key.fg, ..Style::new() })),
Span::styled("≡ ", row.patch(Style { fg: ui.stale.fg, ..Style::new() })),
Span::styled(name, row.patch(Style { fg: ui.heading.fg, ..Style::new() })),
Span::styled(" ".repeat(pad), row),
])
}
Spot::Folder => {
let n = e.text.chars().count();
let t = if n + 1 > w { format!("…{}", e.text.chars().skip(n + 2 - w).collect::<String>()) } else { e.text.clone() };
Line::from(Span::styled(format!(" {t:<width$}", width = w - 1), dim))
}
Spot::File(i) => {
let file = &self.files[i];
let on = cur == Some(e.spot);
let row = lit(on);
let open = self.threads.iter().filter(|t| t.path == file.path && !t.stale() && self.in_view(t)).count();
let viewed = self.viewed.get(&file.path).is_some_and(|s| s == "VIEWED");
let right = match open {
0 => String::new(),
n => format!(" ·{n}"),
};
let mark = if viewed { "✓ " } else { " " };
let room = w.saturating_sub(1 + mark.len() + right.chars().count());
let name = cut(&e.text, room);
let pad = room.saturating_sub(name.chars().count());
Line::from(vec![
Span::styled(if on { "▌" } else { " " }, row.patch(Style { fg: ui.key.fg, ..Style::new() })),
Span::styled(mark, row.patch(Style { fg: ui.plus.fg, ..Style::new() })),
Span::styled(name, if on { row.add_modifier(ratatui::style::Modifier::BOLD) } else { row }),
Span::styled(" ".repeat(pad), row),
Span::styled(right, row.patch(Style { fg: ui.thread.fg, ..Style::new() })),
])
}
};
rows.push((line, if e.spot == Spot::Folder { Hit::Nothing } else { Hit::Spot(e.spot) }));
}
if !self.commits.is_empty() {
rows.push((Line::default(), Hit::Nothing));
rows.extend(self.commit_lines(w));
}
let at = |h: Hit| rows.iter().position(|r| r.1 == h);
let want = match (self.files_focus && self.in_commits, sel) {
(true, _) => at(Hit::Commit(self.commit_cur)),
(false, Some(s)) => at(Hit::Spot(s)),
_ => cur.filter(|_| cur != self.files_cur).and_then(|c| at(Hit::Spot(c))),
};
if let Some(i) = want {
self.files_top = self.files_top.min(i).max((i + 1).saturating_sub(room));
}
self.files_cur = cur;
self.files_top = self.files_top.min(rows.len().saturating_sub(room));
let mut lines = vec![heading];
self.list_hits = rows.iter().skip(self.files_top).take(room).map(|r| r.1).collect();
lines.extend(rows.into_iter().skip(self.files_top).take(room).map(|r| r.0));
lines.resize(avail, Line::default());
lines.extend(hints);
f.render_widget(Paragraph::new(lines), Rect { width: area.width - 1, ..area });
let edge = self.ui.linenr;
let divider: Vec<Line> = (0..area.height).map(|_| Line::from(Span::styled("│", edge))).collect();
f.render_widget(Paragraph::new(divider), Rect { x: area.x + area.width - 1, width: 1, ..area });
}
fn commit_lines(&self, w: usize) -> Vec<(Line<'static>, Hit)> {
let ui = &self.ui;
let n = self.commits.len();
let title = match self.commit_shown {
Some((a, b)) if a == b => format!(" COMMITS {} of {n}", a + 1),
Some((a, b)) => format!(" COMMITS {}–{} of {n}", a + 1, b + 1),
None => format!(" COMMITS {n}"),
};
let mut lines = vec![(Line::from(Span::styled(format!("{title:<w$}"), Style { bg: ui.bar, ..ui.header })), Hit::Nothing)];
let last = n + usize::from(self.dirty.has());
let shown = |r: usize| match (self.uncommitted(), self.commit_shown) {
(true, _) => r == n + 1,
(_, None) => r == 0,
(_, Some((a, b))) => r > a && r <= b + 1,
};
let picking = self.commit_anchor.map(|a| (a.min(self.commit_cur), a.max(self.commit_cur)));
let focus = self.files_focus && self.in_commits;
let (cursor, here) = self.list_tints();
for r in 0..=last {
let on = focus && r == self.commit_cur;
let row = match (on, picking.is_some_and(|(a, b)| r >= a && r <= b), shown(r)) {
(true, _, _) => cursor,
(_, true, _) => ui.menu_selected, (_, _, true) => here,
_ => ui.base,
};
let (hash, text) = match r {
0 => (String::new(), "all commits".to_owned()),
r if r > n => (String::new(), format!("uncommitted · {}", self.dirty.says())),
r => (format!("{} ", self.commits[r - 1].short), self.commits[r - 1].subject.clone()),
};
let unsigned = if r > 0 && r <= n && !self.commits[r - 1].signed { "✗ " } else { "" };
let text = cut(&text, w.saturating_sub(2 + hash.chars().count() + unsigned.chars().count()));
let pad = w.saturating_sub(2 + hash.chars().count() + unsigned.chars().count() + text.chars().count());
let line = Line::from(vec![
Span::styled(if on || shown(r) { "▌ " } else { " " }, row.patch(Style { fg: ui.key.fg, ..Style::new() })),
Span::styled(hash, row.patch(Style { fg: ui.stale.fg, ..Style::new() })),
Span::styled(unsigned, row.patch(Style { fg: ui.error.fg, ..Style::new() })),
Span::styled(text, if shown(r) { row.add_modifier(ratatui::style::Modifier::BOLD) } else { row }),
Span::styled(" ".repeat(pad), row),
]);
lines.push((line, Hit::Commit(r)));
}
lines
}
pub(super) fn click_file(&mut self, y: usize, shift: bool) {
match y.checked_sub(1).and_then(|y| self.list_hits.get(y)).copied() {
Some(Hit::Spot(s)) => self.goto_spot(s),
Some(Hit::Commit(r)) => {
self.commit_cur = r;
self.commit_anchor = self.commit_shown.map(|(a, _)| a + 1).filter(|_| shift && r > 0);
self.pick_commits();
}
_ => {} }
}
pub(super) fn scroll_files(&mut self, by: isize) {
self.files_top = self.files_top.saturating_add_signed(by);
}
pub(super) fn load_commits(&mut self) {
let range = format!("{}..HEAD", self.full_base);
let Ok(out) = diff::git(&["log", "--reverse", "--first-parent", "--format=%H%x09%h%x09%at%x09%s", &range]) else { return };
self.commits = out
.lines()
.filter_map(|l| {
let mut p = l.splitn(4, '\t');
let (sha, short, at) = (p.next()?, p.next()?, p.next()?);
let signed = !self.unsigned.iter().any(|u| u == sha);
Some(Commit { sha: sha.into(), short: short.into(), at: at.into(), signed, subject: p.next().unwrap_or("").into() })
})
.collect();
self.dirty = Dirty::of(&diff::git(&["status", "--porcelain"]).unwrap_or_default());
let n = self.commits.len();
self.commit_cur = self.commit_cur.min(n + usize::from(self.dirty.has()));
if self.commit_shown.is_some_and(|(_, b)| b >= n) {
(self.commit_shown, self.to) = (None, None);
self.base = self.full_base.clone();
}
}
pub(super) fn list_keys(&self) -> (&'static str, &'static [(&'static str, &'static str)]) {
match self.in_commits {
true => ("commits", &[
("Enter", "show it (with v: the range)"),
("v", "start a range"),
("m", "its whole message"),
("y", "copy its hash"),
("a", "ask Claude about it / for a change to it"),
("f", "fold what isn't committed into it"),
("Tab", "to the contents"),
("Ctrl-l", "back to the code"),
]),
false => ("contents", &[("j k", "move"), ("Enter", "go there"), ("Tab", "to the commits"), ("Ctrl-l", "back to the code")]),
}
}
fn commit_popup(&mut self) {
let Some(c) = self.commit_cur.checked_sub(1).and_then(|i| self.commits.get(i)) else {
return self.msg = "m on a commit shows its message".into();
};
let text = diff::git(&["log", "-1", "--format=%an <%ae> · %ad%n%n%B", "--date=format:%Y-%m-%d %H:%M", &c.sha]).unwrap_or_else(|e| format!("{e:#}"));
let mut lines: Vec<Line<'static>> = text.trim_end().lines().map(|l| Line::raw(l.to_owned())).collect();
if let Some(l) = lines.first_mut() {
*l = Line::styled(l.to_string(), self.ui.linenr);
}
if let Some(l) = lines.get_mut(2) {
*l = Line::styled(l.to_string(), Style::new().add_modifier(ratatui::style::Modifier::BOLD));
}
self.popup = Some((format!(" commit {} j/k scroll ", c.short), lines, 0));
}
pub(super) fn before_head(&self) -> bool {
self.commit_shown.is_some_and(|(_, b)| b + 1 < self.commits.len())
}
pub(super) fn commits_label(&self) -> Option<String> {
let (a, b) = self.commit_shown?;
Some(match a == b {
true => format!("commit {}", self.commits[a].short),
false => format!("commits {}..{}", self.commits[a].short, self.commits[b].short),
})
}
fn pick_commits(&mut self) {
if self.commit_cur > self.commits.len() {
self.commit_anchor = None;
if !self.uncommitted() {
self.toggle_uncommitted();
}
return;
}
self.commit_anchor = self.commit_anchor.filter(|&a| a <= self.commits.len());
let (a, b) = match self.commit_anchor.take() {
Some(a) => (a.min(self.commit_cur), a.max(self.commit_cur)),
None => (self.commit_cur, self.commit_cur),
};
self.show_commits((a > 0).then(|| (a - 1, b - 1)));
}
pub(super) fn commit_picker(&mut self) {
if self.commits.is_empty() {
return self.msg = "no commits here to pick from".into();
}
let all = self.commit_shown.is_none() && !self.uncommitted();
let mut items = vec![Item::new(format!("{} all commits: the whole diff", if all { "●" } else { " " }), Target::Commit(None))];
items.extend(self.commits.iter().enumerate().rev().map(|(i, c)| {
let on = if self.commit_shown == Some((i, i)) { "●" } else { " " };
Item::new(format!("{on} {} {}", c.short, c.subject), Target::Commit(Some(c.sha.clone())))
}));
self.picker = Some(Picker::new(" commits ", items));
}
pub(super) fn show_commits(&mut self, sel: Option<(usize, usize)>) {
let sel = sel.filter(|&(a, b)| a <= b && b < self.commits.len());
let (base, to) = match sel {
None => (self.full_base.clone(), None),
Some((a, b)) => (if a == 0 { self.full_base.clone() } else { self.commits[a - 1].sha.clone() }, Some(self.commits[b].sha.clone())),
};
(self.base, self.to, self.commit_shown) = (base, to, sel);
self.commit_msgs = sel.map_or(vec![], |(a, b)| {
let mut args = vec!["log", "--no-walk=unsorted", "--format=%B%x00"];
args.extend(self.commits[a..=b].iter().map(|c| c.sha.as_str()));
let out = diff::git(&args).unwrap_or_default();
out.split('\0').map(|m| m.trim_matches('\n').to_owned()).take(b + 1 - a).collect()
});
self.extra.clear(); self.msg = match (self.reload(false), self.commits_label()) {
(Err(e), _) => format!("{e:#}"),
(Ok(()), None) => "all commits: the whole diff".into(),
(Ok(()), Some(l)) if self.before_head() => format!("{l} (comments are on the PR's head: they show with the last commit)"),
(Ok(()), Some(l)) => l,
};
}
pub(super) fn files_key(&mut self, k: KeyEvent) -> bool {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let spots: Vec<Spot> = self.file_entries().into_iter().map(|e| e.spot).filter(|&s| s != Spot::Folder).collect();
let at = self.files_sel.or(self.current_spot()).and_then(|c| spots.iter().position(|&s| s == c));
let step = |app: &mut App, d: isize| {
let i = at.map_or(0, |i| (i as isize + d).clamp(0, spots.len() as isize - 1) as usize);
app.files_sel = spots.get(i).copied();
};
let last_file = at.is_some_and(|i| i + 1 == spots.len()) || spots.is_empty();
match k.code {
KeyCode::Char('j') | KeyCode::Down if !ctrl && self.in_commits => self.commit_cur = (self.commit_cur + 1).min(self.commits.len() + usize::from(self.dirty.has())),
KeyCode::Char('k') | KeyCode::Up if !ctrl && self.in_commits && self.commit_cur == 0 => self.in_commits = false,
KeyCode::Char('k') | KeyCode::Up if !ctrl && self.in_commits => self.commit_cur -= 1,
KeyCode::Char('v') if self.in_commits && self.commit_cur > self.commits.len() && self.commit_anchor.is_none() => {} KeyCode::Char('v') if self.in_commits => self.commit_anchor = if self.commit_anchor.is_some() { None } else { Some(self.commit_cur) },
KeyCode::Enter if self.in_commits => self.pick_commits(),
KeyCode::Tab | KeyCode::BackTab => self.focus_list(!self.in_commits),
KeyCode::Char('m') if self.in_commits => self.commit_popup(),
KeyCode::Char('a') if self.in_commits => match self.commit_cur.checked_sub(1) {
Some(i) => self.ask_commit(i),
None => self.msg = "a on a commit asks Claude about it, or for a change to it".into(),
},
KeyCode::Char('f') if self.in_commits => match self.commit_cur.checked_sub(1).and_then(|i| self.commits.get(i)).map(|c| c.sha.clone()) {
Some(sha) => self.amend_into(&sha),
None => self.msg = "f on a commit folds what isn't committed into it (amends it)".into(),
},
KeyCode::Char('y') if self.in_commits => match self.commit_cur.checked_sub(1).and_then(|i| self.commits.get(i)).map(|c| c.sha.clone()) {
Some(sha) => {
clipboard(&sha);
self.msg = format!("copied {sha}");
}
None => self.msg = "y on a commit copies its hash".into(),
},
KeyCode::Esc if self.commit_anchor.is_some() => self.commit_anchor = None,
KeyCode::Char('j') | KeyCode::Down if !ctrl && last_file && !self.commits.is_empty() => {
self.in_commits = true;
self.commit_cur = self.commit_shown.map_or(0, |(a, _)| a + 1);
}
KeyCode::Char('j') | KeyCode::Down if !ctrl => step(self, 1),
KeyCode::Char('k') | KeyCode::Up if !ctrl => step(self, -1),
KeyCode::Char('w') if ctrl => return false, KeyCode::Char('h') | KeyCode::Left | KeyCode::Backspace => {}
KeyCode::Enter => {
if let Some(s) = self.files_sel {
self.goto_spot(s);
}
(self.files_focus, self.commit_anchor) = (false, None);
}
KeyCode::Char('l') | KeyCode::Right | KeyCode::Esc => (self.files_focus, self.commit_anchor) = (false, None),
_ => {
self.files_focus = false;
return false;
}
}
true
}
fn goto_spot(&mut self, s: Spot) {
match s {
Spot::File(i) => self.goto_file(i),
Spot::Section(id) => {
if let Some(r) = self.rows.iter().position(|r| r.main == Some(id) && r.line.kind == Kind::File) {
self.goto(r, 0);
self.top = self.d_of[r].max(self.pin as usize);
}
}
Spot::Folder => {}
}
}
fn goto_file(&mut self, i: usize) {
let path = self.files[i].path.clone();
if self.collapsed.remove(&path) {
self.build();
}
if let Some(r) = self.rows.iter().position(|r| r.file == i && r.line.kind == Kind::File) {
self.goto(r, 0);
self.top = self.d_of[r].max(self.pin as usize);
}
}
pub(super) fn focus_list(&mut self, commits: bool) {
if self.files_w == 0 {
return self.msg = "the files' list is hidden (Ctrl-w e shows it)".into();
}
(self.files_focus, self.files_sel) = (true, self.current_spot());
self.in_commits = commits && !self.commits.is_empty();
if self.in_commits {
self.commit_cur = self.commit_shown.map_or(0, |(a, _)| a + 1);
}
}
pub(super) fn toggle_files(&mut self) {
let on = self.files_w == 0;
self.files_pref = Some(on);
self.files_focus &= on;
self.save_pane("files", on.into());
self.msg = if on { "the files' list (Ctrl-w e hides it)" } else { "files' list hidden (Ctrl-w e shows it)" }.into();
}
}
pub(crate) fn cut(s: &str, n: usize) -> String {
match s.chars().count() > n {
true => format!("{}…", s.chars().take(n.saturating_sub(1)).collect::<String>()),
false => s.to_owned(),
}
}
#[test]
fn what_isnt_committed() {
let d = Dirty::of("M a.rs\nMM b.rs\n M c.rs\n?? d.rs\nA e.rs\n");
assert_eq!((d.staged, d.changed, d.new), (3, 2, 1));
assert_eq!(d.says(), "3 staged, 2 changed, 1 new");
assert!(!Dirty::of("").has());
}