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
+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();