feat: toggle diff explorer and show file change indicators

This commit is contained in:
2026-10-11 00:54:35 +02:00
parent 83c37f5a19
commit 2f595ca4ca
9 changed files with 454 additions and 38 deletions
+227 -26
View File
@@ -861,9 +861,23 @@ fn draw(
if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
panels,
None,
);
} else if let Some(panel) = dock {
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
panels,
None,
);
}
}
if let Some(palette) = editor.palette() {
@@ -1140,7 +1154,7 @@ fn draw_diff(
notice_area,
);
}
let (panes, navigator) = if diff.files.is_some() {
let (panes, navigator) = if diff.files_visible && diff.files.is_some() {
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
} else {
(workspace, None)
@@ -1150,7 +1164,14 @@ fn draw_diff(
if let Some(picker) = &diff.files
&& let Some(navigator) = navigator
{
draw_file_list(frame, picker, navigator, file_navigation_hint, panels);
draw_file_list(
frame,
picker,
navigator,
file_navigation_hint,
panels,
comparison.map(|comparison| comparison.files.as_slice()),
);
}
if let Some(file) = selected {
draw_diff_pane(frame, diff, file, before_area, true);
@@ -1167,7 +1188,7 @@ fn draw_diff(
}
frame.render_widget(
Paragraph::new(
"Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
"^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
)
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -1740,6 +1761,7 @@ fn draw_file_list(
panel: Rect,
navigation_hint: &str,
panels: &mut WheelPanels,
changed_files: Option<&[ChangedFile]>,
) {
if panel.width == 0 || panel.height == 0 {
return;
@@ -1862,20 +1884,46 @@ fn draw_file_list(
})
.add_modifier(Modifier::BOLD);
}
let label = match picker.view {
FileView::List => {
format!("{} {}", if selected { ">" } else { " " }, entry.label)
}
FileView::Tree => format!(
"{} {:indent$}{}{}",
if selected { ">" } else { " " },
"",
entry.label,
if entry.is_dir { "/" } else { "" },
indent = entry.depth * 2,
),
};
Line::styled(label, style)
let changed = entry
.catalog_index
.and_then(|index| changed_files.and_then(|files| files.get(index)));
let prefix = format!(
"{} {:indent$}",
if selected { ">" } else { " " },
"",
indent = if picker.view == FileView::Tree {
entry.depth * 2
} else {
0
},
);
let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 });
spans.push(Span::raw(prefix));
if let Some(file) = changed {
let color = match file.status {
FileStatus::Added => Color::Green,
FileStatus::Removed => Color::Red,
FileStatus::Modified => Color::Yellow,
FileStatus::Renamed => Color::Cyan,
};
spans.push(Span::styled(
diff_status(file.status),
Style::default().fg(color),
));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(entry.label));
if entry.is_dir && picker.view == FileView::Tree {
spans.push(Span::raw("/"));
}
if let Some(file) = changed
&& file.status == FileStatus::Renamed
&& let Some(old_path) = &file.old_path
{
spans.push(Span::raw(" ← "));
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
}
Line::from(spans).style(style)
})
.collect();
frame.render_widget(Paragraph::new(lines), results_area);
@@ -1891,7 +1939,9 @@ fn draw_file_list(
));
}
}
let help = if picker.focused {
let help = if changed_files.is_some() {
"A added · M modified · D deleted · R renamed · ^E hide".into()
} else if picker.focused {
let controls = if picker.root.as_os_str().is_empty() {
if browsing {
"Enter · i filter · Esc close · Tab panes · ^T view"
@@ -2299,7 +2349,6 @@ 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("> added.txt"));
}
#[test]
@@ -2328,7 +2377,6 @@ mod tests {
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
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();
@@ -2417,6 +2465,130 @@ mod tests {
);
}
#[test]
fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() {
let files: Vec<_> = [
("z.txt", FileStatus::Added),
("chain/a/m.txt", FileStatus::Modified),
("d.txt", FileStatus::Removed),
("r.txt", FileStatus::Renamed),
]
.into_iter()
.map(|(path, status)| {
let mut file = deletion_file();
file.path = path.into();
file.status = status;
file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into());
file
})
.collect();
let mut picker =
FilePicker::from_paths(PathBuf::new(), files.iter().map(|file| file.path.clone()));
picker.reveal_path(std::path::Path::new("chain/a/m.txt"));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
for focused in [true, false] {
picker.focused = focused;
for index in 0..files.len() {
picker.reveal_path(&files[index].path);
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
Some(&files),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for (file, color) in
files
.iter()
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
{
let path = if view == FileView::Tree {
std::path::Path::new(file.path.file_name().unwrap())
} else {
file.path.as_path()
};
let expected =
format!("{} {}", diff_status(file.status), path.to_string_lossy());
let (x, y) = (0..16)
.find_map(|y| {
let row = (0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>();
row.find(&expected).map(|offset| {
(UnicodeWidthStr::width(&row[..offset]) as u16, y)
})
})
.unwrap_or_else(|| panic!("missing {expected}"));
assert_eq!(buffer[(x, y)].fg, color);
}
assert!(screen_text(&terminal).contains("r.txt ← old�name.txt"));
assert_eq!(picker.accept(), Some(files[index].path.clone()));
assert!(picker.query.is_empty());
}
}
}
picker.query = "m.txt".into();
picker.refilter();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
Some(&files),
);
})
.unwrap();
assert!(screen_text(&terminal).contains("M chain/a/m.txt"));
assert!(!screen_text(&terminal).contains("R r.txt"));
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
assert!(!screen_text(&terminal).contains("M chain/a/m.txt"));
}
#[test]
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
let mut diff = diff_comparison(vec![deletion_file()]);
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 original = panels.diff.as_ref().unwrap().regions;
diff.handle(Input::Ctrl('e'));
render_diff(&mut terminal, &diff, &mut panels);
let hidden = panels.diff.as_ref().unwrap().regions;
assert_eq!(hidden[0].0, Rect::default());
assert!(panels.files.is_none());
assert_eq!(hidden[1].0.x, 0);
assert_eq!(hidden[1].0.right(), hidden[2].0.x);
assert_eq!(hidden[2].0.right(), 120);
assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width);
assert!(!screen_text(&terminal).contains("Explorer"));
diff.handle(Input::Ctrl('e'));
render_diff(&mut terminal, &diff, &mut panels);
assert_eq!(panels.diff.as_ref().unwrap().regions, original);
assert_eq!(panels.files.unwrap().0, original[0].0);
}
#[test]
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
@@ -2443,7 +2615,6 @@ 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,
@@ -2613,6 +2784,20 @@ mod tests {
assert_eq!(diff.focus, Focus::After);
assert_eq!(diff.top, 0);
assert_eq!(diff.file, 3);
let selected = diff.files.as_ref().unwrap().selected;
session.handle(Input::Ctrl('e'));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
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::After);
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
assert!(!diff.files.as_ref().unwrap().focused);
assert!(viewports.panels.files.is_none());
assert_eq!(session.editor().lines(), original);
assert_eq!(session.editor().cursor(), cursor);
assert!(viewports.document.geometry.is_none());
@@ -3892,7 +4077,7 @@ end
let area = Rect::new(3, 2, 40, 6);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap();
terminal
.draw(|frame| draw_file_list(frame, picker, area, "", &mut panels))
.draw(|frame| draw_file_list(frame, picker, area, "", &mut panels, None))
.unwrap();
let mouse = MouseEvent {
kind: MouseEventKind::ScrollDown,
@@ -3919,6 +4104,7 @@ end
area,
"",
&mut panels,
None,
);
})
.unwrap();
@@ -3966,6 +4152,7 @@ end
area,
"",
&mut panels,
None,
)
})
.unwrap();
@@ -4010,7 +4197,7 @@ end
terminal
.draw(|frame| {
frame.set_cursor_position((59, 11));
draw_file_list(frame, &picker, area, "", &mut panels);
draw_file_list(frame, &picker, area, "", &mut panels, None);
})
.unwrap();
let text = screen_text(&terminal);
@@ -4027,7 +4214,7 @@ end
}
picker.navigate(false);
terminal
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels))
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None))
.unwrap();
assert!(panels.manual_files.is_none());
assert!(screen_text(&terminal).contains("> file-10"));
@@ -4060,6 +4247,7 @@ end
frame.area(),
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4167,6 +4355,7 @@ end
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4212,6 +4401,7 @@ end
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4235,6 +4425,7 @@ end
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
);
})
.unwrap();
@@ -4258,6 +4449,7 @@ end
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4274,6 +4466,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4294,6 +4487,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
);
})
.unwrap();
@@ -4331,6 +4525,7 @@ end
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4388,6 +4583,7 @@ end
frame.area(),
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4438,6 +4634,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4489,6 +4686,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4536,6 +4734,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4567,6 +4766,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
@@ -4584,6 +4784,7 @@ end
area,
editor.file_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();