feat: add syntax-aware diff backgrounds and diagonal gap shading
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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![]);
|
||||
|
||||
Reference in New Issue
Block a user