feat: reuse the docked file tree for diff navigation

This commit is contained in:
2026-10-11 00:12:50 +02:00
parent 81a2bf9a02
commit 83c37f5a19
9 changed files with 531 additions and 117 deletions
+191 -35
View File
@@ -1,4 +1,5 @@
use crate::editor::Input;
use crate::files::{FilePicker, FileView};
use std::path::PathBuf;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -146,6 +147,7 @@ pub struct DiffState {
pub loading: bool,
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub files: Option<FilePicker>,
pub file: usize,
pub focus: Focus,
pub top: usize,
@@ -174,6 +176,7 @@ impl DiffState {
loading: true,
error: None,
comparison: None,
files: None,
file: 0,
focus: Focus::Files,
top: 0,
@@ -240,6 +243,14 @@ impl DiffState {
self.error = None;
}
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
let mut files = FilePicker::from_paths(
PathBuf::new(),
comparison.files.iter().map(|file| file.path.clone()),
);
if let Some(file) = comparison.files.first() {
files.reveal_path(&file.path);
}
self.files = Some(files);
self.comparison = Some(comparison);
self.phase = Phase::View;
self.focus = Focus::Files;
@@ -295,18 +306,8 @@ impl DiffState {
pub(crate) fn navigate(&mut self, forward: bool) {
if self.phase == Phase::View {
if self.focus == Focus::Files {
let count = self
.comparison
.as_ref()
.map_or(0, |comparison| comparison.files.len());
let file = if forward {
self.file.saturating_add(1).min(count.saturating_sub(1))
} else {
self.file.saturating_sub(1)
};
if file != self.file {
self.file = file;
self.align_file();
if let Some(files) = &mut self.files {
files.navigate(forward);
}
} else {
self.top = if forward {
@@ -334,6 +335,15 @@ impl DiffState {
/// Returns true only when the modal should close. All other inputs are consumed.
pub(crate) fn handle(&mut self, input: Input) -> bool {
if input == Input::Escape {
if self.phase == Phase::View
&& self.focus == Focus::Files
&& let Some(files) = &mut self.files
&& files.searching
&& files.view == FileView::Tree
{
files.finish_search();
return false;
}
if self.phase != Phase::After {
return true;
}
@@ -424,6 +434,58 @@ impl DiffState {
}
fn handle_view(&mut self, input: Input) {
if self.focus == Focus::Files {
let files = self.files.as_mut().expect("comparison file picker");
match input {
Input::Char('\t') | Input::BackTab => {}
Input::Ctrl('t') => {
files.toggle_view();
return;
}
Input::Home => {
files.selected = 0;
return;
}
Input::End => {
let last = files.entries().len().saturating_sub(1);
files.selected = last;
return;
}
Input::Up | Input::Ctrl('k') => {
files.navigate(false);
return;
}
Input::Down | Input::Ctrl('j') => {
files.navigate(true);
return;
}
Input::Left | Input::Ctrl('h') => {
files.left();
return;
}
Input::Right | Input::Ctrl('l') => {
files.right();
return;
}
Input::Enter => {
if let Some(path) = files.accept()
&& let Some(index) = self.comparison.as_ref().and_then(|comparison| {
comparison.files.iter().position(|file| file.path == path)
})
{
self.file = index;
self.focus = Focus::Before;
self.files.as_mut().expect("comparison picker").focused = false;
self.align_file();
}
return;
}
_ => {
files.handle_filter_input(&input);
return;
}
}
}
match input {
Input::Char('\t') => {
self.focus = match self.focus {
@@ -441,12 +503,6 @@ impl DiffState {
}
Input::Up | Input::Ctrl('k') => self.navigate(false),
Input::Down | Input::Ctrl('j') => self.navigate(true),
Input::Right | Input::Ctrl('l') if self.focus == Focus::Files => {
self.focus = Focus::Before
}
Input::Left | Input::Ctrl('h') if self.focus == Focus::Files => {
self.focus = Focus::After
}
Input::Left | Input::Ctrl('h') => match self.focus {
Focus::Before => self.left_before = self.left_before.saturating_sub(1),
Focus::After => self.left_after = self.left_after.saturating_sub(1),
@@ -462,25 +518,17 @@ impl DiffState {
Focus::Files => {}
},
Input::Home | Input::End => {
if self.focus == Focus::Files {
self.file = if input == Input::Home {
0
} else {
self.comparison
.as_ref()
.map_or(0, |comparison| comparison.files.len().saturating_sub(1))
};
self.align_file();
self.top = if input == Input::Home {
0
} else {
self.top = if input == Input::Home {
0
} else {
self.rows.len().saturating_sub(1)
};
}
self.rows.len().saturating_sub(1)
};
}
_ => {}
}
if let Some(files) = &mut self.files {
files.focused = self.focus == Focus::Files;
}
}
fn align_file(&mut self) {
@@ -859,6 +907,107 @@ mod tests {
assert!(editor.diff().is_none());
}
#[test]
fn file_to_directory_change_opens_both_historical_paths() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "x".into();
changes.files[1].path = "x/y".into();
changes.files.truncate(2);
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::BackTab);
editor.handle(Input::Char('i'));
text(&mut editor, "x/y");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 1);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
}
#[test]
fn changed_tree_filters_navigates_and_opens_without_touching_buffers() {
let mut editor = editor();
let original = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "src/deep/renamed.txt".into();
changes.files[1].path = "src/deep/added.txt".into();
changes.files[2].path = "archive/gone/removed.bin".into();
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.focused && !picker.searching);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
std::path::Path::new("src/deep/renamed.txt")
);
assert!(picker.entries().any(|row| row.label == "src/deep"));
editor.bind("tree", "z", Some("core.left".into())).unwrap();
editor.handle(Input::Char('z'));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('l'));
editor.handle(Input::Char('l'));
editor
.bind("selection", "ctrl+n", Some("core.down".into()))
.unwrap();
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 2);
assert_eq!(state.focus, Focus::Before);
assert!(!state.files.as_ref().unwrap().focused);
assert!(state.rows.is_empty());
editor.handle(Input::BackTab);
editor.handle(Input::Escape);
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
editor.handle(Input::Ctrl('u'));
assert!(
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.query
.is_empty()
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::List
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::Tree
);
editor.handle(Input::Ctrl('r'));
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
}
#[test]
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
let mut editor = editor();
@@ -901,6 +1050,8 @@ mod tests {
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 1);
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
@@ -910,7 +1061,9 @@ mod tests {
.iter()
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
);
editor.handle(Input::End);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 2);
assert!(editor.diff().unwrap().rows.is_empty());
assert!(
@@ -918,7 +1071,9 @@ mod tests {
.notice
.is_some()
);
editor.handle(Input::Home);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
}
@@ -1016,6 +1171,7 @@ mod tests {
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
editor.handle(Input::Escape);
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "original\\nsecond");
}
+15 -18
View File
@@ -1281,6 +1281,9 @@ impl Editor {
fn handle_diff(&mut self, input: Input) {
// Escape and Tab always retain their modal close/back/focus semantics.
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
if self.handle_tree_binding(&input) {
return;
}
match self.selection_bindings.get(&input) {
Some(Binding::Action(Action::Down)) => {
self.diff.as_mut().expect("active diff").navigate(true);
@@ -2544,7 +2547,11 @@ impl Editor {
}
pub fn file_navigation_hint(&self) -> &str {
if let Some(picker) = &self.file_picker
if let Some(picker) = self
.diff
.as_ref()
.and_then(|diff| diff.files.as_ref())
.or(self.file_picker.as_ref())
&& picker.view == FileView::Tree
{
if picker.searching {
@@ -2612,10 +2619,12 @@ impl Editor {
}
fn handle_tree_binding(&mut self, input: &Input) -> bool {
let Some(picker) = self
.file_picker
.as_mut()
.filter(|picker| picker.focused && picker.view == FileView::Tree)
let picker = if let Some(diff) = &mut self.diff {
diff.files.as_mut()
} else {
self.file_picker.as_mut()
};
let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree)
else {
return false;
};
@@ -2897,19 +2906,7 @@ impl Editor {
}
Input::Ctrl('r') => picker.refresh(),
Input::Ctrl('t') => picker.toggle_view(),
Input::Ctrl('u') => picker.clear_filter(),
Input::Backspace if picker.view == FileView::List || picker.searching => {
picker.backspace()
}
Input::Char('i') if picker.view == FileView::Tree && !picker.searching => {
picker.start_search()
}
Input::Char(ch)
if !ch.is_control() && (picker.view == FileView::List || picker.searching) =>
{
picker.query.push(ch);
picker.refilter();
}
_ if picker.handle_filter_input(&input) => {}
Input::Enter => {
let Some(path) = picker.accept() else { return };
let source = self.jump_location();
+100 -1
View File
@@ -43,6 +43,7 @@ pub struct FileRow<'a> {
pub expanded: bool,
}
#[derive(Debug)]
struct Node {
path: PathBuf,
label: String,
@@ -52,6 +53,7 @@ struct Node {
children: Vec<usize>,
}
#[derive(Debug)]
struct TreeNode {
node: usize,
label: String,
@@ -59,6 +61,7 @@ struct TreeNode {
children: Vec<usize>,
}
#[derive(Debug)]
struct VisibleRow {
node: usize,
tree: Option<usize>,
@@ -66,6 +69,7 @@ struct VisibleRow {
expanded: bool,
}
#[derive(Debug)]
pub struct FilePicker {
pub root: PathBuf,
pub query: String,
@@ -154,10 +158,51 @@ impl FilePicker {
children: Vec::new(),
});
}
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
}
/// Builds the changed-path catalog without consulting the working tree.
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = PathBuf>) -> Self {
let mut catalog = HashSet::new();
for path in paths {
for parent in path
.ancestors()
.skip(1)
.filter(|path| !path.as_os_str().is_empty())
{
catalog.insert((parent.to_owned(), true));
}
catalog.insert((path, false));
}
let nodes = catalog
.into_iter()
.map(|(path, is_dir)| {
let label = label(&path);
Node {
path,
search_label: label.to_lowercase(),
label,
is_dir,
parent: None,
children: Vec::new(),
}
})
.collect();
Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None)
}
fn from_nodes(
root: PathBuf,
mut nodes: Vec<Node>,
presentation: FilePresentation,
view: FileView,
warning: Option<String>,
) -> Self {
nodes.sort_by(|left, right| left.path.cmp(&right.path));
let by_path: HashMap<_, _> = nodes
.iter()
.enumerate()
.filter(|(_, node)| node.is_dir)
.map(|(index, node)| (node.path.clone(), index))
.collect();
let mut roots = Vec::new();
@@ -223,7 +268,7 @@ impl FilePicker {
}
picker.project(None, false);
picker.remember_selection(false);
Ok(picker)
picker
}
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
@@ -321,6 +366,37 @@ impl FilePicker {
}
}
pub(crate) fn reveal_path(&mut self, path: &Path) {
if let Some(node) = self
.nodes
.iter()
.position(|node| node.path == path && !node.is_dir)
{
self.reveal(node);
self.project(Some(node), false);
self.remember_selection(true);
}
}
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
use crate::editor::Input;
match input {
Input::Ctrl('u') => self.clear_filter(),
Input::Backspace if self.view == FileView::List || self.searching => self.backspace(),
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
self.start_search()
}
Input::Char(ch)
if !ch.is_control() && (self.view == FileView::List || self.searching) =>
{
self.query.push(*ch);
self.refilter();
}
_ => return false,
}
true
}
pub fn set_view(&mut self, view: FileView) {
if self.view == view {
return;
@@ -699,6 +775,29 @@ impl FilePicker {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
let mut picker = FilePicker::from_paths(
PathBuf::new(),
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from),
);
assert_eq!(
picker
.entries()
.map(|row| row.label.to_owned())
.collect::<Vec<_>>(),
["src/deep", "x", "x"]
);
picker.reveal_path(Path::new("x"));
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
picker.reveal_path(Path::new("x/y"));
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
picker.reveal_path(Path::new("src/deep/deleted.rs"));
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
}
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
+206 -61
View File
@@ -422,6 +422,7 @@ fn draw(
diff,
area,
editor.selection_navigation_hint(),
editor.file_navigation_hint(),
panels,
);
return;
@@ -907,6 +908,7 @@ fn draw_diff(
diff: &DiffState,
area: Rect,
navigation_hint: &str,
file_navigation_hint: &str,
panels: &mut WheelPanels,
) {
if area.width == 0 || area.height == 0 {
@@ -1138,59 +1140,17 @@ fn draw_diff(
notice_area,
);
}
let navigator_width = if workspace.width >= 40 {
(workspace.width / 4).min(32)
let (panes, navigator) = if diff.files.is_some() {
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
} else {
0
(workspace, None)
};
let [navigator, panes] =
Layout::horizontal([Constraint::Length(navigator_width), Constraint::Fill(1)])
.areas(workspace);
let [before_area, after_area] =
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
let navigator_inner = diff_panel(
frame,
navigator,
" Changed files ",
diff.focus == Focus::Files,
);
if let Some(comparison) = comparison
&& navigator_inner.width > 0
&& navigator_inner.height > 0
if let Some(picker) = &diff.files
&& let Some(navigator) = navigator
{
let height = usize::from(navigator_inner.height);
let first = diff.file.saturating_sub(height.saturating_sub(1));
let lines: Vec<Line> = comparison
.files
.iter()
.enumerate()
.skip(first)
.take(height)
.map(|(index, file)| {
let label = format!(
"{} {} {}",
if index == diff.file { ">" } else { " " },
diff_status(file.status),
display_text(&file.path.to_string_lossy())
);
let style = if index == diff.file {
Style::default()
.bg(if diff.focus == Focus::Files {
Color::Blue
} else {
Color::DarkGray
})
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::styled(
visible_line(&label, 0, usize::from(navigator_inner.width)),
style,
)
})
.collect();
frame.render_widget(Paragraph::new(lines), navigator_inner);
draw_file_list(frame, picker, navigator, file_navigation_hint, panels);
}
if let Some(file) = selected {
draw_diff_pane(frame, diff, file, before_area, true);
@@ -1207,14 +1167,14 @@ fn draw_diff(
}
frame.render_widget(
Paragraph::new(
"Tab focus · ↑/↓ scroll/files · ←/→ horizontal · Home/End · Esc close · read-only",
"Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
)
.style(Style::default().fg(Color::DarkGray)),
help,
);
panels.diff = Some(DiffGeometry {
regions: [
(navigator, Focus::Files),
(navigator.unwrap_or_default(), Focus::Files),
(before_area, Focus::Before),
(after_area, Focus::After),
],
@@ -1811,7 +1771,7 @@ fn draw_file_list(
if bordered {
frame.render_widget(block, panel);
}
let show_root = content.height >= 4;
let show_root = content.height >= 4 && !picker.root.as_os_str().is_empty();
let show_error = picker.error.is_some() && content.height >= 2;
let show_help = content.height >= 5 + u16::from(show_error);
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
@@ -1932,14 +1892,26 @@ fn draw_file_list(
}
}
let help = if picker.focused {
let controls = match (picker.presentation, browsing) {
(FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T",
(FilePresentation::Docked, true) => "Enter · i filter · Esc/Tab edit · ^W · ^R · ^T",
(_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T",
(FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T",
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
let controls = if picker.root.as_os_str().is_empty() {
if browsing {
"Enter · i filter · Esc close · Tab panes · ^T view"
} else {
"Enter · Esc · Tab panes · ^T view"
}
} else {
match (picker.presentation, browsing) {
(FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T",
(FilePresentation::Docked, true) => {
"Enter · i filter · Esc/Tab edit · ^W · ^R · ^T"
}
(_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T",
(FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T",
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
}
};
format!("^U clear · {navigation_hint} · {controls}")
} else if picker.root.as_os_str().is_empty() {
"Editor focus · Tab to browse changed files".into()
} else {
"Editor focus · invoke explorer to browse".into()
};
@@ -2212,7 +2184,7 @@ mod tests {
terminal
.draw(|frame| {
panels.begin_frame();
draw_diff(frame, diff, frame.area(), "↑/↓", panels);
draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels);
})
.unwrap();
}
@@ -2327,7 +2299,7 @@ mod tests {
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
}
assert!(screen_text(&terminal).contains("> A added.txt"));
assert!(screen_text(&terminal).contains("> added.txt"));
}
#[test]
@@ -2348,7 +2320,7 @@ mod tests {
};
let mut diff = diff_comparison(vec![deletion_file(), added]);
diff.handle(Input::Down);
diff.handle(Input::Char('\t'));
diff.handle(Input::Enter);
for _ in 0..4 {
diff.handle(Input::Right);
}
@@ -2356,7 +2328,7 @@ mod tests {
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> R new-name.txt"));
assert!(text.contains("> new-name.txt"));
assert!(text.contains("Before · R · old-name.txt"));
assert!(text.contains("After · R · new-name.txt"));
let geometry = panels.diff.as_ref().unwrap();
@@ -2377,6 +2349,74 @@ mod tests {
);
}
#[test]
fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() {
let mut leaf = deletion_file();
leaf.path = "chain/a/b/leaf.txt".into();
let mut root = deletion_file();
root.path = "root.txt".into();
let mut diff = diff_comparison(vec![leaf, root]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let regions = panels.diff.as_ref().unwrap().regions;
let navigator = regions[0].0;
let before = regions[1].0;
let after = regions[2].0;
let workspace = Rect::new(0, 1, 120, 16);
let (panes, dock) = dock_layout(workspace, DockSide::Right, true);
assert_eq!(Some(navigator), dock);
assert_eq!(before.x, panes.x);
assert_eq!(before.right(), after.x);
assert_eq!(after.right(), navigator.x);
assert_eq!(navigator.right(), workspace.right());
assert_eq!(panels.files.unwrap().0, navigator);
let tree_text = |terminal: &Terminal<ratatui::backend::TestBackend>| {
let buffer = terminal.backend().buffer();
(navigator.y..navigator.bottom())
.map(|y| {
(navigator.x..navigator.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let text = tree_text(&terminal);
assert!(text.contains("Explorer · Tree"));
assert!(text.contains("chain/a/b/"));
assert!(text.contains("leaf.txt"));
assert!(!text.contains("chain/a/b/leaf.txt"));
let cursor = terminal.get_cursor_position().unwrap();
assert!(navigator.contains(cursor));
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].bg,
Color::Blue
);
assert_eq!(
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
Color::Cyan
);
diff.handle(Input::Left);
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
let text = tree_text(&terminal);
assert!(!text.contains("leaf.txt"));
assert!(text.contains("chain/a/b/"));
assert_eq!(diff.file, 0);
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(!diff.files.as_ref().unwrap().focused);
assert_eq!(
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
Color::DarkGray
);
assert_eq!(
terminal.backend().buffer()[(before.x, before.y)].fg,
Color::Cyan
);
}
#[test]
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
@@ -2391,6 +2431,7 @@ mod tests {
let empty = diff_comparison(vec![]);
render_diff(&mut terminal, &empty, &mut panels);
assert!(screen_text(&terminal).contains("No changed files"));
assert!(screen_text(&terminal).contains("Explorer · Tree"));
let binary = diff_comparison(vec![ChangedFile {
path: "image.bin".into(),
old_path: None,
@@ -2402,6 +2443,7 @@ mod tests {
}]);
render_diff(&mut terminal, &binary, &mut panels);
assert!(screen_text(&terminal).contains("Binary file"));
assert!(screen_text(&terminal).contains("> image.bin"));
let blank = diff_comparison(vec![ChangedFile {
path: "empty.txt".into(),
old_path: None,
@@ -2418,6 +2460,16 @@ mod tests {
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
for state in [&diff, &empty, &binary, &blank] {
render_diff(&mut tiny, state, &mut panels);
if let Some(geometry) = &panels.diff {
for (rect, _) in geometry.regions {
assert!(rect.right() <= width && rect.bottom() <= height);
}
let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect);
assert!(before.right() <= after.x);
if navigator.width > 0 {
assert!(after.right() <= navigator.x);
}
}
}
}
}
@@ -2465,6 +2517,8 @@ mod tests {
assert!(viewports.document.geometry.is_none());
assert_eq!(editor.lines(), original);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
@@ -2473,6 +2527,97 @@ mod tests {
assert_eq!(editor.cursor(), cursor);
}
#[test]
fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() {
let directory = Directory::new();
let path = directory.file("source.txt", &["original"; 60].join("\n"));
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&directory.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Diff fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(output.status.success(), "{:?}", output);
};
git(&["init", "-q"]);
for index in 0..8 {
directory.file(&format!("file-{index}.txt"), "first\nsecond\nthird\n");
}
git(&["add", "."]);
git(&["commit", "-qm", "base"]);
for index in 0..8 {
fs::remove_file(directory.0.join(format!("file-{index}.txt"))).unwrap();
}
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let original = session.editor().lines().to_vec();
let cursor = session.editor().cursor();
let ready = |session: &mut EditorSession, phase| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
session.poll();
let diff = session.editor().diff().unwrap();
assert!(diff.error.is_none(), "{:?}", diff.error);
if diff.phase == phase && !diff.loading {
break;
}
assert!(std::time::Instant::now() < deadline, "{diff:?}");
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, Phase::Before);
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
ready(&mut session, Phase::View);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let regions = viewports.panels.diff.as_ref().unwrap().regions;
let tree = regions[0].0;
let before = regions[1].0;
let after = regions[2].0;
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::Files);
assert_eq!(diff.files.as_ref().unwrap().selected, 3);
assert_eq!(diff.file, 0);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().file, 3);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::Before);
assert_eq!(diff.top, 2);
assert_eq!(diff.file, 3);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::After);
assert_eq!(diff.top, 0);
assert_eq!(diff.file, 3);
assert_eq!(session.editor().lines(), original);
assert_eq!(session.editor().cursor(), cursor);
assert!(viewports.document.geometry.is_none());
}
#[test]
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
let directory = Directory::new();