feat: add local and remote repository diff views
This commit is contained in:
+870
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user