feat: enable native readonly diff interaction and synchronized pane cursors

This commit is contained in:
2026-10-11 11:31:59 +02:00
parent f43a43fd53
commit 5186714ef5
8 changed files with 1329 additions and 272 deletions
+324 -37
View File
@@ -174,13 +174,13 @@ impl BufferViewports {
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
self.draw(frame, session.editor());
if let Some((comparison_id, file, top, left_after)) = self
if let Some((comparison_id, file, top, left_before, left_after)) = self
.panels
.diff
.as_ref()
.and_then(|geometry| geometry.viewport)
{
session.set_diff_viewport(comparison_id, file, top, left_after);
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
}
}
@@ -720,7 +720,7 @@ fn draw(
struct DiffGeometry {
regions: [(Rect, Focus); 3],
viewport: Option<(u64, usize, usize, usize)>,
viewport: Option<(u64, usize, usize, usize, usize)>,
}
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
@@ -915,6 +915,7 @@ fn paint_diff_editor(
editor: &Editor,
diff: &DiffState,
area: Rect,
focus: Focus,
viewport: (usize, usize),
) -> Option<Position> {
let (top, left) = viewport;
@@ -931,12 +932,16 @@ fn paint_diff_editor(
let DisplayRow::Line(aligned) = *row else {
continue;
};
let Some(source) = diff.rows[aligned].after else {
let Some(source) = (match focus {
Focus::Before => diff.rows[aligned].before,
Focus::After => diff.rows[aligned].after,
Focus::Files => None,
}) else {
continue;
};
let line_area = Rect::new(area.x, area.y + (display - top) as u16, area.width, 1);
paint_editor_line(frame, editor, source, line_area, left, &carets, &selections);
if source == editor.cursor().row && editor.diff_editing() {
if source == editor.cursor().row && editor.diff_interacting() {
let column = display_column(&editor.lines()[source], editor.cursor().column);
if column >= left && column < left.saturating_add(usize::from(area.width)) {
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
@@ -1243,16 +1248,27 @@ fn draw_diff(
display_text(before),
display_text(after),
diff.focus,
editor.filter(|editor| editor.diff_editable()).map_or_else(
|| "read-only".into(),
|editor| format!(
"After editable · {}{} · {}:{}",
editor.mode(),
if editor.dirty() { " [+]" } else { "" },
editor.cursor().row + 1,
editor.cursor().column + 1,
),
)
editor
.filter(|editor| editor.diff_interacting())
.map_or_else(
|| "read-only".into(),
|editor| format!(
"{} · {}{} · {}:{}",
if editor.diff_editing() {
"editable"
} else {
"read-only"
},
if editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
},
if editor.dirty() { " [+]" } else { "" },
editor.cursor().row + 1,
editor.cursor().column + 1,
),
)
))
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
header,
@@ -1289,25 +1305,42 @@ fn draw_diff(
};
let mut document_caret = None;
let mut top = diff.top;
let mut before_left = diff.left_before;
let mut after_left = diff.left_after;
if let Some(editor) = editor.filter(|editor| {
editor.diff_editing() && diff.follow_caret && after_area != Rect::default()
}) {
let height = usize::from(after_area.height.saturating_sub(2));
if let Some(row) = diff.after_display_row(editor.cursor().row) {
if let Some(editor) = editor.filter(|editor| editor.diff_interacting() && diff.follow_caret)
&& let Some(focus) = editor.diff_buffer_focus()
{
let (pane, left) = match focus {
Focus::Before => (before_area, &mut before_left),
Focus::After => (after_area, &mut after_left),
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
};
let height = usize::from(pane.height.saturating_sub(2));
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
if row < top {
top = row;
} else if height > 0 && row >= top.saturating_add(height) {
top = row + 1 - height;
}
}
let digits = editor.lines().len().max(1).ilog10() as usize + 1;
let width = usize::from(after_area.width.saturating_sub(2)).saturating_sub(digits + 2);
let source_len = selected.map_or(0, |file| match focus {
Focus::Before => file.before.len(),
Focus::After => file.after.len(),
Focus::Files => unreachable!("native buffer belongs to a text pane"),
});
let digits = source_len.max(1).ilog10() as usize + 1;
let content_width = usize::from(pane.width.saturating_sub(2));
let gutter = if content_width > digits + 2 {
digits + 2
} else {
0
};
let width = content_width.saturating_sub(gutter);
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
if column < after_left {
after_left = column;
} else if width > 0 && column >= after_left.saturating_add(width) {
after_left = column + 1 - width;
if column < *left {
*left = column;
} else if width > 0 && column >= left.saturating_add(width) {
*left = column + 1 - width;
}
}
if let Some(picker) = &diff.files
@@ -1329,15 +1362,28 @@ fn draw_diff(
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
} else {
if before_area != Rect::default() {
draw_diff_pane(
let text_area = draw_diff_pane(
frame,
diff,
file,
before_area,
true,
&syntax.before,
(top, diff.left_before),
(top, before_left),
);
if let Some(editor) =
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
{
document_caret = paint_diff_editor(
frame,
editor,
diff,
text_area,
Focus::Before,
(top, before_left),
)
.map(|position| (text_area, position));
}
}
if after_area != Rect::default() {
let text_area = draw_diff_pane(
@@ -1349,10 +1395,18 @@ fn draw_diff(
&syntax.after,
(top, after_left),
);
if let Some(editor) = editor.filter(|editor| editor.diff_editable()) {
document_caret =
paint_diff_editor(frame, editor, diff, text_area, (top, after_left))
.map(|position| (text_area, position));
if let Some(editor) =
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
{
document_caret = paint_diff_editor(
frame,
editor,
diff,
text_area,
Focus::After,
(top, after_left),
)
.map(|position| (text_area, position));
}
}
}
@@ -1368,7 +1422,7 @@ fn draw_diff(
}
frame.render_widget(
Paragraph::new(
"File-picker/explorer bindings · Tab focus · ↑/↓ scroll/tree · [/] expand above/below in Normal · :diff.close",
"Native buffer bindings · Tab focus · [/] expand above/below in Normal · :diff.close",
)
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -1381,7 +1435,7 @@ fn draw_diff(
],
viewport: diff
.comparison_id
.map(|id| (id, diff.file, top, after_left)),
.map(|id| (id, diff.file, top, before_left, after_left)),
});
if let Some(picker) = &diff.files
&& diff.files_visible
@@ -2732,7 +2786,8 @@ mod tests {
);
assert_eq!(present.x, 0);
assert_eq!(present.width, 160);
assert_eq!(geometry.viewport.unwrap().3, 2);
assert_eq!(geometry.viewport.unwrap().3, 0);
assert_eq!(geometry.viewport.unwrap().4, 2);
assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus);
assert_eq!(
terminal.backend().buffer()[(present.x, present.y)].fg,
@@ -3514,7 +3569,6 @@ mod tests {
.unwrap();
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
assert!(viewports.document.geometry.is_none());
assert_eq!(editor.lines(), original);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
@@ -3523,9 +3577,241 @@ mod tests {
.unwrap();
assert!(screen_text(&terminal).contains("original"));
assert!(viewports.document.geometry.is_some());
assert_eq!(editor.lines(), original);
assert_eq!(editor.cursor(), cursor);
}
fn readonly_diff_editor(file: ChangedFile) -> Editor {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
files: vec![file],
})),
);
editor
}
fn render_native_diff(
terminal: &mut Terminal<ratatui::backend::TestBackend>,
views: &mut BufferViewports,
editor: &mut Editor,
) -> Position {
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
editor.set_diff_viewport(id, file, top, before, after);
terminal.get_cursor_position().unwrap()
}
#[test]
fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() {
let mut editor = readonly_diff_editor(syntax_diff_file(
"history.rs",
"fn old() {}\nlet value = \"old\";",
"fn new() {}\nlet value = \"new\";",
));
editor.handle(Input::Char('\t'));
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert!(before.contains(caret));
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
"f"
);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].fg,
Color::Magenta
);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].bg,
Color::Rgb(64, 24, 24)
);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].bg,
Color::Blue
);
editor.handle(Input::Escape);
editor.handle(Input::Down);
editor.handle(Input::Home);
editor.handle(Input::Char('V'));
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y + 1)].bg,
Color::Blue
);
assert_eq!(
terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg,
Color::Blue
);
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
for ch in "value".chars() {
editor.handle(Input::Char(ch));
}
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
editor.handle(Input::Enter);
let searched = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before.contains(searched));
assert_eq!(
terminal.backend().buffer()[(searched.x, searched.y)].bg,
Color::Yellow
);
assert_eq!(
terminal.backend().buffer()[(searched.x, searched.y)].fg,
Color::Black
);
let before_row = editor
.diff()
.unwrap()
.source_display_row(Focus::Before, editor.cursor().row)
.unwrap();
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
editor.handle(Input::Char('\t'));
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
let switched = render_native_diff(&mut terminal, &mut views, &mut editor);
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
assert!(after.contains(switched));
assert_eq!(switched.y, searched.y);
assert_eq!(switched.x - after.x, searched.x - before.x);
assert_eq!(
editor
.diff()
.unwrap()
.source_display_row(Focus::After, editor.cursor().row),
Some(before_row)
);
assert_eq!(
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
column
);
assert_eq!(
terminal.backend().buffer()[(switched.x, switched.y)].bg,
Color::Rgb(24, 48, 32)
);
editor.handle(Input::Right);
let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
let returned = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before.contains(returned));
assert_eq!(returned.y, moved.y);
assert_eq!(returned.x - before.x, moved.x - after.x);
assert_eq!(returned.x, searched.x + 1);
}
#[test]
fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() {
let before = (0..80)
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old row", "new row");
let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut views = BufferViewports::default();
let mut retained_before_left = 0;
for focus in [Focus::Before, Focus::After] {
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, focus);
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
render_native_diff(&mut terminal, &mut views, &mut editor);
for _ in 0..40 {
editor.handle(Input::Down);
render_native_diff(&mut terminal, &mut views, &mut editor);
}
let top = editor.diff().unwrap().top;
assert!(top > 0);
let bottom = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Up);
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(editor.diff().unwrap().top, top);
assert_eq!(reversed.y + 1, bottom.y);
while editor
.diff()
.unwrap()
.source_display_row(focus, editor.cursor().row)
.unwrap()
> top
{
editor.handle(Input::Up);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(editor.diff().unwrap().top, top);
}
editor.handle(Input::Up);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(editor.diff().unwrap().top < top);
for _ in 0..80 {
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
}
let left = if focus == Focus::Before {
editor.diff().unwrap().left_before
} else {
editor.diff().unwrap().left_after
};
assert!(left > 0);
let right = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Left);
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap();
assert_eq!(
if focus == Focus::Before {
offsets.3
} else {
offsets.4
},
left
);
assert_eq!(reversed.x + 1, right.x);
while editor.cursor().column > left {
editor.handle(Input::Left);
render_native_diff(&mut terminal, &mut views, &mut editor);
let diff = editor.diff().unwrap();
assert_eq!(
if focus == Focus::Before {
diff.left_before
} else {
diff.left_after
},
left
);
}
editor.handle(Input::Left);
render_native_diff(&mut terminal, &mut views, &mut editor);
if focus == Focus::Before {
retained_before_left = editor.diff().unwrap().left_before;
assert!(retained_before_left < left);
} else {
assert!(editor.diff().unwrap().left_after < left);
assert_eq!(editor.diff().unwrap().left_before, retained_before_left);
}
}
}
#[test]
fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() {
let directory = Directory::new();
@@ -3807,9 +4093,10 @@ mod tests {
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
assert!(!diff.files.as_ref().unwrap().focused);
assert!(viewports.panels.files.is_none());
assert!(viewports.document.geometry.is_none());
session.execute("diff.close");
assert_eq!(session.editor().lines(), original);
assert_eq!(session.editor().cursor(), cursor);
assert!(viewports.document.geometry.is_none());
}
#[test]