feat(pi-natives/chunk): implemented chunk grep tree output for safe edit
- Added hierarchical chunk grep output grouped by directory, file, and chunk with aligned chunk anchors. - Updated chunk rendering to show clipped head/tail context with explicit truncation and expand markers. - Changed chunk edit and streaming validation to use chunkToolEditSchema-based checks for safe edit detection. - Enhanced grep rendering to use structured ChunkedGrepMatch data with displayPath and chunk checksum metadata.
This commit is contained in:
@@ -70,6 +70,12 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result<EditResult
|
||||
let initial_parse_errors = state.tree.parse_errors;
|
||||
let initial_chunk_paths: std::collections::HashSet<String> =
|
||||
state.tree.chunks.iter().map(|c| c.path.clone()).collect();
|
||||
let initial_chunk_checksums: std::collections::HashMap<String, String> = state
|
||||
.tree
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|chunk| (chunk.path.clone(), chunk.checksum.clone()))
|
||||
.collect();
|
||||
let normalize_indent = params.normalize_indent.unwrap_or(true);
|
||||
let mut touched_paths = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
@@ -286,6 +292,7 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result<EditResult
|
||||
&diff_after,
|
||||
params.anchor_style,
|
||||
&touched_paths,
|
||||
&initial_chunk_checksums,
|
||||
normalize_indent,
|
||||
)
|
||||
} else {
|
||||
@@ -1852,17 +1859,15 @@ fn render_changed_hunks(
|
||||
after: &str,
|
||||
anchor_style: Option<ChunkAnchorStyle>,
|
||||
touched_paths: &[String],
|
||||
before_checksums: &std::collections::HashMap<String, String>,
|
||||
normalize_indent: bool,
|
||||
) -> String {
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let show_leaf_preview = state.language == "tlaplus";
|
||||
let focused_paths = compute_focus(state.tree(), touched_paths);
|
||||
let hunks = generate_diff_hunks(before, after, 0);
|
||||
|
||||
// Map each hunk to the chunk that should display it.
|
||||
// Walk from the deepest containing chunk upward until we find one that
|
||||
// has children (and therefore a closing tag in the tree output).
|
||||
let tree = state.tree();
|
||||
let tab_replacement = if normalize_indent {
|
||||
NORMALIZED_TAB_REPLACEMENT
|
||||
@@ -1874,39 +1879,57 @@ fn render_changed_hunks(
|
||||
let lookup: HashMap<&str, &ChunkNode> =
|
||||
tree.chunks.iter().map(|c| (c.path.as_str(), c)).collect();
|
||||
let render_indent = normalize_indent.then_some((file_indent_char, file_indent_step));
|
||||
|
||||
let mut inline_hunks: HashMap<String, Vec<crate::chunk::render::InlineHunk>> = HashMap::new();
|
||||
let mut orphan_hunks: Vec<&DiffHunk> = Vec::new();
|
||||
let mut changed_anchor_paths = HashSet::new();
|
||||
|
||||
for hunk in &hunks {
|
||||
// Find the deepest chunk containing this hunk's new-file start line.
|
||||
let owner = crate::chunk::render::find_hunk_owner_chunk(tree, &lookup, hunk.new_start);
|
||||
match owner {
|
||||
Some(chunk_path) => {
|
||||
let indent = crate::chunk::render::hunk_indent_for_chunk(
|
||||
&lookup,
|
||||
chunk_path,
|
||||
state.source(),
|
||||
tab_replacement,
|
||||
render_indent,
|
||||
);
|
||||
let mut lines = Vec::with_capacity(hunk.lines.len() + 1);
|
||||
lines.push(format!("{indent}{}", hunk.header));
|
||||
for line in &hunk.lines {
|
||||
let normalized =
|
||||
render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step);
|
||||
lines.push(format!("{indent}{normalized}"));
|
||||
}
|
||||
inline_hunks
|
||||
.entry(chunk_path.to_owned())
|
||||
.or_default()
|
||||
.push(crate::chunk::render::InlineHunk { lines });
|
||||
},
|
||||
None => orphan_hunks.push(hunk),
|
||||
let owner_path = owner.unwrap_or("");
|
||||
let indent = if owner_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
crate::chunk::render::hunk_indent_for_chunk(
|
||||
&lookup,
|
||||
owner_path,
|
||||
state.source(),
|
||||
tab_replacement,
|
||||
render_indent,
|
||||
)
|
||||
};
|
||||
let mut lines = Vec::with_capacity(hunk.lines.len() + 1);
|
||||
lines.push(format!("{indent}{}", hunk.header));
|
||||
for line in &hunk.lines {
|
||||
let normalized =
|
||||
render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step);
|
||||
lines.push(format!("{indent}{normalized}"));
|
||||
}
|
||||
inline_hunks
|
||||
.entry(owner_path.to_owned())
|
||||
.or_default()
|
||||
.push(crate::chunk::render::InlineHunk { lines });
|
||||
}
|
||||
|
||||
for path in touched_paths {
|
||||
let mut current = Some(path.as_str());
|
||||
while let Some(chunk_path) = current {
|
||||
if chunk_path.is_empty() {
|
||||
break;
|
||||
}
|
||||
let Some(chunk) = lookup.get(chunk_path) else {
|
||||
current = chunk_path.rfind('.').map(|dot| &chunk_path[..dot]);
|
||||
continue;
|
||||
};
|
||||
if before_checksums
|
||||
.get(&chunk.path)
|
||||
.is_none_or(|previous| previous != &chunk.checksum)
|
||||
{
|
||||
changed_anchor_paths.insert(chunk.path.clone());
|
||||
}
|
||||
current = chunk.parent_path.as_deref();
|
||||
}
|
||||
}
|
||||
|
||||
let tree_text = crate::chunk::render::render_state_with_hunks(
|
||||
crate::chunk::render::render_state_with_hunks(
|
||||
state,
|
||||
&RenderParams {
|
||||
chunk_path: Some(String::new()),
|
||||
@@ -1922,31 +1945,12 @@ fn render_changed_hunks(
|
||||
focused_paths,
|
||||
},
|
||||
inline_hunks,
|
||||
);
|
||||
|
||||
if orphan_hunks.is_empty() {
|
||||
return tree_text;
|
||||
}
|
||||
|
||||
// Append orphan hunks (not belonging to any named chunk) at the end.
|
||||
let orphan_text = orphan_hunks
|
||||
.iter()
|
||||
.flat_map(|hunk| {
|
||||
let mut lines = Vec::with_capacity(hunk.lines.len() + 1);
|
||||
lines.push(hunk.header.clone());
|
||||
lines.extend(hunk.lines.iter().map(|line| {
|
||||
render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step)
|
||||
}));
|
||||
lines
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!("{tree_text}\n\n{orphan_text}")
|
||||
changed_anchor_paths,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a focus list that includes touched chunks as Expanded, their
|
||||
/// immediate siblings as Collapsed, and all ancestors as Container.
|
||||
/// Build a focus list that includes touched chunks as Expanded and all
|
||||
/// ancestors as Container.
|
||||
/// Falls back to no focus (full render) when more than 20 chunks were touched.
|
||||
fn compute_focus(
|
||||
tree: &crate::chunk::types::ChunkTree,
|
||||
@@ -2001,23 +2005,6 @@ fn compute_focus(
|
||||
.get(parent_path)
|
||||
.and_then(|p| p.parent_path.as_deref());
|
||||
}
|
||||
|
||||
// Immediate prev/next sibling -> Collapsed.
|
||||
if let Some(parent_path) = chunk.parent_path.as_deref()
|
||||
&& let Some(parent) = lookup.get(parent_path)
|
||||
&& let Some(idx) = parent.children.iter().position(|p| p == path)
|
||||
{
|
||||
if idx > 0 {
|
||||
focus
|
||||
.entry(parent.children[idx - 1].clone())
|
||||
.or_insert(ChunkFocusMode::Collapsed);
|
||||
}
|
||||
if idx + 1 < parent.children.len() {
|
||||
focus
|
||||
.entry(parent.children[idx + 1].clone())
|
||||
.or_insert(ChunkFocusMode::Collapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root chunk must always be Container so the walk starts.
|
||||
|
||||
@@ -3047,23 +3047,33 @@ end
|
||||
})
|
||||
.expect("render_read should succeed");
|
||||
|
||||
// The output should contain truncation markers indicating the chunk continues.
|
||||
// The output should keep the chunk head and tail context and collapse the
|
||||
// omitted middle ranges with generic expansion markers.
|
||||
assert!(
|
||||
result.text.contains("to expand above"),
|
||||
"should show top truncation marker when chunk extends above visible range: {}",
|
||||
result.text.contains("1|function longFunc() {"),
|
||||
"should keep the chunk signature when the visible range clips the head: {}",
|
||||
result.text
|
||||
);
|
||||
assert!(
|
||||
result.text.contains("to expand below"),
|
||||
"should show bottom truncation marker when chunk extends below visible range: {}",
|
||||
result.text.contains("9|let h = 8;"),
|
||||
"should keep tail context when the visible range clips the body: {}",
|
||||
result.text
|
||||
);
|
||||
// The visible content should still be there.
|
||||
assert!(
|
||||
result.text.contains("let c = 3"),
|
||||
"visible content should be rendered: {}",
|
||||
result.text
|
||||
);
|
||||
assert!(
|
||||
result.text.contains("[truncated… sel=L2-L2 to expand]"),
|
||||
"should show a generic truncation marker above the requested lines: {}",
|
||||
result.text
|
||||
);
|
||||
assert!(
|
||||
result.text.contains("[truncated… sel=L8-L8 to expand]"),
|
||||
"should show a generic truncation marker below the requested lines: {}",
|
||||
result.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3089,13 +3099,8 @@ end
|
||||
|
||||
// Should NOT have any truncation markers.
|
||||
assert!(
|
||||
!result.text.contains("to expand above"),
|
||||
"no top clip marker when chunk fits: {}",
|
||||
result.text
|
||||
);
|
||||
assert!(
|
||||
!result.text.contains("to expand below"),
|
||||
"no bottom clip marker when chunk fits: {}",
|
||||
!result.text.contains("[truncated…"),
|
||||
"no clip marker when chunk fits: {}",
|
||||
result.text
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ env_uint! {
|
||||
static PREVIEW_TAIL_LINES: usize = "PI_CHUNK_PREVIEW_TAIL_LINES" or 4 => [1, usize::MAX];
|
||||
}
|
||||
|
||||
const CLIPPED_TAIL_CONTEXT_LINES: u32 = 3;
|
||||
|
||||
fn normalize_rendered_line(
|
||||
line: &str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
@@ -88,6 +90,26 @@ fn compute_fenced_code_lines(source_lines: &[&str], language: &str) -> HashSet<u
|
||||
}
|
||||
|
||||
pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
|
||||
render_state_impl(state, params, HashMap::new(), HashSet::new(), false, false)
|
||||
}
|
||||
|
||||
pub fn render_state_with_hunks(
|
||||
state: &ChunkStateInner,
|
||||
params: &RenderParams,
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
) -> String {
|
||||
render_state_impl(state, params, inline_hunks, changed_anchor_paths, true, true)
|
||||
}
|
||||
|
||||
fn render_state_impl(
|
||||
state: &ChunkStateInner,
|
||||
params: &RenderParams,
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
compact_meta: bool,
|
||||
omit_anchor_line_counts: bool,
|
||||
) -> String {
|
||||
let tree = state.tree();
|
||||
let lookup = build_lookup(tree);
|
||||
let chunk_path = params
|
||||
@@ -124,6 +146,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
|
||||
params.visible_range.as_ref(),
|
||||
params.render_children_only,
|
||||
params.show_leaf_preview,
|
||||
&masked_source,
|
||||
&source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -139,6 +162,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
|
||||
params.visible_range.as_ref(),
|
||||
params.render_children_only,
|
||||
params.show_leaf_preview,
|
||||
&masked_source,
|
||||
&source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -167,21 +191,20 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
|
||||
normalize_indent,
|
||||
fenced_lines,
|
||||
focus,
|
||||
inline_hunks: HashMap::new(),
|
||||
inline_hunks,
|
||||
compact_meta,
|
||||
omit_anchor_line_counts,
|
||||
changed_anchor_paths,
|
||||
};
|
||||
|
||||
push_line(
|
||||
&mut ctx.out,
|
||||
format!(
|
||||
"{}| {}",
|
||||
" ".repeat(num_width),
|
||||
format_header_meta(
|
||||
params.title.as_str(),
|
||||
rendered_line_count,
|
||||
params.language_tag.as_deref(),
|
||||
chunk.checksum.as_str(),
|
||||
params.omit_checksum,
|
||||
)
|
||||
push_meta(
|
||||
&mut ctx,
|
||||
format_header_meta(
|
||||
params.title.as_str(),
|
||||
rendered_line_count,
|
||||
params.language_tag.as_deref(),
|
||||
chunk.checksum.as_str(),
|
||||
params.omit_checksum,
|
||||
),
|
||||
);
|
||||
push_blank_meta(&mut ctx);
|
||||
@@ -196,6 +219,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
|
||||
between_top_level_definitions: true,
|
||||
});
|
||||
}
|
||||
emit_inline_hunks_for(&mut ctx, "");
|
||||
return ctx.out;
|
||||
}
|
||||
|
||||
@@ -402,6 +426,12 @@ struct VisibleSpan {
|
||||
end: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LineSegment {
|
||||
start: u32,
|
||||
end: u32,
|
||||
}
|
||||
|
||||
fn intersect_visible_span(
|
||||
chunk: &ChunkNode,
|
||||
visible_range: Option<&VisibleLineRange>,
|
||||
@@ -428,8 +458,68 @@ enum LeafEntry {
|
||||
Ellipsis { start_abs: u32, end_abs: u32 },
|
||||
}
|
||||
|
||||
fn clipped_head_context_line_count(
|
||||
source: &str,
|
||||
chunk: &ChunkNode,
|
||||
preview_head_lines: usize,
|
||||
) -> u32 {
|
||||
let (head_lines, body_lines) = chunk_head_body_lines(source, chunk);
|
||||
let fallback = preview_head_lines.max(1) as u32;
|
||||
if head_lines == 0 {
|
||||
return fallback;
|
||||
}
|
||||
if body_lines == 0 {
|
||||
return head_lines.min(fallback);
|
||||
}
|
||||
head_lines
|
||||
}
|
||||
|
||||
fn push_segment(segments: &mut Vec<LineSegment>, start: u32, end: u32) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = segments.last_mut()
|
||||
&& start <= last.end.saturating_add(1)
|
||||
{
|
||||
last.end = last.end.max(end);
|
||||
return;
|
||||
}
|
||||
segments.push(LineSegment { start, end });
|
||||
}
|
||||
|
||||
fn clipped_leaf_segments(
|
||||
source: &str,
|
||||
chunk: &ChunkNode,
|
||||
span: VisibleSpan,
|
||||
preview_head_lines: usize,
|
||||
) -> Vec<LineSegment> {
|
||||
let mut segments = Vec::new();
|
||||
if span.start > chunk.start_line {
|
||||
let head_context_lines = clipped_head_context_line_count(source, chunk, preview_head_lines);
|
||||
let preview_end = chunk
|
||||
.start_line
|
||||
.saturating_add(head_context_lines.saturating_sub(1))
|
||||
.min(chunk.end_line)
|
||||
.min(span.start.saturating_sub(1));
|
||||
push_segment(&mut segments, chunk.start_line, preview_end);
|
||||
}
|
||||
push_segment(&mut segments, span.start, span.end);
|
||||
if span.end < chunk.end_line {
|
||||
let preview_start = chunk
|
||||
.end_line
|
||||
.saturating_add(1)
|
||||
.saturating_sub(CLIPPED_TAIL_CONTEXT_LINES)
|
||||
.max(chunk.start_line)
|
||||
.max(span.end.saturating_add(1));
|
||||
push_segment(&mut segments, preview_start, chunk.end_line);
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
fn build_leaf_entries(
|
||||
source_lines: &[&str],
|
||||
source: &str,
|
||||
chunk: &ChunkNode,
|
||||
span: VisibleSpan,
|
||||
tab_replacement: &str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
@@ -441,23 +531,43 @@ fn build_leaf_entries(
|
||||
let low = span.start;
|
||||
let high = span.end;
|
||||
let visible_line_count = (high - low + 1) as usize;
|
||||
let raw = (low..=high)
|
||||
.map(|line| {
|
||||
let normalize = if fenced_lines.contains(&line) {
|
||||
None
|
||||
} else {
|
||||
normalize_indent
|
||||
};
|
||||
LeafEntry::Line {
|
||||
abs_line: line,
|
||||
text: source_lines
|
||||
.get(line.saturating_sub(1) as usize)
|
||||
.map_or(String::new(), |text| {
|
||||
normalize_rendered_line(text, normalize, tab_replacement)
|
||||
}),
|
||||
let make_line = |line: u32| {
|
||||
let normalize = if fenced_lines.contains(&line) {
|
||||
None
|
||||
} else {
|
||||
normalize_indent
|
||||
};
|
||||
LeafEntry::Line {
|
||||
abs_line: line,
|
||||
text: source_lines
|
||||
.get(line.saturating_sub(1) as usize)
|
||||
.map_or(String::new(), |text| {
|
||||
normalize_rendered_line(text, normalize, tab_replacement)
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
if span.start > chunk.start_line || span.end < chunk.end_line {
|
||||
let segments = clipped_leaf_segments(source, chunk, span, preview_head_lines);
|
||||
let mut entries = Vec::new();
|
||||
let mut last_end: Option<u32> = None;
|
||||
for segment in segments {
|
||||
if let Some(previous_end) = last_end {
|
||||
let gap_start = previous_end.saturating_add(1);
|
||||
let gap_end = segment.start.saturating_sub(1);
|
||||
if gap_start <= gap_end {
|
||||
entries.push(LeafEntry::Ellipsis { start_abs: gap_start, end_abs: gap_end });
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for line in segment.start..=segment.end {
|
||||
entries.push(make_line(line));
|
||||
}
|
||||
last_end = Some(segment.end);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
let raw = (low..=high).map(make_line).collect::<Vec<_>>();
|
||||
|
||||
if visible_line_count <= full_display_threshold {
|
||||
return raw;
|
||||
@@ -531,6 +641,7 @@ fn for_each_rendered_source_line(
|
||||
lookup: &ChunkLookup<'_>,
|
||||
visible_range: Option<&VisibleLineRange>,
|
||||
show_leaf_preview: bool,
|
||||
source: &str,
|
||||
source_lines: &[&str],
|
||||
tab_replacement: &str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
@@ -555,6 +666,8 @@ fn for_each_rendered_source_line(
|
||||
if let Some(span) = span {
|
||||
for entry in build_leaf_entries(
|
||||
source_lines,
|
||||
source,
|
||||
chunk,
|
||||
span,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -572,8 +685,26 @@ fn for_each_rendered_source_line(
|
||||
}
|
||||
|
||||
if let Some(span) = span {
|
||||
if span.start > chunk.start_line {
|
||||
let head_context_lines =
|
||||
clipped_head_context_line_count(source, chunk, preview_head_lines);
|
||||
let first_child_start = children
|
||||
.first()
|
||||
.map_or_else(|| chunk.end_line.saturating_add(1), |child| child.start_line);
|
||||
let preview_end = chunk
|
||||
.start_line
|
||||
.saturating_add(head_context_lines.saturating_sub(1))
|
||||
.min(chunk.end_line)
|
||||
.min(first_child_start.saturating_sub(1))
|
||||
.min(span.start.saturating_sub(1));
|
||||
for line in chunk.start_line..=preview_end {
|
||||
if should_render_gap_line(tree, chunk, lookup, line) {
|
||||
visit(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cursor = chunk.start_line;
|
||||
for child in children {
|
||||
for child in &children {
|
||||
let gap_end = child.start_line.saturating_sub(1);
|
||||
if gap_end >= cursor {
|
||||
for line in cursor..=gap_end {
|
||||
@@ -590,6 +721,7 @@ fn for_each_rendered_source_line(
|
||||
lookup,
|
||||
visible_range,
|
||||
show_leaf_preview,
|
||||
source,
|
||||
source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -608,6 +740,21 @@ fn for_each_rendered_source_line(
|
||||
}
|
||||
}
|
||||
}
|
||||
if span.end < chunk.end_line {
|
||||
let last_child_end = children.last().map_or(0, |child| child.end_line);
|
||||
let preview_start = chunk
|
||||
.end_line
|
||||
.saturating_add(1)
|
||||
.saturating_sub(CLIPPED_TAIL_CONTEXT_LINES)
|
||||
.max(chunk.start_line)
|
||||
.max(last_child_end.saturating_add(1))
|
||||
.max(span.end.saturating_add(1));
|
||||
for line in preview_start..=chunk.end_line {
|
||||
if should_render_gap_line(tree, chunk, lookup, line) {
|
||||
visit(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -618,6 +765,7 @@ fn for_each_rendered_source_line(
|
||||
lookup,
|
||||
visible_range,
|
||||
show_leaf_preview,
|
||||
source,
|
||||
source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -637,6 +785,7 @@ fn compute_rendered_line_count(
|
||||
visible_range: Option<&VisibleLineRange>,
|
||||
render_children_only: bool,
|
||||
show_leaf_preview: bool,
|
||||
source: &str,
|
||||
source_lines: &[&str],
|
||||
tab_replacement: &str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
@@ -645,24 +794,23 @@ fn compute_rendered_line_count(
|
||||
preview_head_lines: usize,
|
||||
preview_tail_lines: usize,
|
||||
) -> usize {
|
||||
if let Some(range) = visible_range {
|
||||
return (range.end_line - range.start_line + 1) as usize;
|
||||
}
|
||||
if render_children_only {
|
||||
// Root reads render child chunks only, but the header should still report the
|
||||
// file's true total line count.
|
||||
return if chunk.path.is_empty() {
|
||||
tree.line_count as usize
|
||||
} else {
|
||||
chunk.line_count as usize
|
||||
};
|
||||
}
|
||||
let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None);
|
||||
let has_out_of_span_child = children
|
||||
.iter()
|
||||
.any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line);
|
||||
if !has_out_of_span_child {
|
||||
return chunk.line_count as usize;
|
||||
if visible_range.is_none() {
|
||||
if render_children_only {
|
||||
// Root reads render child chunks only, but the header should still report the
|
||||
// file's true total line count.
|
||||
return if chunk.path.is_empty() {
|
||||
tree.line_count as usize
|
||||
} else {
|
||||
chunk.line_count as usize
|
||||
};
|
||||
}
|
||||
let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None);
|
||||
let has_out_of_span_child = children
|
||||
.iter()
|
||||
.any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line);
|
||||
if !has_out_of_span_child {
|
||||
return chunk.line_count as usize;
|
||||
}
|
||||
}
|
||||
let mut rendered_lines = std::collections::BTreeSet::new();
|
||||
for_each_rendered_source_line(
|
||||
@@ -671,6 +819,7 @@ fn compute_rendered_line_count(
|
||||
lookup,
|
||||
visible_range,
|
||||
show_leaf_preview,
|
||||
source,
|
||||
source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -686,25 +835,28 @@ fn compute_rendered_line_count(
|
||||
}
|
||||
|
||||
struct RenderCtx<'a> {
|
||||
out: String,
|
||||
tree: &'a ChunkTree,
|
||||
lookup: &'a ChunkLookup<'a>,
|
||||
source: &'a str,
|
||||
source_lines: &'a [&'a str],
|
||||
num_width: usize,
|
||||
visible_range: Option<&'a VisibleLineRange>,
|
||||
omit_checksum: bool,
|
||||
anchor_style: ChunkAnchorStyle,
|
||||
show_leaf_preview: bool,
|
||||
last_was_blank_meta: bool,
|
||||
full_display_threshold: usize,
|
||||
preview_head_lines: usize,
|
||||
preview_tail_lines: usize,
|
||||
tab_replacement: &'a str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
fenced_lines: HashSet<u32>,
|
||||
focus: Option<HashMap<&'a str, ChunkFocusMode>>,
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
out: String,
|
||||
tree: &'a ChunkTree,
|
||||
lookup: &'a ChunkLookup<'a>,
|
||||
source: &'a str,
|
||||
source_lines: &'a [&'a str],
|
||||
num_width: usize,
|
||||
visible_range: Option<&'a VisibleLineRange>,
|
||||
omit_checksum: bool,
|
||||
anchor_style: ChunkAnchorStyle,
|
||||
show_leaf_preview: bool,
|
||||
last_was_blank_meta: bool,
|
||||
full_display_threshold: usize,
|
||||
preview_head_lines: usize,
|
||||
preview_tail_lines: usize,
|
||||
tab_replacement: &'a str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
fenced_lines: HashSet<u32>,
|
||||
focus: Option<HashMap<&'a str, ChunkFocusMode>>,
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
compact_meta: bool,
|
||||
omit_anchor_line_counts: bool,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
}
|
||||
|
||||
fn push_line(out: &mut String, line: String) {
|
||||
@@ -722,16 +874,26 @@ fn push_blank_meta(ctx: &mut RenderCtx<'_>) {
|
||||
ctx.last_was_blank_meta = true;
|
||||
}
|
||||
|
||||
fn push_meta(ctx: &mut RenderCtx<'_>, body: String) {
|
||||
fn push_meta_marked(ctx: &mut RenderCtx<'_>, body: String, changed: bool) {
|
||||
ctx.last_was_blank_meta = false;
|
||||
push_line(&mut ctx.out, format!("{}| {}", " ".repeat(ctx.num_width), body));
|
||||
let gutter = if changed {
|
||||
format!("*{}", " ".repeat(ctx.num_width.saturating_sub(1)))
|
||||
} else {
|
||||
" ".repeat(ctx.num_width)
|
||||
};
|
||||
let separator = if ctx.compact_meta { "|" } else { "| " };
|
||||
push_line(&mut ctx.out, format!("{gutter}{separator}{body}"));
|
||||
}
|
||||
|
||||
fn push_meta(ctx: &mut RenderCtx<'_>, body: String) {
|
||||
push_meta_marked(ctx, body, false);
|
||||
}
|
||||
|
||||
fn push_code(ctx: &mut RenderCtx<'_>, abs_line: u32, source_text: &str) {
|
||||
ctx.last_was_blank_meta = false;
|
||||
push_line(
|
||||
&mut ctx.out,
|
||||
format!("{}| {}", abs_line.to_string().pad_start(ctx.num_width, ' '), source_text),
|
||||
format!("{}|{}", abs_line.to_string().pad_start(ctx.num_width, ' '), source_text),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -768,30 +930,85 @@ fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a truncation marker when `visible_range` clips a chunk.
|
||||
/// `above` = true for top clip, false for bottom clip.
|
||||
fn emit_range_clip_marker(
|
||||
ctx: &mut RenderCtx<'_>,
|
||||
chunk: &ChunkNode,
|
||||
span: &VisibleSpan,
|
||||
above: bool,
|
||||
) {
|
||||
if ctx.visible_range.is_none() {
|
||||
return;
|
||||
}
|
||||
let (hidden, direction, start, end) = if above {
|
||||
let n = span.start.saturating_sub(chunk.start_line);
|
||||
(n, "above", chunk.start_line, span.start.saturating_sub(1))
|
||||
} else {
|
||||
let n = chunk.end_line.saturating_sub(span.end);
|
||||
(n, "below", span.end + 1, chunk.end_line)
|
||||
};
|
||||
if hidden == 0 {
|
||||
fn push_truncation_marker(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, start: u32, end: u32) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
let indent =
|
||||
chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement, ctx.normalize_indent);
|
||||
push_meta(ctx, format!("{indent}[truncated\u{2026} sel=L{start}-L{end} to expand {direction}]"));
|
||||
push_meta(ctx, format!("{indent}[truncated\u{2026} sel=L{start}-L{end} to expand]"));
|
||||
}
|
||||
|
||||
fn emit_explicit_gap_lines(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, from: u32, to: u32) {
|
||||
if from > to {
|
||||
return;
|
||||
}
|
||||
for line in from..=to {
|
||||
if !should_render_gap_line(ctx.tree, chunk, ctx.lookup, line) {
|
||||
continue;
|
||||
}
|
||||
let normalize = if ctx.fenced_lines.contains(&line) {
|
||||
None
|
||||
} else {
|
||||
ctx.normalize_indent
|
||||
};
|
||||
let text = ctx
|
||||
.source_lines
|
||||
.get(line.saturating_sub(1) as usize)
|
||||
.map_or(String::new(), |source_text| {
|
||||
normalize_rendered_line(source_text, normalize, ctx.tab_replacement)
|
||||
});
|
||||
push_code(ctx, line, &text);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_container_clip_above(
|
||||
ctx: &mut RenderCtx<'_>,
|
||||
chunk: &ChunkNode,
|
||||
span: &VisibleSpan,
|
||||
children: &[&ChunkNode],
|
||||
) {
|
||||
if ctx.visible_range.is_none() || span.start <= chunk.start_line {
|
||||
return;
|
||||
}
|
||||
let head_context_lines =
|
||||
clipped_head_context_line_count(ctx.source, chunk, ctx.preview_head_lines);
|
||||
let first_child_start = children
|
||||
.first()
|
||||
.map_or_else(|| chunk.end_line.saturating_add(1), |child| child.start_line);
|
||||
let preview_end = chunk
|
||||
.start_line
|
||||
.saturating_add(head_context_lines.saturating_sub(1))
|
||||
.min(chunk.end_line)
|
||||
.min(first_child_start.saturating_sub(1))
|
||||
.min(span.start.saturating_sub(1));
|
||||
if preview_end >= chunk.start_line {
|
||||
emit_explicit_gap_lines(ctx, chunk, chunk.start_line, preview_end);
|
||||
}
|
||||
push_truncation_marker(ctx, chunk, preview_end.saturating_add(1), span.start.saturating_sub(1));
|
||||
}
|
||||
|
||||
fn emit_container_clip_below(
|
||||
ctx: &mut RenderCtx<'_>,
|
||||
chunk: &ChunkNode,
|
||||
span: &VisibleSpan,
|
||||
children: &[&ChunkNode],
|
||||
) {
|
||||
if ctx.visible_range.is_none() || span.end >= chunk.end_line {
|
||||
return;
|
||||
}
|
||||
let last_child_end = children.last().map_or(0, |child| child.end_line);
|
||||
let preview_start = chunk
|
||||
.end_line
|
||||
.saturating_add(1)
|
||||
.saturating_sub(CLIPPED_TAIL_CONTEXT_LINES)
|
||||
.max(chunk.start_line)
|
||||
.max(last_child_end.saturating_add(1))
|
||||
.max(span.end.saturating_add(1));
|
||||
push_truncation_marker(ctx, chunk, span.end.saturating_add(1), preview_start.saturating_sub(1));
|
||||
if preview_start <= chunk.end_line {
|
||||
emit_explicit_gap_lines(ctx, chunk, preview_start, chunk.end_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_render_lines(
|
||||
@@ -819,6 +1036,8 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan)
|
||||
|
||||
for entry in build_leaf_entries(
|
||||
ctx.source_lines,
|
||||
ctx.source,
|
||||
chunk,
|
||||
span,
|
||||
ctx.tab_replacement,
|
||||
ctx.normalize_indent,
|
||||
@@ -830,16 +1049,37 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan)
|
||||
match entry {
|
||||
LeafEntry::Line { abs_line, text } => push_code(ctx, abs_line, &text),
|
||||
LeafEntry::Ellipsis { start_abs, end_abs, .. } => {
|
||||
push_meta(ctx, format!("[truncated\u{2026} sel=L{start_abs}-L{end_abs} to expand]"));
|
||||
push_truncation_marker(ctx, chunk, start_abs, end_abs);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit any inline diff hunks mapped to the given chunk path.
|
||||
fn render_open_anchor_line(ctx: &RenderCtx<'_>, chunk: &ChunkNode) -> String {
|
||||
let anchor_indent =
|
||||
chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement, ctx.normalize_indent);
|
||||
let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk);
|
||||
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
|
||||
let anchor_label = chunk_anchor_label(chunk, style);
|
||||
if ctx.omit_anchor_line_counts {
|
||||
style.render_without_counts(&anchor_indent, anchor_label.as_str(), chunk.checksum.as_str())
|
||||
} else {
|
||||
style.render(
|
||||
&anchor_indent,
|
||||
anchor_label.as_str(),
|
||||
chunk.checksum.as_str(),
|
||||
head_lines,
|
||||
body_lines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_inline_hunks_for(ctx: &mut RenderCtx<'_>, chunk_path: &str) {
|
||||
let lines: Vec<String> = match ctx.inline_hunks.get(chunk_path) {
|
||||
Some(hunks) => hunks.iter().flat_map(|h| h.lines.iter().cloned()).collect(),
|
||||
let lines = match ctx.inline_hunks.get(chunk_path) {
|
||||
Some(hunks) => hunks
|
||||
.iter()
|
||||
.flat_map(|hunk| hunk.lines.iter().cloned())
|
||||
.collect::<Vec<_>>(),
|
||||
None => return,
|
||||
};
|
||||
for line in lines {
|
||||
@@ -869,24 +1109,10 @@ fn emit_chunk_subtree(
|
||||
if options.between_top_level_definitions && depth == 0 && !options.is_first_top_level {
|
||||
push_blank_meta(ctx);
|
||||
}
|
||||
let anchor_indent = chunk_body_anchor_indent(
|
||||
ctx.source_lines,
|
||||
chunk,
|
||||
ctx.tab_replacement,
|
||||
ctx.normalize_indent,
|
||||
);
|
||||
let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk);
|
||||
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
|
||||
let anchor_label = chunk_anchor_label(chunk, style);
|
||||
push_meta(
|
||||
push_meta_marked(
|
||||
ctx,
|
||||
style.render(
|
||||
&anchor_indent,
|
||||
anchor_label.as_str(),
|
||||
chunk.checksum.as_str(),
|
||||
head_lines,
|
||||
body_lines,
|
||||
),
|
||||
render_open_anchor_line(ctx, chunk),
|
||||
ctx.changed_anchor_paths.contains(chunk.path.as_str()),
|
||||
);
|
||||
return;
|
||||
},
|
||||
@@ -909,39 +1135,22 @@ fn emit_chunk_subtree(
|
||||
push_blank_meta(ctx);
|
||||
}
|
||||
if !chunk.path.is_empty() {
|
||||
let anchor_indent = chunk_body_anchor_indent(
|
||||
ctx.source_lines,
|
||||
chunk,
|
||||
ctx.tab_replacement,
|
||||
ctx.normalize_indent,
|
||||
);
|
||||
let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk);
|
||||
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
|
||||
let anchor_label = chunk_anchor_label(chunk, style);
|
||||
push_meta(
|
||||
push_meta_marked(
|
||||
ctx,
|
||||
style.render(
|
||||
&anchor_indent,
|
||||
anchor_label.as_str(),
|
||||
chunk.checksum.as_str(),
|
||||
head_lines,
|
||||
body_lines,
|
||||
),
|
||||
render_open_anchor_line(ctx, chunk),
|
||||
ctx.changed_anchor_paths.contains(chunk.path.as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
if !has_kids {
|
||||
if !chunk.path.is_empty() && ctx.inline_hunks.contains_key(chunk.path.as_str()) {
|
||||
emit_inline_hunks_for(ctx, &chunk.path);
|
||||
return;
|
||||
}
|
||||
if ctx.show_leaf_preview
|
||||
&& let Some(span) = span
|
||||
{
|
||||
emit_range_clip_marker(ctx, chunk, &span, true);
|
||||
emit_leaf_body(ctx, chunk, span);
|
||||
emit_range_clip_marker(ctx, chunk, &span, false);
|
||||
}
|
||||
// Emit inline diff hunks even when children are filtered out by
|
||||
// focus (the chunk is "effectively leaf" but may own hunks).
|
||||
if !chunk.path.is_empty() {
|
||||
emit_inline_hunks_for(ctx, &chunk.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -954,10 +1163,9 @@ fn emit_chunk_subtree(
|
||||
== Some(ChunkFocusMode::Container);
|
||||
|
||||
if let Some(span) = span {
|
||||
// Top clip marker for container chunks
|
||||
emit_range_clip_marker(ctx, chunk, &span, true);
|
||||
emit_container_clip_above(ctx, chunk, &span, &children);
|
||||
let mut cursor = chunk.start_line;
|
||||
for child in children {
|
||||
for child in &children {
|
||||
let gap_end = child.start_line.saturating_sub(1);
|
||||
if gap_end >= cursor && !is_container {
|
||||
for line in cursor..=gap_end {
|
||||
@@ -977,9 +1185,7 @@ fn emit_chunk_subtree(
|
||||
if cursor <= span.end && !is_container {
|
||||
emit_line_gap(ctx, cursor, span.end);
|
||||
}
|
||||
// Bottom clip marker for container chunks
|
||||
emit_range_clip_marker(ctx, chunk, &span, false);
|
||||
// Emit inline diff hunks before the closing tag.
|
||||
emit_container_clip_below(ctx, chunk, &span, &children);
|
||||
if !chunk.path.is_empty() {
|
||||
emit_inline_hunks_for(ctx, &chunk.path);
|
||||
}
|
||||
@@ -1015,6 +1221,7 @@ fn compute_num_width(
|
||||
visible_range: Option<&VisibleLineRange>,
|
||||
render_children_only: bool,
|
||||
show_leaf_preview: bool,
|
||||
source: &str,
|
||||
source_lines: &[&str],
|
||||
tab_replacement: &str,
|
||||
normalize_indent: Option<(char, usize)>,
|
||||
@@ -1023,18 +1230,17 @@ fn compute_num_width(
|
||||
preview_head_lines: usize,
|
||||
preview_tail_lines: usize,
|
||||
) -> usize {
|
||||
if let Some(range) = visible_range {
|
||||
return range.end_line.to_string().len().max(1);
|
||||
}
|
||||
if render_children_only {
|
||||
return tree.line_count.to_string().len().max(1);
|
||||
}
|
||||
let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None);
|
||||
let has_out_of_span_child = children
|
||||
.iter()
|
||||
.any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line);
|
||||
if !has_out_of_span_child {
|
||||
return chunk.end_line.to_string().len().max(1);
|
||||
if visible_range.is_none() {
|
||||
if render_children_only {
|
||||
return tree.line_count.to_string().len().max(1);
|
||||
}
|
||||
let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None);
|
||||
let has_out_of_span_child = children
|
||||
.iter()
|
||||
.any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line);
|
||||
if !has_out_of_span_child {
|
||||
return chunk.end_line.to_string().len().max(1);
|
||||
}
|
||||
}
|
||||
let mut max_line = 1usize;
|
||||
for_each_rendered_source_line(
|
||||
@@ -1043,6 +1249,7 @@ fn compute_num_width(
|
||||
lookup,
|
||||
visible_range,
|
||||
show_leaf_preview,
|
||||
source,
|
||||
source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
@@ -1057,36 +1264,16 @@ fn compute_num_width(
|
||||
max_line.to_string().len().max(1)
|
||||
}
|
||||
|
||||
/// Find the chunk that should own a diff hunk for inline display.
|
||||
///
|
||||
/// Walks from the deepest chunk containing `line` upward until it finds a
|
||||
/// chunk with children (which will have a closing tag in the tree output).
|
||||
/// Returns the chunk path, or `None` for file-root orphans.
|
||||
/// Find the deepest visible chunk that should own a diff hunk for inline
|
||||
/// display.
|
||||
pub fn find_hunk_owner_chunk<'a>(
|
||||
tree: &'a ChunkTree,
|
||||
lookup: &ChunkLookup<'a>,
|
||||
_lookup: &ChunkLookup<'a>,
|
||||
line: u32,
|
||||
) -> Option<&'a str> {
|
||||
let deepest = line_to_containing_chunk(tree, line)?;
|
||||
// If the deepest chunk has children, it gets a closing tag — use it.
|
||||
if !deepest.children.is_empty() {
|
||||
return Some(&deepest.path);
|
||||
}
|
||||
// Leaf: promote to parent (which has children and a closing tag).
|
||||
if let Some(parent_path) = deepest.parent_path.as_deref()
|
||||
&& let Some(parent) = lookup.get(parent_path)
|
||||
&& !parent.path.is_empty()
|
||||
{
|
||||
return Some(&parent.path);
|
||||
}
|
||||
// Root-level leaf — no parent with a closing tag.
|
||||
None
|
||||
line_to_containing_chunk(tree, line).map(|chunk| chunk.path.as_str())
|
||||
}
|
||||
|
||||
/// Compute the indentation string for inline hunks placed inside a chunk.
|
||||
///
|
||||
/// Uses the chunk's own body anchor indent plus one additional level, which
|
||||
/// aligns hunks at the same depth as the chunk's child anchors.
|
||||
pub fn hunk_indent_for_chunk(
|
||||
lookup: &ChunkLookup<'_>,
|
||||
chunk_path: &str,
|
||||
@@ -1104,141 +1291,3 @@ pub fn hunk_indent_for_chunk(
|
||||
None => format!("{base}{tab_replacement}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a chunk tree with diff hunks inlined into their owning chunk blocks.
|
||||
pub fn render_state_with_hunks(
|
||||
state: &ChunkStateInner,
|
||||
params: &RenderParams,
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
) -> String {
|
||||
let tree = state.tree();
|
||||
let lookup = build_lookup(tree);
|
||||
let chunk_path = params
|
||||
.chunk_path
|
||||
.as_deref()
|
||||
.unwrap_or(tree.root_path.as_str());
|
||||
let Some(chunk) = get_chunk(&lookup, chunk_path) else {
|
||||
return String::new();
|
||||
};
|
||||
let masked_source = mask_chunk_display_source(state.source(), state.language());
|
||||
let source_lines = masked_source.split('\n').collect::<Vec<_>>();
|
||||
let full_display_threshold = *FULL_DISPLAY_THRESHOLD;
|
||||
let preview_head_lines = *PREVIEW_HEAD_LINES;
|
||||
let preview_tail_lines = *PREVIEW_TAIL_LINES;
|
||||
let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" ");
|
||||
let normalize_indent = params.normalize_indent.unwrap_or(false).then(|| {
|
||||
(
|
||||
detect_file_indent_char(state.source(), tree),
|
||||
detect_file_indent_step(state.source(), tree) as usize,
|
||||
)
|
||||
});
|
||||
|
||||
let fenced_lines = compute_fenced_code_lines(&source_lines, &tree.language);
|
||||
|
||||
let anchor_style = params.anchor_style.unwrap_or_default();
|
||||
let focus: Option<HashMap<&str, ChunkFocusMode>> = params
|
||||
.focused_paths
|
||||
.as_ref()
|
||||
.map(|paths| paths.iter().map(|fp| (fp.path.as_str(), fp.mode)).collect());
|
||||
let num_width = compute_num_width(
|
||||
tree,
|
||||
chunk,
|
||||
&lookup,
|
||||
params.visible_range.as_ref(),
|
||||
params.render_children_only,
|
||||
params.show_leaf_preview,
|
||||
&source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
&fenced_lines,
|
||||
full_display_threshold,
|
||||
preview_head_lines,
|
||||
preview_tail_lines,
|
||||
);
|
||||
let rendered_line_count = compute_rendered_line_count(
|
||||
tree,
|
||||
chunk,
|
||||
&lookup,
|
||||
params.visible_range.as_ref(),
|
||||
params.render_children_only,
|
||||
params.show_leaf_preview,
|
||||
&source_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
&fenced_lines,
|
||||
full_display_threshold,
|
||||
preview_head_lines,
|
||||
preview_tail_lines,
|
||||
);
|
||||
|
||||
let mut ctx = RenderCtx {
|
||||
out: String::new(),
|
||||
tree,
|
||||
lookup: &lookup,
|
||||
source: &masked_source,
|
||||
source_lines: &source_lines,
|
||||
num_width,
|
||||
visible_range: params.visible_range.as_ref(),
|
||||
omit_checksum: params.omit_checksum,
|
||||
anchor_style,
|
||||
show_leaf_preview: params.show_leaf_preview,
|
||||
last_was_blank_meta: false,
|
||||
full_display_threshold,
|
||||
preview_head_lines,
|
||||
preview_tail_lines,
|
||||
tab_replacement,
|
||||
normalize_indent,
|
||||
fenced_lines,
|
||||
focus,
|
||||
inline_hunks,
|
||||
};
|
||||
|
||||
push_line(
|
||||
&mut ctx.out,
|
||||
format!(
|
||||
"{}| {}",
|
||||
" ".repeat(num_width),
|
||||
format_header_meta(
|
||||
params.title.as_str(),
|
||||
rendered_line_count,
|
||||
params.language_tag.as_deref(),
|
||||
chunk.checksum.as_str(),
|
||||
params.omit_checksum,
|
||||
)
|
||||
),
|
||||
);
|
||||
push_blank_meta(&mut ctx);
|
||||
|
||||
if params.render_children_only {
|
||||
let focus_ref = ctx.focus.as_ref();
|
||||
let children =
|
||||
visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref(), focus_ref);
|
||||
for (index, child) in children.iter().enumerate() {
|
||||
emit_chunk_subtree(&mut ctx, child, 0, ChunkSubtreeOptions {
|
||||
is_first_top_level: index == 0,
|
||||
between_top_level_definitions: true,
|
||||
});
|
||||
}
|
||||
// Emit any hunks mapped to the root (empty path).
|
||||
emit_inline_hunks_for(&mut ctx, "");
|
||||
return ctx.out;
|
||||
}
|
||||
|
||||
if chunk.children.is_empty() && ctx.focus.is_none() {
|
||||
if params.show_leaf_preview
|
||||
&& intersect_visible_span(chunk, params.visible_range.as_ref()).is_some()
|
||||
{
|
||||
emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions {
|
||||
is_first_top_level: true,
|
||||
between_top_level_definitions: false,
|
||||
});
|
||||
}
|
||||
return ctx.out;
|
||||
}
|
||||
|
||||
emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions {
|
||||
is_first_top_level: true,
|
||||
between_top_level_definitions: false,
|
||||
});
|
||||
ctx.out
|
||||
}
|
||||
|
||||
@@ -412,8 +412,8 @@ impl ChunkState {
|
||||
end_line: visible_range.end_line.min(self.inner.tree().line_count),
|
||||
};
|
||||
let notice = format!(
|
||||
"[Notice: chunk view scoped to requested lines L{}-L{}; non-overlapping lines \
|
||||
omitted.]",
|
||||
"[Notice: chunk view scoped to requested lines L{}-L{}; clipped chunks keep head/tail \
|
||||
context and collapse non-overlapping children.]",
|
||||
clamped_range.start_line, clamped_range.end_line
|
||||
);
|
||||
let text = self.render(RenderParams {
|
||||
|
||||
@@ -261,6 +261,11 @@ impl ChunkAnchorStyle {
|
||||
self.render_i(("[<", ">]"), indent, name, crc, &suffix)
|
||||
}
|
||||
|
||||
/// Render an opening anchor tag without the `(N lns)` suffix.
|
||||
pub fn render_without_counts(&self, indent: &str, name: &str, crc: &str) -> String {
|
||||
self.render_i(("[<", ">]"), indent, name, crc, "")
|
||||
}
|
||||
|
||||
/// Render a closing anchor tag: `[</ name#crc >]`.
|
||||
/// Returns empty string for `None` style.
|
||||
pub fn render_close(&self, indent: &str, name: &str, crc: &str) -> String {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for `computeHashlineDiff` to accept hashline edits with `loc` and `content` payloads without requiring pre-resolved `op` fields
|
||||
@@ -10,10 +9,13 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Grouped chunk-mode `grep` results by directory, file, and chunk so directory searches now render as hierarchical sections (`#`/`##`) with per-chunk anchor lines
|
||||
- Updated chunk-mode `grep` output to include match lines under their containing chunk entries with consistent line-number alignment based on file length
|
||||
- Changed eager todo enforcement to only apply on the first user message of a conversation, skipping subsequent user turns that may correct, clarify, or redirect the prior task
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed chunk streaming output detection to verify chunk edits with `chunkToolEditSchema`, preventing non-chunk edit payloads from being rendered as chunk diffs
|
||||
- Fixed tool execution output to return the original `toolResult` text content from tools instead of sanitizing it before sending completion messages
|
||||
- Fixed session accent rendering in the status line and editor to reset only foreground color (`\x1b[39m`) so applying a session color no longer clears other ANSI styles
|
||||
- Session name sanitization: strip C0/C1 control characters (including ANSI ESC) from session names at storage time and in status line rendering, preventing escape sequence injection into TUI output
|
||||
|
||||
@@ -257,16 +257,34 @@ export async function formatChunkedRead(params: {
|
||||
return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
|
||||
}
|
||||
|
||||
export async function formatChunkedGrepLine(params: {
|
||||
export type ChunkedGrepMatch = {
|
||||
displayPath: string;
|
||||
fileLineCount: number;
|
||||
chunkPath?: string;
|
||||
chunkChecksum?: string;
|
||||
lineNumber: number;
|
||||
line: string;
|
||||
};
|
||||
|
||||
export async function describeChunkedGrepMatch(params: {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
line: string;
|
||||
cwd: string;
|
||||
language?: string;
|
||||
}): Promise<string> {
|
||||
}): Promise<ChunkedGrepMatch> {
|
||||
const { filePath, lineNumber, line, cwd, language } = params;
|
||||
const { state } = await loadChunkStateForFile(filePath, language);
|
||||
return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line);
|
||||
const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined;
|
||||
const chunkInfo = chunkPath ? state.chunk(chunkPath) : null;
|
||||
return {
|
||||
displayPath: displayPathForFile(filePath, cwd),
|
||||
fileLineCount: state.lineCount,
|
||||
chunkPath,
|
||||
chunkChecksum: chunkInfo?.checksum,
|
||||
lineNumber,
|
||||
line,
|
||||
};
|
||||
}
|
||||
|
||||
const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { FileDiagnosticsResult } from "../lsp";
|
||||
import { renderDiff as renderDiffColored } from "../modes/components/diff";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
} from "../tools/render-utils";
|
||||
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
||||
import type { DiffError, DiffResult } from "./diff";
|
||||
import type { ChunkToolEdit } from "./modes/chunk";
|
||||
import { type ChunkToolEdit, chunkToolEditSchema } from "./modes/chunk";
|
||||
import type { HashlineToolEdit } from "./modes/hashline";
|
||||
import type { Operation } from "./modes/patch";
|
||||
|
||||
@@ -182,7 +183,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
||||
}
|
||||
|
||||
function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
|
||||
return "sel" in edit;
|
||||
return Type.Partial(chunkToolEditSchema).Check(edit);
|
||||
}
|
||||
|
||||
function getStreamingEditContent(content: unknown): string {
|
||||
|
||||
@@ -71,52 +71,52 @@ Given this `read` output for `example.ts`:
|
||||
| example.ts·34L·ts·#QBMH
|
||||
|
|
||||
| [<interface_Config#BWTR>]
|
||||
1| interface Config {
|
||||
1|interface Config {
|
||||
| [<interface_Config.field_host#TTMN>]
|
||||
2| host: string;
|
||||
2| host: string;
|
||||
| [<interface_Config.field_port#QSMH>]
|
||||
3| port: number;
|
||||
3| port: number;
|
||||
| [<interface_Config.field_debug#JPRR>]
|
||||
4| debug: boolean;
|
||||
5| }
|
||||
4| debug: boolean;
|
||||
5|}
|
||||
|
|
||||
| [<class_Counter#HZHY>]
|
||||
7| class Counter {
|
||||
7|class Counter {
|
||||
| [<class_Counter.field_value#QJBY>]
|
||||
8| value: number = 0;
|
||||
8| value: number = 0;
|
||||
9|
|
||||
| [<class_Counter.fn_increment#NQWY>]
|
||||
10| increment(): void {
|
||||
11| this.value += 1;
|
||||
12| }
|
||||
10| increment(): void {
|
||||
11| this.value += 1;
|
||||
12| }
|
||||
13|
|
||||
| [<class_Counter.fn_decrement#PMBP>]
|
||||
14| decrement(): void {
|
||||
15| this.value -= 1;
|
||||
16| }
|
||||
14| decrement(): void {
|
||||
15| this.value -= 1;
|
||||
16| }
|
||||
17|
|
||||
| [<class_Counter.fn_toString#ZQZP>]
|
||||
18| toString(): string {
|
||||
19| return `Counter(${this.value})`;
|
||||
20| }
|
||||
21| }
|
||||
18| toString(): string {
|
||||
19| return `Counter(${this.value})`;
|
||||
20| }
|
||||
21|}
|
||||
|
|
||||
| [<enum_Status#HYQJ>]
|
||||
23| enum Status {
|
||||
23|enum Status {
|
||||
| [<enum_Status.variant_Active#PQNS>]
|
||||
24| Active = "ACTIVE",
|
||||
24| Active = "ACTIVE",
|
||||
| [<enum_Status.variant_Paused#HHNM>]
|
||||
25| Paused = "PAUSED",
|
||||
25| Paused = "PAUSED",
|
||||
| [<enum_Status.variant_Stopped#NHTY>]
|
||||
26| Stopped = "STOPPED",
|
||||
27| }
|
||||
26| Stopped = "STOPPED",
|
||||
27|}
|
||||
|
|
||||
| [<fn_createCounter#PQQY>]
|
||||
29| function createCounter(initial: number): Counter {
|
||||
30| const counter = new Counter();
|
||||
31| counter.value = initial;
|
||||
32| return counter;
|
||||
33| }
|
||||
29|function createCounter(initial: number): Counter {
|
||||
30| const counter = new Counter();
|
||||
31| counter.value = initial;
|
||||
32| return counter;
|
||||
33|}
|
||||
```
|
||||
|
||||
**Replace a whole chunk** (rename a function):
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { computeLineHash } from "../edit/line-hash";
|
||||
import { formatChunkedGrepLine } from "../edit/modes/chunk";
|
||||
import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
||||
import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
|
||||
@@ -274,13 +274,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
matchesByFile.get(relativePath)!.push(match);
|
||||
}
|
||||
if (chunkMode) {
|
||||
const annotatedLines = await Promise.all(
|
||||
const annotatedMatches = await Promise.all(
|
||||
selectedMatches.map(match => {
|
||||
const relativePath = match.path.startsWith("/") ? match.path.slice(1) : match.path;
|
||||
const absoluteFilePath = isDirectory ? path.join(searchPath, relativePath) : searchPath;
|
||||
const displayPath = formatPath(match.path);
|
||||
fileMatchCounts.set(displayPath, (fileMatchCounts.get(displayPath) ?? 0) + 1);
|
||||
return formatChunkedGrepLine({
|
||||
return describeChunkedGrepMatch({
|
||||
filePath: absoluteFilePath,
|
||||
lineNumber: match.lineNumber,
|
||||
line: match.line,
|
||||
@@ -289,7 +287,75 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
});
|
||||
}),
|
||||
);
|
||||
const rawOutput = annotatedLines.join("\n");
|
||||
const chunkMatchesByFile = new Map<string, ChunkedGrepMatch[]>();
|
||||
for (const match of annotatedMatches) {
|
||||
recordFile(match.displayPath);
|
||||
if (!chunkMatchesByFile.has(match.displayPath)) {
|
||||
chunkMatchesByFile.set(match.displayPath, []);
|
||||
}
|
||||
chunkMatchesByFile.get(match.displayPath)!.push(match);
|
||||
}
|
||||
const renderChunkedMatchesForFile = (relativePath: string) => {
|
||||
const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
|
||||
if (fileMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1;
|
||||
const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
|
||||
for (const match of fileMatches) {
|
||||
const chunkKey = match.chunkPath ?? "";
|
||||
if (!matchesByChunk.has(chunkKey)) {
|
||||
matchesByChunk.set(chunkKey, []);
|
||||
}
|
||||
matchesByChunk.get(chunkKey)!.push(match);
|
||||
}
|
||||
for (const [chunkPath, chunkMatches] of matchesByChunk) {
|
||||
if (chunkPath) {
|
||||
const chunkChecksum = chunkMatches[0]?.chunkChecksum;
|
||||
const anchor = chunkChecksum ? `[<${chunkPath}#${chunkChecksum}>]` : `[<${chunkPath}>]`;
|
||||
outputLines.push(` ${anchor}`);
|
||||
}
|
||||
for (const match of chunkMatches) {
|
||||
outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")}|${match.line}`);
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (isDirectory) {
|
||||
const filesByDirectory = new Map<string, string[]>();
|
||||
for (const relativePath of fileList) {
|
||||
const directory = path.dirname(relativePath).replace(/\\/g, "/");
|
||||
if (!filesByDirectory.has(directory)) {
|
||||
filesByDirectory.set(directory, []);
|
||||
}
|
||||
filesByDirectory.get(directory)!.push(relativePath);
|
||||
}
|
||||
for (const [directory, directoryFiles] of filesByDirectory) {
|
||||
if (directory === ".") {
|
||||
for (const relativePath of directoryFiles) {
|
||||
if (outputLines.length > 0) {
|
||||
outputLines.push("");
|
||||
}
|
||||
outputLines.push(`# ${path.basename(relativePath)}`);
|
||||
renderChunkedMatchesForFile(relativePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (outputLines.length > 0) {
|
||||
outputLines.push("");
|
||||
}
|
||||
outputLines.push(`# ${directory}`);
|
||||
for (const relativePath of directoryFiles) {
|
||||
outputLines.push(`## └─ ${path.basename(relativePath)}`);
|
||||
renderChunkedMatchesForFile(relativePath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const relativePath of fileList) {
|
||||
renderChunkedMatchesForFile(relativePath);
|
||||
}
|
||||
}
|
||||
const rawOutput = outputLines.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated);
|
||||
const details: GrepToolDetails = {
|
||||
|
||||
@@ -771,8 +771,8 @@ describe("formatChunkedRead", () => {
|
||||
|
||||
expect(result.text).toContain("worker.ts:class_Worker.fn_run·");
|
||||
expect(result.text).toContain("class_Worker.fn_run#");
|
||||
expect(result.text).toContain("6| \trun(): void {");
|
||||
expect(result.text).toContain("7| \t\tconsole.log(this.name);");
|
||||
expect(result.text).toContain("6|\trun(): void {");
|
||||
expect(result.text).toContain("7|\t\tconsole.log(this.name);");
|
||||
expect(result.text).toContain("console.log(this.name);");
|
||||
});
|
||||
|
||||
@@ -792,8 +792,8 @@ describe("formatChunkedRead", () => {
|
||||
|
||||
expect(result.text).not.toContain("to expand ⋮");
|
||||
expectRenderedChunkPath(result.text, "service.ts:class_Service.fn_handle", "·");
|
||||
expect(result.text).toContain("3| \t\tstep(0);");
|
||||
expect(result.text).toContain("27| \t\tstep(24);");
|
||||
expect(result.text).toContain("3|\t\tstep(0);");
|
||||
expect(result.text).toContain("27|\t\tstep(24);");
|
||||
expect(result.text).toContain("done();");
|
||||
});
|
||||
});
|
||||
@@ -901,8 +901,8 @@ describe("addressable member rendering", () => {
|
||||
});
|
||||
|
||||
expectRenderedChunkPath(result.text, "type_Handler");
|
||||
expect(result.text).toContain("3| type Handler interface {");
|
||||
expect(result.text).toContain("4| \tHandle(method, path string) Result");
|
||||
expect(result.text).toContain("3|type Handler interface {");
|
||||
expect(result.text).toContain("4|\tHandle(method, path string) Result");
|
||||
});
|
||||
|
||||
test("renders Go receiver methods as top-level siblings", async () => {
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("chunk mode tools", () => {
|
||||
expect(text).not.toContain("to expand ⋮");
|
||||
expect(text).toContain(`server.ts:${HANDLE_ERROR_CHUNK_PATH}·`);
|
||||
expect(text).toContain("let total = 0;");
|
||||
expect(text).toContain("29| \t\t\ttotal +=");
|
||||
expect(text).toContain("29|\t\t\ttotal +=");
|
||||
expect(text).toContain("return err.message + total;");
|
||||
});
|
||||
|
||||
@@ -166,11 +166,11 @@ describe("chunk mode tools", () => {
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("2| private handleError(err: Error): string {");
|
||||
expect(text).toContain("3| let total = 0;");
|
||||
expect(text).toContain("29| total +=");
|
||||
expect(text).not.toContain("2| \tprivate handleError");
|
||||
expect(text).not.toContain("29| \t\t\ttotal +=");
|
||||
expect(text).toContain("2| private handleError(err: Error): string {");
|
||||
expect(text).toContain("3| let total = 0;");
|
||||
expect(text).toContain("29| total +=");
|
||||
expect(text).not.toContain("2|\tprivate handleError");
|
||||
expect(text).not.toContain("29|\t\t\ttotal +=");
|
||||
});
|
||||
|
||||
it("renders line-range reads as range-scoped chunk output", async () => {
|
||||
@@ -181,13 +181,16 @@ describe("chunk mode tools", () => {
|
||||
const result = await tool.execute("chunk-read-lines", { path: filePath, sel: "L2-L4" });
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]");
|
||||
expect(text).toContain(
|
||||
"[Notice: chunk view scoped to requested lines L2-L4; clipped chunks keep head/tail context and collapse non-overlapping children.]",
|
||||
);
|
||||
expect(text).toContain("server.ts·");
|
||||
expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}#`);
|
||||
expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}.var_total#`);
|
||||
expect(text).toContain("3|");
|
||||
expect(text).toContain("4|");
|
||||
expect(text).not.toContain("⋯");
|
||||
expect(text).toContain("64|\t\treturn err.message + total;");
|
||||
expect(text).toContain("[truncated… sel=L5-L62 to expand]");
|
||||
expect(text).not.toContain("to expand above");
|
||||
expect(text).not.toContain("to expand below");
|
||||
});
|
||||
@@ -200,15 +203,37 @@ describe("chunk mode tools", () => {
|
||||
const result = await tool.execute("chunk-read-lines-in-path", { path: `${filePath}:L2-L4` });
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]");
|
||||
expect(text).toContain(
|
||||
"[Notice: chunk view scoped to requested lines L2-L4; clipped chunks keep head/tail context and collapse non-overlapping children.]",
|
||||
);
|
||||
expect(text).toContain("server.ts·");
|
||||
expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}#`);
|
||||
expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}.var_total#`);
|
||||
expect(text).toContain("3|");
|
||||
expect(text).toContain("64|\t\treturn err.message + total;");
|
||||
expect(text).toContain("[truncated… sel=L5-L62 to expand]");
|
||||
expect(text).not.toContain("to expand above");
|
||||
expect(text).not.toContain("to expand below");
|
||||
});
|
||||
|
||||
it("keeps chunk head and tail context when a line range lands in the middle of a large chunk", async () => {
|
||||
const filePath = path.join(tmpDir, "server.ts");
|
||||
await Bun.write(filePath, buildLargeTypescriptFixture());
|
||||
const tool = new ReadTool(createSession(tmpDir));
|
||||
|
||||
const result = await tool.execute("chunk-read-lines-middle", { path: filePath, sel: "L40-L42" });
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("2|\tprivate handleError(err: Error): string {");
|
||||
expect(text).toContain("40|\t\t\ttotal += 36;");
|
||||
expect(text).toContain("42|\t\t\ttotal += 38;");
|
||||
expect(text).toContain("63|\t\t\ttotal += 59;");
|
||||
expect(text).toContain("64|\t\treturn err.message + total;");
|
||||
expect(text).toContain("65|\t}");
|
||||
expect(text).toContain("[truncated… sel=L3-L39 to expand]");
|
||||
expect(text).toContain("[truncated… sel=L43-L62 to expand]");
|
||||
});
|
||||
|
||||
it("ignores a chunk selector checksum suffix on read", async () => {
|
||||
const filePath = path.join(tmpDir, "server.ts");
|
||||
const originalSource = buildLargeTypescriptFixture();
|
||||
@@ -271,8 +296,8 @@ describe("chunk mode tools", () => {
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain(`server.ts:${HANDLE_ERROR_CHUNK_PATH}`);
|
||||
expect(text).toContain(".ret>64|");
|
||||
expect(text).toContain("[<class_Server.fn_handle.ret#");
|
||||
expect(text).toContain("64| return err.message + total;");
|
||||
expect(text).toContain("err.message");
|
||||
});
|
||||
|
||||
@@ -292,7 +317,39 @@ describe("chunk mode tools", () => {
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain('> 1|const top = "match";');
|
||||
expect(text).toContain(' 1|const top = "match";');
|
||||
});
|
||||
|
||||
it("groups chunk-mode grep output by directory, file, and chunk", async () => {
|
||||
const toolsDir = path.join(tmpDir, "src", "tools");
|
||||
const coreDir = path.join(tmpDir, "src", "core");
|
||||
await fs.mkdir(toolsDir, { recursive: true });
|
||||
await fs.mkdir(coreDir, { recursive: true });
|
||||
await Bun.write(
|
||||
path.join(toolsDir, "grep.ts"),
|
||||
["function execute(): string {", ' return "needle";', "}", ""].join("\n"),
|
||||
);
|
||||
await Bun.write(
|
||||
path.join(coreDir, "server.ts"),
|
||||
["class Server {", " start(): string {", ' return "needle";', " }", "}", ""].join("\n"),
|
||||
);
|
||||
const tool = new GrepTool(createSession(tmpDir));
|
||||
|
||||
const result = await tool.execute("chunk-grep-grouped", {
|
||||
pattern: "needle",
|
||||
path: tmpDir,
|
||||
glob: "src/**/*.ts",
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("# src/tools");
|
||||
expect(text).toContain("## └─ grep.ts");
|
||||
expect(text).toContain(" [<fn_execut#");
|
||||
expect(text).toContain(' 2| return "needle";');
|
||||
expect(text).toContain("# src/core");
|
||||
expect(text).toContain("## └─ server.ts");
|
||||
expect(text).toContain(" [<class_Server.fn_start#");
|
||||
expect(text).toContain(' 3| return "needle";');
|
||||
});
|
||||
|
||||
it("replaces a chunk using a copied selector in path", async () => {
|
||||
@@ -327,6 +384,7 @@ describe("chunk mode tools", () => {
|
||||
expect(updatedSource).toContain("normalized:");
|
||||
expect(updatedSource).not.toContain("total +=");
|
||||
expect(editText).toContain("server.ts·");
|
||||
expect(editText).toContain("[<class_Server.fn_handle#");
|
||||
expect(editText).toContain("@@ -3,62 +3,1 @@");
|
||||
});
|
||||
|
||||
@@ -365,12 +423,16 @@ describe("chunk mode tools", () => {
|
||||
} as never);
|
||||
const editText = getText(editResult);
|
||||
|
||||
expect(editText).toContain("[<class_Server#");
|
||||
expect(editText).toContain("1| class Server {");
|
||||
expect(editText).toContain("[<class_Server.fn_handle#");
|
||||
expect(editText).toContain('3| \t\tconsole.log("new");');
|
||||
expect(editText).toContain("@@ -3,1 +3,1 @@");
|
||||
expect(editText).toContain("[<class_Server.fn_other#");
|
||||
expect(editText).toMatch(/^ {2}\|server\.ts·10L·typescript·#/m);
|
||||
expect(editText).toMatch(/^\* \|\[<class_Server#/m);
|
||||
expect(editText).toMatch(/^\* \|\s+\[<class_Server\.fn_handle#/m);
|
||||
expect(editText).toMatch(/^ {2}\|\s+@@ -3,1 \+3,1 @@$/m);
|
||||
expect(editText).toContain('console.log("old");');
|
||||
expect(editText).toContain('console.log("new");');
|
||||
expect(editText).not.toContain("1|class Server {");
|
||||
expect(editText).not.toContain("crc updated");
|
||||
expect(editText).not.toContain(" lns)");
|
||||
expect(editText).not.toContain("[<class_Server.fn_other#");
|
||||
});
|
||||
|
||||
it("replaces a whole method chunk when PI_CHUNK_AUTOINDENT=0", async () => {
|
||||
|
||||
Reference in New Issue
Block a user