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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- 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.
|
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
|
## 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.
|
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.
|
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.
|
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
|
## 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.
|
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.
|
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 loading: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub comparison: Option<Comparison>,
|
pub comparison: Option<Comparison>,
|
||||||
|
pub comparison_id: Option<u64>,
|
||||||
pub files: Option<FilePicker>,
|
pub files: Option<FilePicker>,
|
||||||
pub files_visible: bool,
|
pub files_visible: bool,
|
||||||
pub file: usize,
|
pub file: usize,
|
||||||
@@ -177,6 +178,7 @@ impl DiffState {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
comparison: None,
|
comparison: None,
|
||||||
|
comparison_id: None,
|
||||||
files: None,
|
files: None,
|
||||||
files_visible: true,
|
files_visible: true,
|
||||||
file: 0,
|
file: 0,
|
||||||
@@ -215,7 +217,7 @@ impl DiffState {
|
|||||||
self.error = None;
|
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();
|
let expected = self.request_kind.take();
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
@@ -254,6 +256,7 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
self.files = Some(files);
|
self.files = Some(files);
|
||||||
self.comparison = Some(comparison);
|
self.comparison = Some(comparison);
|
||||||
|
self.comparison_id = Some(id);
|
||||||
self.phase = Phase::View;
|
self.phase = Phase::View;
|
||||||
self.focus = Focus::Files;
|
self.focus = Focus::Files;
|
||||||
self.file = 0;
|
self.file = 0;
|
||||||
@@ -1236,6 +1239,24 @@ mod tests {
|
|||||||
assert_eq!(editor.diff().unwrap().file, 0);
|
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]
|
#[test]
|
||||||
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
|
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
+1
-1
@@ -1259,7 +1259,7 @@ impl Editor {
|
|||||||
let Some(diff) = &mut self.diff else {
|
let Some(diff) = &mut self.diff else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
diff.apply_result(result);
|
diff.apply_result(id, result);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ impl SyntaxCache {
|
|||||||
self.error.as_deref()
|
self.error.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn parse_counts(&self) -> (usize, usize) {
|
||||||
|
(self.parses, self.injection_parses)
|
||||||
|
}
|
||||||
|
|
||||||
fn highlight(
|
fn highlight(
|
||||||
&mut self,
|
&mut self,
|
||||||
syntax: &'static LanguageSpec,
|
syntax: &'static LanguageSpec,
|
||||||
|
|||||||
+470
-51
@@ -41,7 +41,7 @@ use crate::{
|
|||||||
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
|
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
|
||||||
},
|
},
|
||||||
session::EditorSession,
|
session::EditorSession,
|
||||||
syntax::{SyntaxCache, SyntaxKind},
|
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
|
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
|
||||||
@@ -427,6 +427,7 @@ fn draw(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
panels.diff_syntax = None;
|
||||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -925,6 +926,19 @@ fn draw_diff(
|
|||||||
file_navigation_hint: &str,
|
file_navigation_hint: &str,
|
||||||
panels: &mut WheelPanels,
|
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 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1113,13 +1127,15 @@ fn draw_diff(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let comparison = diff.comparison.as_ref();
|
let syntax_error = panels
|
||||||
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
.diff_syntax
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error()));
|
||||||
let notice = diff.error.as_deref().or_else(|| {
|
let notice = diff.error.as_deref().or_else(|| {
|
||||||
if diff.loading {
|
if diff.loading {
|
||||||
Some("Loading comparison…")
|
Some("Loading comparison…")
|
||||||
} else {
|
} 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([
|
let [header, notice_area, workspace, help] = Layout::vertical([
|
||||||
@@ -1145,7 +1161,7 @@ fn draw_diff(
|
|||||||
if let Some(notice) = notice {
|
if let Some(notice) = notice {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
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
|
Color::Red
|
||||||
} else {
|
} else {
|
||||||
Color::Yellow
|
Color::Yellow
|
||||||
@@ -1173,9 +1189,11 @@ fn draw_diff(
|
|||||||
comparison.map(|comparison| comparison.files.as_slice()),
|
comparison.map(|comparison| comparison.files.as_slice()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(file) = selected {
|
if let Some(file) = selected
|
||||||
draw_diff_pane(frame, diff, file, before_area, true);
|
&& let Some(syntax) = &panels.diff_syntax
|
||||||
draw_diff_pane(frame, diff, file, after_area, false);
|
{
|
||||||
|
draw_diff_pane(frame, diff, file, before_area, true, &syntax.before);
|
||||||
|
draw_diff_pane(frame, diff, file, after_area, false, &syntax.after);
|
||||||
} else {
|
} else {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(if diff.loading {
|
Paragraph::new(if diff.loading {
|
||||||
@@ -1217,6 +1235,7 @@ fn draw_diff_pane(
|
|||||||
file: &ChangedFile,
|
file: &ChangedFile,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
before: bool,
|
before: bool,
|
||||||
|
syntax: &SyntaxCache,
|
||||||
) {
|
) {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -1259,30 +1278,52 @@ fn draw_diff_pane(
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
let width = usize::from(content.width).saturating_sub(gutter);
|
let width = usize::from(content.width).saturating_sub(gutter);
|
||||||
let lines: Vec<Line> = diff
|
for (aligned_row, row) in diff
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
|
.enumerate()
|
||||||
.skip(diff.top)
|
.skip(diff.top)
|
||||||
.take(usize::from(content.height))
|
.take(usize::from(content.height))
|
||||||
.map(|row| {
|
{
|
||||||
|
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 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)
|
let changed = matches!(row.kind, RowKind::Changed)
|
||||||
|| (before && row.kind == RowKind::Removed)
|
|| (before && row.kind == RowKind::Removed)
|
||||||
|| (!before && row.kind == RowKind::Added);
|
|| (!before && row.kind == RowKind::Added);
|
||||||
let color = if changed {
|
let background = if changed {
|
||||||
if before { Color::Red } else { Color::Green }
|
if before {
|
||||||
|
Color::Rgb(64, 24, 24)
|
||||||
|
} else {
|
||||||
|
Color::Rgb(24, 48, 32)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Color::Reset
|
Color::Reset
|
||||||
};
|
};
|
||||||
let Some(index) = index else {
|
let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') {
|
||||||
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(
|
Cow::Owned(
|
||||||
text.chars()
|
source_text
|
||||||
|
.chars()
|
||||||
.map(|ch| {
|
.map(|ch| {
|
||||||
if ch.is_control() && ch != '\t' {
|
if ch.is_control() && ch != '\t' {
|
||||||
'�'
|
'�'
|
||||||
@@ -1293,28 +1334,38 @@ fn draw_diff_pane(
|
|||||||
.collect::<String>(),
|
.collect::<String>(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Cow::Borrowed(text.as_str())
|
Cow::Borrowed(source_text.as_str())
|
||||||
};
|
};
|
||||||
let mut spans = Vec::with_capacity(2);
|
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 {
|
if gutter > 0 {
|
||||||
let marker = if changed {
|
let marker = if changed {
|
||||||
if before { '-' } else { '+' }
|
if before { '-' } else { '+' }
|
||||||
} else {
|
} else {
|
||||||
' '
|
' '
|
||||||
};
|
};
|
||||||
spans.push(Span::styled(
|
let color = if changed {
|
||||||
format!("{:>digits$}{marker} ", index + 1),
|
if before { Color::Red } else { Color::Green }
|
||||||
Style::default().fg(if changed { color } else { Color::DarkGray }),
|
} else {
|
||||||
));
|
Color::DarkGray
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
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 {
|
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)]
|
#[derive(Default)]
|
||||||
struct WheelPanels {
|
struct WheelPanels {
|
||||||
completion: Option<Rect>,
|
completion: Option<Rect>,
|
||||||
@@ -1387,6 +1466,7 @@ struct WheelPanels {
|
|||||||
files: Option<(Rect, usize, usize)>,
|
files: Option<(Rect, usize, usize)>,
|
||||||
manual_files: Option<FileWheelOffset>,
|
manual_files: Option<FileWheelOffset>,
|
||||||
diff: Option<DiffGeometry>,
|
diff: Option<DiffGeometry>,
|
||||||
|
diff_syntax: Option<DiffSyntaxCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileWheelOffset {
|
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 {
|
fn syntax_style(kind: SyntaxKind) -> Style {
|
||||||
Style::default().fg(match kind {
|
Style::default().fg(match kind {
|
||||||
SyntaxKind::Comment => Color::DarkGray,
|
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 {
|
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();
|
let mut diff = DiffState::new();
|
||||||
diff.take_request();
|
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.handle(Input::Enter);
|
diff.handle(Input::Enter);
|
||||||
diff.take_request();
|
diff.take_request();
|
||||||
diff.apply_result(Ok(crate::diff::Outcome::Comparison(
|
diff.apply_result(
|
||||||
crate::diff::Comparison {
|
id,
|
||||||
|
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||||
before: "old revision".into(),
|
before: "old revision".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
files,
|
files,
|
||||||
},
|
})),
|
||||||
)));
|
);
|
||||||
diff
|
diff
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2243,7 +2507,7 @@ mod tests {
|
|||||||
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
|
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
|
||||||
let mut diff = DiffState::new();
|
let mut diff = DiffState::new();
|
||||||
diff.take_request();
|
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 terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||||
let mut panels = WheelPanels::default();
|
let mut panels = WheelPanels::default();
|
||||||
diff.handle(Input::End);
|
diff.handle(Input::End);
|
||||||
@@ -2297,11 +2561,22 @@ mod tests {
|
|||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
let y = before.y + 2;
|
let y = before.y + 2;
|
||||||
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
|
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(), "-");
|
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
|
||||||
for x in after.x + 1..after.right() - 1 {
|
for x in before.x + 1..before.right() - 1 {
|
||||||
assert_eq!(buffer[(x, y)].symbol(), " ");
|
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[(before.x + 1, y + 1)].symbol(), "3");
|
||||||
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
|
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
|
||||||
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
|
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
|
||||||
@@ -2311,20 +2586,22 @@ mod tests {
|
|||||||
render_diff(&mut terminal, &diff, &mut panels);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g");
|
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[(before.x, before.y)].fg, Color::Cyan);
|
||||||
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
|
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
|
||||||
assert!(screen_text(&terminal).contains("Before focus"));
|
assert!(screen_text(&terminal).contains("Before focus"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_added_rows_leave_before_blank_and_show_green_after_numbers() {
|
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
|
||||||
let diff = diff_comparison(vec![ChangedFile {
|
let mut diff = diff_comparison(vec![ChangedFile {
|
||||||
path: "added.txt".into(),
|
path: "added.txt".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Added,
|
status: FileStatus::Added,
|
||||||
before: vec![],
|
before: vec![],
|
||||||
after: vec!["first addition".into(), "second addition".into()],
|
after: vec!["first addition".into(), String::new()],
|
||||||
hunks: vec![crate::diff::Hunk {
|
hunks: vec![crate::diff::Hunk {
|
||||||
before_start: 0,
|
before_start: 0,
|
||||||
before_len: 0,
|
before_len: 0,
|
||||||
@@ -2343,13 +2620,147 @@ mod tests {
|
|||||||
for offset in 0..2 {
|
for offset in 0..2 {
|
||||||
let y = before.y + 1 + offset;
|
let y = before.y + 1 + offset;
|
||||||
for x in before.x + 1..before.right() - 1 {
|
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 + 1, y)].symbol(), (offset + 1).to_string());
|
||||||
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
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]
|
#[test]
|
||||||
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
||||||
@@ -2387,7 +2798,15 @@ mod tests {
|
|||||||
assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}");
|
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 + 4, after.y + 1)].symbol(), " ");
|
||||||
assert_eq!(buffer[(after.x + 8, 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'));
|
diff.handle(Input::Char('\t'));
|
||||||
render_diff(&mut terminal, &diff, &mut panels);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("After focus"));
|
assert!(screen_text(&terminal).contains("After focus"));
|
||||||
@@ -2597,7 +3016,7 @@ mod tests {
|
|||||||
render_diff(&mut terminal, &diff, &mut panels);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("Loading repository"));
|
assert!(screen_text(&terminal).contains("Loading repository"));
|
||||||
diff.take_request();
|
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);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("permission denied"));
|
assert!(screen_text(&terminal).contains("permission denied"));
|
||||||
let empty = diff_comparison(vec![]);
|
let empty = diff_comparison(vec![]);
|
||||||
|
|||||||
Reference in New Issue
Block a user