feat: add syntax-aware diff backgrounds and diagonal gap shading

This commit is contained in:
2026-10-11 01:08:45 +02:00
parent 2f595ca4ca
commit db5d417a17
7 changed files with 529 additions and 77 deletions
+3 -1
View File
@@ -20,7 +20,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
@@ -64,6 +64,8 @@ The buffer viewport includes a fixed left gutter with right-aligned hybrid line
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
## Clipboard providers
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group.
+1
View File
@@ -49,6 +49,7 @@ Diff checks cover staged category/filter navigation, stale request cancellation,
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
## Performance and process limits
+5 -1
View File
@@ -215,7 +215,11 @@ The read-only comparison has Before on the left, After on the right, and the cha
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
Added/deleted rows are aligned with blank counterparts. Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
Missing counterparts use neutral `/` stripes instead of blank rows. Each successive aligned row shifts the stripes one column left, forming continuous diagonals; the phase follows aligned row and horizontal offset so scrolling does not restart the pattern. Actual empty source lines retain their normal/change background and are not hatched.
Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
+22 -1
View File
@@ -147,6 +147,7 @@ pub struct DiffState {
pub loading: bool,
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub comparison_id: Option<u64>,
pub files: Option<FilePicker>,
pub files_visible: bool,
pub file: usize,
@@ -177,6 +178,7 @@ impl DiffState {
loading: true,
error: None,
comparison: None,
comparison_id: None,
files: None,
files_visible: true,
file: 0,
@@ -215,7 +217,7 @@ impl DiffState {
self.error = None;
}
pub(crate) fn apply_result(&mut self, result: Result<Outcome, String>) {
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
let expected = self.request_kind.take();
self.loading = false;
self.pending = None;
@@ -254,6 +256,7 @@ impl DiffState {
}
self.files = Some(files);
self.comparison = Some(comparison);
self.comparison_id = Some(id);
self.phase = Phase::View;
self.focus = Focus::Files;
self.file = 0;
@@ -1236,6 +1239,24 @@ mod tests {
assert_eq!(editor.diff().unwrap().file, 0);
}
#[test]
fn comparison_identity_comes_from_accepted_requests_not_working_labels() {
let mut editor = editor();
open(&mut editor);
let first = choose_pair(&mut editor);
assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(first));
editor.handle(Input::Escape);
open(&mut editor);
assert_eq!(editor.diff().unwrap().comparison_id, None);
let second = choose_pair(&mut editor);
assert!(second > first);
assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, None);
assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
}
#[test]
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
let mut editor = editor();
+1 -1
View File
@@ -1259,7 +1259,7 @@ impl Editor {
let Some(diff) = &mut self.diff else {
return false;
};
diff.apply_result(result);
diff.apply_result(id, result);
true
}
+5
View File
@@ -156,6 +156,11 @@ impl SyntaxCache {
self.error.as_deref()
}
#[cfg(test)]
pub(crate) fn parse_counts(&self) -> (usize, usize) {
(self.parses, self.injection_parses)
}
fn highlight(
&mut self,
syntax: &'static LanguageSpec,
+492 -73
View File
@@ -41,7 +41,7 @@ use crate::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
},
session::EditorSession,
syntax::{SyntaxCache, SyntaxKind},
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
@@ -427,6 +427,7 @@ fn draw(
);
return;
}
panels.diff_syntax = None;
syntax.update(editor.lines(), editor.path(), editor.text_revision());
if area.width == 0 || area.height == 0 {
return;
@@ -925,6 +926,19 @@ fn draw_diff(
file_navigation_hint: &str,
panels: &mut WheelPanels,
) {
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
if diff.phase == Phase::View
&& let Some(id) = diff.comparison_id
&& let Some(file) = selected
{
panels
.diff_syntax
.get_or_insert_with(DiffSyntaxCache::default)
.update(id, diff.file, file);
} else {
panels.diff_syntax = None;
}
if area.width == 0 || area.height == 0 {
return;
}
@@ -1113,13 +1127,15 @@ fn draw_diff(
return;
}
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
let syntax_error = panels
.diff_syntax
.as_ref()
.and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error()));
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some("Loading comparison…")
} else {
selected.and_then(|file| file.notice.as_deref())
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
}
});
let [header, notice_area, workspace, help] = Layout::vertical([
@@ -1145,7 +1161,7 @@ fn draw_diff(
if let Some(notice) = notice {
frame.render_widget(
Paragraph::new(display_text(notice)).style(Style::default().fg(
if diff.error.is_some() {
if diff.error.is_some() || syntax_error.is_some() {
Color::Red
} else {
Color::Yellow
@@ -1173,9 +1189,11 @@ fn draw_diff(
comparison.map(|comparison| comparison.files.as_slice()),
);
}
if let Some(file) = selected {
draw_diff_pane(frame, diff, file, before_area, true);
draw_diff_pane(frame, diff, file, after_area, false);
if let Some(file) = selected
&& let Some(syntax) = &panels.diff_syntax
{
draw_diff_pane(frame, diff, file, before_area, true, &syntax.before);
draw_diff_pane(frame, diff, file, after_area, false, &syntax.after);
} else {
frame.render_widget(
Paragraph::new(if diff.loading {
@@ -1217,6 +1235,7 @@ fn draw_diff_pane(
file: &ChangedFile,
area: Rect,
before: bool,
syntax: &SyntaxCache,
) {
if area.width == 0 || area.height == 0 {
return;
@@ -1259,62 +1278,94 @@ fn draw_diff_pane(
0
};
let width = usize::from(content.width).saturating_sub(gutter);
let lines: Vec<Line> = diff
for (aligned_row, row) in diff
.rows
.iter()
.enumerate()
.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 row_area = Rect::new(
content.x,
content.y + (aligned_row - diff.top) as u16,
content.width,
1,
);
let index = if before { row.before } else { row.after };
let Some(index) = index else {
let style = Style::default()
.fg(Color::Rgb(72, 72, 72))
.bg(Color::Rgb(28, 28, 28));
let buffer = frame.buffer_mut();
for x in 0..content.width {
let slash = (usize::from(x) % 4 + aligned_row % 4 + left % 4) % 4 == 0;
buffer[(content.x + x, row_area.y)]
.set_symbol(if slash { "/" } else { " " })
.set_style(style);
}
continue;
};
let Some(source_text) = source.get(index) else {
continue;
};
let changed = matches!(row.kind, RowKind::Changed)
|| (before && row.kind == RowKind::Removed)
|| (!before && row.kind == RowKind::Added);
let background = if changed {
if before {
Color::Rgb(64, 24, 24)
} else {
Color::Rgb(24, 48, 32)
}
} else {
Color::Reset
};
let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') {
Cow::Owned(
source_text
.chars()
.map(|ch| {
if ch.is_control() && ch != '\t' {
'�'
} else {
ch
}
})
.collect::<String>(),
)
} else {
Cow::Borrowed(source_text.as_str())
};
frame.render_widget(
Paragraph::new(visible_line(&text, left, width))
.style(Style::default().fg(Color::Reset).bg(background)),
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
);
if gutter > 0 {
let marker = if changed {
if before { '-' } else { '+' }
} else {
' '
};
let color = if changed {
if before { Color::Red } else { Color::Green }
} else {
Color::Reset
Color::DarkGray
};
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);
frame.render_widget(
Paragraph::new(format!("{:>digits$}{marker} ", index + 1))
.style(Style::default().fg(color).bg(background)),
Rect::new(content.x, row_area.y, gutter as u16, 1),
);
}
paint_syntax_line(
frame,
source_text,
syntax.line(index),
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
left,
);
}
}
fn floating_panel(area: Rect) -> Rect {
@@ -1379,6 +1430,34 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
}
}
#[derive(Default)]
struct DiffSyntaxCache {
key: Option<(u64, usize)>,
revision: u64,
before: SyntaxCache,
after: SyntaxCache,
}
impl DiffSyntaxCache {
fn update(&mut self, comparison_id: u64, file_index: usize, file: &ChangedFile) {
let key = Some((comparison_id, file_index));
if self.key != key {
self.key = key;
self.revision = self
.revision
.checked_add(1)
.expect("diff syntax revisions exhausted");
}
self.before.update(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.revision,
);
self.after
.update(&file.after, Some(&file.path), self.revision);
}
}
#[derive(Default)]
struct WheelPanels {
completion: Option<Rect>,
@@ -1387,6 +1466,7 @@ struct WheelPanels {
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
diff: Option<DiffGeometry>,
diff_syntax: Option<DiffSyntaxCache>,
}
struct FileWheelOffset {
@@ -2067,6 +2147,45 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hin
);
}
fn paint_syntax_line(
frame: &mut Frame,
source: &str,
spans: &[SyntaxSpan],
area: Rect,
left: usize,
) {
if spans.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let right = left.saturating_add(usize::from(area.width));
let mut span_index = 0;
let mut column = 0;
for (byte, grapheme) in source.grapheme_indices(true) {
if column >= right {
break;
}
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme) + grapheme.chars().filter(|ch| ch.is_control()).count()
};
let end = column + cells;
while span_index < spans.len() && spans[span_index].end_byte <= byte {
span_index += 1;
}
if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte)
&& end > left
&& (grapheme == "\t" || (column >= left && end <= right))
{
let style = syntax_style(span.kind);
for cell in column.max(left)..end.min(right) {
frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)].set_style(style);
}
}
column = end;
}
}
fn syntax_style(kind: SyntaxKind) -> Style {
Style::default().fg(match kind {
SyntaxKind::Comment => Color::DarkGray,
@@ -2192,20 +2311,165 @@ mod tests {
}
}
fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile {
let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
ChangedFile {
path: path.into(),
old_path: None,
status: FileStatus::Modified,
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: before.len(),
after_start: 0,
after_len: after.len(),
}],
before,
after,
notice: None,
}
}
#[test]
fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels::default();
for (source, foreground) in [
("fn main() {}", Color::Magenta),
("// current snapshot", Color::DarkGray),
("const VALUE: u8 = 3;", Color::Magenta),
] {
let mut diff =
diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]);
diff.files_visible = false;
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)];
assert_eq!(cell.fg, foreground, "{source}");
assert_eq!(cell.bg, Color::Rgb(24, 48, 32));
let before = panels.diff.as_ref().unwrap().regions[1].0;
let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)];
assert_eq!(cell.fg, Color::Magenta);
assert_eq!(cell.bg, Color::Rgb(64, 24, 24));
}
}
#[test]
fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() {
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
file.old_path = Some("old.py".into());
let mut syntax = DiffSyntaxCache::default();
syntax.update(1, 0, &file);
assert_eq!(syntax.before.language(), Some("Python"));
assert_eq!(syntax.after.language(), Some("Rust"));
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
file.path = "unknown.extension".into();
syntax.update(1, 0, &file);
assert_eq!(syntax.after.language(), None);
assert!(syntax.after.line(0).is_empty());
let mut file = syntax_diff_file(
"script",
"```rust\nfn old() {}\n```",
"#!/usr/bin/env python3\nreturn 42",
);
file.old_path = Some("history.md".into());
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(syntax.before.language(), Some("Markdown"));
assert_eq!(syntax.after.language(), Some("Python"));
assert!(syntax.before.parse_counts().1 > 0);
assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword);
for (region, background) in [
(
panels.diff.as_ref().unwrap().regions[1].0,
Color::Rgb(64, 24, 24),
),
(
panels.diff.as_ref().unwrap().regions[2].0,
Color::Rgb(24, 48, 32),
),
] {
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)];
assert_eq!(cell.fg, Color::Magenta);
assert_eq!(cell.bg, background);
}
}
#[test]
fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() {
let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new");
let second = syntax_diff_file("b.rs", "// prior file", "// selected file");
let mut diff = diff_comparison(vec![first, second]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
let counts = (syntax.before.parse_counts(), syntax.after.parse_counts());
assert_eq!(counts, ((1, 0), (1, 0)));
for focus in [Focus::Before, Focus::After, Focus::Files] {
diff.focus = focus;
diff.top = 1;
diff.left_before = 1;
diff.left_after = 2;
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(
(syntax.before.parse_counts(), syntax.after.parse_counts()),
counts
);
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
Color::DarkGray,
);
}
diff.files
.as_mut()
.unwrap()
.reveal_path(std::path::Path::new("b.rs"));
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(syntax.before.parse_counts(), (2, 0));
assert_eq!(syntax.after.parse_counts(), (2, 0));
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
Color::DarkGray,
);
let editor = Editor::open(None, vec![]).unwrap();
let mut views = BufferViewports {
panels,
..BufferViewports::default()
};
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
assert!(views.panels.diff_syntax.is_none());
}
fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut diff = DiffState::new();
diff.take_request();
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
diff.apply_result(id, 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 {
diff.apply_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
before: "old revision".into(),
after: "Working checkout".into(),
files,
},
)));
})),
);
diff
}
@@ -2243,7 +2507,7 @@ mod tests {
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())));
diff.apply_result(1, 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);
@@ -2297,11 +2561,22 @@ mod tests {
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 + 4, y)].fg, Color::Reset);
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
for x in after.x + 1..after.right() - 1 {
assert_eq!(buffer[(x, y)].symbol(), " ");
for x in before.x + 1..before.right() - 1 {
assert_eq!(buffer[(x, y)].bg, Color::Rgb(64, 24, 24));
assert_eq!(buffer[(x, y + 1)].bg, Color::Reset);
}
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
.map(|x| {
let cell = &buffer[(x, y)];
let column = x - after.x - 1;
assert_eq!(cell.symbol(), [" ", " ", " ", "/"][usize::from(column % 4)]);
assert_eq!(cell.bg, Color::Rgb(28, 28, 28));
assert_eq!(cell.fg, Color::Rgb(72, 72, 72));
cell.symbol().to_owned()
})
.collect();
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");
@@ -2311,20 +2586,22 @@ mod tests {
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(), " ");
for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) {
assert_eq!(buffer[(x, after.y + 1)].symbol(), 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 {
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
let mut 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()],
after: vec!["first addition".into(), String::new()],
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 0,
@@ -2343,12 +2620,146 @@ mod tests {
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(), " ");
let column = x - before.x - 1;
let cell = &buffer[(x, y)];
let pattern = if offset == 0 {
["/", " ", " ", " "]
} else {
[" ", " ", " ", "/"]
};
assert_eq!(cell.symbol(), pattern[usize::from(column % 4)]);
assert_eq!(cell.bg, Color::Rgb(28, 28, 28));
assert_eq!(cell.fg, Color::Rgb(72, 72, 72));
}
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_eq!(buffer[(after.x + 2, y)].fg, Color::Green);
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset);
for x in after.x + 1..after.right() - 1 {
assert_eq!(buffer[(x, y)].bg, Color::Rgb(24, 48, 32));
if offset == 1 && x >= after.x + 4 {
assert_eq!(buffer[(x, y)].symbol(), " ");
}
}
}
diff.top = 1;
diff.left_before = 1;
render_diff(&mut terminal, &diff, &mut panels);
let buffer = terminal.backend().buffer();
for x in before.x + 1..before.right() - 1 {
let column = x - before.x - 1;
assert_eq!(
buffer[(x, before.y + 1)].symbol(),
[" ", " ", "/", " "][usize::from(column % 4)],
);
}
assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│");
assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│");
}
#[test]
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
let mut diff = diff_comparison(vec![ChangedFile {
path: "changed.rs".into(),
old_path: None,
status: FileStatus::Modified,
before: [
"fn old() {",
"\tlet word = \"界e\u{301}old\";",
"\tlet count = 42; //\u{1b}note",
"",
"}",
]
.map(String::from)
.to_vec(),
after: [
"fn new() {",
"\tlet word = \"界e\u{301}new\";",
"\tlet count = 24; //\u{1b}note",
"",
"}",
]
.map(String::from)
.to_vec(),
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 5,
after_start: 0,
after_len: 5,
}],
notice: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 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 (pane, background) in [
(before, Color::Rgb(64, 24, 24)),
(after, Color::Rgb(24, 48, 32)),
] {
for row in 0..5 {
// TestBackend emits the wide glyph, not its continuation cells.
let mut x = pane.x + 1;
while x < pane.right() - 1 {
assert_eq!(
buffer[(x, pane.y + 1 + row)].bg,
background,
"pane={pane:?} x={x} row={row} symbol={:?}",
buffer[(x, pane.y + 1 + row)].symbol()
);
x += UnicodeWidthStr::width(buffer[(x, pane.y + 1 + row)].symbol()).max(1)
as u16;
}
}
for (column, row, foreground) in [
(0, 0, Color::Magenta),
(3, 0, Color::LightBlue),
(4, 1, Color::Magenta),
(15, 1, Color::Green),
(16, 1, Color::Green),
(18, 1, Color::Green),
(16, 2, Color::Yellow),
(22, 2, Color::DarkGray),
(23, 2, Color::DarkGray),
] {
assert_eq!(
buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg,
foreground
);
}
assert_eq!(buffer[(pane.x + 20, pane.y + 2)].symbol(), "界");
assert_eq!(buffer[(pane.x + 22, pane.y + 2)].symbol(), "e\u{301}");
assert_eq!(buffer[(pane.x + 26, pane.y + 3)].symbol(), "�");
assert_eq!(buffer[(pane.x + 27, pane.y + 3)].symbol(), "n");
for x in pane.x + 4..pane.right() - 1 {
assert_eq!(buffer[(x, pane.y + 4)].symbol(), " ");
assert_eq!(buffer[(x, pane.y + 4)].fg, Color::Reset);
}
}
diff.top = 1;
diff.left_before = 17;
diff.left_after = 16;
render_diff(&mut terminal, &diff, &mut panels);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " ");
assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset);
assert_eq!(
buffer[(before.x + 4, before.y + 1)].bg,
Color::Rgb(64, 24, 24)
);
assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}");
assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green);
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界");
assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green);
assert_eq!(
buffer[(after.x + 4, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}");
assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green);
}
#[test]
@@ -2387,7 +2798,15 @@ mod tests {
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);
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset);
assert_eq!(
buffer[(after.x + 8, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
assert_eq!(
buffer[(before.x + 4, before.y + 1)].bg,
Color::Rgb(64, 24, 24)
);
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("After focus"));
@@ -2597,7 +3016,7 @@ mod tests {
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Loading repository"));
diff.take_request();
diff.apply_result(Err("permission denied".into()));
diff.apply_result(1, Err("permission denied".into()));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("permission denied"));
let empty = diff_comparison(vec![]);