feat: add local and remote repository diff views

This commit is contained in:
2026-10-10 23:01:34 +02:00
parent f6da7922a1
commit 81a2bf9a02
15 changed files with 4947 additions and 5 deletions
+870 -2
View File
@@ -25,13 +25,14 @@ use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::Line,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
@@ -414,8 +415,18 @@ fn draw(
) {
panels.begin_frame();
document.geometry = None;
syntax.update(editor.lines(), editor.path(), editor.text_revision());
let area = frame.area();
if let Some(diff) = editor.diff() {
draw_diff(
frame,
diff,
area,
editor.selection_navigation_hint(),
panels,
);
return;
}
syntax.update(editor.lines(), editor.path(), editor.text_revision());
if area.width == 0 || area.height == 0 {
return;
}
@@ -870,6 +881,461 @@ fn draw(
}
}
struct DiffGeometry {
regions: [(Rect, Focus); 3],
}
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
if area.width < 4 || area.height < 4 {
return area;
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused {
Color::Cyan
} else {
Color::DarkGray
}))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
fn draw_diff(
frame: &mut Frame,
diff: &DiffState,
area: Rect,
navigation_hint: &str,
panels: &mut WheelPanels,
) {
if area.width == 0 || area.height == 0 {
return;
}
frame.render_widget(Clear, area);
if diff.phase != Phase::View {
let panel = floating_panel(area);
let stage = if diff.phase == Phase::Before {
"Before"
} else {
"After"
};
let title = format!(" Diff · Choose {stage} ");
let content = diff_panel(frame, panel, &title, true);
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some(if diff.after.is_some() {
"Loading comparison…"
} else {
"Loading repository…"
})
} else {
diff.catalog
.as_ref()
.and_then(|catalog| catalog.notice.as_deref())
}
});
let [
stage_area,
tabs_area,
query_area,
notice_area,
pinned_area,
results,
help,
] = Layout::vertical([
Constraint::Length(u16::from(content.height >= 5)),
Constraint::Length(u16::from(content.height >= 4)),
Constraint::Length(1),
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
Constraint::Length(u16::from(content.height >= 2)),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 7)),
])
.areas(content);
let before = diff
.before
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
frame.render_widget(
Paragraph::new(format!("Choose {stage} · Before: {}", display_text(before))),
stage_area,
);
if let Some(catalog) = &diff.catalog {
let tabs: Vec<Span> = catalog
.kind
.categories()
.iter()
.enumerate()
.map(|(index, category)| {
Span::styled(
format!(" {} ", category.label()),
if index == diff.category {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
},
)
})
.collect();
frame.render_widget(Paragraph::new(Line::from(tabs)), tabs_area);
}
let query = format!("> {}", display_text(&diff.query));
let width = usize::from(query_area.width);
let query_width = display_column(&query, usize::MAX);
let offset = query_width.saturating_sub(width.saturating_sub(1));
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width))
.style(Style::default().fg(Color::Yellow)),
query_area,
);
if query_area.width > 0 && query_area.height > 0 {
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
}
if let Some(notice) = notice {
frame.render_widget(
Paragraph::new(display_text(notice)).style(Style::default().fg(
if diff.error.is_some() {
Color::Red
} else {
Color::Yellow
},
)),
notice_area,
);
}
let mut entries = diff.entries();
if let Some(working) = entries.next() {
let label = format!(
"{} {} · pinned",
if diff.selected == 0 { ">" } else { " " },
display_text(&working.label)
);
frame.render_widget(
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
if diff.selected == 0 {
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
},
),
pinned_area,
);
}
let row_height = if results.width >= 50 && results.height >= 4 {
2
} else {
1
};
let count = usize::from(results.height) / row_height;
let first = diff.selected.saturating_sub(count.saturating_sub(1)).max(1);
let mut lines = Vec::with_capacity(count * row_height);
for (index, revision) in entries
.enumerate()
.map(|(index, revision)| (index + 1, revision))
.skip(first - 1)
.take(count)
{
let style = if index == diff.selected {
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = format!(
"{} {}",
if index == diff.selected { ">" } else { " " },
display_text(&revision.label)
);
let width = usize::from(results.width);
if row_height == 2 {
lines.push(Line::styled(visible_line(&label, 0, width), style));
lines.push(Line::styled(
visible_line(
&format!(" {}", display_text(&revision.description)),
0,
width,
),
style.fg(Color::DarkGray),
));
} else {
lines.push(Line::styled(
visible_line(
&format!("{label} · {}", display_text(&revision.description)),
0,
width,
),
style,
));
}
}
if lines.is_empty() && !diff.loading {
frame.render_widget(Paragraph::new("No matching revisions"), results);
} else {
frame.render_widget(Paragraph::new(lines), results);
}
frame.render_widget(
Paragraph::new(format!(
"{navigation_hint} · Tab categories · type search · Enter select · Esc back/cancel"
))
.style(Style::default().fg(Color::DarkGray)),
help,
);
panels.diff = Some(DiffGeometry {
regions: [
(panel, Focus::Files),
(Rect::default(), Focus::Before),
(Rect::default(), Focus::After),
],
});
return;
}
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some("Loading comparison…")
} else {
selected.and_then(|file| file.notice.as_deref())
}
});
let [header, notice_area, workspace, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(u16::from(notice.is_some() && area.height >= 3)),
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
])
.areas(area);
let (before, after) = comparison.map_or(("", ""), |comparison| {
(comparison.before.as_str(), comparison.after.as_str())
});
frame.render_widget(
Paragraph::new(format!(
"Diff · {} → {} · {:?} focus · read-only",
display_text(before),
display_text(after),
diff.focus
))
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
header,
);
if let Some(notice) = notice {
frame.render_widget(
Paragraph::new(display_text(notice)).style(Style::default().fg(
if diff.error.is_some() {
Color::Red
} else {
Color::Yellow
},
)),
notice_area,
);
}
let navigator_width = if workspace.width >= 40 {
(workspace.width / 4).min(32)
} else {
0
};
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
{
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);
}
if let Some(file) = selected {
draw_diff_pane(frame, diff, file, before_area, true);
draw_diff_pane(frame, diff, file, after_area, false);
} else {
frame.render_widget(
Paragraph::new(if diff.loading {
"Loading comparison…"
} else {
"No changed files"
}),
panes,
);
}
frame.render_widget(
Paragraph::new(
"Tab focus · ↑/↓ scroll/files · ←/→ horizontal · Home/End · Esc close · read-only",
)
.style(Style::default().fg(Color::DarkGray)),
help,
);
panels.diff = Some(DiffGeometry {
regions: [
(navigator, Focus::Files),
(before_area, Focus::Before),
(after_area, Focus::After),
],
});
}
fn diff_status(status: FileStatus) -> &'static str {
match status {
FileStatus::Added => "A",
FileStatus::Removed => "D",
FileStatus::Modified => "M",
FileStatus::Renamed => "R",
}
}
fn draw_diff_pane(
frame: &mut Frame,
diff: &DiffState,
file: &ChangedFile,
area: Rect,
before: bool,
) {
if area.width == 0 || area.height == 0 {
return;
}
let label = if before { "Before" } else { "After" };
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
let title = format!(
" {label} · {} · {} ",
diff_status(file.status),
display_text(&path.to_string_lossy())
);
let focus = if before { Focus::Before } else { Focus::After };
let content = diff_panel(frame, area, &title, diff.focus == focus);
if diff.rows.is_empty() {
frame.render_widget(
Paragraph::new(
file.notice
.as_deref()
.map(display_text)
.unwrap_or_else(|| "Empty file".into()),
),
content,
);
return;
}
let source = if before { &file.before } else { &file.after };
let left = if before {
diff.left_before
} else {
diff.left_after
};
let digits = source.len().max(1).ilog10() as usize + 1;
let gutter = if usize::from(content.width) > digits + 2 {
digits + 2
} else {
0
};
let width = usize::from(content.width).saturating_sub(gutter);
let lines: Vec<Line> = diff
.rows
.iter()
.skip(diff.top)
.take(usize::from(content.height))
.map(|row| {
let index = if before { row.before } else { row.after };
let changed = matches!(row.kind, RowKind::Changed)
|| (before && row.kind == RowKind::Removed)
|| (!before && row.kind == RowKind::Added);
let color = if changed {
if before { Color::Red } else { Color::Green }
} else {
Color::Reset
};
let Some(index) = index else {
return Line::from("");
};
let Some(text) = source.get(index) else {
return Line::from("");
};
let text = if text.chars().any(|ch| ch.is_control() && ch != '\t') {
Cow::Owned(
text.chars()
.map(|ch| {
if ch.is_control() && ch != '\t' {
'�'
} else {
ch
}
})
.collect::<String>(),
)
} else {
Cow::Borrowed(text.as_str())
};
let mut spans = Vec::with_capacity(2);
if gutter > 0 {
let marker = if changed {
if before { '-' } else { '+' }
} else {
' '
};
spans.push(Span::styled(
format!("{:>digits$}{marker} ", index + 1),
Style::default().fg(if changed { color } else { Color::DarkGray }),
));
}
spans.push(Span::styled(
visible_line(&text, left, width),
Style::default().fg(color),
));
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(lines), content);
}
fn floating_panel(area: Rect) -> Rect {
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
@@ -939,6 +1405,7 @@ struct WheelPanels {
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
diff: Option<DiffGeometry>,
}
struct FileWheelOffset {
@@ -973,6 +1440,7 @@ impl WheelPanels {
self.hover = None;
self.completion = None;
self.files = None;
self.diff = None;
}
// Keyboard/paste may change the catalog without changing its length.
@@ -1008,6 +1476,37 @@ impl WheelPanels {
_ => return false,
};
let editor = session.editor();
if editor.diff().is_some() {
let position = Position::new(mouse.column, mouse.row);
let focus = self.diff.as_ref().and_then(|geometry| {
geometry
.regions
.iter()
.find_map(|(area, focus)| area.contains(position).then_some(*focus))
});
if let Some(focus) = focus {
if session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View)
{
for _ in 0..3 {
if session
.editor()
.diff()
.is_some_and(|diff| diff.focus == focus)
{
break;
}
session.handle(Input::Char('\t'));
}
}
for _ in 0..3 {
session.handle(if down { Input::Down } else { Input::Up });
}
}
return true;
}
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
return true;
}
@@ -1640,6 +2139,375 @@ mod tests {
.join("\n")
}
fn diff_catalog() -> crate::diff::Catalog {
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
let mut entries = vec![Revision {
target: RevisionTarget::Working,
label: "Working checkout".into(),
description: "On-disk files".into(),
category: Category::Commits,
}];
entries.extend((0..20).map(|index| Revision {
target: RevisionTarget::Commit(format!("commit-{index:02}")),
label: format!("commit-{index:02}"),
description: if index == 19 {
"needle description".into()
} else {
"ordinary change".into()
},
category: Category::Commits,
}));
entries.push(Revision {
target: RevisionTarget::Commit("branch-tip".into()),
label: "feature-branch".into(),
description: "branch description".into(),
category: Category::Branches,
});
Catalog {
kind: RepoKind::Git,
entries,
notice: None,
}
}
fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
let mut diff = DiffState::new();
diff.take_request();
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
diff.handle(Input::Enter);
diff.handle(Input::Enter);
diff.take_request();
diff.apply_result(Ok(crate::diff::Outcome::Comparison(
crate::diff::Comparison {
before: "old revision".into(),
after: "Working checkout".into(),
files,
},
)));
diff
}
fn deletion_file() -> ChangedFile {
ChangedFile {
path: "deleted-lines.txt".into(),
old_path: None,
status: FileStatus::Modified,
before: ["keep", "gone", "tail"].map(String::from).to_vec(),
after: ["keep", "tail"].map(String::from).to_vec(),
hunks: vec![crate::diff::Hunk {
before_start: 1,
before_len: 1,
after_start: 1,
after_len: 0,
}],
notice: None,
}
}
fn render_diff(
terminal: &mut Terminal<ratatui::backend::TestBackend>,
diff: &DiffState,
panels: &mut WheelPanels,
) {
terminal
.draw(|frame| {
panels.begin_frame();
draw_diff(frame, diff, frame.area(), "↑/↓", panels);
})
.unwrap();
}
#[test]
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
let mut diff = DiffState::new();
diff.take_request();
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let mut panels = WheelPanels::default();
diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose Before"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("> commit-19"));
assert!(!text.contains("commit-00"));
for ch in "needle".chars() {
diff.handle(Input::Char(ch));
}
diff.handle(Input::Down);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> needle"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("> commit-19"));
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
diff.handle(Input::Escape);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("> needle"));
assert!(screen_text(&terminal).contains("> commit-19"));
diff.handle(Input::Ctrl('u'));
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("feature-branch"));
assert!(!text.contains("ordinary change"));
assert!(text.contains("Working checkout · pinned"));
let buffer = terminal.backend().buffer();
assert!(
buffer
.content
.iter()
.any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan)
);
}
#[test]
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
let y = before.y + 2;
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Red);
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
for x in after.x + 1..after.right() - 1 {
assert_eq!(buffer[(x, y)].symbol(), " ");
}
assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3");
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t");
diff.handle(Input::Char('\t'));
diff.handle(Input::Down);
render_diff(&mut terminal, &diff, &mut panels);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g");
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan);
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
assert!(screen_text(&terminal).contains("Before focus"));
}
#[test]
fn diff_added_rows_leave_before_blank_and_show_green_after_numbers() {
let diff = diff_comparison(vec![ChangedFile {
path: "added.txt".into(),
old_path: None,
status: FileStatus::Added,
before: vec![],
after: vec!["first addition".into(), "second addition".into()],
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 0,
after_start: 0,
after_len: 2,
}],
notice: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
for offset in 0..2 {
let y = before.y + 1 + offset;
for x in before.x + 1..before.right() - 1 {
assert_eq!(buffer[(x, y)].symbol(), " ");
}
assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string());
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"));
}
#[test]
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
let added = ChangedFile {
path: "new-name.txt".into(),
old_path: Some("old-name.txt".into()),
status: FileStatus::Renamed,
before: vec!["\t界e\u{301}OLD".into()],
after: vec!["\t界e\u{301}NEW".into()],
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 1,
after_start: 0,
after_len: 1,
}],
notice: None,
};
let mut diff = diff_comparison(vec![deletion_file(), added]);
diff.handle(Input::Down);
diff.handle(Input::Char('\t'));
for _ in 0..4 {
diff.handle(Input::Right);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
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("Before · R · old-name.txt"));
assert!(text.contains("After · R · new-name.txt"));
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界");
assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}");
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界");
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Green);
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("After focus"));
assert_eq!(
terminal.backend().buffer()[(after.x, after.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();
let mut panels = WheelPanels::default();
let mut diff = DiffState::new();
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Loading repository"));
diff.take_request();
diff.apply_result(Err("permission denied".into()));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("permission denied"));
let empty = diff_comparison(vec![]);
render_diff(&mut terminal, &empty, &mut panels);
assert!(screen_text(&terminal).contains("No changed files"));
let binary = diff_comparison(vec![ChangedFile {
path: "image.bin".into(),
old_path: None,
status: FileStatus::Added,
before: vec![],
after: vec![],
hunks: vec![],
notice: Some("Binary file; text comparison unavailable".into()),
}]);
render_diff(&mut terminal, &binary, &mut panels);
assert!(screen_text(&terminal).contains("Binary file"));
let blank = diff_comparison(vec![ChangedFile {
path: "empty.txt".into(),
old_path: None,
status: FileStatus::Added,
before: vec![],
after: vec![],
hunks: vec![],
notice: None,
}]);
render_diff(&mut terminal, &blank, &mut panels);
assert!(screen_text(&terminal).contains("Empty file"));
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] {
let mut tiny =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
for state in [&diff, &empty, &binary, &blank] {
render_diff(&mut tiny, state, &mut panels);
}
}
}
#[test]
fn actual_diff_draw_path_is_modal_and_escape_restores_document_viewport() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("source.txt", "original\nsecond")),
vec![],
)
.unwrap();
let original = editor.lines().to_vec();
let cursor = editor.cursor();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("Choose Before"));
assert!(viewports.document.geometry.is_none());
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
before: "old revision".into(),
after: "Working checkout".into(),
files: vec![deletion_file()],
})),
);
editor.handle(Input::Paste("must not edit".into()));
editor.handle(Input::Char('i'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
assert!(viewports.document.geometry.is_none());
assert_eq!(editor.lines(), original);
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("original"));
assert!(viewports.document.geometry.is_some());
assert_eq!(editor.cursor(), cursor);
}
#[test]
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
let directory = Directory::new();
let path = directory.file("rows.txt", &["original"; 60].join("\n"));
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let id = session.editor().buffer_id();
let offset = viewports.offsets[&id];
let cursor = session.editor().cursor();
session.execute("diff");
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 0)
));
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 60, 15)
));
assert_eq!(viewports.offsets[&id], offset);
assert_eq!(session.editor().cursor(), cursor);
assert!(viewports.document.geometry.is_none());
session.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.document.geometry.is_some());
}
#[test]
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
let mut completion = Completion {