//! EditToolCallBlock - displays file edit diffs with syntax highlighting. //! //! # Progressive highlight //! //! First paint uses per-hunk syntect (fast). When the post-edit file is available //! and under size/line caps, a background worker upgrades to full-file-scoped //! styles so mid-file multi-line scopes (e.g. closing `"""`) paint correctly. //! //! # Caps ([`EDIT_HL_MAX_BYTES`] / [`EDIT_HL_MAX_LINES`]) //! //! Full-file HL costs up to **O(file lines)** in syntect, not O(hunk lines) — //! the walk stops at the last hunk line, but a hunk near EOF pays for the whole //! file. Caps keep background work bounded so a multi-megabyte monorepo dump //! never freezes the worker or balloons the style map: //! //! | Gate | Default | On exceed | //! |------|---------|-----------| //! | File bytes | 2 MiB | stay [`EditHighlightPhase::HunkOnly`] | //! | Line count | 50_000 | stay hunk-only | //! //! Cost magnitudes: see `benches/edit_highlight` (hunk-only first paint is //! cheap; full-file is once-per-upgrade; naïve prefix-per-hunk is not shipped). use std::borrow::Cow; use std::collections::HashMap; use std::ops::Range; use std::path::Path; use std::sync::Arc; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use similar::ChangeTag; use syntect::easy::HighlightLines; use syntect::highlighting::Style as SyntectStyle; use super::TOOL_HEADER_RANGE; use crate::diff::{DiffHunk, diff_hunks_to_patch}; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, RenderedBlockOutput, Selectable, SelectionBoundaries, SelectionBoundary, SelectionBoundaryEntry, }; use crate::syntax::{Syntect, get_syntect}; use crate::theme::{Theme, ThemeKind}; /// Skip full-file HL when the post-edit file exceeds this size (2 MiB). /// /// Full-file syntect on multi-MB sources is poor background work vs staying /// hunk-only; see `benches/edit_highlight`. pub const EDIT_HL_MAX_BYTES: u64 = 2 * 1024 * 1024; /// Skip full-file HL when the post-edit file has more lines than this (50k). /// /// Larger files stay hunk-only so the worker cannot unbounded-walk a dump. pub const EDIT_HL_MAX_LINES: usize = 50_000; /// Content spans for one source line: FG styles only (height-neutral). pub type EditLineStyles = Vec<(Style, String)>; /// Progressive syntax-highlight state for an edit block. /// /// `HunkOnly` / `Pending` use per-hunk syntect; `FileScoped` maps full-file FG /// styles onto Equal/Insert hunk text (Deletes keep per-hunk syntect). Clone via [`Arc`]. #[derive(Debug, Clone, Default)] pub enum EditHighlightPhase { #[default] HunkOnly, /// Full-file job in flight; paint is still hunk-only. Pending { job_id: u64 }, /// Precomputed full-file styles keyed by 1-based new-file line. FileScoped { by_new_line: Arc>, /// Theme the styles were baked under; paint falls back to hunk-only /// on mismatch so a runtime theme flip never mixes palettes. theme: ThemeKind, }, } /// Configuration for diff rendering (tweakable for dev testing). #[derive(Debug, Clone)] pub struct DiffRenderConfig { /// If true, add 2-char indent before line numbers. pub indent: bool, /// If true, background extends from line start (including gutter). /// If false (default), background only covers the content area. pub gutter_bg: bool, /// If true, skip indent columns in background (keep them clean). /// If false, include indent in background. pub indent_bg: bool, /// Separator string between hunks. /// Options: "───" (line), "…" (ellipsis), "⋯" (midline ellipsis), "" (none). pub hunk_separator: String, /// Show two line-number columns (old + new) like GitHub's unified diff. /// When false (default), show a single column with the new-file line number. pub dual_line_numbers: bool, } impl Default for DiffRenderConfig { fn default() -> Self { Self { indent: true, gutter_bg: false, indent_bg: false, hunk_separator: "…".to_string(), dual_line_numbers: false, } } } const INDENT: &str = " "; const GUTTER_GAP: &str = " "; const CONTENT_GAP: &str = " "; /// Expand tabs using the global tab_width (`Cow::Borrowed` when none). fn expand_tabs(text: &str) -> Cow<'_, str> { let tw = crate::appearance::tab_width(); if tw == 0 || !text.contains('\t') { return Cow::Borrowed(text); } Cow::Owned(text.replace('\t', &" ".repeat(tw as usize))) } /// A rendered diff line with optional background color. pub struct DiffLineOutput { pub line: Line<'static>, pub background: Option, /// Column where background starts (for partial background). pub content_start_col: u16, /// Number of gutter spans at the start of this line (for selection exclusion). pub gutter_span_count: usize, /// Raw content text for this line (excluding gutter). pub content_text: String, /// Joiner for soft-wrap reconstruction (None = hard break, Some = continuation). pub joiner: Option, /// True for hunk separator lines (not selectable, break range continuity). pub is_separator: bool, } /// Render a diff hunk to lines with syntax highlighting. /// /// Returns lines with optional background colors for insert/delete. pub fn render_diff_hunk_highlighted( hunk: &DiffHunk, path: &Path, theme: &Theme, width: u16, config: &DiffRenderConfig, ) -> Vec { render_diff_hunks_core(std::slice::from_ref(hunk), path, None, theme, width, config) } /// Render multiple hunks with separators, returning lines with backgrounds. pub fn render_diff_hunks_highlighted( hunks: &[DiffHunk], path: &Path, theme: &Theme, width: u16, config: &DiffRenderConfig, ) -> Vec { render_diff_hunks_core(hunks, path, None, theme, width, config) } /// The single hunk walker behind both public fronts, so gutters, backgrounds, /// separators, and wrap behavior are identical across highlight phases by /// construction. Every line renders its per-hunk syntect spans (keeping the /// highlighter state exactly as in the hunk-only phase); when `by_new_line` /// is given, matching Equal/Insert lines swap in the full-file styles. fn render_diff_hunks_core( hunks: &[DiffHunk], path: &Path, by_new_line: Option<&HashMap>, theme: &Theme, width: u16, config: &DiffRenderConfig, ) -> Vec { let syntect = get_syntect(); let mut lines = Vec::new(); for (i, hunk) in hunks.iter().enumerate() { if i > 0 && !lines.is_empty() && !config.hunk_separator.is_empty() { let indent = if config.indent { INDENT } else { "" }; let sep_text = match hunk_gap_lines(&hunks[i - 1], hunk) { Some(1) => format!("{} 1 unchanged line", config.hunk_separator), Some(n) => format!("{} {n} unchanged lines", config.hunk_separator), None => config.hunk_separator.clone(), }; let sep_line = Line::from(vec![ Span::raw(indent), Span::styled(sep_text, theme.muted()), ]); lines.push(DiffLineOutput { line: sep_line, background: None, content_start_col: 0, gutter_span_count: 0, content_text: String::new(), joiner: None, is_separator: true, }); } if hunk.is_empty() { continue; } let layout = gutter_layout(hunk, config); let indent_width = if config.indent { INDENT.len() } else { 0 }; let content_width = (width as usize).saturating_sub(layout.total); // Fresh per-hunk highlighter, same as the hunk-only phase always did. let mut highlighter = syntect.highlight_lines_by_file_path(path); for line in hunk { let trimmed = line.text.trim_end_matches(['\r', '\n']); let raw_text = expand_tabs(trimmed); // Cold spans render unconditionally so Delete lines and any map // miss (text drift) paint exactly like the hunk-only phase. let mut content_spans = render_content_spans(&raw_text, line.tag, theme, &mut highlighter, syntect); if let Some(map) = by_new_line && let Some(spans) = map_spans_for_line(line, &raw_text, map, theme) { content_spans = spans; } lines.extend(assemble_diff_line_outputs( line, content_spans, &layout, indent_width, content_width, theme, config, )); } } lines } /// Unchanged new-file lines hidden between two hunks, when computable. /// /// Uses the `ln` of the new-file lines (Equal/Insert) bordering the gap. /// `None` — a hunk with no new-file lines, or a non-positive gap — keeps the /// bare separator. Non-monotonic `ln` happens on coalesced multi-call blocks /// whose later edit landed above an earlier one (each call's hunks are /// numbered against its own file snapshot), so a count would be wrong there. fn hunk_gap_lines(prev: &DiffHunk, next: &DiffHunk) -> Option { let prev_last = prev.iter().rev().find(|l| l.tag != ChangeTag::Delete)?.ln; let next_first = next.iter().find(|l| l.tag != ChangeTag::Delete)?.ln; next_first .checked_sub(prev_last) .and_then(|d| d.checked_sub(1)) .filter(|n| *n > 0) } /// Expanded (tabs → spaces) text for Equal/Insert hunk lines, keyed by 1-based ln. fn hunk_new_line_texts(hunks: &[DiffHunk]) -> HashMap { let mut out = HashMap::new(); for hunk in hunks { for line in hunk { if matches!(line.tag, ChangeTag::Equal | ChangeTag::Insert) && line.ln > 0 { let trimmed = line.text.trim_end_matches(['\r', '\n']); out.insert(line.ln, expand_tabs(trimmed).into_owned()); } } } out } /// Whether `file_text` is within the full-file HL caps (bytes + lines). pub fn file_text_within_hl_caps(file_text: &str) -> bool { if file_text.len() as u64 > EDIT_HL_MAX_BYTES { return false; } let lines = if file_text.is_empty() { 0 } else { file_text.bytes().filter(|&b| b == b'\n').count() + usize::from(!file_text.ends_with('\n')) }; lines <= EDIT_HL_MAX_LINES } /// Full-file HL once; keep new-side lines referenced by `hunks`. /// /// Production upgrade path (edit-HL worker): one syntect walk over `file_text` /// up to the last hunk line, retaining only Equal/Insert lines present in /// `hunks`. Expands tabs before HL /// (same as cold paint). Returns `None` if any needed disk line differs from /// hunk text (so upgrade never rewrites displayed content). That refusal is also /// the expected outcome for multi-edit blocks whose earlier hunks' `ln` were /// shifted by a later edit above them, not a missed upgrade. /// Caller enforces caps ([`file_text_within_hl_caps`]) and UTF-8. pub fn compute_file_scoped_styles( path: &Path, file_text: &str, hunks: &[DiffHunk], ) -> Option> { let expected = hunk_new_line_texts(hunks); if expected.is_empty() { return Some(HashMap::new()); } let syntect = get_syntect(); let mut highlighter = syntect.highlight_lines_by_file_path(path)?; // Lines past the last needed one cannot affect earlier syntect state. let max_needed = expected.keys().copied().max().unwrap_or(0); let mut out = HashMap::with_capacity(expected.len()); for (idx, line) in file_text.lines().enumerate() { let ln = idx + 1; if ln > max_needed { break; } let expanded = expand_tabs(line); let owned = format!("{expanded}\n"); let ranges = highlighter .highlight_line(&owned, &syntect.syntax_set) .unwrap_or_default(); let Some(expected_text) = expected.get(&ln) else { continue; }; if expanded.as_ref() != expected_text.as_str() { return None; } let mut spans = Vec::new(); for (style, segment) in ranges { let mut text = segment.to_owned(); while text.ends_with('\n') || text.ends_with('\r') { text.pop(); } if text.is_empty() { continue; } spans.push((syntect_to_ratatui_fg(style), text)); } if spans.is_empty() { spans.push((Style::default(), String::new())); } out.insert(ln, spans); } if out.len() != expected.len() { return None; } Some(out) } /// Render hunks with precomputed full-file styles (FileScoped). A thin front /// over [`render_diff_hunks_core`], so gutters/BG/wrap — and the Delete lines' /// per-hunk syntect paint — match [`render_diff_hunks_highlighted`] by /// construction; the map only overrides Equal/Insert foregrounds. pub fn render_diff_hunks_with_styles( hunks: &[DiffHunk], path: &Path, by_new_line: &HashMap, theme: &Theme, width: u16, config: &DiffRenderConfig, ) -> Vec { render_diff_hunks_core(hunks, path, Some(by_new_line), theme, width, config) } /// Full-file map spans for one line, when they may override the cold spans: /// Equal always; Insert only on banded themes (bandless paints changed lines /// with a solid line FG); Delete never (it keeps the per-hunk syntect paint the /// user already saw). `None` — missing line, text drift, or nothing visible — /// keeps the cold spans. fn map_spans_for_line( line: &crate::diff::DiffLine, expanded: &str, by_new_line: &HashMap, theme: &Theme, ) -> Option>> { let overridable = match line.tag { ChangeTag::Equal => true, ChangeTag::Insert => !theme.diff_uses_line_fg(), ChangeTag::Delete => false, }; if !overridable { return None; } let styles = by_new_line.get(&line.ln)?; let joined: String = styles.iter().map(|(_, t)| t.as_str()).collect(); if joined != expanded { return None; } let spans: Vec> = styles .iter() .filter(|(_, t)| !t.is_empty()) .map(|(st, t)| Span::styled(t.clone(), *st)) .collect(); if spans.is_empty() { return None; } Some(spans) } /// Assemble gutter + content spans into one or more wrapped [`DiffLineOutput`]s. fn assemble_diff_line_outputs( line: &crate::diff::DiffLine, content_spans: Vec>, layout: &GutterLayout, indent_width: usize, content_width: usize, theme: &Theme, config: &DiffRenderConfig, ) -> Vec { let bg = match line.tag { ChangeTag::Equal => None, ChangeTag::Delete => Some(theme.diff_delete_bg), ChangeTag::Insert => Some(theme.diff_insert_bg), }; let content_char_count: usize = content_spans .iter() .map(|s| s.content.chars().count()) .sum(); let raw_content: String = content_spans.iter().map(|s| s.content.as_ref()).collect(); if content_width == 0 || content_char_count <= content_width { let mut spans = Vec::new(); render_gutter(&mut spans, line, layout, theme, config); let gutter_count = spans.len(); spans.extend(content_spans); let bg_start = compute_bg_start(config, layout.total, indent_width); return vec![DiffLineOutput { line: Line::from(spans), background: bg, content_start_col: bg_start, gutter_span_count: gutter_count, content_text: raw_content, joiner: None, is_separator: false, }]; } // Wrap: fall back to solid FG for wrapped segments (same as hunk-only path). let mut outputs = Vec::new(); let gutter_padding = " ".repeat(layout.total); let wrapped_lines = wrap_text(&raw_content, content_width); let bg_start = compute_bg_start(config, layout.total, indent_width); for (i, wrapped) in wrapped_lines.iter().enumerate() { let mut spans = Vec::new(); let gutter_count = if i == 0 { render_gutter(&mut spans, line, layout, theme, config); spans.len() } else { spans.push(Span::raw(gutter_padding.clone())); 1 }; let style = match line.tag { ChangeTag::Equal => Style::default().fg(theme.diff_equal_fg), ChangeTag::Delete | ChangeTag::Insert => { if theme.diff_uses_line_fg() { let fg = if line.tag == ChangeTag::Delete { theme.diff_delete_fg } else { theme.diff_insert_fg }; Style::default().fg(fg) } else { Style::default().fg(theme.text_primary) } } }; spans.push(Span::styled(wrapped.clone(), style)); outputs.push(DiffLineOutput { line: Line::from(spans), background: bg, content_start_col: bg_start, gutter_span_count: gutter_count, content_text: wrapped.clone(), joiner: if i == 0 { None } else { Some(String::new()) }, is_separator: false, }); } outputs } /// Compute where background starts based on config. fn compute_bg_start(config: &DiffRenderConfig, gutter_width: usize, indent_width: usize) -> u16 { if config.gutter_bg { if config.indent && config.indent_bg { indent_width as u16 } else { 0 } } else { gutter_width as u16 } } fn wrap_text(text: &str, max_width: usize) -> Vec { if max_width == 0 || text.is_empty() { return vec![text.to_string()]; } let mut lines = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; for word in text.split_inclusive(|c: char| c.is_whitespace()) { let word_width = word.chars().count(); if current_width + word_width > max_width && !current_line.is_empty() { lines.push(current_line); current_line = String::new(); current_width = 0; } current_line.push_str(word); current_width += word_width; } if !current_line.is_empty() { lines.push(current_line); } if lines.is_empty() { lines.push(String::new()); } lines } /// Gutter layout computed from a hunk and config. struct GutterLayout { /// Width of old-file column (0 in single mode). width_old: usize, /// Width of new-file column. width_new: usize, /// Total gutter width including indent and gaps. total: usize, /// Whether dual mode is active. dual: bool, } fn gutter_layout(hunk: &DiffHunk, config: &DiffRenderConfig) -> GutterLayout { let mut max_old = 1usize; let mut max_new = 1usize; for line in hunk { max_old = max_old.max(line.lo.max(1)); max_new = max_new.max(line.ln.max(1)); } let indent_width = if config.indent { INDENT.len() } else { 0 }; if config.dual_line_numbers { let width_old = max_old.ilog10() as usize + 1; let width_new = max_new.ilog10() as usize + 1; let total = indent_width + width_old + GUTTER_GAP.len() + width_new + CONTENT_GAP.len(); GutterLayout { width_old, width_new, total, dual: true, } } else { let max_num = max_old.max(max_new); let width_new = max_num.ilog10() as usize + 1; let total = indent_width + width_new + CONTENT_GAP.len(); GutterLayout { width_old: 0, width_new, total, dual: false, } } } fn render_gutter( spans: &mut Vec>, line: &crate::diff::DiffLine, layout: &GutterLayout, theme: &Theme, config: &DiffRenderConfig, ) { let gutter_style = Style::default().fg(theme.diff_gutter_fg); if config.indent { spans.push(Span::raw(INDENT)); } if layout.dual { let w_old = layout.width_old; let w_new = layout.width_new; match line.tag { ChangeTag::Equal => { spans.push(Span::styled(format!("{:>w_old$}", line.lo), gutter_style)); spans.push(Span::raw(GUTTER_GAP)); spans.push(Span::styled(format!("{:>w_new$}", line.ln), gutter_style)); } ChangeTag::Delete => { spans.push(Span::styled( format!("{:>w_old$}", line.lo), Style::default().fg(theme.diff_delete_fg), )); spans.push(Span::raw(GUTTER_GAP)); spans.push(Span::styled(" ".repeat(w_new), gutter_style)); } ChangeTag::Insert => { spans.push(Span::styled(" ".repeat(w_old), gutter_style)); spans.push(Span::raw(GUTTER_GAP)); spans.push(Span::styled( format!("{:>w_new$}", line.ln), Style::default().fg(theme.diff_insert_fg), )); } } } else { let w = layout.width_new; match line.tag { ChangeTag::Equal => { spans.push(Span::styled(format!("{:>w$}", line.ln), gutter_style)); } ChangeTag::Delete => { spans.push(Span::styled( format!("{:>w$}", line.lo), Style::default().fg(theme.diff_delete_fg), )); } ChangeTag::Insert => { spans.push(Span::styled( format!("{:>w$}", line.ln), Style::default().fg(theme.diff_insert_fg), )); } } } spans.push(Span::raw(CONTENT_GAP)); } /// Span with `style`; empty text paints a single space so the row keeps a /// visible background band and stays selectable. fn painted(text: &str, style: Style) -> Span<'static> { let text = if text.is_empty() { " " } else { text }; Span::styled(text.to_string(), style) } fn render_content_spans( content: &str, tag: ChangeTag, theme: &Theme, highlighter: &mut Option>, syntect: &Syntect, ) -> Vec> { let mut spans = Vec::new(); if tag != ChangeTag::Equal && theme.diff_uses_line_fg() { let fg = match tag { ChangeTag::Delete => theme.diff_delete_fg, _ => theme.diff_insert_fg, }; spans.push(painted(content, Style::default().fg(fg))); return spans; } if let Some(hl) = highlighter.as_mut() && let Ok(ranges) = hl.highlight_line(&format!("{content}\n"), &syntect.syntax_set) { let mut wrote = false; for (style, segment) in ranges { let mut text = segment.to_owned(); while text.ends_with('\n') || text.ends_with('\r') { text.pop(); } if text.is_empty() { continue; } let fg_style = syntect_to_ratatui_fg(style); spans.push(Span::styled(text, fg_style)); wrote = true; } if wrote { return spans; } } let style = match tag { ChangeTag::Equal => Style::default().fg(theme.diff_equal_fg), ChangeTag::Delete | ChangeTag::Insert => Style::default().fg(theme.text_primary), }; spans.push(painted(content, style)); spans } /// Convert syntect style to ratatui style (FG only, no BG). fn syntect_to_ratatui_fg(style: SyntectStyle) -> Style { crate::syntax::syntect_to_ratatui_fg(style) } #[derive(Debug, Clone)] pub struct EditToolCallBlock { pub path: String, pub hunks: Vec, /// Number of edits (for multi-edit display). pub edit_count: usize, /// Error message if the tool call failed (None = success). pub error: Option, /// When the tool started running (Phase 2: time tracking). pub started_at: Option, /// Elapsed time in ms after completion (Phase 2: time tracking). pub elapsed_ms: Option, /// Header prefix (e.g. "Edit " or "Creating "). pub prefix: &'static str, /// One-liner summary can't be trusted: the call touched multiple files /// (apply_patch emits one Diff per file, only the first becomes hunks) or /// the path fell back to the tool title. Suppresses the diffstat suffix; /// `ScrollbackState`'s materialize policy keeps such blocks expanded. pub summary_untrusted: bool, /// Cached `(insertions, deletions)` count, computed eagerly from hunks. change_counts: (usize, usize), /// Hunk-only first paint; may upgrade to FileScoped via the edit-HL worker. pub highlight: EditHighlightPhase, } impl EditToolCallBlock { /// Pre-completed blocks have no meaningful local timing — `started_at` /// is `None`. Timing is only set for blocks that enter a running UI /// state (via `set_last_running(true)` in `ScrollbackState`). pub fn new(path: impl Into, hunks: Vec) -> Self { let edit_count = hunks.len().max(1); let change_counts = Self::compute_changes(&hunks); Self { path: path.into(), hunks, edit_count, error: None, started_at: None, elapsed_ms: None, prefix: "Edit ", summary_untrusted: false, change_counts, highlight: EditHighlightPhase::HunkOnly, } } /// Set the header prefix (e.g. "Creating " for write). pub fn with_prefix(mut self, prefix: &'static str) -> Self { self.prefix = prefix; self } /// Mark the one-liner summary as covering only part of the call. pub fn with_untrusted_summary(mut self) -> Self { self.summary_untrusted = true; self } pub fn with_error(mut self, error: impl Into) -> Self { self.error = Some(error.into()); self } pub fn is_success(&self) -> bool { self.error.is_none() } /// Set error (mutable) — compute elapsed time if not already set (Phase 2). pub fn set_error(&mut self, error: Option) { if self.elapsed_ms.is_none() && let Some(start) = self.started_at { self.elapsed_ms = Some(start.elapsed().as_millis() as i64); } self.error = error; } /// Finalize elapsed time from `started_at`. /// /// Idempotent: no-op if `started_at` is `None` (pre-completed block) /// or if `elapsed_ms` is already set (already finalized). pub fn finish(&mut self) { if self.elapsed_ms.is_some() { return; } if let Some(start) = self.started_at { self.elapsed_ms = Some(start.elapsed().as_millis() as i64); } } pub fn elapsed_ms(&self) -> Option { match self.elapsed_ms { Some(ms) => Some(ms), None => self .started_at .map(|start| start.elapsed().as_millis() as i64), } } pub fn with_edit_count(mut self, count: usize) -> Self { self.edit_count = count; self } /// Get copyable text as a unified diff patch. /// /// Generates a patch format suitable for `git apply` or clipboard sharing. pub fn copy_text(&self) -> String { diff_hunks_to_patch(&self.path, &self.hunks) } pub fn set_hunks(&mut self, hunks: Vec) { self.edit_count = hunks.len().max(1); self.change_counts = Self::compute_changes(&hunks); self.hunks = hunks; } fn compute_changes(hunks: &[DiffHunk]) -> (usize, usize) { let mut insertions = 0; let mut deletions = 0; for hunk in hunks { for line in hunk { match line.tag { ChangeTag::Insert => insertions += 1, ChangeTag::Delete => deletions += 1, ChangeTag::Equal => {} } } } (insertions, deletions) } fn count_changes(&self) -> (usize, usize) { self.change_counts } /// Header line: path painted for `surface` (Collapsed / Expanded / Fullscreen). #[allow(clippy::too_many_arguments)] fn header_line( &self, theme: &Theme, muted: bool, show_summary: bool, dim_details: bool, surface: crate::render::tool_paths::ToolPathSurface, cwd: Option<&Path>, width: Option, ) -> Line<'static> { let text_style = if muted { theme.muted() } else { theme.primary() }; let bold_style = text_style.add_modifier(Modifier::BOLD); let path_style = if muted { theme.muted() } else { Style::default().fg(theme.path) }; let detail_style = if dim_details { theme.dim() } else { theme.muted() }; let prefix = self.prefix; // Build the suffix spans first so we can reserve space for them. // The suffix (diffstat / "(N edits)") renders only on the collapsed // one-liner: expanded and fullscreen surfaces show the hunks, so // their headers stay bare. Diffstat counts keep their diff colors // even when the header is muted; untrusted summaries (multi-file, // title-fallback path) never show counts that would only describe // the first diff. let collapsed = matches!( surface, crate::render::tool_paths::ToolPathSurface::Collapsed ); let suffix_spans: Vec> = if collapsed && show_summary && !self.hunks.is_empty() && !self.summary_untrusted { let (ins, del) = self.count_changes(); if ins > 0 || del > 0 { vec![ Span::styled( format!(" +{ins}"), Style::default().fg(theme.diff_insert_fg), ), Span::styled("/", detail_style), Span::styled(format!("-{del}"), Style::default().fg(theme.diff_delete_fg)), ] } else { Vec::new() } } else if collapsed && self.edit_count > 1 { vec![Span::styled( format!(" ({} edits)", self.edit_count), detail_style, )] } else { Vec::new() }; let suffix_width: usize = suffix_spans .iter() .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref())) .sum(); let path = crate::render::tool_paths::path_for_tool_surface( &self.path, surface, cwd, width, prefix.len() + suffix_width, ); let mut spans = vec![ Span::styled(prefix, bold_style), Span::styled(path, path_style), ]; spans.extend(suffix_spans); Line::from(spans) } /// Absolute `file://` for OSC8 regardless of painted path surface. fn path_link_url(&self, cwd: Option<&Path>) -> Option> { crate::render::osc8::tool_path_file_url(&self.path, cwd) } /// Render this block's hunks for its current highlight phase — the single /// dispatch point shared by scrollback `output()` and the fullscreen block /// viewer. FileScoped styles baked under a different theme than the live /// one are skipped (hunk-only paint) so a theme flip never mixes palettes. pub fn render_diff_lines( &self, theme: &Theme, width: u16, config: &DiffRenderConfig, ) -> Vec { let path = Path::new(&self.path); match &self.highlight { EditHighlightPhase::FileScoped { by_new_line, theme: baked, } if *baked == crate::theme::cache::current_kind() => render_diff_hunks_with_styles( &self.hunks, path, by_new_line.as_ref(), theme, width, config, ), _ => render_diff_hunks_highlighted(&self.hunks, path, theme, width, config), } } } struct WrappedEditHeaderLine { content: Line<'static>, joiner: Option, path_spans: Option>, selection_text: Option, selection_boundary: Option, } #[derive(Default)] struct EditSelectionBoundary { prefix: String, suffix: String, } fn split_joiner_by_path( joiner: &str, source_start: usize, path_range: &Range, ) -> (String, String) { let source_end = source_start.saturating_add(joiner.len()); let owned_start = source_start.max(path_range.start); let owned_end = source_end.min(path_range.end); if owned_start >= owned_end { return (joiner.to_owned(), String::new()); } let local_start = owned_start - source_start; let local_end = owned_end - source_start; let mut remainder = String::with_capacity(joiner.len() - (local_end - local_start)); remainder.push_str(&joiner[..local_start]); remainder.push_str(&joiner[local_end..]); (remainder, joiner[local_start..local_end].to_owned()) } fn wrap_edit_header( header: Line<'static>, width: usize, extra_indent: usize, ) -> Vec { let prefix_width = header .spans .first() .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref())) .unwrap_or(0); let total_indent = extra_indent + prefix_width; let wrap_width = width.saturating_sub(total_indent); let prefix_bytes = header.spans.first().map_or(0, |span| span.content.len()); let path_bytes = header.spans.get(1).map_or(0, |span| span.content.len()); let path_text = header .spans .get(1) .map_or_else(String::new, |span| span.content.to_string()); let (wrapped, joiners, path_range, prefix_span) = if !header.spans.is_empty() && prefix_width > 0 { let prefix_span = header.spans[0].clone(); let content_line = Line::from(header.spans[1..].to_vec()); let (wrapped, joiners) = crate::render::wrapping::word_wrap_lines_with_joiners( std::iter::once(content_line), wrap_width, ); (wrapped, joiners, 0..path_bytes, Some(prefix_span)) } else { let (wrapped, joiners) = crate::render::wrapping::word_wrap_lines_with_joiners(std::iter::once(header), width); ( wrapped, joiners, prefix_bytes..prefix_bytes.saturating_add(path_bytes), None, ) }; let indent = " ".repeat(total_indent); let mut source_offset = 0usize; let mut rows: Vec = Vec::with_capacity(wrapped.len()); let mut last_path_row: Option = None; for (row, (mut content, joiner)) in wrapped.into_iter().zip(joiners).enumerate() { let (original_joiner, non_path_joiner, path_joiner) = match joiner { Some(joiner) => { let (remainder, owned) = split_joiner_by_path(&joiner, source_offset, &path_range); source_offset = source_offset.saturating_add(joiner.len()); (Some(joiner), Some(remainder), owned) } None => (None, None, String::new()), }; let mut span_offset = source_offset; let mut path_span_start = None; let mut path_span_end = 0usize; for (span_idx, span) in content.spans.iter().enumerate() { let span_end = span_offset.saturating_add(span.content.len()); if span_offset < path_range.end && path_range.start < span_end { path_span_start.get_or_insert(span_idx); path_span_end = span_idx + 1; } span_offset = span_end; } source_offset = span_offset; let selection_text = path_span_start.map(|start| { let mut selected = String::new(); for span in &content.spans[start..path_span_end] { selected.push_str(span.content.as_ref()); } selected }); if selection_text.is_none() && !path_joiner.is_empty() && let Some(previous) = last_path_row.and_then(|index| rows.get_mut(index)) { previous .selection_boundary .get_or_insert_with(EditSelectionBoundary::default) .suffix .push_str(&path_joiner); } let decoration_spans = if let Some(prefix) = &prefix_span { let decoration = if row == 0 { prefix.clone() } else { Span::raw(indent.clone()) }; content.spans.insert(0, decoration); 1 } else { 0 }; let path_spans = path_span_start.map(|start| start + decoration_spans..path_span_end + decoration_spans); let has_path = path_spans.is_some(); let first_path_row = has_path && last_path_row.is_none(); let selection_boundary = first_path_row .then_some(path_joiner) .filter(|prefix| !prefix.is_empty()) .map(|prefix| EditSelectionBoundary { prefix, suffix: String::new(), }); let output_joiner = if has_path && !first_path_row { original_joiner } else { non_path_joiner }; let output_index = rows.len(); rows.push(WrappedEditHeaderLine { content, joiner: output_joiner, path_spans, selection_text, selection_boundary, }); if has_path { last_path_row = Some(output_index); } } if source_offset < path_range.end && let Some(previous) = last_path_row.and_then(|index| rows.get_mut(index)) { let suffix_start = source_offset.max(path_range.start) - path_range.start; previous .selection_boundary .get_or_insert_with(EditSelectionBoundary::default) .suffix .push_str(&path_text[suffix_start..]); } rows } impl EditToolCallBlock { pub(crate) fn rendered_output(&self, ctx: &BlockContext) -> RenderedBlockOutput { let theme = Theme::current(); let edit_cfg = &ctx.appearance.scrollback.blocks.edit; let tool_cfg = &ctx.appearance.scrollback.blocks.tool; let muted_collapsed = ctx.mute_when_collapsed(tool_cfg.muted_collapsed); let dim_details = tool_cfg.dim_details; // Explicit pager.toml value wins; unset follows the shell-owned flag. let show_summary = edit_cfg.effective_line_summary(crate::appearance::cache::load_collapsed_edit_blocks()); let cwd = ctx.cwd.as_deref(); let link_url = self.path_link_url(cwd); match ctx.mode { DisplayMode::Collapsed => { let line = self.header_line( &theme, muted_collapsed, show_summary, dim_details, crate::render::tool_paths::ToolPathSurface::Collapsed, cwd, Some(ctx.content_width()), ); // Spans: ["Edit ", path, optional suffix spans...] // Only the path span (index 1) is selectable. let path_end = if line.spans.len() > 2 { 2 } else { line.spans.len() }; RenderedBlockOutput::from(BlockOutput { lines: vec![BlockLine { selectable: Selectable::Spans(1..path_end), selection_range: Some(TOOL_HEADER_RANGE), // Copy the painted path span (basename when collapsed). content: line, link_url, ..Default::default() }], }) } DisplayMode::Truncated | DisplayMode::Expanded => { let config = DiffRenderConfig { indent: edit_cfg.indent, gutter_bg: edit_cfg.gutter_bg, indent_bg: edit_cfg.indent_bg, hunk_separator: edit_cfg.hunk_separator.clone(), dual_line_numbers: edit_cfg.dual_line_numbers, }; let header = self.header_line( &theme, false, show_summary, dim_details, crate::render::tool_paths::ToolPathSurface::Expanded, cwd, None, ); let bullet_indent = ctx .appearance .scrollback .blocks .tool .bullet .char() .map(|c| unicode_width::UnicodeWidthStr::width(c) + 1) .unwrap_or(0); let wrapped_header = wrap_edit_header(header, ctx.width as usize, bullet_indent); let mut lines: Vec = Vec::with_capacity(wrapped_header.len()); let mut boundary_entries = Vec::new(); for (line_index, line) in wrapped_header.into_iter().enumerate() { let has_path = line.path_spans.is_some(); if let Some(boundary) = line.selection_boundary { boundary_entries.push(SelectionBoundaryEntry { line_index, boundary: Arc::new(SelectionBoundary::new( boundary.prefix, boundary.suffix, )), }); } lines.push(BlockLine { selectable: line.path_spans.map_or(Selectable::None, Selectable::Spans), selection_range: Some(TOOL_HEADER_RANGE), selection_text: line.selection_text, joiner: line.joiner, content: line.content, link_url: if has_path { link_url.clone() } else { None }, ..Default::default() }); } if let Some(err) = &self.error { lines.push(BlockLine::separator(Line::from(""))); for line in err.lines() { lines.push(BlockLine::separator(Line::from(Span::styled( line.to_string(), theme.muted(), )))); } } if !self.hunks.is_empty() { // Empty line after header (non-selectable) lines.push(BlockLine::separator(Line::from(""))); let diff_lines = self.render_diff_lines(&theme, ctx.width, &config); let mut diff_range_id: u16 = 1; for output in diff_lines { if output.is_separator { lines.push(BlockLine::separator(output.line)); diff_range_id += 1; continue; } let total = output.line.spans.len(); let gc = output.gutter_span_count; let mut block_line = BlockLine { selectable: if gc < total { Selectable::Spans(gc..total) } else { Selectable::All }, selection_range: Some(diff_range_id), selection_text: Some(output.content_text), joiner: output.joiner, content: output.line, ..Default::default() }; if let Some(bg_color) = output.background { block_line = block_line.with_background_from(bg_color, output.content_start_col); } lines.push(block_line); } } if let Some(max) = ctx.max_lines { lines.truncate(max as usize); } boundary_entries.retain(|entry| entry.line_index < lines.len()); RenderedBlockOutput { output: BlockOutput { lines }, boundaries: SelectionBoundaries::from_entries(boundary_entries), } } } } } impl BlockContent for EditToolCallBlock { fn output(&self, ctx: &BlockContext) -> BlockOutput { self.rendered_output(ctx).output } fn accent(&self, ctx: &BlockContext) -> Option { // Failed edits are marked by a red bullet(), not this accent. ctx.appearance .scrollback .blocks .edit .accent .map(AccentStyle::static_color) } fn bullet(&self, _ctx: &BlockContext) -> Option { // Failed edit: red bullet. Successful: default (gray/primary). if self.error.is_some() { let theme = Theme::current(); Some(AccentStyle::static_color(theme.accent_error)) } else { None } } fn accent_background(&self, ctx: &BlockContext) -> bool { ctx.appearance.scrollback.blocks.edit.accent_bg } fn has_vpad(&self, ctx: &BlockContext) -> bool { ctx.appearance.scrollback.blocks.edit.vpad } fn background(&self, ctx: &BlockContext) -> BlockBackground { ctx.appearance.scrollback.blocks.edit.bg } fn has_raw_mode(&self) -> bool { false } fn is_foldable(&self) -> bool { !self.hunks.is_empty() || self.error.is_some() } fn default_display_mode(&self) -> DisplayMode { // Context-free: the effective expanded default (pager.toml shape > // collapsed_edit_blocks flag) and the untrusted-summary escape live // in ScrollbackState's materialize policy (push / replace_tool_block). DisplayMode::Collapsed } fn finished_display_mode(&self) -> Option { if self.error.is_some() { Some(DisplayMode::Collapsed) } else { // Keep current mode (the config-aware default, or a user fold). None } } fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode { match current { DisplayMode::Collapsed => DisplayMode::Expanded, _ => DisplayMode::Collapsed, } } fn preamble(&self, ctx: &BlockContext) -> Option> { let theme = Theme::current(); let dim_details = ctx.appearance.scrollback.blocks.tool.dim_details; // Same effective toggle as `rendered_output` (moot here: the suffix // is collapsed-only and this is the Fullscreen surface). let show_summary = ctx .appearance .scrollback .blocks .edit .effective_line_summary(crate::appearance::cache::load_collapsed_edit_blocks()); Some(Text::from(self.header_line( &theme, false, show_summary, dim_details, crate::render::tool_paths::ToolPathSurface::Fullscreen, ctx.cwd.as_deref(), None, ))) } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; use crate::appearance::AppearanceConfig; use crate::diff::DiffLine; use crate::scrollback::types::DisplayMode; fn test_ctx() -> BlockContext { BlockContext { mode: DisplayMode::Expanded, is_running: false, width: 80, raw: false, max_lines: None, appearance: AppearanceConfig::default(), is_selected: false, cwd: None, } } fn make_hunk() -> DiffHunk { vec![ DiffLine { text: "let x = 1;\n".into(), lo: 10, ln: 10, tag: ChangeTag::Equal, }, DiffLine { text: "let y = 2;\n".into(), lo: 11, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "let y = 3;\n".into(), lo: 0, ln: 11, tag: ChangeTag::Insert, }, DiffLine { text: "let z = 4;\n".into(), lo: 12, ln: 12, tag: ChangeTag::Equal, }, ] } fn line_to_string(line: &Line<'_>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } fn diff_outputs_to_string(outputs: &[DiffLineOutput]) -> String { outputs .iter() .map(|o| line_to_string(&o.line)) .collect::>() .join("\n") } #[test] fn test_gutter_layout_single() { let hunk = make_hunk(); let config = DiffRenderConfig::default(); let layout = gutter_layout(&hunk, &config); assert!(!layout.dual); assert_eq!(layout.width_new, 2); } #[test] fn test_gutter_layout_dual() { let hunk = make_hunk(); let config = DiffRenderConfig { dual_line_numbers: true, ..Default::default() }; let layout = gutter_layout(&hunk, &config); assert!(layout.dual); assert_eq!(layout.width_old, 2); assert_eq!(layout.width_new, 2); } use crate::render::tool_paths::ToolPathSurface; use std::path::Path; #[test] fn test_edit_block_header() { let block = EditToolCallBlock::new("src/main.rs", vec![]); let theme = Theme::current(); let header = block.header_line( &theme, false, false, false, ToolPathSurface::Expanded, None, None, ); let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "Edit src/main.rs"); } #[test] fn test_edit_block_header_multi_edit() { // The "(N edits)" fallback is collapsed-only, like the diffstat. let block = EditToolCallBlock::new("src/main.rs", vec![]).with_edit_count(3); let theme = Theme::current(); let header = block.header_line( &theme, false, false, false, ToolPathSurface::Collapsed, None, Some(80), ); let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "Edit main.rs (3 edits)"); } #[test] fn header_diffstat_spans_use_diff_colors() { let block = EditToolCallBlock::new("/Users/me/project/src/foo.rs", vec![make_hunk()]); let theme = Theme::current(); let header = block.header_line( &theme, false, true, false, ToolPathSurface::Collapsed, None, Some(80), ); // Spans: ["Edit ", basename, " +1", "/", "-1"] — path stays span 1 so // the collapsed arm's selection/link invariant holds. Sole pin of the // exact diffstat suffix format. assert_eq!(header.spans.len(), 5); assert_eq!(header.spans[0].content.as_ref(), "Edit "); assert_eq!(header.spans[1].content.as_ref(), "foo.rs"); assert_eq!(header.spans[2].content.as_ref(), " +1"); assert_eq!(header.spans[2].style.fg, Some(theme.diff_insert_fg)); assert_eq!(header.spans[3].content.as_ref(), "/"); assert_eq!(header.spans[4].content.as_ref(), "-1"); assert_eq!(header.spans[4].style.fg, Some(theme.diff_delete_fg)); // The suffix is collapsed-only: expanded and fullscreen headers stay // bare — the hunks/body carry the information there. Both suffix // shapes (diffstat, "(N edits)" fallback) are gated. let multi = block.clone().with_edit_count(3); for surface in [ToolPathSurface::Expanded, ToolPathSurface::Fullscreen] { let header = block.header_line(&theme, false, true, false, surface, None, None); assert_eq!(header.spans.len(), 2, "no suffix spans on {surface:?}"); let header = multi.header_line(&theme, false, false, false, surface, None, None); assert_eq!( header.spans.len(), 2, "no (N edits) fallback on {surface:?}" ); } } #[test] fn untrusted_summary_suppresses_diffstat() { // Counts would only describe the first diff of a multi-file call, so // the suffix falls back to "(N edits)" / nothing. let block = EditToolCallBlock::new("src/foo.rs", vec![make_hunk()]).with_untrusted_summary(); let theme = Theme::current(); let header = block.header_line( &theme, false, true, false, ToolPathSurface::Collapsed, None, Some(80), ); let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "Edit foo.rs"); let block = block.with_edit_count(3); let header = block.header_line( &theme, false, true, false, ToolPathSurface::Collapsed, None, Some(80), ); let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "Edit foo.rs (3 edits)"); } #[test] fn collapsed_mode_renders_header_only() { // The block's context-free default is Collapsed (the effective // expanded default is applied by ScrollbackState's materialize // policy, pinned in state/mod.rs). let block = EditToolCallBlock::new("src/foo.rs", vec![make_hunk()]); let entry = crate::scrollback::entry::ScrollbackEntry::new( crate::scrollback::block::RenderBlock::ToolCall( crate::scrollback::blocks::tool::ToolCallBlock::Edit(block.clone()), ), ); assert_eq!(entry.display_mode, DisplayMode::Collapsed); let mut ctx = test_ctx(); ctx.mode = DisplayMode::Collapsed; let output = block.output(&ctx); assert_eq!(output.lines.len(), 1, "collapsed shows the header only"); // Exact suffix format is pinned by header_diffstat_spans_use_diff_colors. let text = line_to_string(&output.lines[0].content); assert!(text.starts_with("Edit foo.rs"), "header line: {text:?}"); assert!(!text.contains("let y"), "no diff body while collapsed"); } #[test] fn expanded_shows_relative_when_under_cwd_preamble_absolute() { let abs = "/Users/me/project/src/foo.rs"; let cwd = Path::new("/Users/me/project"); let block = EditToolCallBlock::new(abs, vec![]); let theme = Theme::current(); let header = block.header_line( &theme, false, false, false, ToolPathSurface::Expanded, Some(cwd), None, ); let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "Edit src/foo.rs"); let mut ctx = test_ctx(); ctx.mode = DisplayMode::Expanded; ctx.cwd = Some(cwd.to_path_buf()); let preamble = block.preamble(&ctx).unwrap(); let preamble_text: String = preamble .lines .iter() .flat_map(|l| l.spans.iter()) .map(|s| s.content.as_ref()) .collect(); assert_eq!(preamble_text, "Edit /Users/me/project/src/foo.rs"); } #[test] fn collapsed_selection_matches_painted_basename() { use crate::scrollback::types::derive_selection_text; let block = EditToolCallBlock::new("/Users/me/project/src/foo.rs", vec![]); let mut ctx = test_ctx(); ctx.mode = DisplayMode::Collapsed; let output = block.output(&ctx); let header = &output.lines[0]; assert_eq!(header.content.spans[1].content.as_ref(), "foo.rs"); assert_eq!(derive_selection_text(header), "foo.rs"); assert!(header.selection_text.is_none()); } #[test] fn header_link_url_is_absolute_file_url_for_all_surfaces() { let abs = "/Users/me/project/src/foo.rs"; let cwd = Path::new("/Users/me/project"); let block = EditToolCallBlock::new(abs, vec![]); let url = block.path_link_url(Some(cwd)).expect("file url"); assert!(url.starts_with("file://"), "got {url}"); assert!(url.contains("foo.rs"), "got {url}"); let mut ctx = test_ctx(); ctx.cwd = Some(cwd.to_path_buf()); ctx.mode = DisplayMode::Collapsed; let collapsed = block.output(&ctx); assert_eq!( collapsed.lines[0].content.spans[1].content.as_ref(), "foo.rs" ); assert_eq!(collapsed.lines[0].link_url.as_deref(), Some(url.as_ref())); ctx.mode = DisplayMode::Expanded; let expanded = block.output(&ctx); assert_eq!( expanded.lines[0].content.spans[1].content.as_ref(), "src/foo.rs" ); assert_eq!(expanded.lines[0].link_url.as_deref(), Some(url.as_ref())); } #[test] fn wrapped_header_path_provenance_survives_all_wrap_shapes() { let cases = [ "src/averyverylongfilename.rs", "dir with spaces/file name.rs", "src/界面/éclair_文件.rs", " foo.rs", "foo.rs ", ]; let mut saw_mid_word = false; for path in cases { for width in 8..=48 { // Mirrors production: expanded headers are prefix + path only // (the diffstat suffix is collapsed-only, and collapsed // headers never reach wrap_edit_header). let header = Line::from(vec![Span::raw("Edit "), Span::raw(path.to_owned())]); let wrapped = wrap_edit_header(header, width, 2); let mut reassembled = String::new(); for row in &wrapped { let Some(fragment) = &row.selection_text else { assert!( row.path_spans.is_none(), "non-selectable row retained path spans at width {width}" ); continue; }; assert!(row.path_spans.is_some()); if !reassembled.is_empty() && let Some(joiner) = &row.joiner { reassembled.push_str(joiner); } if let Some(boundary) = &row.selection_boundary { reassembled.push_str(&boundary.prefix); } reassembled.push_str(fragment); if let Some(boundary) = &row.selection_boundary { reassembled.push_str(&boundary.suffix); } saw_mid_word |= row.joiner.as_deref() == Some(""); } assert_eq!(reassembled, path, "path mismatch at width {width}"); } } assert!(saw_mid_word, "missing mid-word continuation coverage"); } #[test] fn test_edit_block_output_line_count() { let block = EditToolCallBlock::new("src/lib.rs", vec![make_hunk()]); let ctx = test_ctx(); let output = block.output(&ctx); // header + empty + 4 diff assert_eq!(output.lines.len(), 6); } #[test] fn test_edit_block_backgrounds() { let block = EditToolCallBlock::new("test.rs", vec![make_hunk()]); let ctx = test_ctx(); let output = block.output(&ctx); // header assert_eq!(output.lines[0].background, None); // equal assert_eq!(output.lines[2].background, None); // delete assert!(output.lines[3].background.is_some()); // insert assert!(output.lines[4].background.is_some()); // equal assert_eq!(output.lines[5].background, None); // Insert/delete shading is semantic, NOT a decorative panel — it must // survive minimal mode's flat rendering (EntryRenderer::flat_background), // so it must never be marked `background_is_panel`. assert!( output.lines.iter().all(|l| !l.background_is_panel), "diff shading must not be marked panel" ); } #[test] fn test_edit_block_no_accent() { let block = EditToolCallBlock::new("test.rs", vec![]); let ctx = test_ctx(); assert!(block.accent(&ctx).is_none()); } #[test] fn test_diff_line_exact_layout() { let hunk = vec![ DiffLine { text: "old line\n".into(), lo: 5, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "new line\n".into(), lo: 0, ln: 5, tag: ChangeTag::Insert, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config); assert_eq!(outputs.len(), 2); assert_eq!(line_to_string(&outputs[0].line), " 5 old line"); assert_eq!(line_to_string(&outputs[1].line), " 5 new line"); assert_eq!(outputs[0].content_start_col, 5); assert_eq!(outputs[1].content_start_col, 5); } #[test] fn test_diff_line_exact_layout_two_digit() { let hunk = vec![ DiffLine { text: "context\n".into(), lo: 10, ln: 10, tag: ChangeTag::Equal, }, DiffLine { text: "deleted\n".into(), lo: 11, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "inserted\n".into(), lo: 0, ln: 11, tag: ChangeTag::Insert, }, DiffLine { text: "more context\n".into(), lo: 12, ln: 12, tag: ChangeTag::Equal, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config); assert_eq!(outputs.len(), 4); assert_eq!(line_to_string(&outputs[0].line), " 10 context"); assert_eq!(line_to_string(&outputs[1].line), " 11 deleted"); assert_eq!(line_to_string(&outputs[2].line), " 11 inserted"); assert_eq!(line_to_string(&outputs[3].line), " 12 more context"); for output in &outputs { assert_eq!(output.content_start_col, 6); } } #[test] fn test_diff_gutter_bg_flag() { let hunk = vec![DiffLine { text: "inserted\n".into(), lo: 0, ln: 5, tag: ChangeTag::Insert, }]; let theme = Theme::current(); let path = Path::new("test.txt"); // Default: indent=false, gutter_bg=false let config_default = DiffRenderConfig::default(); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config_default); // Without indent, gutter is narrower assert_eq!(outputs[0].background, Some(theme.diff_insert_bg)); let config_gutter = DiffRenderConfig { indent: false, gutter_bg: true, indent_bg: true, ..Default::default() }; let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config_gutter); assert_eq!(outputs[0].content_start_col, 0); assert_eq!(outputs[0].background, Some(theme.diff_insert_bg)); } #[test] fn test_diff_reflow_wrapping() { let hunk = vec![DiffLine { text: "this is a very long line that should wrap when width is narrow\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 30, &config); assert!(outputs.len() > 1, "got {} lines", outputs.len()); let first = line_to_string(&outputs[0].line); assert_eq!(&first[..5], " 1 "); let second = line_to_string(&outputs[1].line); assert_eq!(&second[..5], " "); for output in &outputs { assert_eq!(output.content_start_col, 5); assert_eq!(output.background, None); } } /// Regression pin: wrapped continuation segments of a /// changed line used to fall back to `text_primary` (= Reset on bandless /// themes), reading like plain context. #[test] fn test_diff_reflow_keeps_change_fg_for_bandless_theme() { let theme = Theme::terminal_default(); assert!(theme.diff_uses_line_fg(), "palette must be bandless"); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); for (tag, expected_fg, label) in [ (ChangeTag::Insert, theme.diff_insert_fg, "insert"), (ChangeTag::Delete, theme.diff_delete_fg, "delete"), ] { let hunk = vec![DiffLine { text: "this is a long changed line that needs to wrap around into \ several continuation segments\n" .into(), lo: if tag == ChangeTag::Delete { 1 } else { 0 }, ln: if tag == ChangeTag::Insert { 1 } else { 0 }, tag, }]; let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 30, &config); assert!(outputs.len() > 1, "{label}: expected wrapping"); for (i, output) in outputs.iter().enumerate() { let content_fg = output .line .spans .iter() .skip(output.gutter_span_count) .find_map(|s| s.style.fg) .expect("content span with fg"); assert_eq!( content_fg, expected_fg, "{label}: wrapped segment {i} must keep the change foreground" ); } } } #[test] fn test_diff_reflow_preserves_background() { let hunk = vec![DiffLine { text: "this is a long inserted line that needs to wrap around\n".into(), lo: 0, ln: 1, tag: ChangeTag::Insert, }]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 30, &config); assert!(outputs.len() > 1); for output in &outputs { assert_eq!(output.background, Some(theme.diff_insert_bg)); } } #[test] fn test_diff_hunk_separator() { let hunk1 = vec![DiffLine { text: "line1\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }]; let hunk2 = vec![DiffLine { text: "line2\n".into(), lo: 10, ln: 10, tag: ChangeTag::Equal, }]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunks_highlighted(&[hunk1, hunk2], path, &theme, 80, &config); // Lines 2..=9 sit between the hunks: computable gap of 8. assert_eq!(outputs.len(), 3); assert!(outputs[1].is_separator); assert_eq!(line_to_string(&outputs[1].line), " … 8 unchanged lines"); assert_eq!(outputs[1].background, None); } #[test] fn hunk_separator_singular_gap() { let hunk1 = vec![DiffLine { text: "line1\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }]; let hunk2 = vec![DiffLine { text: "line3\n".into(), lo: 3, ln: 3, tag: ChangeTag::Equal, }]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunks_highlighted(&[hunk1, hunk2], path, &theme, 80, &config); assert_eq!(line_to_string(&outputs[1].line), " … 1 unchanged line"); } #[test] fn hunk_separator_bare_for_non_monotonic_or_adjacent() { let mk = |ln: usize| { vec![DiffLine { text: format!("line{ln}\n"), lo: ln, ln, tag: ChangeTag::Equal, }] }; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); // Non-monotonic ln (a coalesced later edit above an earlier one): // never render a negative/zero count, keep the bare separator. let outputs = render_diff_hunks_highlighted(&[mk(20), mk(4)], path, &theme, 80, &config); assert_eq!(line_to_string(&outputs[1].line), " …"); // Adjacent hunks (no hidden lines) keep the bare separator too. let outputs = render_diff_hunks_highlighted(&[mk(5), mk(6)], path, &theme, 80, &config); assert_eq!(line_to_string(&outputs[1].line), " …"); // A hunk with no new-file lines (pure deletion) is not computable. let pure_delete = vec![DiffLine { text: "gone\n".into(), lo: 9, ln: 9, tag: ChangeTag::Delete, }]; let outputs = render_diff_hunks_highlighted(&[mk(5), pure_delete], path, &theme, 80, &config); assert_eq!(line_to_string(&outputs[1].line), " …"); } #[test] fn snapshot_diff_basic() { let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&make_hunk(), path, &theme, 80, &config); insta::assert_snapshot!("diff_basic", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_three_digit_lines() { let hunk = vec![ DiffLine { text: "context before\n".into(), lo: 99, ln: 99, tag: ChangeTag::Equal, }, DiffLine { text: "old code\n".into(), lo: 100, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "new code\n".into(), lo: 0, ln: 100, tag: ChangeTag::Insert, }, DiffLine { text: "context after\n".into(), lo: 101, ln: 101, tag: ChangeTag::Equal, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config); insta::assert_snapshot!("diff_three_digit_lines", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_reflow() { let hunk = vec![ DiffLine { text: "short line\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }, DiffLine { text: "this is a very long line that will definitely wrap to multiple lines\n" .into(), lo: 0, ln: 2, tag: ChangeTag::Insert, }, DiffLine { text: "another short one\n".into(), lo: 2, ln: 3, tag: ChangeTag::Equal, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 40, &config); insta::assert_snapshot!("diff_reflow", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_multiple_hunks() { let hunk1 = vec![ DiffLine { text: "first hunk context\n".into(), lo: 5, ln: 5, tag: ChangeTag::Equal, }, DiffLine { text: "deleted in first\n".into(), lo: 6, ln: 0, tag: ChangeTag::Delete, }, ]; let hunk2 = vec![ DiffLine { text: "second hunk context\n".into(), lo: 50, ln: 49, tag: ChangeTag::Equal, }, DiffLine { text: "inserted in second\n".into(), lo: 0, ln: 50, tag: ChangeTag::Insert, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunks_highlighted(&[hunk1, hunk2], path, &theme, 80, &config); insta::assert_snapshot!("diff_multiple_hunks", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_merged_hunks_gap_markers() { // Shape of a coalesced block: hunks from consecutive same-file edits // appended in completion order, monotonically increasing, so every // separator carries a computable gap count. let hunk1 = vec![ DiffLine { text: "fn one() {\n".into(), lo: 3, ln: 3, tag: ChangeTag::Equal, }, DiffLine { text: "old_one();\n".into(), lo: 4, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "new_one();\n".into(), lo: 0, ln: 4, tag: ChangeTag::Insert, }, ]; let hunk2 = vec![ DiffLine { text: "ctx_two();\n".into(), lo: 12, ln: 12, tag: ChangeTag::Equal, }, DiffLine { text: "add_two();\n".into(), lo: 0, ln: 13, tag: ChangeTag::Insert, }, ]; let hunk3 = vec![ DiffLine { text: "ctx_three();\n".into(), lo: 19, ln: 20, tag: ChangeTag::Equal, }, DiffLine { text: "add_three();\n".into(), lo: 0, ln: 21, tag: ChangeTag::Insert, }, ]; let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("test.txt"); let outputs = render_diff_hunks_highlighted(&[hunk1, hunk2, hunk3], path, &theme, 80, &config); insta::assert_snapshot!( "diff_merged_hunks_gap_markers", diff_outputs_to_string(&outputs) ); } fn dual_config() -> DiffRenderConfig { DiffRenderConfig { dual_line_numbers: true, ..Default::default() } } #[test] fn snapshot_diff_basic_dual() { let theme = Theme::current(); let config = dual_config(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&make_hunk(), path, &theme, 80, &config); insta::assert_snapshot!("diff_basic_dual", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_three_digit_lines_dual() { let hunk = vec![ DiffLine { text: "context before\n".into(), lo: 99, ln: 99, tag: ChangeTag::Equal, }, DiffLine { text: "old code\n".into(), lo: 100, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "new code\n".into(), lo: 0, ln: 100, tag: ChangeTag::Insert, }, DiffLine { text: "context after\n".into(), lo: 101, ln: 101, tag: ChangeTag::Equal, }, ]; let theme = Theme::current(); let config = dual_config(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config); insta::assert_snapshot!( "diff_three_digit_lines_dual", diff_outputs_to_string(&outputs) ); } #[test] fn snapshot_diff_reflow_dual() { let hunk = vec![ DiffLine { text: "short line\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }, DiffLine { text: "this is a very long line that will definitely wrap to multiple lines\n" .into(), lo: 0, ln: 2, tag: ChangeTag::Insert, }, DiffLine { text: "another short one\n".into(), lo: 2, ln: 3, tag: ChangeTag::Equal, }, ]; let theme = Theme::current(); let config = dual_config(); let path = Path::new("test.txt"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 40, &config); insta::assert_snapshot!("diff_reflow_dual", diff_outputs_to_string(&outputs)); } #[test] fn snapshot_diff_multiple_hunks_dual() { let hunk1 = vec![ DiffLine { text: "first hunk context\n".into(), lo: 5, ln: 5, tag: ChangeTag::Equal, }, DiffLine { text: "deleted in first\n".into(), lo: 6, ln: 0, tag: ChangeTag::Delete, }, ]; let hunk2 = vec![ DiffLine { text: "second hunk context\n".into(), lo: 50, ln: 49, tag: ChangeTag::Equal, }, DiffLine { text: "inserted in second\n".into(), lo: 0, ln: 50, tag: ChangeTag::Insert, }, ]; let theme = Theme::current(); let config = dual_config(); let path = Path::new("test.txt"); let outputs = render_diff_hunks_highlighted(&[hunk1, hunk2], path, &theme, 80, &config); insta::assert_snapshot!("diff_multiple_hunks_dual", diff_outputs_to_string(&outputs)); } /// Three-line all-Insert Go hunk with a tab-indented body (new-file lines 1-3). fn go_tab_hunk() -> DiffHunk { vec![ DiffLine { text: "func main() {\n".into(), lo: 0, ln: 1, tag: ChangeTag::Insert, }, DiffLine { text: "\tfmt.Println(\"hello\")\n".into(), lo: 0, ln: 2, tag: ChangeTag::Insert, }, DiffLine { text: "}\n".into(), lo: 0, ln: 3, tag: ChangeTag::Insert, }, ] } #[test] fn tabs_expanded_in_diff_lines() { // Simulates creating a new file with tab-indented content (e.g. Go, Makefile). // All lines are Insert — tabs must be expanded to spaces so they're visible. let hunk = go_tab_hunk(); let theme = Theme::current(); let config = DiffRenderConfig::default(); let path = Path::new("main.go"); let outputs = render_diff_hunk_highlighted(&hunk, path, &theme, 80, &config); assert_eq!(outputs.len(), 3); // The tab on line 2 should be expanded to spaces (default tab_width=4). let line2 = line_to_string(&outputs[1].line); assert!( !line2.contains('\t'), "tab character should be expanded, got: {:?}", line2, ); assert!( line2.contains(" fmt"), "tab should become 4 spaces, got: {:?}", line2, ); assert!( !outputs[1].content_text.contains('\t'), "content_text should also have tabs expanded", ); } // Asserts use **raw syntect RGB** (not ratatui FG after quantize). Under // `NO_COLOR` quantize maps every RGB → Reset, which would make keyword vs // string asserts tautological / false. type Rgb = (u8, u8, u8); type SyntectSpans = Vec<(Rgb, String)>; /// Pins KigiNight via the shared test-lock guard; hold it for the whole /// test so a concurrent theme flip can't skew the compared highlighter walks. fn pin_kiginight_syntect() -> std::sync::MutexGuard<'static, ()> { let guard = crate::theme::cache::pin_theme(); assert!( !Theme::kiginight().diff_uses_line_fg(), "KigiNight must be banded for Equal-line syntect path in render" ); guard } fn rgb_of(style: SyntectStyle) -> Rgb { (style.foreground.r, style.foreground.g, style.foreground.b) } fn highlight_line_raw(line: &str, hl: &mut HighlightLines<'_>) -> SyntectSpans { let syntect = get_syntect(); let owned = format!("{line}\n"); let ranges = hl .highlight_line(&owned, &syntect.syntax_set) .unwrap_or_default(); let mut spans = Vec::new(); for (style, segment) in ranges { let mut text = segment.to_owned(); while text.ends_with('\n') || text.ends_with('\r') { text.pop(); } if text.is_empty() { continue; } spans.push((rgb_of(style), text)); } spans } /// Hunk-only: fresh highlighter, only the given lines in order (prod path). /// Caller pins the theme via `pin_kiginight_syntect` (lock is not reentrant). fn hunk_only_raw_styles(path: &Path, lines: &[&str]) -> Vec { let syntect = get_syntect(); let mut hl = syntect .highlight_lines_by_file_path(path) .expect("syntax for path"); lines .iter() .map(|line| highlight_line_raw(line, &mut hl)) .collect() } /// Full-file then slice: silent HL from line 1, return styles for all lines. /// Caller pins the theme via `pin_kiginight_syntect` (lock is not reentrant). fn full_file_raw_styles(path: &Path, file_text: &str) -> Vec { let syntect = get_syntect(); let mut hl = syntect .highlight_lines_by_file_path(path) .expect("syntax for path"); file_text .lines() .map(|line| highlight_line_raw(line, &mut hl)) .collect() } /// Triple-quote-spill shape: synthetic file text + 1-based line of the /// closing `"""`. Content is fictional (layout stress only). fn fixture_python_parts() -> (String, usize) { let file = "\ class ProcessQueueItem(BaseModel): \"\"\"Request body for processing a single queue item. The item id is in the path; keep notes in the body. \"\"\" notes: str = Field(..., min_length=1) category_id: CategoryId = Field(default=DEFAULT_CATEGORY_ID) " .to_string(); let close_ln = file .lines() .enumerate() .find(|(_, l)| l.trim() == "\"\"\"") .map(|(i, _)| i + 1) .expect("closing triple-quote"); (file, close_ln) } /// Control: self-contained Rust line → keyword RGB ≠ string RGB (raw syntect). #[test] fn syntax_highlight_splits_keyword_and_string_fg() { let _guard = pin_kiginight_syntect(); let path = Path::new("probe.rs"); let lines = hunk_only_raw_styles(path, &["let x = \"hello\";"]); assert_eq!(lines.len(), 1); let styles = &lines[0]; let let_rgb = styles .iter() .find(|(_, t)| t.contains("let")) .map(|(c, _)| *c) .expect("keyword span"); let str_rgb = styles .iter() .find(|(_, t)| t.contains("hello")) .map(|(c, _)| *c) .expect("string span"); assert_ne!( let_rgb, str_rgb, "keyword and string must not share syntect FG; styles={styles:?}" ); } /// Build a DiffHunk spanning the closing `"""` through end of the fixture. fn fixture_python_close_hunk() -> (String, DiffHunk, usize) { let (file, close_ln) = fixture_python_parts(); let all: Vec<&str> = file.lines().collect(); // 0-based let start = close_ln - 1; let mut hunk = DiffHunk::new(); for (i, line) in all[start..].iter().enumerate() { // 1-based let ln = start + i + 1; hunk.push(DiffLine { text: format!("{line}\n"), lo: ln, ln, tag: ChangeTag::Equal, }); } let field_offset = all[start..] .iter() .position(|l| l.contains("notes") || l.contains("category_id")) .expect("field line in hunk"); // 1-based field ln (file, hunk, start + field_offset + 1) } /// Fix pin: file-scoped field styles match full-file raw styles /// (and differ from cold hunk-only spill) after a mid-file closing `"""`. #[test] fn file_scoped_matches_full_file_on_field_line() { let _guard = pin_kiginight_syntect(); let path = Path::new("queue_item.py"); let (file, hunk, field_ln) = fixture_python_close_hunk(); let close_ln = hunk[0].ln; let hunk_lines: Vec<&str> = hunk.iter().map(|l| l.text.trim_end_matches('\n')).collect(); let field_offset = field_ln - close_ln; let full = full_file_raw_styles(path, &file); let cold = hunk_only_raw_styles(path, &hunk_lines); let field_full = &full[field_ln - 1]; let close_full = &full[close_ln - 1]; assert_ne!( field_full, close_full, "full-file HL must not paint field line like the docstring closer" ); assert_ne!( cold[field_offset], *field_full, "cold hunk-only must still spill vs full-file on field line" ); assert!( file_text_within_hl_caps(&file), "fixture must be under HL caps" ); let map = compute_file_scoped_styles(path, &file, std::slice::from_ref(&hunk)) .expect("matching file+hunk must yield styles"); let field_styles = map .get(&field_ln) .expect("file-scoped map must include field line"); // Compare span texts to full-file raw; styles come from the same HL walk. let field_text: String = field_styles.iter().map(|(_, t)| t.as_str()).collect(); let full_text: String = field_full.iter().map(|(_, t)| t.as_str()).collect(); assert_eq!( field_text, full_text, "file-scoped field text must equal full-file slice text" ); // Span segmentation must match full-file (same syntect walk with correct state). let scoped_segs: Vec<&str> = field_styles.iter().map(|(_, t)| t.as_str()).collect(); let full_segs: Vec<&str> = field_full.iter().map(|(_, t)| t.as_str()).collect(); assert_eq!( scoped_segs, full_segs, "file-scoped segmentation must match full-file on field line; \ scoped={scoped_segs:?} full={full_segs:?}" ); // Paint uses hunk text (not a disk rewrite). let field_hunk_text = hunk_lines[field_offset].to_string(); let mut block = EditToolCallBlock::new("queue_item.py", vec![hunk]); block.highlight = EditHighlightPhase::FileScoped { by_new_line: Arc::new(map), theme: crate::theme::cache::current_kind(), }; let out = block.output(&test_ctx()); let joined: String = out .lines .iter() .map(|l| { l.content .spans .iter() .map(|s| s.content.as_ref()) .collect::() }) .collect::>() .join("\n"); assert!( joined.contains(field_hunk_text.trim()), "FileScoped output must include hunk field text; got:\n{joined}" ); } /// Pins both directions of the `render_diff_lines` theme gate with one /// paintable map: under its baking theme it repaints (positive control); /// baked under another theme it must not paint — hunk-only output. #[test] fn file_scoped_stale_theme_falls_back_to_hunk_only() { let _guard = pin_kiginight_syntect(); let path = Path::new("queue_item.py"); let (file, hunk, field_ln) = fixture_python_close_hunk(); // Real computed map: paintable by construction (line keys and texts // match the hunk). let map = compute_file_scoped_styles(path, &file, std::slice::from_ref(&hunk)) .expect("matching file+hunk must yield styles"); assert!( map.contains_key(&field_ln), "computed map must cover the field line" ); let by_new_line = Arc::new(map); let mut block = EditToolCallBlock::new("queue_item.py", vec![hunk]); let hunk_only = block.output(&test_ctx()); let render = |out: &BlockOutput| -> Vec { out.lines .iter() .map(|l| { l.content .spans .iter() .map(|s| format!("{:?}:{}", s.style.fg, s.content)) .collect::() }) .collect() }; // Positive control: under its baking theme the map must repaint the // spilled field line, so the stale assert below cannot go vacuous. block.highlight = EditHighlightPhase::FileScoped { by_new_line: Arc::clone(&by_new_line), theme: crate::theme::cache::current_kind(), }; let fresh_out = block.output(&test_ctx()); assert_ne!( render(&hunk_only), render(&fresh_out), "fresh-theme FileScoped must paint the map (differ from hunk-only)" ); // Stale: the SAME paintable map baked under another theme is skipped. let stale = ThemeKind::KigiDay; assert_ne!(stale, crate::theme::cache::current_kind()); block.highlight = EditHighlightPhase::FileScoped { by_new_line, theme: stale, }; let stale_out = block.output(&test_ctx()); assert_eq!( render(&hunk_only), render(&stale_out), "stale-theme FileScoped must paint exactly like hunk-only" ); } /// Disk line ≠ hunk line → compute refuses upgrade (no content swap). #[test] fn compute_file_scoped_rejects_disk_hunk_mismatch() { let path = Path::new("probe.py"); let file = "x = 1\ny = 2\n"; let hunk = vec![DiffLine { // differs from disk line 1 text: "x = 999\n".into(), lo: 1, ln: 1, tag: ChangeTag::Insert, }]; assert!( compute_file_scoped_styles(path, file, std::slice::from_ref(&hunk)).is_none(), "mismatch must refuse FileScoped" ); } /// Caps reject oversized file text so the worker stays HunkOnly. #[test] fn file_text_caps_reject_huge_input() { let huge = "x\n".repeat(EDIT_HL_MAX_LINES + 1); assert!(!file_text_within_hl_caps(&huge)); let ok = "x\n".repeat(10); assert!(file_text_within_hl_caps(&ok)); } /// FileScoped paint expands tabs like the cold path. #[test] fn tabs_expanded_in_file_scoped_paint() { let hunk = go_tab_hunk(); let file = "func main() {\n\tfmt.Println(\"hello\")\n}\n"; let path = Path::new("main.go"); let map = compute_file_scoped_styles(path, file, std::slice::from_ref(&hunk)) .expect("matching go fixture"); let theme = Theme::current(); let config = DiffRenderConfig::default(); let outputs = render_diff_hunks_with_styles(&[hunk], path, &map, &theme, 80, &config); assert_eq!(outputs.len(), 3); let line2 = line_to_string(&outputs[1].line); assert!( !line2.contains('\t'), "FileScoped must expand tabs, got: {line2:?}" ); assert!( line2.contains(" fmt") || line2.contains("fmt"), "tab-expanded content expected, got: {line2:?}" ); } /// Runnable pin: cold hunk-only and full-file disagree on the field line /// after a mid-file closing `"""`. #[test] fn triple_quote_hunk_only_differs_from_full_file_today() { let _guard = pin_kiginight_syntect(); let path = Path::new("queue_item.py"); let (file, hunk, field_ln) = fixture_python_close_hunk(); let close_ln = hunk[0].ln; let hunk_lines: Vec<&str> = hunk.iter().map(|l| l.text.trim_end_matches('\n')).collect(); let field_offset = field_ln - close_ln; let cold = hunk_only_raw_styles(path, &hunk_lines); let full = full_file_raw_styles(path, &file); // Full-file: field line is not painted like the closing """ string line. let close_styles = &full[close_ln - 1]; let field_full = &full[field_ln - 1]; assert_ne!( field_full, close_styles, "full-file HL must not paint field line like the docstring closer; \ field={field_full:?} close={close_styles:?}" ); assert_ne!( cold[field_offset], *field_full, "expected cold-start mismatch (hunk-only string spill vs full-file); \ if equal, the bug may already be fixed or the fixture no longer triggers" ); } }