use std::borrow::Cow;
use std::collections::HashSet;
use lasso::Rodeo;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
Frame,
};
use syntect::easy::HighlightLines;
use smallvec::smallvec;
use super::common::render_rally_status_bar;
use crate::app::{
hash_string, App, CachedDiffLine, DiffCache, InputMode, InternedSpan, LineInputContext,
SpanVec,
};
use crate::diff::{classify_line, LineType};
use crate::syntax::{
apply_line_highlights, collect_line_highlights, collect_line_highlights_with_injections,
get_theme, highlight_code_line, syntax_for_file, Highlighter, ParserPool,
};
pub fn expand_tabs(s: &str, tab_width: u8) -> Cow<'_, str> {
if !s.contains('\t') {
Cow::Borrowed(s)
} else {
let spaces = " ".repeat(tab_width as usize);
Cow::Owned(s.replace('\t', &spaces))
}
}
pub fn build_plain_diff_cache(patch: &str, tab_width: u8) -> DiffCache {
let patch_hash = hash_string(patch);
let expanded = expand_tabs(patch, tab_width);
let mut interner = Rodeo::default();
let lines: Vec<CachedDiffLine> = expanded
.lines()
.map(|line| {
let (line_type, content) = classify_line(line);
let fg_style = match line_type.fg_color() {
Some(c) => Style::default().fg(c),
None => Style::default(),
};
let spans: SpanVec = if let Some(marker) = line_type.marker() {
smallvec![
InternedSpan {
content: interner.get_or_intern(marker),
style: fg_style,
},
InternedSpan {
content: interner.get_or_intern(content),
style: fg_style,
},
]
} else {
smallvec![InternedSpan {
content: interner.get_or_intern(line),
style: fg_style,
}]
};
CachedDiffLine { spans, line_type }
})
.collect();
DiffCache {
file_index: 0,
patch_hash,
lines,
interner,
highlighted: false,
markdown_rich: false,
}
}
pub fn build_diff_cache(
patch: &str,
filename: &str,
theme_name: &str,
parser_pool: &mut ParserPool,
markdown_rich: bool,
tab_width: u8,
) -> DiffCache {
let patch_hash = hash_string(patch);
let expanded = expand_tabs(patch, tab_width);
let patch = expanded.as_ref();
let mut interner = Rodeo::default();
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let (combined_source, line_mapping, priming_lines) =
build_combined_source_for_highlight_with_priming(patch, ext);
let highlighter = Highlighter::for_file(filename, theme_name);
let cst_result = highlighter.parse_source(&combined_source, parser_pool);
let use_cst = cst_result.is_some();
let lines: Vec<CachedDiffLine> = if use_cst {
let result = cst_result.as_ref().unwrap();
let base_style_cache = highlighter
.style_cache()
.expect("CST highlighter should have style_cache");
let rich_cache;
let style_cache = if markdown_rich && (ext == "md" || ext == "markdown") {
rich_cache = base_style_cache.clone().with_markdown_rich_overrides();
&rich_cache
} else {
base_style_cache
};
let line_highlights = if ext == "svelte" || ext == "vue" || ext == "md" || ext == "markdown"
{
collect_line_highlights_with_injections(
&combined_source,
&result.tree,
result.lang,
style_cache,
parser_pool,
ext,
)
} else {
let query = parser_pool
.get_or_create_query(result.lang)
.expect("Query should be available for supported language");
let capture_names: Vec<String> = query
.capture_names()
.iter()
.map(|s| s.to_string())
.collect();
collect_line_highlights(
&combined_source,
&result.tree,
query,
&capture_names,
style_cache,
)
};
build_lines_with_cst(
patch,
filename,
theme_name,
&line_highlights,
&line_mapping,
priming_lines,
&mut interner,
)
} else {
build_lines_with_syntect(patch, filename, theme_name, &mut interner)
};
let mut lines = lines;
if markdown_rich && (ext == "md" || ext == "markdown") {
apply_markdown_rich_transforms(&mut lines, &mut interner);
apply_markdown_table_transforms(&mut lines, &mut interner);
}
DiffCache {
file_index: 0, patch_hash,
lines,
interner,
highlighted: true,
markdown_rich,
}
}
pub fn build_commit_diff_cache(
full_diff: &str,
theme_name: &str,
parser_pool: &mut ParserPool,
tab_width: u8,
) -> DiffCache {
let patch_hash = hash_string(full_diff);
let sections = split_diff_by_file(full_diff);
let mut combined_interner = Rodeo::default();
let mut combined_lines: Vec<CachedDiffLine> = Vec::new();
for (filename, section_patch) in §ions {
let section_cache = build_diff_cache(
section_patch,
filename,
theme_name,
parser_pool,
false, tab_width,
);
for line in §ion_cache.lines {
let spans: SpanVec = line
.spans
.iter()
.map(|span| {
let text = section_cache.resolve(span.content);
InternedSpan {
content: combined_interner.get_or_intern(text),
style: span.style,
}
})
.collect();
combined_lines.push(CachedDiffLine {
spans,
line_type: line.line_type,
});
}
}
DiffCache {
file_index: 0,
patch_hash,
lines: combined_lines,
interner: combined_interner,
highlighted: true,
markdown_rich: false,
}
}
fn split_diff_by_file(diff: &str) -> Vec<(String, String)> {
let mut sections = Vec::new();
let mut current_filename = String::new();
let mut current_lines: Vec<&str> = Vec::new();
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
if !current_lines.is_empty() {
let patch = current_lines.join("\n");
sections.push((current_filename.clone(), patch));
current_lines.clear();
}
current_filename = extract_diff_filename(rest);
}
current_lines.push(line);
}
if !current_lines.is_empty() {
let patch = current_lines.join("\n");
sections.push((current_filename, patch));
}
sections
}
fn extract_diff_filename(rest: &str) -> String {
let len = rest.len();
if len >= 5 && len % 2 == 1 {
let mid = len / 2;
if rest.as_bytes()[mid] == b' '
&& rest.get(mid + 1..mid + 3) == Some("b/")
&& rest.get(..2) == Some("a/")
{
let a_path = &rest[2..mid];
let b_path = &rest[mid + 3..];
if a_path == b_path {
return b_path.to_string();
}
}
}
rest.rsplit_once(" b/")
.map(|(_, p)| p)
.unwrap_or("unknown")
.to_string()
}
fn apply_markdown_rich_transforms(lines: &mut [CachedDiffLine], interner: &mut Rodeo) {
use crate::syntax::themes::{MARKDOWN_BLOCK_PUNCT_COLOR, MARKDOWN_INLINE_PUNCT_COLOR};
let bullet_spur = interner.get_or_intern("・");
let bullet_space_spur = interner.get_or_intern("・ ");
for line in lines.iter_mut() {
if line.spans.len() <= 1 {
continue;
}
let first = interner.resolve(&line.spans[0].content);
let is_removed = first == "-";
if first != "+" && first != "-" && first != " " {
continue;
}
if is_removed {
apply_markdown_rich_transforms_text_based(
line,
interner,
bullet_spur,
bullet_space_spur,
);
} else {
apply_markdown_rich_transforms_sentinel(
line,
interner,
bullet_spur,
bullet_space_spur,
MARKDOWN_BLOCK_PUNCT_COLOR,
MARKDOWN_INLINE_PUNCT_COLOR,
);
}
}
}
fn apply_markdown_rich_transforms_sentinel(
line: &mut CachedDiffLine,
interner: &mut Rodeo,
bullet_spur: lasso::Spur,
bullet_space_spur: lasso::Spur,
block_punct_color: Color,
inline_punct_color: Color,
) {
let mut removals: Vec<usize> = Vec::new();
let mut replacements: Vec<(usize, lasso::Spur)> = Vec::new();
let span_count = line.spans.len();
for i in 1..span_count {
let content = interner.resolve(&line.spans[i].content).to_string();
let fg = line.spans[i].style.fg;
if fg == Some(block_punct_color) {
if content.chars().all(|c| c == '#') && !content.is_empty() {
removals.push(i);
if i + 1 < span_count {
let next = interner.resolve(&line.spans[i + 1].content);
if next == " " {
removals.push(i + 1);
}
}
} else if content.trim() == "-" || content.trim() == "+" || content.trim() == "*" {
let spur = if content.ends_with(' ') {
bullet_space_spur
} else {
bullet_spur
};
replacements.push((i, spur));
}
} else if fg == Some(inline_punct_color) {
if content.chars().all(|c| c == '*' || c == '_') && !content.is_empty() {
removals.push(i);
}
}
}
for (i, spur) in &replacements {
line.spans[*i].content = *spur;
line.spans[*i].style = Style::default();
}
removals.sort_unstable();
removals.dedup();
for i in removals.into_iter().rev() {
line.spans.remove(i);
}
for span in line.spans[1..].iter_mut() {
if span.style.fg == Some(block_punct_color) || span.style.fg == Some(inline_punct_color) {
span.style = span.style.fg(Color::DarkGray);
}
}
}
fn apply_markdown_rich_transforms_text_based(
line: &mut CachedDiffLine,
interner: &mut Rodeo,
bullet_spur: lasso::Spur,
bullet_space_spur: lasso::Spur,
) {
let full_content: String = line.spans[1..]
.iter()
.map(|s| interner.resolve(&s.content))
.collect();
let trimmed = full_content.trim_start();
if let Some(rest) = trimmed.strip_prefix('#') {
let hash_count = 1 + rest.chars().take_while(|c| *c == '#').count();
let after_hashes = &trimmed[hash_count..];
if after_hashes.is_empty() || after_hashes.starts_with(' ') {
let prefix_to_remove = if after_hashes.starts_with(' ') {
hash_count + 1 } else {
hash_count
};
remove_leading_chars_from_spans(line, interner, prefix_to_remove);
}
}
else if let Some(first_char) = trimmed.chars().next() {
if (first_char == '-' || first_char == '+' || first_char == '*')
&& trimmed.len() > 1
&& trimmed.chars().nth(1) == Some(' ')
{
replace_leading_marker_with_bullet(line, interner, bullet_spur, bullet_space_spur);
}
}
remove_inline_emphasis_delimiters(line, interner);
}
fn remove_leading_chars_from_spans(
line: &mut CachedDiffLine,
interner: &mut Rodeo,
chars_to_remove: usize,
) {
let mut remaining = chars_to_remove;
let mut removals: Vec<usize> = Vec::new();
for i in 1..line.spans.len() {
if remaining == 0 {
break;
}
let content = interner.resolve(&line.spans[i].content).to_string();
if removals.is_empty() && content.chars().all(|c| c.is_whitespace()) && !content.is_empty()
{
continue;
}
let char_count = content.chars().count();
if char_count <= remaining {
removals.push(i);
remaining -= char_count;
} else {
let new_content: String = content.chars().skip(remaining).collect();
line.spans[i].content = interner.get_or_intern(&new_content);
remaining = 0;
}
}
for i in removals.into_iter().rev() {
line.spans.remove(i);
}
}
fn replace_leading_marker_with_bullet(
line: &mut CachedDiffLine,
interner: &mut Rodeo,
bullet_spur: lasso::Spur,
bullet_space_spur: lasso::Spur,
) {
for i in 1..line.spans.len() {
let content = interner.resolve(&line.spans[i].content).to_string();
if content.chars().all(|c| c.is_whitespace()) && !content.is_empty() {
continue;
}
let trimmed = content.trim_start();
if let Some(first) = trimmed.chars().next() {
if first == '-' || first == '+' || first == '*' {
if trimmed.starts_with("- ")
|| trimmed.starts_with("+ ")
|| trimmed.starts_with("* ")
{
let leading_ws: String =
content.chars().take_while(|c| c.is_whitespace()).collect();
let after_marker: String = trimmed.chars().skip(2).collect();
if leading_ws.is_empty() && after_marker.is_empty() {
line.spans[i].content = bullet_space_spur;
} else {
let new_content = format!("{}・ {}", leading_ws, after_marker);
line.spans[i].content = interner.get_or_intern(&new_content);
}
} else {
line.spans[i].content = bullet_spur;
}
}
}
break; }
}
fn remove_inline_emphasis_delimiters(line: &mut CachedDiffLine, interner: &mut Rodeo) {
for span in line.spans[1..].iter_mut() {
let content = interner.resolve(&span.content).to_string();
if content.contains('*') || content.contains('_') {
if content.chars().all(|c| c == '*' || c == '_') && !content.is_empty() {
span.content = interner.get_or_intern("");
}
}
}
}
fn apply_markdown_table_transforms(lines: &mut [CachedDiffLine], interner: &mut Rodeo) {
let mut table_line_info: Vec<Option<TableLineKind>> = Vec::with_capacity(lines.len());
for line in lines.iter() {
if line.spans.len() <= 1 {
table_line_info.push(None);
continue;
}
let first = interner.resolve(&line.spans[0].content);
if first != "+" && first != "-" && first != " " {
table_line_info.push(None);
continue;
}
let full_content: String = line.spans[1..]
.iter()
.map(|s| interner.resolve(&s.content))
.collect();
let trimmed = full_content.trim_start();
if !trimmed.starts_with('|') {
table_line_info.push(None);
continue;
}
let is_separator = !trimmed.is_empty()
&& trimmed
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c == ' ');
if is_separator {
table_line_info.push(Some(TableLineKind::Separator));
} else {
table_line_info.push(Some(TableLineKind::Data));
}
}
let mut header_indices: Vec<usize> = Vec::new();
for (i, info) in table_line_info.iter().enumerate() {
if matches!(info, Some(TableLineKind::Separator)) {
if i > 0 {
if let Some(TableLineKind::Data) = table_line_info[i - 1] {
header_indices.push(i - 1);
}
}
}
}
for (i, line) in lines.iter_mut().enumerate() {
let Some(kind) = &table_line_info[i] else {
continue;
};
match kind {
TableLineKind::Separator => {
let full_content: String = line.spans[1..]
.iter()
.map(|s| interner.resolve(&s.content))
.collect();
let separator = build_table_separator(&full_content);
let style = Style::default().fg(Color::DarkGray);
line.spans.truncate(1);
line.spans.push(InternedSpan {
content: interner.get_or_intern(&separator),
style,
});
}
TableLineKind::Data => {
let is_header = header_indices.contains(&i);
for span in line.spans[1..].iter_mut() {
let content = interner.resolve(&span.content);
if content.contains('|') {
let new_content = content.replace('|', "│");
span.content = interner.get_or_intern(&new_content);
}
if is_header {
span.style = span.style.add_modifier(Modifier::BOLD);
}
}
}
}
}
}
#[derive(Debug)]
enum TableLineKind {
Data,
Separator,
}
fn build_table_separator(content: &str) -> String {
let trimmed = content.trim_start();
let chars: Vec<char> = trimmed.chars().collect();
if chars.is_empty() {
return content.to_string();
}
let pipe_positions: Vec<usize> = chars
.iter()
.enumerate()
.filter(|(_, c)| **c == '|')
.map(|(i, _)| i)
.collect();
if pipe_positions.is_empty() {
return content.to_string();
}
let first_pipe = pipe_positions[0];
let last_pipe = *pipe_positions.last().unwrap();
let has_trailing_pipe = chars[last_pipe + 1..].iter().all(|c| c.is_whitespace());
let leading_ws: String = content.chars().take_while(|c| c.is_whitespace()).collect();
let mut result = leading_ws;
for (i, c) in chars.iter().enumerate() {
if *c == '|' {
if i == first_pipe {
result.push('├');
} else if i == last_pipe && has_trailing_pipe {
result.push('┤');
} else {
result.push('┼');
}
} else if *c == '-' {
result.push('─');
} else {
result.push(*c); }
}
result
}
fn build_combined_source_for_highlight(patch: &str) -> (String, Vec<(usize, LineType)>) {
let mut source = String::new();
let mut line_mapping: Vec<(usize, LineType)> = Vec::new();
for (diff_line_idx, line) in patch.lines().enumerate() {
let (line_type, content) = classify_line(line);
match line_type {
LineType::Added | LineType::Context => {
line_mapping.push((diff_line_idx, line_type));
source.push_str(content);
source.push('\n');
}
LineType::Removed | LineType::Header | LineType::Meta => {}
}
}
(source, line_mapping)
}
fn looks_like_script_content(source: &str) -> bool {
let script_patterns = [
"import ",
"export ",
"from '",
"from \"",
"const ",
"let ",
"var ",
"function ",
"=> {",
"=> (",
"class ",
"extends ",
"if (",
"else {",
"for (",
"while (",
"switch (",
"return ",
"async ",
"await ",
"interface ",
"type ",
": string",
": number",
": boolean",
"implements ",
"declare ",
"defineProps",
"defineEmits",
"defineExpose",
"defineSlots",
"ref(",
"reactive(",
"computed(",
"watch(",
"onMounted(",
"defineComponent",
];
for pattern in script_patterns {
if source.contains(pattern) {
return true;
}
}
false
}
fn build_combined_source_for_highlight_with_priming(
patch: &str,
ext: &str,
) -> (String, Vec<(usize, LineType)>, usize) {
let (base_source, line_mapping) = build_combined_source_for_highlight(patch);
if ext != "vue" && ext != "svelte" {
return (base_source, line_mapping, 0);
}
if base_source.contains("<script") {
return (base_source, line_mapping, 0);
}
if !looks_like_script_content(&base_source) {
return (base_source, line_mapping, 0);
}
let priming_prefix = "<script lang=\"ts\">\n";
let priming_suffix = "</script>\n";
let priming_lines = 1;
let primed_source = format!("{}{}{}", priming_prefix, base_source, priming_suffix);
(primed_source, line_mapping, priming_lines)
}
#[allow(clippy::too_many_arguments)]
fn build_lines_with_cst(
patch: &str,
filename: &str,
theme_name: &str,
line_highlights: &crate::syntax::LineHighlights,
line_mapping: &[(usize, LineType)],
priming_lines: usize,
interner: &mut Rodeo,
) -> Vec<CachedDiffLine> {
let mut diff_to_source: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
for (source_idx, (diff_idx, _)) in line_mapping.iter().enumerate() {
diff_to_source.insert(*diff_idx, source_idx + priming_lines);
}
let syntax = syntax_for_file(filename);
let theme = get_theme(theme_name);
let mut syntect_highlighter = syntax.map(|s| HighlightLines::new(s, theme));
patch
.lines()
.enumerate()
.map(|(i, line)| {
let (line_type, content) = classify_line(line);
let spans = match line_type {
LineType::Header => {
smallvec![InternedSpan {
content: interner.get_or_intern(line),
style: Style::default().fg(Color::Cyan),
}]
}
LineType::Meta => {
smallvec![InternedSpan {
content: interner.get_or_intern(line),
style: Style::default().fg(Color::Yellow),
}]
}
LineType::Added | LineType::Context => {
let source_line_index = diff_to_source.get(&i).copied();
let marker_style = match line_type {
LineType::Added => Style::default().fg(Color::Green),
_ => Style::default(),
};
let marker = match line_type {
LineType::Added => "+",
LineType::Context => " ",
_ => "",
};
let mut spans: SpanVec = smallvec![InternedSpan {
content: interner.get_or_intern(marker),
style: marker_style,
}];
let captures = source_line_index.and_then(|idx| line_highlights.get(idx));
let code_spans = apply_line_highlights(content, captures, interner);
spans.extend(code_spans);
spans
}
LineType::Removed => {
let marker = InternedSpan {
content: interner.get_or_intern("-"),
style: Style::default().fg(Color::Red),
};
let code_spans = highlight_or_fallback(
content,
&mut syntect_highlighter,
Color::Red,
interner,
);
let mut spans: SpanVec = smallvec![marker];
spans.extend(code_spans);
spans
}
};
CachedDiffLine { spans, line_type }
})
.collect()
}
fn build_lines_with_syntect(
patch: &str,
filename: &str,
theme_name: &str,
interner: &mut Rodeo,
) -> Vec<CachedDiffLine> {
let syntax = syntax_for_file(filename);
let theme = get_theme(theme_name);
let mut highlighter = syntax.map(|s| HighlightLines::new(s, theme));
if filename.ends_with(".vue") {
if let Some(ref mut hl) = highlighter {
let ss = two_face::syntax::extra_newlines();
let _ = hl.highlight_line("<script lang=\"ts\">\n", &ss);
}
}
patch
.lines()
.map(|line| {
let (line_type, content) = classify_line(line);
let spans = build_line_spans(line_type, line, content, &mut highlighter, interner);
CachedDiffLine { spans, line_type }
})
.collect()
}
pub fn render_cached_lines<'a>(
cache: &'a DiffCache,
range: std::ops::Range<usize>,
selected_line: usize,
comment_lines: &HashSet<usize>,
bg_color: bool,
multiline_range: Option<(usize, usize)>,
) -> Vec<Line<'a>> {
let len = cache.lines.len();
let safe_start = range.start.min(len);
let safe_end = range.end.min(len);
let safe_range = safe_start..safe_start.max(safe_end);
cache.lines[safe_range.clone()]
.iter()
.enumerate()
.map(|(rel_idx, cached)| {
let abs_idx = safe_range.start + rel_idx;
let is_selected = abs_idx == selected_line;
let is_in_multiline = multiline_range
.map(|(start, end)| abs_idx >= start && abs_idx <= end)
.unwrap_or(false);
let marker = if comment_lines.contains(&abs_idx) {
Some(Span::styled("● ", Style::default().fg(Color::Yellow)))
} else {
None
};
let base = cached
.spans
.iter()
.map(|s| Span::styled(cache.resolve(s.content), s.style));
let all_spans: Vec<Span<'_>> = marker.into_iter().chain(base).collect();
let line = Line::from(all_spans);
if is_in_multiline {
if is_selected {
line.style(
Style::default()
.bg(Color::Rgb(0, 40, 80))
.add_modifier(Modifier::REVERSED),
)
} else {
line.style(Style::default().bg(Color::Rgb(0, 40, 80)))
}
} else if is_selected {
line.style(Style::default().add_modifier(Modifier::REVERSED))
} else if bg_color {
if let Some(bg) = cached.line_type.bg_color() {
line.style(Style::default().bg(bg))
} else {
line
}
} else {
line
}
})
.collect()
}
pub fn render(frame: &mut Frame, app: &App) {
if app.cmt.comment_panel_open {
render_with_inline_comment(frame, app);
return;
}
let has_rally = app.has_background_rally();
let constraints = if has_rally {
vec![
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(3),
]
} else {
vec![Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
render_header(frame, app, chunks[0]);
render_diff_content(frame, app, chunks[1]);
if has_rally {
render_rally_status_bar(frame, chunks[2], app);
render_footer(frame, app, chunks[3]);
} else {
render_footer(frame, app, chunks[2]);
}
}
fn render_with_inline_comment(frame: &mut Frame, app: &App) {
let has_rally = app.has_background_rally();
let constraints = if has_rally {
vec![
Constraint::Length(3),
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(40),
Constraint::Length(3),
]
} else {
vec![
Constraint::Length(3),
Constraint::Percentage(55),
Constraint::Percentage(40),
Constraint::Length(3),
]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
render_header(frame, app, chunks[0]);
render_diff_content(frame, app, chunks[1]);
if has_rally {
render_rally_status_bar(frame, chunks[2], app);
render_inline_comments(frame, app, chunks[3]);
render_footer(frame, app, chunks[4]);
} else {
render_inline_comments(frame, app, chunks[2]);
render_footer(frame, app, chunks[3]);
}
}
pub(crate) fn render_header(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let header_text = app
.files()
.get(app.selected_file)
.map(|file| {
format!(
"{} (+{} -{})",
file.filename, file.additions, file.deletions
)
})
.unwrap_or_else(|| "No file selected".to_string());
let header =
Paragraph::new(header_text).block(Block::default().borders(Borders::ALL).title("Diff"));
frame.render_widget(header, area);
}
pub(crate) fn render_diff_content(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let visible_height = area.height.saturating_sub(2) as usize;
let (lines, scroll_row) = if let Some(ref cache) = app.diff_store.current {
let line_count = cache.lines.len();
let max_scroll = line_count.saturating_sub(visible_height);
let start = app.diff_scroll.scroll_offset.min(max_scroll);
let end = (start + visible_height + 10).min(line_count);
let multiline_range = app
.multiline_selection
.as_ref()
.map(|s| (s.start(), s.end()));
let rendered = render_cached_lines(
cache,
start..end,
app.diff_scroll.selected_line,
&app.cmt.file_comment_lines,
app.config.diff.bg_color,
multiline_range,
);
(rendered, 0u16)
} else {
let file = app.files().get(app.selected_file);
let theme_name = &app.config.diff.theme;
let rendered = match file {
Some(f) => match f.patch.as_ref() {
Some(patch) => parse_patch_to_lines(
patch,
app.diff_scroll.selected_line,
&f.filename,
theme_name,
&app.cmt.file_comment_lines,
app.config.diff.tab_width,
),
None => {
if app.is_lazy_diff_loading() {
vec![Line::from("Loading diff...")]
} else {
vec![Line::from("No diff available")]
}
}
},
None => vec![Line::from("No file selected")],
};
(rendered, app.diff_scroll.scroll_offset as u16)
};
let diff_block = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: false })
.scroll((scroll_row, 0));
frame.render_widget(diff_block, area);
if let Some(ref cache) = app.diff_store.current {
let total_lines = cache.lines.len();
let visible_height = area.height.saturating_sub(2) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
if max_scroll > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let clamped_position = app.diff_scroll.scroll_offset.min(max_scroll);
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(clamped_position);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
}
fn parse_patch_to_lines(
patch: &str,
selected_line: usize,
filename: &str,
theme_name: &str,
comment_lines: &HashSet<usize>,
tab_width: u8,
) -> Vec<Line<'static>> {
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
filename,
theme_name,
&mut parser_pool,
false,
tab_width,
);
cache
.lines
.iter()
.enumerate()
.map(|(i, cached)| {
let is_selected = i == selected_line;
let marker = if comment_lines.contains(&i) {
Some(Span::styled(
"● ".to_string(),
if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::Yellow)
},
))
} else {
None
};
let base = cached.spans.iter().map(|s| {
let text = cache.resolve(s.content).to_string();
let mut style = s.style;
if is_selected {
style = style.add_modifier(Modifier::REVERSED);
}
Span::styled(text, style)
});
let all_spans: Vec<Span<'static>> = marker.into_iter().chain(base).collect();
Line::from(all_spans)
})
.collect()
}
fn build_line_spans(
line_type: LineType,
original_line: &str,
content: &str,
highlighter: &mut Option<HighlightLines<'_>>,
interner: &mut Rodeo,
) -> SpanVec {
let fg_style = match line_type.fg_color() {
Some(c) => Style::default().fg(c),
None => Style::default(),
};
if let Some(marker_str) = line_type.marker() {
let marker = InternedSpan {
content: interner.get_or_intern(marker_str),
style: fg_style,
};
let fallback_color = line_type.fg_color().unwrap_or(Color::Reset);
let code_spans = highlight_or_fallback(content, highlighter, fallback_color, interner);
let mut spans: SpanVec = smallvec![marker];
spans.extend(code_spans);
spans
} else {
smallvec![InternedSpan {
content: interner.get_or_intern(original_line),
style: fg_style,
}]
}
}
fn highlight_or_fallback(
content: &str,
highlighter: &mut Option<HighlightLines<'_>>,
fallback_color: Color,
interner: &mut Rodeo,
) -> SpanVec {
match highlighter {
Some(h) => {
let spans = highlight_code_line(content, h, interner);
if spans.is_empty() {
smallvec![InternedSpan {
content: interner.get_or_intern(content),
style: Style::default(),
}]
} else {
spans
}
}
None => smallvec![InternedSpan {
content: interner.get_or_intern(content),
style: Style::default().fg(fallback_color),
}],
}
}
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let help_text = if app.multiline_selection.is_some() {
"j/k/↑↓: extend selection | c: comment | s: suggest | Esc: cancel".to_string()
} else if app.cmt.comment_panel_open {
"r: reply | Esc: close".to_string()
} else {
super::footer::footer_hint_back(&app.config.keybindings)
};
let footer_line = super::footer::build_footer_line(app, &help_text);
let footer = Paragraph::new(footer_line).block(super::footer::build_footer_block(app));
frame.render_widget(footer, area);
}
fn render_inline_comments(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let indices = app.get_comment_indices_at_current_line();
let mut lines: Vec<Line> = vec![];
if indices.is_empty() {
lines.push(Line::from(Span::styled(
"No comments. c: comment, s: suggestion",
Style::default().fg(Color::DarkGray),
)));
} else if let Some(ref comments) = app.cmt.review_comments {
let has_multiple = indices.len() > 1;
for (i, &idx) in indices.iter().enumerate() {
let Some(comment) = comments.get(idx) else {
continue;
};
if i > 0 {
lines.push(Line::from(Span::styled(
"───────────────────────────────────────",
Style::default().fg(Color::DarkGray),
)));
}
let indicator = if has_multiple {
if i == app.cmt.selected_inline_comment {
Span::styled("> ", Style::default().fg(Color::Yellow))
} else {
Span::styled(" ", Style::default())
}
} else {
Span::raw("")
};
lines.push(Line::from(vec![
indicator,
Span::styled(
format!("@{}", comment.user.login),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!(" (line {})", comment.line.unwrap_or(0)),
Style::default().fg(Color::DarkGray),
),
]));
for line in comment.body.lines() {
lines.push(Line::from(line.to_string()));
}
lines.push(Line::from("")); }
}
let title = "Comments (j/k/↑↓: scroll, c: comment, s: suggest, r: reply)";
let total_lines = lines.len();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(title);
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: true })
.scroll((app.cmt.comment_panel_scroll, 0));
frame.render_widget(paragraph, area);
if total_lines > 1 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let max_scroll = total_lines.saturating_sub(1);
let mut scrollbar_state =
ScrollbarState::new(max_scroll).position(app.cmt.comment_panel_scroll as usize);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
pub fn render_text_input(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(frame.area());
render_header(frame, app, chunks[0]);
match &app.input_mode {
Some(InputMode::Comment(ctx)) => {
render_comment_context(frame, app, chunks[1], ctx);
render_text_input_area(
frame,
app,
chunks[2],
"Comment",
"Type your comment here...",
);
}
Some(InputMode::Suggestion {
context,
original_code,
..
}) => {
render_suggestion_context(frame, app, chunks[1], context, original_code);
render_suggestion_input(frame, app, chunks[2]);
}
Some(InputMode::Reply {
reply_to_user,
reply_to_body,
..
}) => {
render_reply_context(frame, chunks[1], reply_to_user, reply_to_body);
render_text_input_area(frame, app, chunks[2], "Reply", "Type your reply here...");
}
Some(InputMode::IssueComment { .. }) => {
render_text_input_area(
frame,
app,
chunks[2],
"Issue Comment",
"Type your comment here...",
);
}
None => {}
}
}
fn render_comment_context(
frame: &mut Frame,
app: &App,
area: ratatui::layout::Rect,
ctx: &LineInputContext,
) {
let filename = app
.files()
.get(ctx.file_index)
.map(|f| f.filename.as_str())
.unwrap_or("Unknown file");
let lines = vec![
Line::from(vec![
Span::styled("File: ", Style::default().fg(Color::DarkGray)),
Span::styled(filename, Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled("Line: ", Style::default().fg(Color::DarkGray)),
Span::styled(
ctx.line_number.to_string(),
Style::default().fg(Color::Yellow),
),
]),
];
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Comment Location"),
)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_text_input_area(
frame: &mut Frame,
app: &App,
area: ratatui::layout::Rect,
label: &str,
placeholder: &str,
) {
let submit_key = app.input_text_area.submit_key_display();
let title = format!("{} ({}: submit, Esc: cancel)", label, submit_key);
app.input_text_area
.render_with_title(frame, area, &title, placeholder);
}
fn render_suggestion_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let submit_key = app.input_text_area.submit_key_display();
let title = format!("Suggested code ({}: submit, Esc: cancel)", submit_key);
if let Some(ref cache) = app.suggestion_highlight_cache {
app.input_text_area
.render_highlighted(frame, area, &title, "Edit the code...", &cache.lines);
} else {
app.input_text_area
.render_with_title(frame, area, &title, "Edit the code...");
}
}
pub fn highlight_text_for_suggestion(
content: &str,
filename: &str,
theme_name: &str,
) -> Vec<Line<'static>> {
use crate::syntax::{
apply_line_highlights, collect_line_highlights_with_injections, Highlighter, ParserPool,
};
use lasso::Rodeo;
if content.is_empty() {
return vec![Line::from("")];
}
let highlighter = Highlighter::for_file(filename, theme_name);
if let Some(style_cache) = highlighter.style_cache() {
let mut parser_pool = ParserPool::new();
if let Some(cst_result) = highlighter.parse_source(content, &mut parser_pool) {
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let line_highlights = collect_line_highlights_with_injections(
content,
&cst_result.tree,
cst_result.lang,
style_cache,
&mut parser_pool,
ext,
);
let mut interner = Rodeo::default();
return content
.split('\n')
.enumerate()
.map(|(idx, line)| {
let captures = line_highlights.get(idx);
let interned_spans = apply_line_highlights(line, captures, &mut interner);
let spans: Vec<Span<'static>> = interned_spans
.iter()
.map(|s| Span::styled(interner.resolve(&s.content).to_owned(), s.style))
.collect();
Line::from(spans)
})
.collect();
}
}
{
use crate::syntax::{get_theme, syntax_for_file};
use syntect::easy::HighlightLines;
if let Some(syntax) = syntax_for_file(filename) {
let theme = get_theme(theme_name);
let mut hl = HighlightLines::new(syntax, theme);
return content
.split('\n')
.map(|line| {
let spans = crate::syntax::highlight_code_line_legacy(line, &mut hl);
Line::from(spans)
})
.collect();
}
}
content
.split('\n')
.map(|l| Line::from(l.to_owned()))
.collect()
}
fn render_suggestion_context(
frame: &mut Frame,
app: &App,
area: ratatui::layout::Rect,
ctx: &LineInputContext,
original_code: &str,
) {
let filename = app
.files()
.get(ctx.file_index)
.map(|f| f.filename.as_str())
.unwrap_or("Unknown file");
let mut lines = vec![
Line::from(vec![
Span::styled("File: ", Style::default().fg(Color::DarkGray)),
Span::styled(filename, Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled("Line: ", Style::default().fg(Color::DarkGray)),
Span::styled(
ctx.line_number.to_string(),
Style::default().fg(Color::Yellow),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Original code:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
];
let theme_name = &app.config.diff.theme;
let highlighted_lines = highlight_text_for_suggestion(original_code, filename, theme_name);
for line in highlighted_lines {
lines.push(line);
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Edit the code below. It will be posted as a GitHub suggestion.",
Style::default().fg(Color::DarkGray),
)]));
let paragraph = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title("Suggestion"))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_reply_context(
frame: &mut Frame,
area: ratatui::layout::Rect,
reply_to_user: &str,
reply_to_body: &str,
) {
let mut lines = vec![
Line::from(vec![
Span::styled("Reply to ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("@{}", reply_to_user),
Style::default().fg(Color::Cyan),
),
]),
Line::from(""),
];
for line in reply_to_body.lines() {
lines.push(Line::from(Span::styled(
format!("> {}", line),
Style::default().fg(Color::DarkGray),
)));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Original Comment"),
)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_diff_filename_simple() {
assert_eq!(
extract_diff_filename("a/src/main.rs b/src/main.rs"),
"src/main.rs"
);
}
#[test]
fn test_extract_diff_filename_path_containing_b_slash() {
assert_eq!(
extract_diff_filename("a/src/ b/file.rs b/src/ b/file.rs"),
"src/ b/file.rs"
);
}
#[test]
fn test_extract_diff_filename_rename_fallback() {
assert_eq!(
extract_diff_filename("a/old_name.rs b/new_name.rs"),
"new_name.rs"
);
}
#[test]
fn test_extract_diff_filename_unknown() {
assert_eq!(extract_diff_filename("garbage"), "unknown");
}
#[test]
fn test_build_diff_cache_with_dracula_theme() {
use ratatui::style::Color;
let patch = r#"@@ -1,5 +1,6 @@
use std::collections::HashMap;
fn main() {
+ let x = 42;
println!("Hello");
}"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(patch, "test.rs", "Dracula", &mut parser_pool, false, 4);
let line1 = &cache.lines[1]; let use_span = line1
.spans
.iter()
.find(|s| cache.resolve(s.content) == "use");
assert!(use_span.is_some(), "Should have 'use' span in line 1");
let use_style = use_span.unwrap().style;
match use_style.fg {
Some(Color::Rgb(255, 121, 198)) => {}
Some(Color::Rgb(r, g, b)) => {
panic!(
"'use' has wrong color. Expected Rgb(255, 121, 198), got Rgb({}, {}, {})",
r, g, b
);
}
other => {
panic!("Expected Rgb color for 'use' keyword, got {:?}", other);
}
}
}
#[test]
fn test_removed_lines_have_syntax_highlighting_in_cst_path() {
use ratatui::style::Color;
let patch = r#"@@ -1,5 +1,5 @@
use std::collections::HashMap;
fn main() {
- let old_value = 100;
+ let new_value = 200;
}"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(patch, "test.rs", "Dracula", &mut parser_pool, false, 4);
let removed_line = &cache.lines[4];
assert_eq!(cache.resolve(removed_line.spans[0].content), "-");
let let_span = removed_line
.spans
.iter()
.find(|s| cache.resolve(s.content) == "let");
assert!(
let_span.is_some(),
"Removed line should have 'let' span with syntax highlighting"
);
let let_style = let_span.unwrap().style;
match let_style.fg {
Some(Color::Red) => {
panic!(
"'let' in removed line has plain red color. \
It should have syntax highlighting (e.g., Dracula cyan)."
);
}
Some(Color::Rgb(r, g, b)) => {
assert!(
!(r == 255 && g == 0 && b == 0),
"'let' should have syntax highlighting, not plain red"
);
}
None => {
panic!("'let' in removed line should have a foreground color");
}
_ => {
}
}
}
#[test]
fn test_removed_lines_typescript_highlighting() {
use ratatui::style::Color;
let patch = r#"@@ -1,3 +1,3 @@
-const oldValue = 42;
+const newValue = 100;
export default oldValue;"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(patch, "test.ts", "Dracula", &mut parser_pool, false, 4);
let removed_line = &cache.lines[1];
let const_span = removed_line
.spans
.iter()
.find(|s| cache.resolve(s.content) == "const");
assert!(
const_span.is_some(),
"Removed TypeScript line should have 'const' span with syntax highlighting"
);
let const_style = const_span.unwrap().style;
match const_style.fg {
Some(Color::Red) => {
panic!(
"'const' in removed TypeScript line has plain red color. \
It should have syntax highlighting."
);
}
Some(Color::Rgb(r, g, b)) => {
assert!(
!(r == 255 && g == 0 && b == 0),
"'const' should have syntax highlighting, not plain red. Got Rgb({}, {}, {})",
r,
g,
b
);
}
None => {
panic!("'const' in removed line should have a foreground color");
}
_ => {}
}
}
#[test]
fn test_vue_priming_for_script_only_diff() {
use ratatui::style::Color;
let patch = r#"@@ -5,3 +5,4 @@
const count = ref(0);
+const doubled = computed(() => count.value * 2);
function increment() {"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"Component.vue",
"Dracula",
&mut parser_pool,
false,
4,
);
let added_line = &cache.lines[2];
let const_span = added_line
.spans
.iter()
.find(|s| cache.resolve(s.content).contains("const"));
assert!(
const_span.is_some(),
"Vue script content should have 'const' highlighted via priming. Spans: {:?}",
added_line
.spans
.iter()
.map(|s| cache.resolve(s.content))
.collect::<Vec<_>>()
);
let const_style = const_span.unwrap().style;
match const_style.fg {
Some(Color::Green) => {
panic!(
"'const' in Vue diff has plain green color (added line default). \
Priming should enable TypeScript syntax highlighting."
);
}
Some(Color::Rgb(r, g, b)) => {
assert!(
!(r == 0 && g == 128 && b == 0),
"'const' should have syntax highlighting. Got Rgb({}, {}, {})",
r,
g,
b
);
}
None => {
panic!("'const' in Vue script should have a foreground color");
}
_ => {}
}
}
#[test]
fn test_vue_no_priming_when_script_tag_present() {
let patch = r#"@@ -1,5 +1,6 @@
<script lang="ts">
const count = ref(0);
+const doubled = computed(() => count.value * 2);
</script>"#;
let (source, line_mapping, priming_lines) =
build_combined_source_for_highlight_with_priming(patch, "vue");
assert_eq!(
priming_lines, 0,
"Should not add priming when <script> tag is present"
);
assert!(
source.contains("<script"),
"Source should contain original <script> tag"
);
assert_eq!(line_mapping.len(), 4, "Should have 4 mapped lines");
}
#[test]
fn test_vue_priming_adds_script_wrapper() {
let patch = r#"@@ -5,2 +5,3 @@
const count = ref(0);
+const doubled = computed(() => count.value * 2);"#;
let (source, line_mapping, priming_lines) =
build_combined_source_for_highlight_with_priming(patch, "vue");
assert_eq!(priming_lines, 1, "Should add 1 priming line for <script>");
assert!(
source.starts_with("<script lang=\"ts\">\n"),
"Source should start with priming <script> tag"
);
assert!(
source.ends_with("</script>\n"),
"Source should end with closing </script> tag"
);
assert_eq!(line_mapping.len(), 2, "Line mapping should be unchanged");
}
#[test]
fn test_vue_priming_when_template_tag_present_but_script_tag_missing() {
let patch = r#"@@ -7,4 +7,4 @@
import { ref } from 'vue'
const count = ref(0)
-const oldValue = computed(() => count.value)
+const newValue = computed(() => count.value)
@@ -40,5 +40,6 @@
<template>
<div class="foo">
+ <span>{{ newValue }}</span>
</div>
</template>"#;
let (source, line_mapping, priming_lines) =
build_combined_source_for_highlight_with_priming(patch, "vue");
assert_eq!(
priming_lines, 1,
"Should add priming when <script> start tag is missing, even if <template> exists"
);
assert!(
source.starts_with("<script lang=\"ts\">\n"),
"Source should start with priming <script> tag"
);
assert!(
source.contains("<template>"),
"Original template content should still be present"
);
assert_eq!(
line_mapping.len(),
8,
"Line mapping should preserve source lines"
);
}
#[test]
fn test_non_sfc_no_priming() {
let patch = r#"@@ -1,2 +1,3 @@
const x = 1;
+const y = 2;"#;
let (source, _, priming_lines) =
build_combined_source_for_highlight_with_priming(patch, "ts");
assert_eq!(priming_lines, 0, "TypeScript should not have priming");
assert!(
!source.contains("<script"),
"TypeScript source should not have <script> tag"
);
}
#[test]
fn test_build_combined_source_basic() {
let patch = r#"@@ -1,3 +1,3 @@
context line
-removed line
+added line"#;
let (source, mapping) = build_combined_source_for_highlight(patch);
assert!(!source.contains("removed line"));
assert!(source.contains("context line"));
assert!(source.contains("added line"));
assert_eq!(mapping.len(), 2);
assert_eq!(mapping[0], (1, LineType::Context)); assert_eq!(mapping[1], (3, LineType::Added)); }
#[test]
fn test_build_combined_source_multiple_hunks() {
let patch = r#"@@ -1,2 +1,2 @@
first
-old
+new
@@ -10,2 +10,2 @@
second
+another"#;
let (source, mapping) = build_combined_source_for_highlight(patch);
assert_eq!(mapping.len(), 4);
assert_eq!(mapping[0].0, 1); assert_eq!(mapping[1].0, 3); assert_eq!(mapping[2].0, 5); assert_eq!(mapping[3].0, 6);
assert!(!source.contains("@@"));
}
#[test]
fn test_build_combined_source_empty_patch() {
let (source, mapping) = build_combined_source_for_highlight("");
assert!(source.is_empty());
assert!(mapping.is_empty());
}
#[test]
fn plain_and_highlighted_cache_have_same_line_count() {
let patch = "diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"hello\");\n+ println!(\"world\");\n }";
let mut parser_pool = ParserPool::new();
let plain = build_plain_diff_cache(patch, 4);
let highlighted = build_diff_cache(
patch,
"foo.rs",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert_eq!(plain.lines.len(), highlighted.lines.len());
assert!(!plain.highlighted, "plain cache should not be highlighted");
assert!(
highlighted.highlighted,
"highlighted cache should be highlighted"
);
}
#[test]
fn render_cached_lines_inserts_comment_markers() {
let patch = "diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"hello\");\n+ println!(\"world\");\n }";
let mut comment_lines = HashSet::new();
comment_lines.insert(4); comment_lines.insert(6); let mut parser_pool = ParserPool::new();
let plain = build_plain_diff_cache(patch, 4);
let highlighted = build_diff_cache(
patch,
"foo.rs",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert_eq!(plain.lines.len(), highlighted.lines.len());
let plain_first = plain.resolve(plain.lines[4].spans[0].content);
assert!(
!plain_first.contains('●'),
"plain cache should not contain comment marker in spans"
);
let plain_rendered =
render_cached_lines(&plain, 0..plain.lines.len(), 0, &comment_lines, false, None);
let hl_rendered = render_cached_lines(
&highlighted,
0..highlighted.lines.len(),
0,
&comment_lines,
false,
None,
);
for &line_idx in &[4usize, 6] {
let plain_line_text: String = plain_rendered[line_idx]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let hl_line_text: String = hl_rendered[line_idx]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
plain_line_text.contains('●'),
"plain rendered line {} should have comment marker, got: {:?}",
line_idx,
plain_line_text,
);
assert!(
hl_line_text.contains('●'),
"highlighted rendered line {} should have comment marker, got: {:?}",
line_idx,
hl_line_text,
);
}
let no_comment_text: String = plain_rendered[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
!no_comment_text.contains('●'),
"non-comment line should not have marker"
);
}
#[test]
fn test_build_plain_diff_cache_line_styles() {
let patch = "diff --git a/foo.rs b/foo.rs\n@@ -1,3 +1,3 @@\n context\n+added\n-removed";
let cache = build_plain_diff_cache(patch, 4);
assert_eq!(cache.lines.len(), 5);
let meta = &cache.lines[0];
assert_eq!(meta.spans.len(), 1);
assert_eq!(meta.spans[0].style.fg, Some(Color::Yellow));
assert_eq!(meta.line_type, LineType::Meta);
let header = &cache.lines[1];
assert_eq!(header.spans.len(), 1);
assert_eq!(header.spans[0].style.fg, Some(Color::Cyan));
assert_eq!(header.line_type, LineType::Header);
let context = &cache.lines[2];
assert_eq!(context.spans.len(), 2);
assert_eq!(cache.resolve(context.spans[0].content), " ");
assert_eq!(context.spans[0].style.fg, None);
assert_eq!(context.line_type, LineType::Context);
let added = &cache.lines[3];
assert_eq!(added.spans.len(), 2);
assert_eq!(cache.resolve(added.spans[0].content), "+");
assert_eq!(added.spans[0].style.fg, Some(Color::Green));
assert_eq!(added.spans[1].style.fg, Some(Color::Green));
assert_eq!(added.line_type, LineType::Added);
let removed = &cache.lines[4];
assert_eq!(removed.spans.len(), 2);
assert_eq!(cache.resolve(removed.spans[0].content), "-");
assert_eq!(removed.spans[0].style.fg, Some(Color::Red));
assert_eq!(removed.spans[1].style.fg, Some(Color::Red));
assert_eq!(removed.line_type, LineType::Removed);
}
#[test]
fn test_parse_patch_to_lines_basic() {
let patch = r#"@@ -1,3 +1,3 @@
context line
-removed line
+added line"#;
let comment_lines = HashSet::new();
let lines =
parse_patch_to_lines(patch, 0, "test.rs", "base16-ocean.dark", &comment_lines, 4);
assert_eq!(lines.len(), 4);
}
#[test]
fn test_parse_patch_to_lines_with_comments_and_selection() {
let patch = r#"@@ -1,2 +1,3 @@
context
+added
-removed"#;
let mut comment_lines = HashSet::new();
comment_lines.insert(2);
let lines =
parse_patch_to_lines(patch, 2, "test.rs", "base16-ocean.dark", &comment_lines, 4);
assert_eq!(lines.len(), 4);
let selected_line = &lines[2];
let has_reversed = selected_line
.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::REVERSED));
assert!(has_reversed, "Selected line should have REVERSED modifier");
let comment_line_text: String = selected_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
comment_line_text.contains('●'),
"Comment line should have ● marker, got: {:?}",
comment_line_text
);
}
#[test]
fn test_parse_patch_to_lines_respects_tab_width() {
let patch = "@@ -1 +1 @@\n+\tindented";
let comment_lines = HashSet::new();
let lines =
parse_patch_to_lines(patch, 0, "test.rs", "base16-ocean.dark", &comment_lines, 2);
let line_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
line_text.contains(" indented"),
"tab_width=2 should expand tab to 2 spaces, got: {:?}",
line_text
);
let lines =
parse_patch_to_lines(patch, 0, "test.rs", "base16-ocean.dark", &comment_lines, 8);
let line_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
line_text.contains(" indented"),
"tab_width=8 should expand tab to 8 spaces, got: {:?}",
line_text
);
}
#[test]
fn test_expand_tabs_tab_width_one() {
let result = expand_tabs("\thello", 1);
assert_eq!(result, " hello");
}
#[test]
fn test_render_cached_lines_out_of_bounds_range() {
let patch = "@@ -1,2 +1,2 @@\n context\n+added\n-removed";
let cache = build_plain_diff_cache(patch, 4);
assert_eq!(cache.lines.len(), 4);
let result = render_cached_lines(&cache, 100..200, 0, &HashSet::new(), false, None);
assert!(
result.is_empty(),
"Out-of-bounds range should return empty Vec"
);
}
#[test]
fn test_render_cached_lines_empty_cache() {
let cache = build_plain_diff_cache("", 4);
assert!(cache.lines.is_empty());
let result = render_cached_lines(&cache, 0..10, 0, &HashSet::new(), false, None);
assert!(result.is_empty(), "Empty cache should return empty Vec");
}
}
#[cfg(test)]
mod priming_diff_tests {
use super::*;
use crate::syntax::ParserPool;
#[test]
fn test_build_diff_cache_primed_vue() {
let patch = r#"diff --git a/src/composables/useFoo.ts b/src/composables/useFoo.ts
@@ -1,5 +1,7 @@
+import { ref } from 'vue'
+
export const useFoo = () => {
- const old = 1
+ const count = ref(0)
return { count }
}
"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"src/components/Foo.vue",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let import_line = &cache.lines[2];
assert!(
import_line.spans.len() > 2,
"Import line should have syntax highlighting (more than just marker), got {} spans",
import_line.spans.len()
);
}
#[test]
fn test_build_diff_cache_primed_vue_mixed_content() {
let patch = r#"diff --git a/src/components/Foo.vue b/src/components/Foo.vue
@@ -7,14 +7,13 @@
+import { ref } from 'vue'
import SomeComponent from '@/components/SomeComponent.vue'
-import OldComponent from '@/components/OldComponent.vue'
const count = ref(0)
@@ -80,5 +79,5 @@
</div>
- <OldDialog />
+ <NewDialog @close="closeDialog" />
</div>
"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"src/components/Foo.vue",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let mut import_idx = None;
let mut const_idx = None;
for (i, line) in cache.lines.iter().enumerate() {
let text: String = line
.spans
.iter()
.map(|s| cache.resolve(s.content).to_string())
.collect();
if text.contains("import { ref }") {
import_idx = Some(i);
}
if text.contains("const count") {
const_idx = Some(i);
}
}
let import_line = &cache.lines[import_idx.expect("import line not found")];
assert!(
import_line.spans.len() > 2,
"Import line in mixed content should have syntax highlighting, got {} spans",
import_line.spans.len()
);
let const_line = &cache.lines[const_idx.expect("const line not found")];
assert!(
const_line.spans.len() > 2,
"Const line in mixed content should have syntax highlighting, got {} spans",
const_line.spans.len()
);
}
#[test]
fn test_build_diff_cache_primed_vue_with_visible_template_but_hidden_script_tag() {
let patch = r#"diff --git a/src/components/Foo.vue b/src/components/Foo.vue
@@ -7,4 +7,4 @@
import { ref } from 'vue'
const count = ref(0)
-const oldValue = computed(() => count.value)
+const newValue = computed(() => count.value)
@@ -40,5 +40,6 @@
<template>
<div class="foo">
+ <span>{{ newValue }}</span>
</div>
</template>
"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"src/components/Foo.vue",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let const_line = cache
.lines
.iter()
.find(|line| {
let text: String = line
.spans
.iter()
.map(|s| cache.resolve(s.content).to_string())
.collect();
text.contains("const newValue = computed")
})
.expect("const line not found");
assert!(
const_line.spans.len() > 2,
"Script line should have syntax highlighting even when <template> is visible"
);
}
#[test]
fn test_build_diff_cache_syntect_fallback() {
let patch = r#"@@ -1,3 +1,4 @@
name: test
+version: "1.0"
description: hello
author: world"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"data.yaml",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert_eq!(cache.lines.len(), 5);
assert!(
cache.highlighted,
"syntect path should set highlighted=true"
);
let added_line = &cache.lines[2]; assert_eq!(cache.resolve(added_line.spans[0].content), "+");
assert_eq!(
added_line.spans[0].style.fg,
Some(Color::Green),
"Added line marker should be Green"
);
}
#[test]
fn test_build_diff_cache_no_syntax_support() {
let patch = r#"@@ -1,2 +1,3 @@
existing line
+new line
-old line"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"file.unknown",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert_eq!(cache.lines.len(), 4);
let added_line = &cache.lines[2];
assert_eq!(cache.resolve(added_line.spans[0].content), "+");
let added_content_style = added_line.spans[1].style;
assert_eq!(
added_content_style.fg,
Some(Color::Green),
"No-syntax added content should use Green fallback"
);
let removed_line = &cache.lines[3];
assert_eq!(cache.resolve(removed_line.spans[0].content), "-");
let removed_content_style = removed_line.spans[1].style;
assert_eq!(
removed_content_style.fg,
Some(Color::Red),
"No-syntax removed content should use Red fallback"
);
}
#[test]
fn test_build_lines_with_syntect_vue_priming() {
let patch = r#"@@ -1,2 +1,3 @@
const x = 1;
+const y = 2;"#;
let mut interner = Rodeo::default();
let lines =
build_lines_with_syntect(patch, "Component.vue", "base16-ocean.dark", &mut interner);
assert_eq!(lines.len(), 3);
let header_text = interner.resolve(&lines[0].spans[0].content);
assert!(header_text.starts_with("@@"));
let context_marker = interner.resolve(&lines[1].spans[0].content);
assert_eq!(context_marker, " ");
let added_marker = interner.resolve(&lines[2].spans[0].content);
assert_eq!(added_marker, "+");
}
#[test]
fn test_looks_like_script_content_mixed() {
let source = "import { ref } from 'vue'\nconst count = ref(0)\n</div>\n<NewDialog @close=\"closeDialog\" />\n";
assert!(
looks_like_script_content(source),
"Mixed script+template content should be detected as script"
);
}
#[test]
fn test_looks_like_script_content_pure_template() {
let source = "<div>\n <span>hello</span>\n</div>\n";
assert!(
!looks_like_script_content(source),
"Pure template content should not be detected as script"
);
}
#[test]
fn test_build_diff_cache_markdown_rich_flag() {
let patch = r#"@@ -1,3 +1,4 @@
# Heading
+## New Heading
Some text
More text"#;
let mut parser_pool = ParserPool::new();
let cache_normal = build_diff_cache(
patch,
"README.md",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert!(!cache_normal.markdown_rich);
assert!(cache_normal.highlighted);
let cache_rich = build_diff_cache(
patch,
"README.md",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
assert!(cache_rich.markdown_rich);
assert!(cache_rich.highlighted);
}
#[test]
fn test_build_diff_cache_markdown_rich_non_markdown() {
let patch = r#"@@ -1,2 +1,3 @@
fn main() {}
+fn foo() {}
fn bar() {}"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"test.rs",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
assert!(cache.markdown_rich);
}
#[test]
fn test_build_diff_cache_markdown_rich_styles_differ() {
let patch = r#"@@ -1,2 +1,3 @@
# Heading
+## New Heading
Some text"#;
let mut parser_pool = ParserPool::new();
let cache_normal = build_diff_cache(
patch,
"README.md",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let cache_rich = build_diff_cache(
patch,
"README.md",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
assert!(cache_normal.highlighted);
assert!(cache_rich.highlighted);
assert!(!cache_normal.markdown_rich);
assert!(cache_rich.markdown_rich);
assert_eq!(cache_normal.lines.len(), cache_rich.lines.len());
}
#[test]
fn test_build_diff_cache_markdown_injection_path() {
let patch = r#"@@ -1,3 +1,5 @@
# Title
+
+```rust
+fn main() {}
+```"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"test.md",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert!(cache.highlighted);
assert!(!cache.lines.is_empty());
}
#[test]
fn test_build_diff_cache_markdown_extension_variant() {
let patch = r#"@@ -1,2 +1,3 @@
# Title
+Some **bold** text
End"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"doc.markdown",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert!(cache.highlighted);
assert!(!cache.lines.is_empty());
}
#[test]
fn test_build_plain_diff_cache_has_no_markdown_rich() {
let patch = r#"@@ -1,2 +1,2 @@
# Heading
-old text
+new text"#;
let cache = build_plain_diff_cache(patch, 4);
assert!(!cache.highlighted);
assert!(!cache.markdown_rich);
}
fn format_diff_cache_spans(cache: &DiffCache) -> String {
use ratatui::style::Modifier;
cache
.lines
.iter()
.enumerate()
.map(|(i, line)| {
let spans: Vec<String> = line
.spans
.iter()
.map(|span| {
let content = cache.resolve(span.content);
let mut style_parts = Vec::new();
if let Some(fg) = span.style.fg {
style_parts.push(format!("fg:{:?}", fg));
}
if span.style.add_modifier.contains(Modifier::BOLD) {
style_parts.push("BOLD".to_string());
}
if span.style.add_modifier.contains(Modifier::ITALIC) {
style_parts.push("ITALIC".to_string());
}
if span.style.add_modifier.contains(Modifier::UNDERLINED) {
style_parts.push("UNDERLINED".to_string());
}
let style_str = if style_parts.is_empty() {
"default".to_string()
} else {
style_parts.join(",")
};
format!("{:?} [{}]", content, style_str)
})
.collect();
format!("L{}: {}", i, spans.join(" | "))
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn test_snapshot_plain_diff_cache_markdown() {
use insta::assert_snapshot;
let patch = r#"@@ -1,3 +1,4 @@
# Heading
+## New Section
Some text
More text"#;
let cache = build_plain_diff_cache(patch, 4);
assert_snapshot!(format_diff_cache_spans(&cache), @r###"
L0: "@@ -1,3 +1,4 @@" [fg:Cyan]
L1: " " [default] | "# Heading" [default]
L2: "+" [fg:Green] | "## New Section" [fg:Green]
L3: " " [default] | "Some text" [default]
L4: " " [default] | "More text" [default]
"###);
}
#[test]
fn test_snapshot_highlighted_diff_cache_markdown() {
use insta::assert_snapshot;
let patch = r#"@@ -1,3 +1,4 @@
# Heading
+## New Section
Some text
More text"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"README.md",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert!(cache.highlighted);
assert_snapshot!(format_diff_cache_spans(&cache), @r###"
L0: "@@ -1,3 +1,4 @@" [fg:Cyan]
L1: " " [default] | "#" [fg:Gray] | " " [default] | "Heading" [fg:Rgb(143, 161, 179)]
L2: "+" [fg:Green] | "##" [fg:Gray] | " " [default] | "New Section" [fg:Rgb(143, 161, 179)]
L3: " " [default] | "Some text" [default]
L4: " " [default] | "More text" [default]
"###);
}
#[test]
fn test_snapshot_markdown_rich_vs_normal() {
use insta::assert_snapshot;
let patch = r#"@@ -1,2 +1,3 @@
# Title
+**bold text**
plain text"#;
let mut parser_pool = ParserPool::new();
let cache_normal = build_diff_cache(
patch,
"test.md",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let cache_rich = build_diff_cache(
patch,
"test.md",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
let snapshot_normal = format_diff_cache_spans(&cache_normal);
let snapshot_rich = format_diff_cache_spans(&cache_rich);
assert!(!snapshot_normal.is_empty());
assert!(!snapshot_rich.is_empty());
assert_snapshot!(snapshot_rich, @r#"
L0: "@@ -1,2 +1,3 @@" [fg:Cyan]
L1: " " [default] | "Title" [fg:Yellow,BOLD]
L2: "+" [fg:Green] | "bold text" [fg:LightRed,BOLD]
L3: " " [default] | "plain text" [default]
"#);
}
#[test]
fn test_build_table_separator() {
assert_eq!(build_table_separator("| --- | --- |"), "├ ─── ┼ ─── ┤");
assert_eq!(build_table_separator("| --- |"), "├ ─── ┤");
assert_eq!(
build_table_separator("| --- | --- | --- |"),
"├ ─── ┼ ─── ┼ ─── ┤"
);
assert_eq!(build_table_separator("|---|---|"), "├───┼───┤");
assert_eq!(build_table_separator("| --- | ---"), "├ ─── ┼ ───");
assert_eq!(build_table_separator("|---|---"), "├───┼───");
assert_eq!(build_table_separator("| ---"), "├ ───");
}
#[test]
fn test_markdown_table_transforms() {
use insta::assert_snapshot;
let patch = r#"@@ -1,5 +1,5 @@
+| Name | Value |
+| --- | --- |
+| foo | 123 |
+| bar | 456 |
plain text"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"test.md",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
assert_snapshot!(format_diff_cache_spans(&cache), @r#"
L0: "@@ -1,5 +1,5 @@" [fg:Cyan]
L1: "+" [fg:Green] | "│ Name │ Value │" [BOLD]
L2: "+" [fg:Green] | "├ ─── ┼ ─── ┤" [fg:DarkGray]
L3: "+" [fg:Green] | "│ foo │ 123 │" [default]
L4: "+" [fg:Green] | "│ bar │ 456 │" [default]
L5: " " [default] | "plain text" [default]
"#);
}
#[test]
fn test_markdown_list_markers_replaced() {
use insta::assert_snapshot;
let patch = r#"@@ -1,4 +1,4 @@
+- item one
+* item two
++ item three
plain text"#;
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"test.md",
"base16-ocean.dark",
&mut parser_pool,
true,
4,
);
assert_snapshot!(format_diff_cache_spans(&cache), @r#"
L0: "@@ -1,4 +1,4 @@" [fg:Cyan]
L1: "+" [fg:Green] | "・ " [default] | "item one" [default]
L2: "+" [fg:Green] | "・ " [default] | "item two" [default]
L3: "+" [fg:Green] | "・ " [default] | "item three" [default]
L4: " " [default] | "plain text" [default]
"#);
}
#[test]
fn test_expand_tabs() {
let no_tabs = "hello world";
let result = expand_tabs(no_tabs, 4);
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
assert_eq!(result, "hello world");
let with_tabs = "\thello\tworld";
let result = expand_tabs(with_tabs, 4);
assert!(matches!(result, std::borrow::Cow::Owned(_)));
assert_eq!(result, " hello world");
let result = expand_tabs("\tx", 2);
assert_eq!(result, " x");
let result = expand_tabs("", 4);
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn test_build_plain_diff_cache_with_tabs() {
let patch = "@@ -1 +1 @@\n+\tindented\n+\t\tdouble";
let cache = build_plain_diff_cache(patch, 4);
let line1_content: String = cache.lines[1]
.spans
.iter()
.map(|s| cache.resolve(s.content))
.collect();
assert!(
line1_content.contains(" indented"),
"Tab should be expanded to 4 spaces, got: {:?}",
line1_content
);
let line2_content: String = cache.lines[2]
.spans
.iter()
.map(|s| cache.resolve(s.content))
.collect();
assert!(
line2_content.contains(" double"),
"Double tab should be expanded to 8 spaces, got: {:?}",
line2_content
);
}
#[test]
fn test_build_diff_cache_with_tabs() {
let patch = "@@ -1 +1 @@\n+\tindented";
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch,
"test.rs",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
let line_content: String = cache.lines[1]
.spans
.iter()
.map(|s| cache.resolve(s.content))
.collect();
assert!(
line_content.contains(" indented"),
"Tab should be expanded to 4 spaces in highlighted cache, got: {:?}",
line_content
);
}
#[test]
fn test_patch_hash_uses_original() {
let patch_with_tabs = "@@ -1 +1 @@\n+\tindented";
let expected_hash = hash_string(patch_with_tabs);
let cache = build_plain_diff_cache(patch_with_tabs, 4);
assert_eq!(
cache.patch_hash, expected_hash,
"patch_hash should match hash of original patch"
);
let mut parser_pool = ParserPool::new();
let cache = build_diff_cache(
patch_with_tabs,
"test.rs",
"base16-ocean.dark",
&mut parser_pool,
false,
4,
);
assert_eq!(
cache.patch_hash, expected_hash,
"patch_hash in highlighted cache should match hash of original patch"
);
}
}