core-agent-ide/codex-rs/tui/src/diff_render.rs
Felipe Coury c3c75878e8
fix(tui): theme-aware diff backgrounds with fallback behavior (#13037)
## Problem

The TUI diff renderer uses hardcoded background palettes for
insert/delete lines that don't respect the user's chosen syntax theme.
When a theme defines `markup.inserted` / `markup.deleted` scope
backgrounds (the convention used by GitHub, Solarized, Monokai, and most
VS Code themes), those colors are ignored — the diff always renders with
the same green/red tints regardless of theme selection.

Separately, ANSI-16 terminals (and Windows Terminal sessions misreported
as ANSI-16) rendered diff backgrounds as full-saturation blocks that
obliterated syntax token colors, making highlighted diffs unreadable.

## Mental model

Diff backgrounds are resolved in three layers:

1. **Color level detection** — `diff_color_level_for_terminal()` maps
the raw `supports-color` probe + Windows Terminal heuristics to a
`DiffColorLevel` (TrueColor / Ansi256 / Ansi16). Windows Terminal gets
promoted from Ansi16 to TrueColor when `WT_SESSION` is present.

2. **Background resolution** — `resolve_diff_backgrounds()` queries the
active syntax theme for `markup.inserted`/`markup.deleted` (falling back
to `diff.inserted`/`diff.deleted`), then overlays those on top of the
hardcoded palette. For ANSI-256, theme RGB values are quantized to the
nearest xterm-256 index. For ANSI-16, backgrounds are `None`
(foreground-only).

3. **Style composition** — The resolved `ResolvedDiffBackgrounds` is
threaded through every call to `style_add`, `style_del`, `style_sign_*`,
and `style_line_bg_for`, which decide how to compose
foreground+background for each line kind and theme variant.

A new `RichDiffColorLevel` type (a subset of `DiffColorLevel` without
Ansi16) encodes the invariant "we have enough depth for tinted
backgrounds" at the type level, so background-producing functions have
exhaustive matches without unreachable arms.

## Non-goals

- No change to gutter (line number column) styling — gutter backgrounds
still use the hardcoded palette.
- No per-token scope background resolution — this is line-level
background only; syntax token colors come from the existing
`highlight_code_to_styled_spans` path.
- No dark/light theme auto-switching from scope backgrounds —
`DiffTheme` is still determined by querying the terminal's background
color.

## Tradeoffs

- **Theme trust vs. visual safety:** When a theme defines scope
backgrounds, we trust them unconditionally for rich color levels. A
badly authored theme could produce illegible combinations. The fallback
for `None` backgrounds (foreground-only) is intentionally conservative.
- **Quantization quality:** ANSI-256 quantization uses perceptual
distance across indices 16–255, skipping system colors. The result is
approximate — a subtle theme tint may land on a noticeably different
xterm index.
- **Single-query caching:** `resolve_diff_backgrounds` is called once
per `render_change` invocation (i.e., once per file in a diff). If the
theme changes mid-render (live preview), the next file picks up the new
backgrounds.

## Architecture

Files changed:

| File | Role |
|---|---|
| `tui/src/render/highlight.rs` | New: `DiffScopeBackgroundRgbs`,
`diff_scope_background_rgbs()`, scope extraction helpers |
| `tui/src/diff_render.rs` | New: `RichDiffColorLevel`,
`ResolvedDiffBackgrounds`, `resolve_diff_backgrounds*`,
`quantize_rgb_to_ansi256`, Windows Terminal promotion; modified: all
style helpers to accept/thread `ResolvedDiffBackgrounds` |

The scope-extraction code lives in `highlight.rs` because it uses
`syntect::highlighting::Highlighter` and the theme singleton. The
resolution and quantization logic lives in `diff_render.rs` because it
depends on diff-specific types (`DiffTheme`, `DiffColorLevel`, ratatui
`Color`).

## Observability

No runtime logging was added. The most useful debugging aid is the
`diff_color_level_for_terminal` function, which is pure and fully
unit-tested — to diagnose a color-depth mismatch, log its four inputs
(`StdoutColorLevel`, `TerminalName`, `WT_SESSION` presence,
`FORCE_COLOR` presence).

Scope resolution can be tested by loading a custom `.tmTheme` with known
`markup.inserted` / `markup.deleted` backgrounds and checking the diff
output in a truecolor terminal.

## Tests

- **Windows Terminal promotion:** 7 unit tests cover every branch of
`diff_color_level_for_terminal` (ANSI-16 promotion, `WT_SESSION`
unconditional promotion, `FORCE_COLOR` suppression, conservative
`Unknown` level).
- **ANSI-16 foreground-only:** Tests verify that `style_add`,
`style_del`, `style_sign_*`, `style_line_bg_for`, and `style_gutter_for`
all return `None` backgrounds on ANSI-16.
- **Scope resolution:** Tests verify `markup.*` preference over
`diff.*`, `None` when no scope matches, bundled theme resolution, and
custom `.tmTheme` round-trip.
- **Quantization:** Test verifies ANSI-256 quantization of a known RGB
triple.
- **Insta snapshots:** 2 new snapshot tests
(`ansi16_insert_delete_no_background`,
`theme_scope_background_resolution`) lock visual output.
2026-02-27 16:44:56 -07:00

2424 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Renders unified diffs with line numbers, gutter signs, and optional syntax
//! highlighting.
//!
//! Each `FileChange` variant (Add / Delete / Update) is rendered as a block of
//! diff lines, each prefixed by a right-aligned line number, a gutter sign
//! (`+` / `-` / ` `), and the content text. When a recognized file extension
//! is present, the content text is syntax-highlighted using
//! [`crate::render::highlight`].
//!
//! **Theme-aware styling:** diff backgrounds adapt to the terminal's
//! background lightness via [`DiffTheme`]. Dark terminals get muted tints
//! (`#212922` green, `#3C170F` red); light terminals get GitHub-style pastels
//! with distinct gutter backgrounds for contrast. The renderer uses fixed
//! palettes for truecolor / 256-color / 16-color terminals so add/delete lines
//! remain visually distinct even when quantizing to limited palettes.
//!
//! **Syntax-theme scope backgrounds:** when the active syntax theme defines
//! background colors for `markup.inserted` / `markup.deleted` (or fallback
//! `diff.inserted` / `diff.deleted`) scopes, those colors override the
//! hardcoded palette for rich color levels. ANSI-16 mode always uses
//! foreground-only styling regardless of theme scope backgrounds.
//!
//! **Highlighting strategy for `Update` diffs:** the renderer highlights each
//! hunk as a single concatenated block rather than line-by-line. This
//! preserves syntect's parser state across consecutive lines within a hunk
//! (important for multi-line strings, block comments, etc.). Cross-hunk state
//! is intentionally *not* preserved because hunks are visually separated and
//! re-synchronize at context boundaries anyway.
//!
//! **Wrapping:** long lines are hard-wrapped at the available column width.
//! Syntax-highlighted spans are split at character boundaries with styles
//! preserved across the split so that no color information is lost.
use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use unicode_width::UnicodeWidthChar;
/// Display width of a tab character in columns.
const TAB_WIDTH: usize = 4;
// -- Diff background palette --------------------------------------------------
//
// Dark-theme tints are subtle enough to avoid clashing with syntax colors.
// Light-theme values match GitHub's diff colors for familiarity. The gutter
// (line-number column) uses slightly more saturated variants on light
// backgrounds so the numbers remain readable against the pastel line background.
// Truecolor palette.
const DARK_TC_ADD_LINE_BG_RGB: (u8, u8, u8) = (33, 58, 43); // #213A2B
const DARK_TC_DEL_LINE_BG_RGB: (u8, u8, u8) = (74, 34, 29); // #4A221D
const LIGHT_TC_ADD_LINE_BG_RGB: (u8, u8, u8) = (218, 251, 225); // #dafbe1
const LIGHT_TC_DEL_LINE_BG_RGB: (u8, u8, u8) = (255, 235, 233); // #ffebe9
const LIGHT_TC_ADD_NUM_BG_RGB: (u8, u8, u8) = (172, 238, 187); // #aceebb
const LIGHT_TC_DEL_NUM_BG_RGB: (u8, u8, u8) = (255, 206, 203); // #ffcecb
const LIGHT_TC_GUTTER_FG_RGB: (u8, u8, u8) = (31, 35, 40); // #1f2328
// 256-color palette.
const DARK_256_ADD_LINE_BG_IDX: u8 = 22;
const DARK_256_DEL_LINE_BG_IDX: u8 = 52;
const LIGHT_256_ADD_LINE_BG_IDX: u8 = 194;
const LIGHT_256_DEL_LINE_BG_IDX: u8 = 224;
const LIGHT_256_ADD_NUM_BG_IDX: u8 = 157;
const LIGHT_256_DEL_NUM_BG_IDX: u8 = 217;
const LIGHT_256_GUTTER_FG_IDX: u8 = 236;
use crate::color::is_light;
use crate::color::perceptual_distance;
use crate::exec_command::relativize_to_home;
use crate::render::Insets;
use crate::render::highlight::DiffScopeBackgroundRgbs;
use crate::render::highlight::diff_scope_background_rgbs;
use crate::render::highlight::exceeds_highlight_limits;
use crate::render::highlight::highlight_code_to_styled_spans;
use crate::render::line_utils::prefix_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::XTERM_COLORS;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::indexed_color;
use crate::terminal_palette::rgb_color;
use crate::terminal_palette::stdout_color_level;
use codex_core::git_info::get_git_repo_root;
use codex_core::terminal::TerminalName;
use codex_core::terminal::terminal_info;
use codex_protocol::protocol::FileChange;
/// Classifies a diff line for gutter sign rendering and style selection.
///
/// `Insert` renders with a `+` sign and green text, `Delete` with `-` and red
/// text (plus dim overlay when syntax-highlighted), and `Context` with a space
/// and default styling.
#[derive(Clone, Copy)]
pub(crate) enum DiffLineType {
Insert,
Delete,
Context,
}
/// Controls which color palette the diff renderer uses for backgrounds and
/// gutter styling.
///
/// Determined once per `render_change` call via [`diff_theme`], which probes
/// the terminal's queried background color. When the background cannot be
/// determined (common in CI or piped output), `Dark` is used as the safe
/// default.
#[derive(Clone, Copy, Debug)]
enum DiffTheme {
Dark,
Light,
}
/// Palette depth the diff renderer will target.
///
/// This is the *renderer's own* notion of color depth, derived from — but not
/// identical to — the raw [`StdoutColorLevel`] reported by `supports-color`.
/// The indirection exists because some terminals (notably Windows Terminal)
/// advertise only ANSI-16 support while actually rendering truecolor sequences
/// correctly; [`diff_color_level_for_terminal`] promotes those cases so the
/// diff output uses the richer palette.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DiffColorLevel {
TrueColor,
Ansi256,
Ansi16,
}
/// Subset of [`DiffColorLevel`] that supports tinted backgrounds.
///
/// ANSI-16 terminals render backgrounds with bold, saturated palette entries
/// that overpower syntax tokens. This type encodes the invariant "we have
/// enough color depth for pastel tints" so that background-producing helpers
/// (`add_line_bg`, `del_line_bg`, `light_add_num_bg`, `light_del_num_bg`)
/// never need an unreachable ANSI-16 arm.
///
/// Construct via [`RichDiffColorLevel::from_diff_color_level`], which returns
/// `None` for ANSI-16 — callers branch on the `Option` and skip backgrounds
/// entirely when `None`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RichDiffColorLevel {
TrueColor,
Ansi256,
}
impl RichDiffColorLevel {
/// Extract a rich level, returning `None` for ANSI-16.
fn from_diff_color_level(level: DiffColorLevel) -> Option<Self> {
match level {
DiffColorLevel::TrueColor => Some(Self::TrueColor),
DiffColorLevel::Ansi256 => Some(Self::Ansi256),
DiffColorLevel::Ansi16 => None,
}
}
}
/// Pre-resolved background colors for insert and delete diff lines.
///
/// Computed once per `render_change` call from the active syntax theme's
/// scope backgrounds (via [`resolve_diff_backgrounds`]) and then threaded
/// through every style helper so individual lines never re-query the theme.
///
/// Both fields are `None` when the color level is ANSI-16 — callers fall
/// back to foreground-only styling in that case.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ResolvedDiffBackgrounds {
add: Option<Color>,
del: Option<Color>,
}
/// Precomputed render state for diff line styling.
///
/// This bundles the terminal-derived theme and color depth plus theme-resolved
/// diff backgrounds so callers rendering many lines can compute once per render
/// pass and reuse it across all line calls.
#[derive(Clone, Copy, Debug)]
pub(crate) struct DiffRenderStyleContext {
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
}
/// Resolve diff backgrounds for production rendering.
///
/// Queries the active syntax theme for `markup.inserted` / `markup.deleted`
/// (and `diff.*` fallbacks), then delegates to [`resolve_diff_backgrounds_for`].
fn resolve_diff_backgrounds(
theme: DiffTheme,
color_level: DiffColorLevel,
) -> ResolvedDiffBackgrounds {
resolve_diff_backgrounds_for(theme, color_level, diff_scope_background_rgbs())
}
/// Snapshot the current terminal environment into a reusable style context.
///
/// Queries `diff_theme`, `diff_color_level`, and the active syntax theme's
/// scope backgrounds once, bundling them into a [`DiffRenderStyleContext`]
/// that callers thread through every line-rendering call in a single pass.
///
/// Call this at the top of each render frame — not per line — so the diff
/// palette stays consistent within a frame even if the user swaps themes
/// mid-render (theme picker live preview).
pub(crate) fn current_diff_render_style_context() -> DiffRenderStyleContext {
let theme = diff_theme();
let color_level = diff_color_level();
let diff_backgrounds = resolve_diff_backgrounds(theme, color_level);
DiffRenderStyleContext {
theme,
color_level,
diff_backgrounds,
}
}
/// Core background-resolution logic, kept pure for testability.
///
/// Starts from the hardcoded fallback palette and then overrides with theme
/// scope backgrounds when both (a) the color level is rich enough and (b) the
/// theme defines a matching scope. This means the fallback palette is always
/// the baseline and theme scopes are strictly additive.
fn resolve_diff_backgrounds_for(
theme: DiffTheme,
color_level: DiffColorLevel,
scope_backgrounds: DiffScopeBackgroundRgbs,
) -> ResolvedDiffBackgrounds {
let mut resolved = fallback_diff_backgrounds(theme, color_level);
let Some(level) = RichDiffColorLevel::from_diff_color_level(color_level) else {
return resolved;
};
if let Some(rgb) = scope_backgrounds.inserted {
resolved.add = Some(color_from_rgb_for_level(rgb, level));
}
if let Some(rgb) = scope_backgrounds.deleted {
resolved.del = Some(color_from_rgb_for_level(rgb, level));
}
resolved
}
/// Hardcoded palette backgrounds, used when the syntax theme provides no
/// diff-specific scope backgrounds. Returns empty backgrounds for ANSI-16.
fn fallback_diff_backgrounds(
theme: DiffTheme,
color_level: DiffColorLevel,
) -> ResolvedDiffBackgrounds {
match RichDiffColorLevel::from_diff_color_level(color_level) {
Some(level) => ResolvedDiffBackgrounds {
add: Some(add_line_bg(theme, level)),
del: Some(del_line_bg(theme, level)),
},
None => ResolvedDiffBackgrounds::default(),
}
}
/// Convert an RGB triple to the appropriate ratatui `Color` for the given
/// rich color level — passthrough for truecolor, quantized for ANSI-256.
fn color_from_rgb_for_level(rgb: (u8, u8, u8), color_level: RichDiffColorLevel) -> Color {
match color_level {
RichDiffColorLevel::TrueColor => rgb_color(rgb),
RichDiffColorLevel::Ansi256 => quantize_rgb_to_ansi256(rgb),
}
}
/// Find the closest ANSI-256 color (indices 16255) to `target` using
/// perceptual distance.
///
/// Skips the first 16 entries (system colors) because their actual RGB
/// values depend on the user's terminal configuration and are unreliable
/// for distance calculations.
fn quantize_rgb_to_ansi256(target: (u8, u8, u8)) -> Color {
let best_index = XTERM_COLORS
.iter()
.enumerate()
.skip(16)
.min_by(|(_, a), (_, b)| {
perceptual_distance(**a, target).total_cmp(&perceptual_distance(**b, target))
})
.map(|(index, _)| index as u8);
match best_index {
Some(index) => indexed_color(index),
None => indexed_color(DARK_256_ADD_LINE_BG_IDX),
}
}
pub struct DiffSummary {
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl DiffSummary {
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: PathBuf) -> Self {
Self { changes, cwd }
}
}
impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize, None);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(self, &mut lines, width as usize, None);
lines.len() as u16
}
}
impl From<DiffSummary> for Box<dyn Renderable> {
fn from(val: DiffSummary) -> Self {
let mut rows: Vec<Box<dyn Renderable>> = vec![];
for (i, row) in collect_rows(&val.changes).into_iter().enumerate() {
if i > 0 {
rows.push(Box::new(RtLine::from("")));
}
let mut path = RtLine::from(display_path_for(&row.path, &val.cwd));
path.push_span(" ");
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(InsetRenderable::new(
Box::new(row.change) as Box<dyn Renderable>,
Insets::tlbr(0, 2, 0, 0),
)));
}
Box::new(ColumnRenderable::with(rows))
}
}
pub(crate) fn create_diff_summary(
changes: &HashMap<PathBuf, FileChange>,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
let rows = collect_rows(changes);
render_changes_block(rows, wrap_cols, cwd)
}
// Shared row for per-file presentation
#[derive(Clone)]
struct Row {
#[allow(dead_code)]
path: PathBuf,
move_path: Option<PathBuf>,
added: usize,
removed: usize,
change: FileChange,
}
fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
for (path, change) in changes.iter() {
let (added, removed) = match change {
FileChange::Add { content } => (content.lines().count(), 0),
FileChange::Delete { content } => (0, content.lines().count()),
FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff),
};
let move_path = match change {
FileChange::Update {
move_path: Some(new),
..
} => Some(new.clone()),
_ => None,
};
rows.push(Row {
path: path.clone(),
move_path,
added,
removed,
change: change.clone(),
});
}
rows.sort_by_key(|r| r.path.clone());
rows
}
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push(display_path_for(&row.path, cwd).into());
if let Some(move_path) = &row.move_path {
spans.push(format!("{}", display_path_for(move_path, cwd)).into());
}
spans
};
// Header
let total_added: usize = rows.iter().map(|r| r.added).sum();
let total_removed: usize = rows.iter().map(|r| r.removed).sum();
let file_count = rows.len();
let noun = if file_count == 1 { "file" } else { "files" };
let mut header_spans: Vec<RtSpan<'static>> = vec!["".dim()];
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
out.push(RtLine::from(header_spans));
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
if idx > 0 {
out.push("".into());
}
// File header line (skip when single-file header already shows the name)
let skip_file_header = file_count == 1;
if !skip_file_header {
let mut header: Vec<RtSpan<'static>> = Vec::new();
header.push("".dim());
header.extend(render_path(&r));
header.push(" ".into());
header.extend(render_line_count_summary(r.added, r.removed));
out.push(RtLine::from(header));
}
// For renames, use the destination extension for highlighting — the
// diff content reflects the new file, not the old one.
let lang_path = r.move_path.as_deref().unwrap_or(&r.path);
let lang = detect_lang_for_path(lang_path);
let mut lines = vec![];
render_change(&r.change, &mut lines, wrap_cols - 4, lang.as_deref());
out.extend(prefix_lines(lines, " ".into(), " ".into()));
}
out
}
/// Detect the programming language for a file path by its extension.
/// Returns the raw extension string for `normalize_lang` / `find_syntax`
/// to resolve downstream.
fn detect_lang_for_path(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?;
Some(ext.to_string())
}
fn render_change(
change: &FileChange,
out: &mut Vec<RtLine<'static>>,
width: usize,
lang: Option<&str>,
) {
let style_context = current_diff_render_style_context();
match change {
FileChange::Add { content } => {
// Pre-highlight the entire file content as a whole.
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
Some(spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
));
} else {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
));
}
}
}
FileChange::Delete { content } => {
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
Some(spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
));
} else {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
));
}
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut max_line_number = 0;
let mut total_diff_bytes: usize = 0;
let mut total_diff_lines: usize = 0;
for h in patch.hunks() {
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
let text = match l {
diffy::Line::Insert(t)
| diffy::Line::Delete(t)
| diffy::Line::Context(t) => t,
};
total_diff_bytes += text.len();
total_diff_lines += 1;
match l {
diffy::Line::Insert(_) => {
max_line_number = max_line_number.max(new_ln);
new_ln += 1;
}
diffy::Line::Delete(_) => {
max_line_number = max_line_number.max(old_ln);
old_ln += 1;
}
diffy::Line::Context(_) => {
max_line_number = max_line_number.max(new_ln);
old_ln += 1;
new_ln += 1;
}
}
}
}
// Skip per-line syntax highlighting when the patch is too
// large — avoids thousands of parser initializations that
// would stall rendering on big diffs.
let diff_lang = if exceeds_highlight_limits(total_diff_bytes, total_diff_lines) {
None
} else {
lang
};
let line_number_width = line_number_width(max_line_number);
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
let spacer_span = RtSpan::styled(
spacer,
style_gutter_for(
DiffLineType::Context,
style_context.theme,
style_context.color_level,
),
);
out.push(RtLine::from(vec![spacer_span, "".dim()]));
}
is_first_hunk = false;
// Highlight each hunk as a single block so syntect parser
// state is preserved across consecutive lines.
let hunk_syntax_lines = diff_lang.and_then(|language| {
let hunk_text: String = h
.lines()
.iter()
.map(|line| match line {
diffy::Line::Insert(text)
| diffy::Line::Delete(text)
| diffy::Line::Context(text) => *text,
})
.collect();
let syntax_lines = highlight_code_to_styled_spans(&hunk_text, language)?;
(syntax_lines.len() == h.lines().len()).then_some(syntax_lines)
});
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for (line_idx, l) in h.lines().iter().enumerate() {
let syntax_spans = hunk_syntax_lines
.as_ref()
.and_then(|syntax_lines| syntax_lines.get(line_idx));
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
}
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
}
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
}
old_ln += 1;
new_ln += 1;
}
}
}
}
}
}
}
}
/// Format a path for display relative to the current working directory when
/// possible, keeping output stable in jj/no-`.git` workspaces (e.g. image
/// tool calls should show `example.png` instead of an absolute path).
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
if path.is_relative() {
return path.display().to_string();
}
if let Ok(stripped) = path.strip_prefix(cwd) {
return stripped.display().to_string();
}
let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) {
(Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo,
_ => false,
};
let chosen = if path_in_same_repo {
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
} else {
relativize_to_home(path)
.map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()]))
.unwrap_or_else(|| path.to_path_buf())
};
chosen.display().to_string()
}
pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(Hunk::lines)
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
diffy::Line::Context(_) => (a, d),
})
} else {
// For unparsable diffs, return 0 for both counts.
(0, 0)
}
}
/// Render a single plain-text (non-syntax-highlighted) diff line, wrapped to
/// `width` columns, using a pre-computed [`DiffRenderStyleContext`].
///
/// This is the convenience entry point used by the theme picker preview and
/// any caller that does not have syntax spans. Delegates to the inner
/// rendering core with `syntax_spans = None`.
pub(crate) fn push_wrapped_diff_line_with_style_context(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
style_context: DiffRenderStyleContext,
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner_with_theme_and_color_level(
line_number,
kind,
text,
width,
line_number_width,
None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
)
}
/// Render a syntax-highlighted diff line, wrapped to `width` columns, using
/// a pre-computed [`DiffRenderStyleContext`].
///
/// Like [`push_wrapped_diff_line_with_style_context`] but overlays
/// `syntax_spans` (from [`highlight_code_to_styled_spans`]) onto the diff
/// coloring. Delete lines receive a `DIM` modifier so syntax colors do not
/// overpower the removal cue.
pub(crate) fn push_wrapped_diff_line_with_syntax_and_style_context(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
syntax_spans: &[RtSpan<'static>],
style_context: DiffRenderStyleContext,
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner_with_theme_and_color_level(
line_number,
kind,
text,
width,
line_number_width,
Some(syntax_spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
)
}
#[allow(clippy::too_many_arguments)]
fn push_wrapped_diff_line_inner_with_theme_and_color_level(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
syntax_spans: Option<&[RtSpan<'static>]>,
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Vec<RtLine<'static>> {
let ln_str = line_number.to_string();
// Reserve a fixed number of spaces (equal to the widest line number plus a
// trailing spacer) so the sign column stays aligned across the diff block.
let gutter_width = line_number_width.max(1);
let prefix_cols = gutter_width + 1;
let (sign_char, sign_style, content_style) = match kind {
DiffLineType::Insert => (
'+',
style_sign_add(theme, color_level, diff_backgrounds),
style_add(theme, color_level, diff_backgrounds),
),
DiffLineType::Delete => (
'-',
style_sign_del(theme, color_level, diff_backgrounds),
style_del(theme, color_level, diff_backgrounds),
),
DiffLineType::Context => (' ', style_context(), style_context()),
};
let line_bg = style_line_bg_for(kind, diff_backgrounds);
let gutter_style = style_gutter_for(kind, theme, color_level);
// When we have syntax spans, compose them with the diff style for a richer
// view. The sign character keeps the diff color; content gets syntax colors
// with an overlay modifier for delete lines (dim).
if let Some(syn_spans) = syntax_spans {
let gutter = format!("{ln_str:>gutter_width$} ");
let sign = format!("{sign_char}");
let styled: Vec<RtSpan<'static>> = syn_spans
.iter()
.map(|sp| {
let style = if matches!(kind, DiffLineType::Delete) {
sp.style.add_modifier(Modifier::DIM)
} else {
sp.style
};
RtSpan::styled(sp.content.clone().into_owned(), style)
})
.collect();
// Determine how many display columns remain for content after the
// gutter and sign character.
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
// Wrap the styled content spans to fit within the available columns.
let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols);
let mut lines: Vec<RtLine<'static>> = Vec::new();
for (i, chunk) in wrapped_chunks.into_iter().enumerate() {
let mut row_spans: Vec<RtSpan<'static>> = Vec::new();
if i == 0 {
// First line: gutter + sign + content
row_spans.push(RtSpan::styled(gutter.clone(), gutter_style));
row_spans.push(RtSpan::styled(sign.clone(), sign_style));
} else {
// Continuation: empty gutter + two-space indent (matches
// the plain-text wrapping continuation style).
let cont_gutter = format!("{:gutter_width$} ", "");
row_spans.push(RtSpan::styled(cont_gutter, gutter_style));
}
row_spans.extend(chunk);
lines.push(RtLine::from(row_spans).style(line_bg));
}
return lines;
}
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let styled = vec![RtSpan::styled(text.to_string(), content_style)];
let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols);
let mut lines: Vec<RtLine<'static>> = Vec::new();
for (i, chunk) in wrapped_chunks.into_iter().enumerate() {
let mut row_spans: Vec<RtSpan<'static>> = Vec::new();
if i == 0 {
let gutter = format!("{ln_str:>gutter_width$} ");
let sign = format!("{sign_char}");
row_spans.push(RtSpan::styled(gutter, gutter_style));
row_spans.push(RtSpan::styled(sign, sign_style));
} else {
let cont_gutter = format!("{:gutter_width$} ", "");
row_spans.push(RtSpan::styled(cont_gutter, gutter_style));
}
row_spans.extend(chunk);
lines.push(RtLine::from(row_spans).style(line_bg));
}
lines
}
/// Split styled spans into chunks that fit within `max_cols` display columns.
///
/// Returns one `Vec<RtSpan>` per output line. Styles are preserved across
/// split boundaries so that wrapping never loses syntax coloring.
///
/// The algorithm walks characters using their Unicode display width (with tabs
/// expanded to [`TAB_WIDTH`] columns). When a character would overflow the
/// current line, the accumulated text is flushed and a new line begins. A
/// single character wider than the remaining space forces a line break *before*
/// the character so that progress is always made (avoiding infinite loops on
/// CJK characters or tabs at the end of a line).
fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSpan<'static>>> {
let mut result: Vec<Vec<RtSpan<'static>>> = Vec::new();
let mut current_line: Vec<RtSpan<'static>> = Vec::new();
let mut col: usize = 0;
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut remaining = text;
while !remaining.is_empty() {
// Accumulate characters until we fill the line.
let mut byte_end = 0;
let mut chars_col = 0;
for ch in remaining.chars() {
// Tabs have no Unicode width; treat them as TAB_WIDTH columns.
let w = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 });
if col + chars_col + w > max_cols {
// Adding this character would exceed the line width.
// Break here; if this is the first character in `remaining`
// we will flush/start a new line in the `byte_end == 0`
// branch below before consuming it.
break;
}
byte_end += ch.len_utf8();
chars_col += w;
}
if byte_end == 0 {
// Single character wider than remaining space — force onto a
// new line so we make progress.
if !current_line.is_empty() {
result.push(std::mem::take(&mut current_line));
}
// Take at least one character to avoid an infinite loop.
let Some(ch) = remaining.chars().next() else {
break;
};
let ch_len = ch.len_utf8();
current_line.push(RtSpan::styled(remaining[..ch_len].to_string(), style));
// Use fallback width 1 (not 0) so this branch always advances
// even if `ch` has unknown/zero display width.
col = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 1 });
remaining = &remaining[ch_len..];
continue;
}
let (chunk, rest) = remaining.split_at(byte_end);
current_line.push(RtSpan::styled(chunk.to_string(), style));
col += chars_col;
remaining = rest;
// If we exactly filled or exceeded the line, start a new one.
// Do not gate on !remaining.is_empty() — the next span in the
// outer loop may still have content that must start on a fresh line.
if col >= max_cols {
result.push(std::mem::take(&mut current_line));
col = 0;
}
}
}
// Push the last line (always at least one, even if empty).
if !current_line.is_empty() || result.is_empty() {
result.push(current_line);
}
result
}
pub(crate) fn line_number_width(max_line_number: usize) -> usize {
if max_line_number == 0 {
1
} else {
max_line_number.to_string().len()
}
}
/// Testable helper: picks `DiffTheme` from an explicit background sample.
fn diff_theme_for_bg(bg: Option<(u8, u8, u8)>) -> DiffTheme {
if let Some(rgb) = bg
&& is_light(rgb)
{
return DiffTheme::Light;
}
DiffTheme::Dark
}
/// Probe the terminal's background and return the appropriate diff palette.
fn diff_theme() -> DiffTheme {
diff_theme_for_bg(default_bg())
}
/// Return the [`DiffColorLevel`] for the current terminal session.
///
/// This is the environment-reading adapter: it samples runtime signals
/// (`supports-color` level, terminal name, `WT_SESSION`, and `FORCE_COLOR`)
/// and forwards them to [`diff_color_level_for_terminal`].
///
/// Keeping env reads in this thin wrapper lets
/// [`diff_color_level_for_terminal`] stay pure and easy to unit test.
fn diff_color_level() -> DiffColorLevel {
diff_color_level_for_terminal(
stdout_color_level(),
terminal_info().name,
std::env::var_os("WT_SESSION").is_some(),
has_force_color_override(),
)
}
/// Returns whether `FORCE_COLOR` is explicitly set.
fn has_force_color_override() -> bool {
std::env::var_os("FORCE_COLOR").is_some()
}
/// Map a raw [`StdoutColorLevel`] to a [`DiffColorLevel`] using
/// Windows Terminal-specific truecolor promotion rules.
///
/// This helper is intentionally pure (no env access) so tests can validate
/// the policy table by passing explicit inputs.
///
/// Windows Terminal fully supports 24-bit color but the `supports-color`
/// crate often reports only ANSI-16 there because no `COLORTERM` variable
/// is set. We detect Windows Terminal two ways — via `terminal_name`
/// (parsed from `WT_SESSION` / `TERM_PROGRAM` by `terminal_info()`) and
/// via the raw `has_wt_session` flag.
///
/// These signals are intentionally not equivalent: `terminal_name` is a
/// derived classification with `TERM_PROGRAM` precedence, so `WT_SESSION`
/// can be present while `terminal_name` is not `WindowsTerminal`.
///
/// When `WT_SESSION` is present, we promote to truecolor unconditionally
/// unless `FORCE_COLOR` is set. This keeps Windows Terminal rendering rich
/// by default while preserving explicit `FORCE_COLOR` user intent.
///
/// Outside `WT_SESSION`, only ANSI-16 is promoted for identified
/// `WindowsTerminal` sessions; `Unknown` stays conservative.
fn diff_color_level_for_terminal(
stdout_level: StdoutColorLevel,
terminal_name: TerminalName,
has_wt_session: bool,
has_force_color_override: bool,
) -> DiffColorLevel {
if has_wt_session && !has_force_color_override {
return DiffColorLevel::TrueColor;
}
let base = match stdout_level {
StdoutColorLevel::TrueColor => DiffColorLevel::TrueColor,
StdoutColorLevel::Ansi256 => DiffColorLevel::Ansi256,
StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => DiffColorLevel::Ansi16,
};
// Outside `WT_SESSION`, keep the existing Windows Terminal promotion for
// ANSI-16 sessions that likely support truecolor.
if stdout_level == StdoutColorLevel::Ansi16
&& terminal_name == TerminalName::WindowsTerminal
&& !has_force_color_override
{
DiffColorLevel::TrueColor
} else {
base
}
}
// -- Style helpers ------------------------------------------------------------
//
// Each diff line is composed of three visual regions, styled independently:
//
// ┌──────────┬──────┬──────────────────────────────────────────┐
// │ gutter │ sign │ content │
// │ (line #) │ +/- │ (plain or syntax-highlighted text) │
// └──────────┴──────┴──────────────────────────────────────────┘
//
// A fourth, full-width layer — `line_bg` — is applied via `RtLine::style()`
// so that the background tint extends from the leftmost column to the right
// edge of the terminal, including any padding beyond the content.
//
// On dark terminals, the sign and content share one style (colored fg + tinted
// bg), and the gutter is simply dimmed. On light terminals, sign and content
// are split: the sign gets only a colored foreground (no bg, so the line bg
// shows through), while content relies on the line bg alone; the gutter gets
// an opaque, more-saturated background so line numbers stay readable against
// the pastel line tint.
/// Full-width background applied to the `RtLine` itself (not individual spans).
/// Context lines intentionally leave the background unset so the terminal
/// default shows through.
fn style_line_bg_for(kind: DiffLineType, diff_backgrounds: ResolvedDiffBackgrounds) -> Style {
match kind {
DiffLineType::Insert => diff_backgrounds
.add
.map_or_else(Style::default, |bg| Style::default().bg(bg)),
DiffLineType::Delete => diff_backgrounds
.del
.map_or_else(Style::default, |bg| Style::default().bg(bg)),
DiffLineType::Context => Style::default(),
}
}
fn style_context() -> Style {
Style::default()
}
fn add_line_bg(theme: DiffTheme, color_level: RichDiffColorLevel) -> Color {
match (theme, color_level) {
(DiffTheme::Dark, RichDiffColorLevel::TrueColor) => rgb_color(DARK_TC_ADD_LINE_BG_RGB),
(DiffTheme::Dark, RichDiffColorLevel::Ansi256) => indexed_color(DARK_256_ADD_LINE_BG_IDX),
(DiffTheme::Light, RichDiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_ADD_LINE_BG_RGB),
(DiffTheme::Light, RichDiffColorLevel::Ansi256) => indexed_color(LIGHT_256_ADD_LINE_BG_IDX),
}
}
fn del_line_bg(theme: DiffTheme, color_level: RichDiffColorLevel) -> Color {
match (theme, color_level) {
(DiffTheme::Dark, RichDiffColorLevel::TrueColor) => rgb_color(DARK_TC_DEL_LINE_BG_RGB),
(DiffTheme::Dark, RichDiffColorLevel::Ansi256) => indexed_color(DARK_256_DEL_LINE_BG_IDX),
(DiffTheme::Light, RichDiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_DEL_LINE_BG_RGB),
(DiffTheme::Light, RichDiffColorLevel::Ansi256) => indexed_color(LIGHT_256_DEL_LINE_BG_IDX),
}
}
fn light_gutter_fg(color_level: DiffColorLevel) -> Color {
match color_level {
DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_GUTTER_FG_RGB),
DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_GUTTER_FG_IDX),
DiffColorLevel::Ansi16 => Color::Black,
}
}
fn light_add_num_bg(color_level: RichDiffColorLevel) -> Color {
match color_level {
RichDiffColorLevel::TrueColor => rgb_color(LIGHT_TC_ADD_NUM_BG_RGB),
RichDiffColorLevel::Ansi256 => indexed_color(LIGHT_256_ADD_NUM_BG_IDX),
}
}
fn light_del_num_bg(color_level: RichDiffColorLevel) -> Color {
match color_level {
RichDiffColorLevel::TrueColor => rgb_color(LIGHT_TC_DEL_NUM_BG_RGB),
RichDiffColorLevel::Ansi256 => indexed_color(LIGHT_256_DEL_NUM_BG_IDX),
}
}
/// Line-number gutter style. On light backgrounds the gutter has an opaque
/// tinted background so numbers contrast against the pastel line fill. On
/// dark backgrounds a simple `DIM` modifier is sufficient.
fn style_gutter_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style {
match (
theme,
kind,
RichDiffColorLevel::from_diff_color_level(color_level),
) {
(DiffTheme::Light, DiffLineType::Insert, None) => {
Style::default().fg(light_gutter_fg(color_level))
}
(DiffTheme::Light, DiffLineType::Delete, None) => {
Style::default().fg(light_gutter_fg(color_level))
}
(DiffTheme::Light, DiffLineType::Insert, Some(level)) => Style::default()
.fg(light_gutter_fg(color_level))
.bg(light_add_num_bg(level)),
(DiffTheme::Light, DiffLineType::Delete, Some(level)) => Style::default()
.fg(light_gutter_fg(color_level))
.bg(light_del_num_bg(level)),
_ => style_gutter_dim(),
}
}
/// Sign character (`+`) for insert lines. On dark terminals it inherits the
/// full content style (green fg + tinted bg). On light terminals it uses only
/// a green foreground and lets the line-level bg show through.
fn style_sign_add(
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Style {
match theme {
DiffTheme::Light => Style::default().fg(Color::Green),
DiffTheme::Dark => style_add(theme, color_level, diff_backgrounds),
}
}
/// Sign character (`-`) for delete lines. Mirror of [`style_sign_add`].
fn style_sign_del(
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Style {
match theme {
DiffTheme::Light => Style::default().fg(Color::Red),
DiffTheme::Dark => style_del(theme, color_level, diff_backgrounds),
}
}
/// Content style for insert lines (plain, non-syntax-highlighted text).
///
/// Foreground-only on ANSI-16. On rich levels, uses the pre-resolved
/// background from `diff_backgrounds` — which is the theme scope color when
/// available, or the hardcoded palette otherwise. Dark themes add an
/// explicit green foreground for readability over the tinted background;
/// light themes rely on the default (dark) foreground against the pastel.
///
/// When no background is resolved (e.g. a theme that defines no diff
/// scopes and the fallback palette is somehow empty), the style degrades
/// to foreground-only so the line is still legible.
fn style_add(
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Style {
match (theme, color_level, diff_backgrounds.add) {
(_, DiffColorLevel::Ansi16, _) => Style::default().fg(Color::Green),
(DiffTheme::Light, DiffColorLevel::TrueColor, Some(bg))
| (DiffTheme::Light, DiffColorLevel::Ansi256, Some(bg)) => Style::default().bg(bg),
(DiffTheme::Dark, DiffColorLevel::TrueColor, Some(bg))
| (DiffTheme::Dark, DiffColorLevel::Ansi256, Some(bg)) => {
Style::default().fg(Color::Green).bg(bg)
}
(DiffTheme::Light, DiffColorLevel::TrueColor, None)
| (DiffTheme::Light, DiffColorLevel::Ansi256, None) => Style::default(),
(DiffTheme::Dark, DiffColorLevel::TrueColor, None)
| (DiffTheme::Dark, DiffColorLevel::Ansi256, None) => Style::default().fg(Color::Green),
}
}
/// Content style for delete lines (plain, non-syntax-highlighted text).
///
/// Mirror of [`style_add`] with red foreground and the delete-side
/// resolved background.
fn style_del(
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Style {
match (theme, color_level, diff_backgrounds.del) {
(_, DiffColorLevel::Ansi16, _) => Style::default().fg(Color::Red),
(DiffTheme::Light, DiffColorLevel::TrueColor, Some(bg))
| (DiffTheme::Light, DiffColorLevel::Ansi256, Some(bg)) => Style::default().bg(bg),
(DiffTheme::Dark, DiffColorLevel::TrueColor, Some(bg))
| (DiffTheme::Dark, DiffColorLevel::Ansi256, Some(bg)) => {
Style::default().fg(Color::Red).bg(bg)
}
(DiffTheme::Light, DiffColorLevel::TrueColor, None)
| (DiffTheme::Light, DiffColorLevel::Ansi256, None) => Style::default(),
(DiffTheme::Dark, DiffColorLevel::TrueColor, None)
| (DiffTheme::Dark, DiffColorLevel::Ansi256, None) => Style::default().fg(Color::Red),
}
}
fn style_gutter_dim() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
#[test]
fn ansi16_add_style_uses_foreground_only() {
let style = style_add(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
);
assert_eq!(style.fg, Some(Color::Green));
assert_eq!(style.bg, None);
}
#[test]
fn ansi16_del_style_uses_foreground_only() {
let style = style_del(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
);
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.bg, None);
}
#[test]
fn ansi16_sign_styles_use_foreground_only() {
let add_sign = style_sign_add(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
);
assert_eq!(add_sign.fg, Some(Color::Green));
assert_eq!(add_sign.bg, None);
let del_sign = style_sign_del(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
);
assert_eq!(del_sign.fg, Some(Color::Red));
assert_eq!(del_sign.bg, None);
}
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
create_diff_summary(changes, &PathBuf::from("/"), 80)
}
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
terminal
.draw(|f| {
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render_ref(f.area(), f.buffer_mut())
})
.expect("draw");
assert_snapshot!(name, terminal.backend());
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|ch| ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 }))
.sum()
}
fn line_display_width(line: &RtLine<'static>) -> usize {
line.spans
.iter()
.map(|span| display_width(span.content.as_ref()))
.sum()
}
fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) {
// Convert Lines to plain text rows and trim trailing spaces so it's
// easier to validate indentation visually in snapshots.
let text = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.map(|s| s.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(name, text);
}
fn diff_gallery_changes() -> HashMap<PathBuf, FileChange> {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let rust_original =
"fn greet(name: &str) {\n println!(\"hello\");\n println!(\"bye\");\n}\n";
let rust_modified = "fn greet(name: &str) {\n println!(\"hello {name}\");\n println!(\"emoji: 🚀✨ and CJK: 你好世界\");\n}\n";
let rust_patch = diffy::create_patch(rust_original, rust_modified).to_string();
changes.insert(
PathBuf::from("src/lib.rs"),
FileChange::Update {
unified_diff: rust_patch,
move_path: None,
},
);
let py_original = "def add(a, b):\n\treturn a + b\n\nprint(add(1, 2))\n";
let py_modified = "def add(a, b):\n\treturn a + b + 42\n\nprint(add(1, 2))\n";
let py_patch = diffy::create_patch(py_original, py_modified).to_string();
changes.insert(
PathBuf::from("scripts/calc.txt"),
FileChange::Update {
unified_diff: py_patch,
move_path: Some(PathBuf::from("scripts/calc.py")),
},
);
changes.insert(
PathBuf::from("assets/banner.txt"),
FileChange::Add {
content: "HEADER\tVALUE\nrocket\t🚀\ncity\t東京\n".to_string(),
},
);
changes.insert(
PathBuf::from("examples/new_sample.rs"),
FileChange::Add {
content: "pub fn greet(name: &str) {\n println!(\"Hello, {name}!\");\n}\n"
.to_string(),
},
);
changes.insert(
PathBuf::from("tmp/obsolete.log"),
FileChange::Delete {
content: "old line 1\nold line 2\nold line 3\n".to_string(),
},
);
changes.insert(
PathBuf::from("legacy/old_script.py"),
FileChange::Delete {
content: "def legacy(x):\n return x + 1\nprint(legacy(3))\n".to_string(),
},
);
changes
}
fn snapshot_diff_gallery(name: &str, width: u16, height: u16) {
let lines = create_diff_summary(
&diff_gallery_changes(),
&PathBuf::from("/"),
usize::from(width),
);
snapshot_lines(name, lines, width, height);
}
#[test]
fn display_path_prefers_cwd_without_git_repo() {
let cwd = if cfg!(windows) {
PathBuf::from(r"C:\workspace\codex")
} else {
PathBuf::from("/workspace/codex")
};
let path = cwd.join("tui").join("example.png");
let rendered = display_path_for(&path, &cwd);
assert_eq!(
rendered,
PathBuf::from("tui")
.join("example.png")
.display()
.to_string()
);
}
#[test]
fn ui_snapshot_wrap_behavior_insert() {
// Narrow width to force wrapping within our diff line rendering
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
// Call the wrapping function directly so we can precisely control the width
let lines = push_wrapped_diff_line_with_style_context(
1,
DiffLineType::Insert,
long_line,
80,
line_number_width(1),
current_diff_render_style_context(),
);
// Render into a small terminal to capture the visual layout
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
}
#[test]
fn ui_snapshot_apply_update_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_update_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_with_rename_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "A\nB\nC\n";
let modified = "A\nB changed\nC\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("old_name.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("new_name.rs")),
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_multiple_files_block() {
// Two files: one update and one add, to exercise combined header and per-file rows
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
// File a.txt: single-line replacement (one delete, one insert)
let patch_a = diffy::create_patch("one\n", "one changed\n").to_string();
changes.insert(
PathBuf::from("a.txt"),
FileChange::Update {
unified_diff: patch_a,
move_path: None,
},
);
// File b.txt: newly added with one line
changes.insert(
PathBuf::from("b.txt"),
FileChange::Add {
content: "new\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
}
#[test]
fn ui_snapshot_apply_add_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("new_file.txt"),
FileChange::Add {
content: "alpha\nbeta\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_add_block", lines, 80, 10);
}
#[test]
fn ui_snapshot_apply_delete_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("tmp_delete_example.txt"),
FileChange::Delete {
content: "first\nsecond\nthird\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_delete_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines() {
// Create a patch with a long modified line to force wrapping
let original = "line 1\nshort\nline 3\n";
let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("long_example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines_text() {
// This mirrors the desired layout example: sign only on first inserted line,
// subsequent wrapped pieces start aligned under the line number gutter.
let original = "1\n2\n3\n4\n";
let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("wrap_demo.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() {
let original = (1..=110).map(|i| format!("line {i}\n")).collect::<String>();
let modified = (1..=110)
.map(|i| {
if i == 100 {
format!("line {i} changed\n")
} else {
format!("line {i}\n")
}
})
.collect::<String>();
let patch = diffy::create_patch(&original, &modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("hundreds.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_relativizes_path() {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let abs_old = cwd.join("abs_old.rs");
let abs_new = cwd.join("abs_new.rs");
let original = "X\nY\n";
let modified = "X changed\nY\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
abs_old,
FileChange::Update {
unified_diff: patch,
move_path: Some(abs_new),
},
);
let lines = create_diff_summary(&changes, &cwd, 80);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}
#[test]
fn ui_snapshot_syntax_highlighted_insert_wraps() {
// A long Rust line that exceeds 80 cols with syntax highlighting should
// wrap to multiple output lines rather than being clipped.
let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_one) }";
let syntax_spans =
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
1,
DiffLineType::Insert,
long_rust,
80,
line_number_width(1),
spans,
current_diff_render_style_context(),
);
assert!(
lines.len() > 1,
"syntax-highlighted long line should wrap to multiple lines, got {}",
lines.len()
);
snapshot_lines("syntax_highlighted_insert_wraps", lines, 90, 10);
}
#[test]
fn ui_snapshot_syntax_highlighted_insert_wraps_text() {
let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_one) }";
let syntax_spans =
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
1,
DiffLineType::Insert,
long_rust,
80,
line_number_width(1),
spans,
current_diff_render_style_context(),
);
snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines);
}
#[test]
fn ui_snapshot_diff_gallery_80x24() {
snapshot_diff_gallery("diff_gallery_80x24", 80, 24);
}
#[test]
fn ui_snapshot_diff_gallery_94x35() {
snapshot_diff_gallery("diff_gallery_94x35", 94, 35);
}
#[test]
fn ui_snapshot_diff_gallery_120x40() {
snapshot_diff_gallery("diff_gallery_120x40", 120, 40);
}
#[test]
fn ui_snapshot_ansi16_insert_delete_no_background() {
let mut lines = push_wrapped_diff_line_inner_with_theme_and_color_level(
1,
DiffLineType::Insert,
"added in ansi16 mode",
80,
line_number_width(2),
None,
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
);
lines.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
2,
DiffLineType::Delete,
"deleted in ansi16 mode",
80,
line_number_width(2),
None,
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
));
snapshot_lines("ansi16_insert_delete_no_background", lines, 40, 4);
}
#[test]
fn truecolor_dark_theme_uses_configured_backgrounds() {
assert_eq!(
style_line_bg_for(
DiffLineType::Insert,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::TrueColor)
),
Style::default().bg(rgb_color(DARK_TC_ADD_LINE_BG_RGB))
);
assert_eq!(
style_line_bg_for(
DiffLineType::Delete,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::TrueColor)
),
Style::default().bg(rgb_color(DARK_TC_DEL_LINE_BG_RGB))
);
assert_eq!(
style_gutter_for(
DiffLineType::Insert,
DiffTheme::Dark,
DiffColorLevel::TrueColor
),
style_gutter_dim()
);
assert_eq!(
style_gutter_for(
DiffLineType::Delete,
DiffTheme::Dark,
DiffColorLevel::TrueColor
),
style_gutter_dim()
);
}
#[test]
fn ansi256_dark_theme_uses_distinct_add_and_delete_backgrounds() {
assert_eq!(
style_line_bg_for(
DiffLineType::Insert,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
),
Style::default().bg(indexed_color(DARK_256_ADD_LINE_BG_IDX))
);
assert_eq!(
style_line_bg_for(
DiffLineType::Delete,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
),
Style::default().bg(indexed_color(DARK_256_DEL_LINE_BG_IDX))
);
assert_ne!(
style_line_bg_for(
DiffLineType::Insert,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
),
style_line_bg_for(
DiffLineType::Delete,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
),
"256-color mode should keep add/delete backgrounds distinct"
);
}
#[test]
fn theme_scope_backgrounds_override_truecolor_fallback_when_available() {
let backgrounds = resolve_diff_backgrounds_for(
DiffTheme::Dark,
DiffColorLevel::TrueColor,
DiffScopeBackgroundRgbs {
inserted: Some((1, 2, 3)),
deleted: Some((4, 5, 6)),
},
);
assert_eq!(
style_line_bg_for(DiffLineType::Insert, backgrounds),
Style::default().bg(rgb_color((1, 2, 3)))
);
assert_eq!(
style_line_bg_for(DiffLineType::Delete, backgrounds),
Style::default().bg(rgb_color((4, 5, 6)))
);
}
#[test]
fn theme_scope_backgrounds_quantize_to_ansi256() {
let backgrounds = resolve_diff_backgrounds_for(
DiffTheme::Dark,
DiffColorLevel::Ansi256,
DiffScopeBackgroundRgbs {
inserted: Some((0, 95, 0)),
deleted: None,
},
);
assert_eq!(
style_line_bg_for(DiffLineType::Insert, backgrounds),
Style::default().bg(indexed_color(22))
);
assert_eq!(
style_line_bg_for(DiffLineType::Delete, backgrounds),
Style::default().bg(indexed_color(DARK_256_DEL_LINE_BG_IDX))
);
}
#[test]
fn ui_snapshot_theme_scope_background_resolution() {
let backgrounds = resolve_diff_backgrounds_for(
DiffTheme::Dark,
DiffColorLevel::TrueColor,
DiffScopeBackgroundRgbs {
inserted: Some((12, 34, 56)),
deleted: None,
},
);
let snapshot = format!(
"insert={:?}\ndelete={:?}",
style_line_bg_for(DiffLineType::Insert, backgrounds).bg,
style_line_bg_for(DiffLineType::Delete, backgrounds).bg,
);
assert_snapshot!("theme_scope_background_resolution", snapshot);
}
#[test]
fn ansi16_disables_line_and_gutter_backgrounds() {
assert_eq!(
style_line_bg_for(
DiffLineType::Insert,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16)
),
Style::default()
);
assert_eq!(
style_line_bg_for(
DiffLineType::Delete,
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::Ansi16)
),
Style::default()
);
assert_eq!(
style_gutter_for(
DiffLineType::Insert,
DiffTheme::Light,
DiffColorLevel::Ansi16
),
Style::default().fg(Color::Black)
);
assert_eq!(
style_gutter_for(
DiffLineType::Delete,
DiffTheme::Light,
DiffColorLevel::Ansi16
),
Style::default().fg(Color::Black)
);
let themed_backgrounds = resolve_diff_backgrounds_for(
DiffTheme::Light,
DiffColorLevel::Ansi16,
DiffScopeBackgroundRgbs {
inserted: Some((8, 9, 10)),
deleted: Some((11, 12, 13)),
},
);
assert_eq!(
style_line_bg_for(DiffLineType::Insert, themed_backgrounds),
Style::default()
);
assert_eq!(
style_line_bg_for(DiffLineType::Delete, themed_backgrounds),
Style::default()
);
}
#[test]
fn light_truecolor_theme_uses_readable_gutter_and_line_backgrounds() {
assert_eq!(
style_line_bg_for(
DiffLineType::Insert,
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor)
),
Style::default().bg(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB))
);
assert_eq!(
style_line_bg_for(
DiffLineType::Delete,
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor)
),
Style::default().bg(rgb_color(LIGHT_TC_DEL_LINE_BG_RGB))
);
assert_eq!(
style_gutter_for(
DiffLineType::Insert,
DiffTheme::Light,
DiffColorLevel::TrueColor
),
Style::default()
.fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB))
.bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB))
);
assert_eq!(
style_gutter_for(
DiffLineType::Delete,
DiffTheme::Light,
DiffColorLevel::TrueColor
),
Style::default()
.fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB))
.bg(rgb_color(LIGHT_TC_DEL_NUM_BG_RGB))
);
}
#[test]
fn light_theme_wrapped_lines_keep_number_gutter_contrast() {
let lines = push_wrapped_diff_line_inner_with_theme_and_color_level(
12,
DiffLineType::Insert,
"abcdefghij",
8,
line_number_width(12),
None,
DiffTheme::Light,
DiffColorLevel::TrueColor,
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor),
);
assert!(
lines.len() > 1,
"expected wrapped output for gutter style verification"
);
assert_eq!(
lines[0].spans[0].style,
Style::default()
.fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB))
.bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB))
);
assert_eq!(
lines[1].spans[0].style,
Style::default()
.fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB))
.bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB))
);
assert_eq!(lines[0].style.bg, Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB)));
assert_eq!(lines[1].style.bg, Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB)));
}
#[test]
fn windows_terminal_promotes_ansi16_to_truecolor_for_diffs() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Ansi16,
TerminalName::WindowsTerminal,
false,
false,
),
DiffColorLevel::TrueColor
);
}
#[test]
fn wt_session_promotes_ansi16_to_truecolor_for_diffs() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Ansi16,
TerminalName::Unknown,
true,
false,
),
DiffColorLevel::TrueColor
);
}
#[test]
fn non_windows_terminal_keeps_ansi16_diff_palette() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Ansi16,
TerminalName::WezTerm,
false,
false,
),
DiffColorLevel::Ansi16
);
}
#[test]
fn wt_session_promotes_unknown_color_level_to_truecolor() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Unknown,
TerminalName::WindowsTerminal,
true,
false,
),
DiffColorLevel::TrueColor
);
}
#[test]
fn non_wt_windows_terminal_keeps_unknown_color_level_conservative() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Unknown,
TerminalName::WindowsTerminal,
false,
false,
),
DiffColorLevel::Ansi16
);
}
#[test]
fn explicit_force_override_keeps_ansi16_on_windows_terminal() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Ansi16,
TerminalName::WindowsTerminal,
false,
true,
),
DiffColorLevel::Ansi16
);
}
#[test]
fn explicit_force_override_keeps_ansi256_on_windows_terminal() {
assert_eq!(
diff_color_level_for_terminal(
StdoutColorLevel::Ansi256,
TerminalName::WindowsTerminal,
true,
true,
),
DiffColorLevel::Ansi256
);
}
#[test]
fn add_diff_uses_path_extension_for_highlighting() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("highlight_add.rs"),
FileChange::Add {
content: "pub fn sum(a: i32, b: i32) -> i32 { a + b }\n".to_string(),
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
let has_rgb = lines.iter().any(|line| {
line.spans
.iter()
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
});
assert!(
has_rgb,
"add diff for .rs file should produce syntax-highlighted (RGB) spans"
);
}
#[test]
fn delete_diff_uses_path_extension_for_highlighting() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("highlight_delete.py"),
FileChange::Delete {
content: "def scale(x):\n return x * 2\n".to_string(),
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
let has_rgb = lines.iter().any(|line| {
line.spans
.iter()
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
});
assert!(
has_rgb,
"delete diff for .py file should produce syntax-highlighted (RGB) spans"
);
}
#[test]
fn detect_lang_for_common_paths() {
// Standard extensions are detected.
assert!(detect_lang_for_path(Path::new("foo.rs")).is_some());
assert!(detect_lang_for_path(Path::new("bar.py")).is_some());
assert!(detect_lang_for_path(Path::new("app.tsx")).is_some());
// Extensionless files return None.
assert!(detect_lang_for_path(Path::new("Makefile")).is_none());
assert!(detect_lang_for_path(Path::new("randomfile")).is_none());
}
#[test]
fn wrap_styled_spans_single_line() {
// Content that fits in one line should produce exactly one chunk.
let spans = vec![RtSpan::raw("short")];
let result = wrap_styled_spans(&spans, 80);
assert_eq!(result.len(), 1);
}
#[test]
fn wrap_styled_spans_splits_long_content() {
// Content wider than max_cols should produce multiple chunks.
let long_text = "a".repeat(100);
let spans = vec![RtSpan::raw(long_text)];
let result = wrap_styled_spans(&spans, 40);
assert!(
result.len() >= 3,
"100 chars at 40 cols should produce at least 3 lines, got {}",
result.len()
);
}
#[test]
fn wrap_styled_spans_flushes_at_span_boundary() {
// When span A fills exactly to max_cols and span B follows, the line
// must be flushed before B starts. Otherwise B's first character lands
// on an already-full line, producing over-width output.
let style_a = Style::default().fg(Color::Red);
let style_b = Style::default().fg(Color::Blue);
let spans = vec![
RtSpan::styled("aaaa", style_a), // 4 cols, fills line exactly at max_cols=4
RtSpan::styled("bb", style_b), // should start on a new line
];
let result = wrap_styled_spans(&spans, 4);
assert_eq!(
result.len(),
2,
"span ending exactly at max_cols should flush before next span: {result:?}"
);
// First line should only contain the 'a' span.
let first_width: usize = result[0].iter().map(|s| s.content.chars().count()).sum();
assert!(
first_width <= 4,
"first line should be at most 4 cols wide, got {first_width}"
);
}
#[test]
fn wrap_styled_spans_preserves_styles() {
// Verify that styles survive split boundaries.
let style = Style::default().fg(Color::Green);
let text = "x".repeat(50);
let spans = vec![RtSpan::styled(text, style)];
let result = wrap_styled_spans(&spans, 20);
for chunk in &result {
for span in chunk {
assert_eq!(span.style, style, "style should be preserved across wraps");
}
}
}
#[test]
fn wrap_styled_spans_tabs_have_visible_width() {
// A tab should count as TAB_WIDTH columns, not zero.
// With max_cols=8, a tab (4 cols) + "abcde" (5 cols) = 9 cols → must wrap.
let spans = vec![RtSpan::raw("\tabcde")];
let result = wrap_styled_spans(&spans, 8);
assert!(
result.len() >= 2,
"tab + 5 chars should exceed 8 cols and wrap, got {} line(s): {result:?}",
result.len()
);
}
#[test]
fn wrap_styled_spans_wraps_before_first_overflowing_char() {
let spans = vec![RtSpan::raw("abcd\t")];
let result = wrap_styled_spans(&spans, 5);
let line_text: Vec<String> = result
.iter()
.map(|line| {
line.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect();
assert_eq!(line_text, vec!["abcd", "\t", ""]);
let line_width = |line: &[RtSpan<'static>]| -> usize {
line.iter()
.flat_map(|span| span.content.chars())
.map(|ch| ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 }))
.sum()
};
for line in &result {
assert!(
line_width(line) <= 5,
"wrapped line exceeded width 5: {line:?}"
);
}
}
#[test]
fn fallback_wrapping_uses_display_width_for_tabs_and_wide_chars() {
let width = 8;
let lines = push_wrapped_diff_line_with_style_context(
1,
DiffLineType::Insert,
"abcd\t界🙂",
width,
line_number_width(1),
current_diff_render_style_context(),
);
assert!(lines.len() >= 2, "expected wrapped output, got {lines:?}");
for line in &lines {
assert!(
line_display_width(line) <= width,
"fallback wrapped line exceeded width {width}: {line:?}"
);
}
}
#[test]
fn large_update_diff_skips_highlighting() {
// Build a patch large enough to exceed MAX_HIGHLIGHT_LINES (10_000).
// Without the pre-check this would attempt 10k+ parser initializations.
let line_count = 10_500;
let original: String = (0..line_count).map(|i| format!("line {i}\n")).collect();
let modified: String = (0..line_count)
.map(|i| {
if i % 2 == 0 {
format!("line {i} changed\n")
} else {
format!("line {i}\n")
}
})
.collect();
let patch = diffy::create_patch(&original, &modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("huge.rs"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
// Should complete quickly (no per-line parser init). If guardrails
// are bypassed this would be extremely slow.
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
// The diff rendered without timing out — the guardrails prevented
// thousands of per-line parser initializations. Verify we actually
// got output (the patch is non-empty).
assert!(
lines.len() > 100,
"expected many output lines from large diff, got {}",
lines.len(),
);
// No span should contain an RGB foreground color (syntax themes
// produce RGB; plain diff styles only use named Color variants).
for line in &lines {
for span in &line.spans {
if let Some(ratatui::style::Color::Rgb(..)) = span.style.fg {
panic!(
"large diff should not have syntax-highlighted spans, \
got RGB color in style {:?} for {:?}",
span.style, span.content,
);
}
}
}
}
#[test]
fn rename_diff_uses_destination_extension_for_highlighting() {
// A rename from an unknown extension to .rs should highlight as Rust.
// Without the fix, detect_lang_for_path uses the source path (.xyzzy),
// which has no syntax definition, so highlighting is skipped.
let original = "fn main() {}\n";
let modified = "fn main() { println!(\"hi\"); }\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("foo.xyzzy"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("foo.rs")),
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
let has_rgb = lines.iter().any(|line| {
line.spans
.iter()
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
});
assert!(
has_rgb,
"rename from .xyzzy to .rs should produce syntax-highlighted (RGB) spans"
);
}
#[test]
fn update_diff_preserves_multiline_highlight_state_within_hunk() {
let original = "fn demo() {\n let s = \"hello\";\n}\n";
let modified = "fn demo() {\n let s = \"hello\nworld\";\n}\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("demo.rs"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let expected_multiline =
highlight_code_to_styled_spans(" let s = \"hello\nworld\";\n", "rust")
.expect("rust highlighting");
let expected_style = expected_multiline
.get(1)
.and_then(|line| {
line.iter()
.find(|span| span.content.as_ref().contains("world"))
})
.map(|span| span.style)
.expect("expected highlighted span for second multiline string line");
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 120);
let actual_style = lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.as_ref().contains("world"))
.map(|span| span.style)
.expect("expected rendered diff span containing 'world'");
assert_eq!(actual_style, expected_style);
}
}