use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::features::workspace_diff::{ChangeSet, DiffKind, FileChange, FileState};
use crate::shared::i18n::Locale;
use crate::shared::keys;
use crate::shared::theme::Palette;
use crate::shared::ui::{confirm_popup, keep_visible, render_scrollbar, screen_chrome};
use crate::widgets::help_dialog::{HelpContext, HelpSection};
pub(crate) static HELP_SECTION: HelpSection = HelpSection {
title: "ui.help.sec.changes",
context: Some(HelpContext::Changes),
rows: &[
("Tab", "ui.help.ch_pane"),
("↑/↓", "ui.help.ch_select"),
("PageUp/PageDown", "ui.help.ch_scroll"),
("R", "ui.help.ch_revert"),
("Esc", "ui.help.ch_close"),
],
openers: &[],
};
const PAGE_STEP: usize = 10;
const FILE_PANE_WIDTH: u16 = 34;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Files,
Diff,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChangesIntent {
Close,
Quit,
Revert(String),
}
pub struct ChangesScreen {
set: ChangeSet,
palette: Palette,
loc: &'static Locale,
selected: usize,
diff_scroll: usize,
file_scroll: usize,
focus: Focus,
confirming: Option<String>,
}
impl ChangesScreen {
pub fn new(set: ChangeSet, palette: Palette, loc: &'static Locale) -> Self {
Self {
set,
palette,
loc,
selected: 0,
diff_scroll: 0,
file_scroll: 0,
focus: Focus::Files,
confirming: None,
}
}
pub fn set_changes(&mut self, set: ChangeSet) {
let previous = self.selected_path().map(str::to_string);
self.set = set;
self.selected = previous
.and_then(|path| self.set.files.iter().position(|f| f.path == path))
.unwrap_or_else(|| self.selected.min(self.set.files.len().saturating_sub(1)));
self.diff_scroll = 0;
self.confirming = None;
}
pub fn set_palette(&mut self, palette: Palette) {
self.palette = palette;
}
pub fn set_loc(&mut self, loc: &'static Locale) {
self.loc = loc;
}
fn selected_file(&self) -> Option<&FileChange> {
self.set.files.get(self.selected)
}
fn selected_path(&self) -> Option<&str> {
self.selected_file().map(|f| f.path.as_str())
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<ChangesIntent> {
if key.kind != KeyEventKind::Press {
return None;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if key.code == KeyCode::F(10) || (ctrl && keys::hotkey_char(&key) == Some('q')) {
return Some(ChangesIntent::Quit);
}
if let Some(path) = self.confirming.clone() {
self.confirming = None;
return (key.code == KeyCode::Enter).then_some(ChangesIntent::Revert(path));
}
if ctrl {
return None;
}
let last = self.set.files.len().saturating_sub(1);
match key.code {
KeyCode::Esc => return Some(ChangesIntent::Close),
KeyCode::Tab | KeyCode::BackTab => {
self.focus = match self.focus {
Focus::Files => Focus::Diff,
Focus::Diff => Focus::Files,
}
}
KeyCode::Up => self.step(-1, last),
KeyCode::Down => self.step(1, last),
KeyCode::PageUp => self.diff_scroll = self.diff_scroll.saturating_sub(PAGE_STEP),
KeyCode::PageDown => self.diff_scroll = self.scroll_down(PAGE_STEP),
KeyCode::Home => match self.focus {
Focus::Files => self.select(0),
Focus::Diff => self.diff_scroll = 0,
},
KeyCode::End => match self.focus {
Focus::Files => self.select(last),
Focus::Diff => self.diff_scroll = usize::MAX,
},
KeyCode::Char(_) if keys::hotkey_char(&key) == Some('r') => {
if let Some(file) = self.selected_file().filter(|f| f.state.is_revertable()) {
self.confirming = Some(file.path.clone());
}
}
_ => {}
}
None
}
fn step(&mut self, delta: isize, last: usize) {
match self.focus {
Focus::Files => {
let next = self.selected as isize + delta;
self.select(next.clamp(0, last as isize) as usize);
}
Focus::Diff if delta < 0 => self.diff_scroll = self.diff_scroll.saturating_sub(1),
Focus::Diff => self.diff_scroll = self.scroll_down(1),
}
}
fn select(&mut self, index: usize) {
if index != self.selected {
self.diff_scroll = 0;
}
self.selected = index;
}
fn scroll_down(&self, step: usize) -> usize {
let len = self.selected_file().map_or(0, |f| f.lines.len());
self.diff_scroll
.saturating_add(step)
.min(len.saturating_sub(1))
}
fn hints(&self) -> Vec<(&'static str, &'static str, bool)> {
let loc = self.loc;
let mut hk: Vec<(&'static str, &'static str, bool)> = match self.focus {
Focus::Files => vec![
("↑↓", loc.t("ui.changes.hk.select"), false),
("PgUp/Dn", loc.t("ui.changes.hk.scroll"), false),
],
Focus::Diff => vec![("↑↓ PgUp/Dn", loc.t("ui.changes.hk.scroll"), false)],
};
hk.push(("Tab", loc.t("ui.changes.hk.pane"), false));
if self
.selected_file()
.is_some_and(|f| f.state.is_revertable())
{
hk.push(("R", loc.t("ui.changes.hk.revert"), false));
}
hk.extend([
("F1", loc.t("ui.changes.hk.help"), false),
("Esc", loc.t("ui.changes.hk.back"), false),
("Ctrl+Q", loc.t("ui.changes.hk.quit"), false),
]);
hk
}
pub fn render(&mut self, frame: &mut Frame) {
let palette = self.palette;
let loc = self.loc;
let hk = self.hints();
let chrome = screen_chrome(
frame,
&palette,
format!(
"{} {}",
palette.glyphs().title_marker,
loc.t("ui.changes.title")
),
Some(self.summary()),
&hk,
);
let (inner, status_area, hotkeys) = (chrome.inner, chrome.status, chrome.hotkeys);
if self.set.is_empty() {
let key = if self.set.root.is_empty() {
"ui.changes.no_project"
} else {
"ui.changes.empty"
};
frame.render_widget(
Paragraph::new(Line::styled(loc.t(key), palette.muted_style())),
inner,
);
frame.render_widget(Paragraph::new(hotkeys), status_area);
return;
}
let file_w = FILE_PANE_WIDTH.min(inner.width.saturating_sub(12)).max(12);
let [files_area, rule_area, diff_area] = Layout::horizontal([
Constraint::Length(file_w),
Constraint::Length(1),
Constraint::Min(10),
])
.areas(inner);
self.render_files(frame, files_area);
frame.render_widget(
Paragraph::new(vec![
Line::styled("│", palette.border_style(false));
rule_area.height as usize
]),
rule_area,
);
self.render_diff(frame, diff_area);
frame.render_widget(Paragraph::new(hotkeys), status_area);
if let Some(path) = &self.confirming {
confirm_popup(
frame,
&palette,
loc.t("ui.confirm.title"),
&loc.tf("ui.changes.confirm_revert", &[("path", path)]),
loc.t("ui.confirm.footer"),
);
}
}
fn summary(&self) -> String {
let (added, removed) = self
.set
.files
.iter()
.fold((0usize, 0usize), |(a, r), f| (a + f.added, r + f.removed));
self.loc.tf(
"ui.changes.summary",
&[
("files", &self.set.files.len().to_string()),
("added", &added.to_string()),
("removed", &removed.to_string()),
],
)
}
fn render_files(&mut self, frame: &mut Frame, area: Rect) {
let p = &self.palette;
let focused = self.focus == Focus::Files;
let width = area.width as usize;
let view = area.height as usize;
self.file_scroll = keep_visible(self.file_scroll, self.selected, view);
let counts: Vec<String> = self.set.files.iter().map(|f| self.counts_of(f)).collect();
let counts_w = counts.iter().map(|c| c.chars().count()).max().unwrap_or(0);
let room = width.saturating_sub(1 + counts_w + 1).max(4);
let mut lines = Vec::new();
for (i, file) in self.set.files.iter().enumerate().skip(self.file_scroll) {
if lines.len() >= view {
break;
}
let selected = i == self.selected;
let marker = if selected && focused { "▌" } else { " " };
let name = elide_left(&file.path, room);
let pad = room.saturating_sub(name.chars().count());
lines.push(Line::from(vec![
Span::styled(marker.to_string(), p.accent_style()),
Span::styled(
format!("{name}{:pad$} ", "", pad = pad),
if selected {
Style::new().fg(p.text).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(p.text)
},
),
Span::styled(
format!("{:>counts_w$}", counts[i], counts_w = counts_w),
self.counts_style(file),
),
]));
}
frame.render_widget(Paragraph::new(lines), area);
render_scrollbar(
frame,
area,
self.set.files.len(),
view,
self.file_scroll,
focused,
p,
);
}
fn counts_of(&self, file: &FileChange) -> String {
match file.state {
FileState::Created if file.lines.is_empty() => self.loc.t("ui.changes.new").to_string(),
FileState::Modified | FileState::Created => {
format!("+{} −{}", file.added, file.removed)
}
FileState::Gone => self.loc.t("ui.changes.gone").to_string(),
FileState::NotShown => self.loc.t("ui.changes.not_shown").to_string(),
FileState::Unchanged => self.loc.t("ui.changes.unchanged").to_string(),
}
}
fn counts_style(&self, file: &FileChange) -> Style {
match file.state {
FileState::Modified | FileState::Created => Style::new().fg(self.palette.success),
FileState::Gone => Style::new().fg(self.palette.warning),
_ => self.palette.muted_style(),
}
}
fn render_diff(&mut self, frame: &mut Frame, area: Rect) {
let p = &self.palette;
let loc = self.loc;
let focused = self.focus == Focus::Diff;
let Some(file) = self.set.files.get(self.selected) else {
return;
};
let view = area.height as usize;
if file.lines.is_empty() {
let key = match file.state {
FileState::Created => "ui.changes.detail_new",
FileState::Gone => "ui.changes.detail_gone",
FileState::NotShown => "ui.changes.detail_not_shown",
_ => "ui.changes.detail_unchanged",
};
frame.render_widget(
Paragraph::new(Line::styled(loc.t(key), p.muted_style())),
area,
);
return;
}
self.diff_scroll = self.diff_scroll.min(file.lines.len().saturating_sub(1));
let lines: Vec<Line<'static>> = file
.lines
.iter()
.skip(self.diff_scroll)
.take(view)
.map(|line| {
let (marker, style) = match line.kind {
DiffKind::Hunk => ("", p.accent_style()),
DiffKind::Added => ("+", Style::new().fg(p.success)),
DiffKind::Removed => ("−", Style::new().fg(p.error)),
DiffKind::Context => (" ", p.muted_style()),
};
Line::from(Span::styled(format!("{marker}{}", line.text), style))
})
.collect();
frame.render_widget(Paragraph::new(lines), area);
render_scrollbar(
frame,
area,
file.lines.len(),
view,
self.diff_scroll,
focused,
p,
);
}
}
impl FileState {
fn is_revertable(self) -> bool {
!matches!(self, FileState::Gone)
}
}
fn elide_left(path: &str, width: usize) -> String {
let count = path.chars().count();
if count <= width {
return path.to_string();
}
let keep = width.saturating_sub(1);
let tail: String = path.chars().skip(count - keep).collect();
format!("…{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::workspace_diff::DiffLine;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn loc() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn line(kind: DiffKind, text: &str) -> DiffLine {
DiffLine {
kind,
text: text.to_string(),
}
}
fn screen(files: Vec<FileChange>) -> ChangesScreen {
ChangesScreen::new(
ChangeSet {
root: "D:/proj".into(),
files,
},
Palette::default(),
loc(),
)
}
fn modified(path: &str) -> FileChange {
FileChange {
path: path.into(),
state: FileState::Modified,
added: 1,
removed: 1,
lines: vec![
line(DiffKind::Hunk, "@@ -1,3 +1,3 @@"),
line(DiffKind::Context, "keep"),
line(DiffKind::Removed, "was"),
line(DiffKind::Added, "is"),
],
}
}
fn press(s: &mut ChangesScreen, code: KeyCode) -> Option<ChangesIntent> {
s.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn text(s: &mut ChangesScreen) -> String {
let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
let mut out = String::new();
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn the_footer_follows_the_pane_and_the_file() {
let loc = loc();
let mut gone = modified("src/gone.rs");
gone.state = FileState::Gone;
gone.lines.clear();
let mut s = screen(vec![modified("src/a.rs"), gone]);
let keys = |s: &ChangesScreen| -> Vec<&'static str> {
s.hints().iter().map(|(k, _, _)| *k).collect()
};
let descs = |s: &ChangesScreen| -> Vec<&'static str> {
s.hints().iter().map(|(_, d, _)| *d).collect()
};
assert!(descs(&s).contains(&loc.t("ui.changes.hk.select")));
assert!(keys(&s).contains(&"R"), "a modified file can be put back");
press(&mut s, KeyCode::Tab);
assert!(!descs(&s).contains(&loc.t("ui.changes.hk.select")));
assert_eq!(
descs(&s)
.iter()
.filter(|d| **d == loc.t("ui.changes.hk.scroll"))
.count(),
1,
"one hint names the scroll, not two: {:?}",
s.hints()
);
press(&mut s, KeyCode::Tab);
press(&mut s, KeyCode::Down);
assert!(!keys(&s).contains(&"R"), "{:?}", s.hints());
assert!(press(&mut s, KeyCode::Char('r')).is_none());
assert!(s.confirming.is_none(), "and nothing was armed");
assert!(keys(&s).contains(&"F1") && keys(&s).contains(&"Ctrl+Q"));
}
#[test]
fn the_selected_file_diff_is_drawn() {
let mut s = screen(vec![modified("src/a.rs"), modified("src/b.rs")]);
let shown = text(&mut s);
assert!(shown.contains("src/a.rs"), "{shown}");
assert!(shown.contains("src/b.rs"), "{shown}");
assert!(shown.contains("@@ -1,3 +1,3 @@"), "{shown}");
assert!(shown.contains("+is"), "{shown}");
assert!(shown.contains("−was"), "{shown}");
}
#[test]
fn moving_the_selection_changes_the_diff() {
let mut a = modified("src/a.rs");
a.lines = vec![line(DiffKind::Added, "only in a")];
let mut b = modified("src/b.rs");
b.lines = vec![line(DiffKind::Added, "only in b")];
let mut s = screen(vec![a, b]);
assert!(text(&mut s).contains("only in a"));
press(&mut s, KeyCode::Down);
let shown = text(&mut s);
assert!(shown.contains("only in b"), "{shown}");
assert!(!shown.contains("only in a"), "{shown}");
}
#[test]
fn the_selection_is_clamped_at_both_ends() {
let mut s = screen(vec![modified("a"), modified("b")]);
for _ in 0..5 {
press(&mut s, KeyCode::Up);
}
assert_eq!(s.selected, 0);
for _ in 0..5 {
press(&mut s, KeyCode::Down);
}
assert_eq!(s.selected, 1);
}
#[test]
fn revert_is_confirmed_before_it_is_reported() {
let mut s = screen(vec![modified("src/a.rs")]);
assert_eq!(
press(&mut s, KeyCode::Char('r')),
None,
"r must not act yet"
);
assert_eq!(s.confirming.as_deref(), Some("src/a.rs"));
let shown = text(&mut s);
assert!(
shown.contains("src/a.rs"),
"the question must name the file: {shown}"
);
assert_eq!(
press(&mut s, KeyCode::Enter),
Some(ChangesIntent::Revert("src/a.rs".into()))
);
assert_eq!(s.confirming, None);
}
#[test]
fn any_other_key_cancels_the_confirmation() {
let mut s = screen(vec![modified("src/a.rs")]);
press(&mut s, KeyCode::Char('r'));
assert_eq!(press(&mut s, KeyCode::Esc), None, "Esc cancels, not closes");
assert_eq!(s.confirming, None);
assert_eq!(press(&mut s, KeyCode::Esc), Some(ChangesIntent::Close));
}
#[test]
fn a_missing_file_cannot_be_reverted() {
let mut s = screen(vec![FileChange {
path: "gone.rs".into(),
state: FileState::Gone,
added: 0,
removed: 0,
lines: Vec::new(),
}]);
press(&mut s, KeyCode::Char('r'));
assert_eq!(s.confirming, None);
}
#[test]
fn quit_punches_through_the_confirmation() {
let mut s = screen(vec![modified("a")]);
press(&mut s, KeyCode::Char('r'));
assert_eq!(press(&mut s, KeyCode::F(10)), Some(ChangesIntent::Quit));
}
#[test]
fn the_selection_follows_the_file_not_the_index() {
let mut s = screen(vec![modified("a"), modified("b"), modified("c")]);
press(&mut s, KeyCode::Down);
press(&mut s, KeyCode::Down);
assert_eq!(s.selected_path(), Some("c"));
s.set_changes(ChangeSet {
root: "D:/proj".into(),
files: vec![modified("b"), modified("c")],
});
assert_eq!(s.selected_path(), Some("c"));
}
#[test]
fn losing_the_selected_file_clamps_rather_than_dangles() {
let mut s = screen(vec![modified("a"), modified("b")]);
press(&mut s, KeyCode::Down);
s.set_changes(ChangeSet {
root: "D:/proj".into(),
files: vec![modified("a")],
});
assert_eq!(s.selected_path(), Some("a"));
}
#[test]
fn every_state_explains_itself_in_the_diff_pane() {
for state in [
FileState::Gone,
FileState::NotShown,
FileState::Unchanged,
FileState::Created,
] {
let mut s = screen(vec![FileChange {
path: "f.rs".into(),
state,
added: 0,
removed: 0,
lines: Vec::new(),
}]);
let shown = text(&mut s);
let body: String = shown.lines().skip(2).take(6).collect();
assert!(
body.trim().chars().filter(|c| !c.is_whitespace()).count() > 10,
"{state:?} rendered nothing: {shown}"
);
}
}
#[test]
fn an_empty_change_set_says_so() {
let mut s = ChangesScreen::new(ChangeSet::default(), Palette::default(), loc());
let shown = text(&mut s);
assert!(
shown.contains("/project"),
"with no project it must name the command that attaches one: {shown}"
);
let mut with_project = ChangesScreen::new(
ChangeSet {
root: "D:/proj".into(),
files: Vec::new(),
},
Palette::default(),
loc(),
);
let shown = text(&mut with_project);
assert!(shown.contains(loc().t("ui.changes.empty")), "{shown}");
}
#[test]
fn the_panes_are_separated() {
let mut file = modified("src/a.rs");
file.lines = vec![line(DiffKind::Hunk, "@@ -940,7 +940,9 @@")];
let mut s = screen(vec![file]);
let shown = text(&mut s);
assert!(
!shown.contains("−1@@"),
"the file row runs straight into the diff: {shown}"
);
let row = shown
.lines()
.find(|l| l.contains("src/a.rs"))
.expect("the file row");
let bar = row.find('│').expect("the panel border");
assert!(
row[bar + '│'.len_utf8()..].contains('│'),
"no rule between the panes: {row}"
);
}
#[test]
fn the_counts_line_up_across_rows() {
let mut wide = modified("src/a.rs");
wide.added = 12;
wide.removed = 4;
let narrow = modified("src/b.rs");
let mut s = screen(vec![wide, narrow]);
let shown = text(&mut s);
let end_of = |needle: &str| -> usize {
let row = shown
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no row with {needle}: {shown}"));
let at = row.find(needle).unwrap();
row[..at + needle.len()].chars().count()
};
assert_eq!(
end_of("+12 −4"),
end_of("+1 −1"),
"the counts columns are ragged: {shown}"
);
}
#[test]
fn a_long_path_keeps_its_file_name() {
assert_eq!(elide_left("src/a.rs", 20), "src/a.rs");
let cut = elide_left("src/features/tools/code.rs", 14);
assert!(cut.ends_with("code.rs"), "got: {cut}");
assert!(cut.starts_with('…'), "got: {cut}");
assert_eq!(cut.chars().count(), 14);
}
#[test]
fn the_diff_pane_scrolls_within_its_bounds() {
let mut file = modified("a");
file.lines = (0..50)
.map(|i| line(DiffKind::Context, &format!("line {i}")))
.collect();
let mut s = screen(vec![file]);
press(&mut s, KeyCode::PageDown);
assert_eq!(s.diff_scroll, PAGE_STEP);
for _ in 0..20 {
press(&mut s, KeyCode::PageDown);
}
assert_eq!(s.diff_scroll, 49, "must stop at the last line");
press(&mut s, KeyCode::PageUp);
assert_eq!(s.diff_scroll, 39);
}
#[test]
fn changing_file_resets_the_diff_offset() {
let mut long = modified("a");
long.lines = (0..50)
.map(|i| line(DiffKind::Context, &format!("line {i}")))
.collect();
let mut s = screen(vec![long, modified("b")]);
press(&mut s, KeyCode::PageDown);
assert!(s.diff_scroll > 0);
press(&mut s, KeyCode::Down);
assert_eq!(s.diff_scroll, 0);
}
#[test]
fn tab_moves_the_arrows_to_the_diff() {
let mut file = modified("a");
file.lines = (0..50)
.map(|i| line(DiffKind::Context, &format!("line {i}")))
.collect();
let mut s = screen(vec![file, modified("b")]);
press(&mut s, KeyCode::Tab);
press(&mut s, KeyCode::Down);
assert_eq!(s.selected, 0, "the file selection must not move");
assert_eq!(s.diff_scroll, 1);
press(&mut s, KeyCode::Tab);
press(&mut s, KeyCode::Down);
assert_eq!(s.selected, 1);
}
}