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,
}
#[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,
pub(super) 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) && list_room(full);
if !shown {
return 0;
}
let longest = self.files.iter().map(|f| super::row::cols(f.path.rsplit('/').next().unwrap_or(&f.path))).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.has_proposal() { format!("{text} (Claude's proposal)") } else { text };
Some(Entry { spot: Spot::Section(id), text })
})
.collect();
let folders = self.files.iter().any(|f| f.path.contains('/'));
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() || folders {
out.push(Entry { spot: Spot::Folder, text: format!("{}/", if d.is_empty() { "." } else { 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 count = match self.files.len() {
0 => self.no_files().to_owned(),
n => format!("{n} files"),
};
let heading = Line::from(Span::styled(format!("{:<w$}", format!(" CONTENTS · {count}")), 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 + super::row::cols(&name));
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 t: String = cut(&e.text.chars().rev().collect::<String>(), w - 1).chars().rev().collect();
let pad = (w - 1).saturating_sub(super::row::cols(&t));
Line::from(Span::styled(format!(" {t}{}", " ".repeat(pad)), 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 + super::row::cols(mark) + super::row::cols(&right));
let name = cut(&e.text, room);
let pad = room.saturating_sub(super::row::cols(&name));
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 = self.last_commit_row();
let (sa, sb) = self.shown_rows();
let shown = |r: usize| r >= sa && r <= sb;
let focus = self.files_focus && self.in_commits;
let picking = self.commit_anchor.filter(|_| focus).map(|a| (a.min(self.commit_cur), a.max(self.commit_cur)));
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.unsigned.contains(&self.commits[r - 1].sha) { "✗ " } else { "" };
let lead = 2 + super::row::cols(&hash) + super::row::cols(unsigned);
let text = cut(&text, w.saturating_sub(lead));
let pad = w.saturating_sub(lead + super::row::cols(&text));
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 = Some(self.shown_rows().0).filter(|&a| shift && r > 0 && a > 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) {
self.dirty = Dirty::of(&diff::git(&["status", "--porcelain"]).unwrap_or_default());
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 };
let was: Vec<String> = self.commits.iter().map(|c| c.sha.clone()).collect();
self.commits = out
.lines()
.filter_map(|l| {
let mut p = l.splitn(4, '\t');
let (sha, short, at) = (p.next()?, p.next()?, p.next()?);
Some(Commit { sha: sha.into(), short: short.into(), at: at.into(), subject: p.next().unwrap_or("").into() })
})
.collect();
let n = self.commits.len();
let rewritten = std::cell::OnceCell::new();
let at = |sha: &str| self.now_as(sha, &rewritten).and_then(|s| self.commits.iter().position(|c| c.sha == s));
let spot = |k: usize| match k {
0 => Some(0),
k if k <= was.len() => at(&was[k - 1]).map(|i| i + 1),
_ => Some(n + 1),
};
let (cur, anchor) = (spot(self.commit_cur).unwrap_or(self.commit_cur), self.commit_anchor.and_then(spot));
let shown = |sha: &Option<String>| sha.as_deref().and_then(at);
let (first, last) = (shown(&self.shown_first), shown(&self.to));
self.commit_cur = cur.min(self.last_commit_row());
self.commit_anchor = anchor;
if self.commit_shown.is_some() {
match (first, last) {
(Some(a), Some(b)) if a <= b => {
self.commit_shown = Some((a, b));
self.commits_as_now((a, b));
}
_ => self.show_whole(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_here().map(|i| &self.commits[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),
})
}
pub(super) 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 shown_rows(&self) -> (usize, usize) {
match (self.uncommitted(), self.commit_shown) {
(true, _) => (self.commits.len() + 1, self.commits.len() + 1),
(_, None) => (0, 0),
(_, Some((a, b))) => (a + 1, b + 1),
}
}
fn commit_here(&self) -> Option<usize> {
self.commit_cur.checked_sub(1).filter(|&i| i < self.commits.len())
}
pub(super) fn last_commit_row(&self) -> usize {
self.commits.len() + usize::from(self.dirty.has())
}
pub(super) fn commit_picker(&mut self) {
if self.commits.is_empty() {
return self.msg = "no commits here to pick from".into();
}
let (a, b) = self.shown_rows();
let mark = |r: usize| if r >= a && r <= b { "●" } else { " " };
let n = self.commits.len();
let mut items = vec![Item::new(format!("{} all commits: the whole diff", mark(0)), Target::CommitRow(0))];
items.extend(self.commits.iter().enumerate().rev().map(|(i, c)| Item::new(format!("{} {} {}", mark(i + 1), c.short, c.subject), Target::CommitRow(i + 1))));
if self.last_commit_row() > n {
items.insert(1, Item::new(format!("{} uncommitted", mark(n + 1)), Target::CommitRow(n + 1))); }
self.picker = Some(Picker::new(" commits ", items));
}
fn commits_as_now(&mut self, (a, b): (usize, usize)) {
let first = Some(self.commits[a].sha.clone());
let to = Some(self.commits[b].sha.clone());
let base = if a == 0 { self.full_base.clone() } else { self.commits[a - 1].sha.clone() };
if (&first, &to, &base) == (&self.shown_first, &self.to, &self.base) {
return; }
self.set_diff(base, to);
self.shown_first = first;
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();
self.commit_msgs = out.split('\0').map(|m| m.trim_matches('\n').to_owned()).take(b + 1 - a).collect();
}
pub(super) fn show_commits(&mut self, sel: Option<(usize, usize)>) {
let sel = sel.filter(|&(a, b)| a <= b && b < self.commits.len());
match sel {
Some(sel) => {
self.commit_shown = Some(sel);
self.commits_as_now(sel);
}
None => self.show_whole(self.full_base.clone()),
}
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.last_commit_row()),
KeyCode::Char('k') | KeyCode::Up if !ctrl && self.in_commits && self.commit_cur == 0 => (self.in_commits, self.files_sel) = (false, spots.last().copied()),
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_here() {
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_here().map(|i| self.commits[i].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_here().map(|i| self.commits[i].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.focus_list(true),
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 => {
match self.files_sel {
Some(s) => self.goto_spot(s),
None => self.files_focus = false,
}
}
KeyCode::Char('l') | KeyCode::Right | KeyCode::Esc => self.files_focus = false,
_ => {
self.files_focus = false;
return false;
}
}
true
}
pub(super) fn set_files(&mut self, mut files: Vec<diff::FileDiff>) {
let dir = |p: &str| p.rsplit_once('/').map_or("", |(d, _)| d).to_owned();
let mut first: Vec<String> = vec![];
for f in &files {
if !first.contains(&dir(&f.path)) {
first.push(dir(&f.path));
}
}
files.sort_by_key(|f| first.iter().position(|d| *d == dir(&f.path)));
let sel = match self.files_sel {
Some(Spot::File(i)) => self.files.get(i).map(|f| f.path.clone()),
_ => None,
};
self.files = files;
if let Some(p) = sel {
self.files_sel = self.files.iter().position(|f| f.path == p).map(Spot::File);
}
}
pub(super) fn show_file(&mut self, path: &str, shown: impl Fn(&Self) -> bool) {
if self.collapsed.remove(path) {
let keep = self.cursor_key();
self.rebuild(keep);
}
if !shown(self) && self.view != View::All {
self.view = View::All;
let keep = self.cursor_key();
self.rebuild(keep);
}
}
pub(super) fn goto_spot(&mut self, s: Spot) {
self.files_focus = false;
if let Spot::File(i) = s
&& let Some(path) = self.files.get(i).map(|f| f.path.clone())
{
self.show_file(&path, |a| a.rows.iter().any(|r| r.file == i));
}
let header = |r: &Row| {
r.line.kind == Kind::File
&& match s {
Spot::File(i) => r.file == i,
Spot::Section(id) => r.main == Some(id),
Spot::Folder => false,
}
};
if let Some(r) = self.rows.iter().position(header) {
self.jump(r, 0);
self.top = self.d_of[r].max(self.pin as usize);
}
}
pub(super) fn no_files(&self) -> &'static str {
if self.unpublished() { "no files yet" } else { "no changes" }
}
pub(super) fn list_has_spots(&self) -> bool {
!self.file_entries().is_empty() || !self.commits.is_empty()
}
pub(super) fn focus_list(&mut self, commits: bool) {
if self.files_w == 0 {
return self.msg = self.list_hidden_why().into();
}
if !self.list_has_spots() {
return self.msg = format!("the list has nothing to go to ({})", self.no_files());
}
(self.files_focus, self.files_sel, self.commit_anchor) = (true, self.current_spot(), None);
self.in_commits = commits && !self.commits.is_empty();
if self.in_commits {
self.commit_cur = self.shown_rows().0;
}
}
pub(super) fn toggle_files(&mut self) {
let on = self.files_w == 0;
if on && !list_room(self.width as usize) {
return self.msg = self.list_hidden_why().into();
}
self.files_pref = Some(on);
if on && self.zoom.take().is_some() {
self.relayout();
}
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();
}
fn list_hidden_why(&self) -> &'static str {
match list_room(self.width as usize) {
false => "too narrow for the files' list",
true => "the files' list is hidden (Ctrl-w e shows it)",
}
}
}
fn list_room(full: usize) -> bool {
full / 3 >= FILES_MIN
}
pub(crate) fn cut(s: &str, n: usize) -> String {
if super::row::cols(s) <= n {
return s.to_owned();
}
let mut kept = String::new();
for c in s.chars() {
kept.push(c);
if super::row::cols(&kept) >= n {
kept.pop();
break;
}
}
format!("{kept}…")
}
#[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());
}