feat: reuse the docked file tree for diff navigation
This commit is contained in:
+206
-61
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user