Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
2738 lines
95 KiB
Rust
2738 lines
95 KiB
Rust
//! 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<HashMap<usize, EditLineStyles>>,
|
|
/// 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<Color>,
|
|
/// 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<String>,
|
|
/// 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<DiffLineOutput> {
|
|
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<DiffLineOutput> {
|
|
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<usize, EditLineStyles>>,
|
|
theme: &Theme,
|
|
width: u16,
|
|
config: &DiffRenderConfig,
|
|
) -> Vec<DiffLineOutput> {
|
|
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<usize> {
|
|
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<usize, String> {
|
|
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<HashMap<usize, EditLineStyles>> {
|
|
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<usize, EditLineStyles>,
|
|
theme: &Theme,
|
|
width: u16,
|
|
config: &DiffRenderConfig,
|
|
) -> Vec<DiffLineOutput> {
|
|
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<usize, EditLineStyles>,
|
|
theme: &Theme,
|
|
) -> Option<Vec<Span<'static>>> {
|
|
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<Span<'static>> = 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<Span<'static>>,
|
|
layout: &GutterLayout,
|
|
indent_width: usize,
|
|
content_width: usize,
|
|
theme: &Theme,
|
|
config: &DiffRenderConfig,
|
|
) -> Vec<DiffLineOutput> {
|
|
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<String> {
|
|
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<Span<'static>>,
|
|
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<HighlightLines<'_>>,
|
|
syntect: &Syntect,
|
|
) -> Vec<Span<'static>> {
|
|
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<DiffHunk>,
|
|
/// Number of edits (for multi-edit display).
|
|
pub edit_count: usize,
|
|
/// Error message if the tool call failed (None = success).
|
|
pub error: Option<String>,
|
|
/// When the tool started running (Phase 2: time tracking).
|
|
pub started_at: Option<std::time::Instant>,
|
|
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
|
pub elapsed_ms: Option<i64>,
|
|
/// 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<String>, hunks: Vec<DiffHunk>) -> 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<String>) -> 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<String>) {
|
|
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<i64> {
|
|
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<DiffHunk>) {
|
|
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<usize>,
|
|
) -> 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<Span<'static>> =
|
|
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<Arc<str>> {
|
|
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<DiffLineOutput> {
|
|
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<String>,
|
|
path_spans: Option<Range<usize>>,
|
|
selection_text: Option<String>,
|
|
selection_boundary: Option<EditSelectionBoundary>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct EditSelectionBoundary {
|
|
prefix: String,
|
|
suffix: String,
|
|
}
|
|
|
|
fn split_joiner_by_path(
|
|
joiner: &str,
|
|
source_start: usize,
|
|
path_range: &Range<usize>,
|
|
) -> (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<WrappedEditHeaderLine> {
|
|
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<WrappedEditHeaderLine> = Vec::with_capacity(wrapped.len());
|
|
let mut last_path_row: Option<usize> = 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<BlockLine> = 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<AccentStyle> {
|
|
// 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<AccentStyle> {
|
|
// 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<DisplayMode> {
|
|
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<Text<'static>> {
|
|
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::<Vec<_>>()
|
|
.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<SyntectSpans> {
|
|
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<SyntectSpans> {
|
|
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::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.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<String> {
|
|
out.lines
|
|
.iter()
|
|
.map(|l| {
|
|
l.content
|
|
.spans
|
|
.iter()
|
|
.map(|s| format!("{:?}:{}", s.style.fg, s.content))
|
|
.collect::<String>()
|
|
})
|
|
.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"
|
|
);
|
|
}
|
|
}
|