diff --git a/crates/diffr-core/src/plugin/cursor.rs b/crates/diffr-core/src/plugin/cursor.rs index 5fa6f129e..38fd9bb89 100644 --- a/crates/diffr-core/src/plugin/cursor.rs +++ b/crates/diffr-core/src/plugin/cursor.rs @@ -35,6 +35,7 @@ pub enum Kind { Leaf { alignment_id: u32, changed: Vec, + search_highlights: Vec, }, Fold, } @@ -80,6 +81,8 @@ pub enum MoveError { pub struct Cursor { pub file: FileChange, pub sides: Pairing, + /// Whether any leaf holds a search hit; plugins never add one. + hits: bool, /// The region the cursor is on: the one the current callback visits. pub id: u32, /// IDs from here up were made during the current walk, which skips them. @@ -106,9 +109,11 @@ impl Cursor { } }); } + let hits = sides.sides().iter().any(|source| holds_hit(&source.root)); Ok(Self { file, sides, + hits, id: first, limit: next_region_id, next_region_id, @@ -364,6 +369,7 @@ impl Cursor { if let Node::Leaf { alignment_id, changed, + .. } = ®ion.node { changes |= !changed.is_empty() || !alignments.contains(alignment_id); @@ -492,23 +498,47 @@ impl Cursor { .collect::, MoveError>>()?; let piece_alignment = *next_alignment_id; *next_alignment_id += 1; - let mut state = None; + // Both tails' ids first, so each can name the other as its pair. + let piece_ids: Vec> = paths + .iter() + .map(|path| { + path.as_ref().map(|_| { + let id = *next_region_id; + *next_region_id += 1; + id + }) + }) + .collect(); + let piece_state = piece_ids.iter().flatten().next().copied(); let has_lhs = sides.lhs().is_some(); let (mut lhs, mut rhs) = (None, None); for (tree_side, (tree, path)) in trees(sides).into_iter().zip(paths).enumerate() { - let Some(path) = path else { continue }; + let (Some(path), Some(piece_id), Some(piece_state)) = + (path, piece_ids[tree_side], piece_state) + else { + continue; + }; + let partner = + piece_ids + .iter() + .enumerate() + .find_map(|(other, id)| if other == tree_side { None } else { *id }); let (index, parent) = path.split_last().expect("a path is never empty"); let list = siblings(tree, parent); - let piece_id = *next_region_id; - *next_region_id += 1; if tree_side == 0 && has_lhs { lhs = Some(piece_id); } else { rhs = Some(piece_id); } - let piece_state = *state.get_or_insert(piece_id); let leaf = list.remove(*index); - let pieces = split(leaf, offset, piece_id, piece_alignment, piece_state); + let pieces = split( + leaf, + offset, + piece_id, + piece_alignment, + piece_state, + partner, + ); list.splice(*index..*index, pieces); } Ok(region_ids(lhs, rhs)) @@ -595,7 +625,8 @@ impl Cursor { Ok(region_ids(lhs, rhs)) } - /// Merge the fold states of `ids` into the first's; all collapse if any was. + /// Merge the fold states of `ids` into the first's; all collapse if any + /// was, unless a member holds a search hit. pub fn link(&mut self, ids: &[u32]) -> Result<(), MoveError> { let sides = &mut self.sides; check_regions(ids, Grouping::Link)?; @@ -608,6 +639,7 @@ impl Cursor { .iter() .map(|id| Ok(region_of(sides, *id)?.fold_state_id)) .collect::, MoveError>>()?; + collapsed &= !highlighted_states(sides, &states); for tree in trees(sides) { walk_mut(tree, &mut |region| { if states.contains(®ion.fold_state_id) { @@ -619,9 +651,10 @@ impl Cursor { Ok(()) } - /// Set the shared collapsed state. + /// Set the shared collapsed state; a search hit stays open. pub fn set_collapsed(&mut self, region: u32, collapsed: bool) -> Result<(), MoveError> { let state = region_of(&self.sides, region)?.fold_state_id; + let collapsed = collapsed && !highlighted_states(&self.sides, &BTreeSet::from([state])); for tree in trees(&mut self.sides) { walk_mut(tree, &mut |region| { if region.fold_state_id == state { @@ -632,6 +665,14 @@ impl Cursor { Ok(()) } + pub fn has_search_highlights(&self, id: u32) -> Result { + let state = region_of(&self.sides, id)?.fold_state_id; + if !self.hits || self.top_level().contains(&id) { + return Ok(self.hits); + } + Ok(highlighted_states(&self.sides, &BTreeSet::from([state]))) + } + /// Set or clear a region's label. pub fn set_label(&mut self, region: u32, label: Option) -> Result<(), MoveError> { region_mut(&mut self.sides, region)?.visibility.label = label.unwrap_or_default(); @@ -673,9 +714,12 @@ fn view(region: &Region, parent: Option, side: Side, children: Vec) -> Node::Leaf { alignment_id, changed, + search_highlights, + .. } => Kind::Leaf { alignment_id: *alignment_id, changed: changed.clone(), + search_highlights: search_highlights.clone(), }, Node::Fold { .. } => Kind::Fold, }, @@ -849,17 +893,31 @@ fn find_mut(regions: &mut [Region], id: u32) -> Option<&mut Region> { None } -/// A leaf split at relative line `offset`. The second piece takes `id`, -/// `alignment_id` and `fold_state_id`. -fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u32) -> [Region; 2] { - let Node::Leaf { changed, .. } = &leaf.node else { +/// A leaf split at relative line `offset`. The first piece keeps the leaf's +/// identity and pair; the second takes `id`, `alignment_id`, +/// `fold_state_id` and `pair`, the other side's new tail. +fn split( + leaf: Region, + offset: u32, + id: u32, + alignment_id: u32, + fold_state_id: u32, + pair: Option, +) -> [Region; 2] { + let Node::Leaf { + changed, + search_highlights, + pair: head_pair, + .. + } = &leaf.node + else { unreachable!("only leaves are cut"); }; let boundary = SourcePos { line: leaf.range.start.line + offset, column: 0, }; - let piece = |range: SourceRange, id: u32, alignment_id: u32, fold_state_id: u32| { + let piece = |range: SourceRange, id: u32, alignment_id: u32, fold_state_id: u32, pair| { let lines = range.lines(); Region { id, @@ -869,6 +927,12 @@ fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u visibility: leaf.visibility.clone(), node: Node::Leaf { alignment_id, + pair, + search_highlights: search_highlights + .iter() + .copied() + .filter(|span| lines.contains(&span.line)) + .collect(), changed: changed .iter() .copied() @@ -885,6 +949,7 @@ fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u leaf.id, leaf.alignment_id().expect("a leaf"), leaf.fold_state_id, + *head_pair, ); let tail = piece( SourceRange { @@ -894,6 +959,7 @@ fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u id, alignment_id, fold_state_id, + pair, ); [head, tail] } @@ -926,3 +992,24 @@ mod mutations; #[cfg(test)] mod tests; + +/// Whether any region in `states` holds a search hit. +fn highlighted_states(sides: &Pairing, states: &BTreeSet) -> bool { + let mut any = false; + for source in sides.sides() { + walk(std::slice::from_ref(&source.root), &mut |region| { + any |= states.contains(®ion.fold_state_id) && holds_hit(region); + }); + } + any +} + +/// Whether a region or anything under it holds a search hit. +fn holds_hit(region: &Region) -> bool { + match ®ion.node { + Node::Leaf { + search_highlights, .. + } => !search_highlights.is_empty(), + Node::Fold { children, .. } => children.iter().any(holds_hit), + } +} diff --git a/crates/diffr-core/src/plugin/cursor/mutations.rs b/crates/diffr-core/src/plugin/cursor/mutations.rs index c170d20fb..715337f8d 100644 --- a/crates/diffr-core/src/plugin/cursor/mutations.rs +++ b/crates/diffr-core/src/plugin/cursor/mutations.rs @@ -24,7 +24,9 @@ fn leaf(id: u32, alignment: u32, start: u32, end: u32, changed: &[u32]) -> Regio tags: vec![], visibility: Visibility::default(), node: Node::Leaf { + search_highlights: vec![], alignment_id: alignment, + pair: None, changed: changed .iter() .map(|&line| Span { @@ -196,6 +198,16 @@ fn cutting_a_paired_leaf_cuts_both_sides_with_fresh_ids_and_a_shared_alignment() ], "target {target}" ); + // Each new tail names the other side's new tail as its pair. + let pair = |region: &Region| match region.node { + Node::Leaf { pair, .. } => pair, + Node::Fold { .. } => panic!("a leaf"), + }; + let tails = [&lhs.root.children()[2..4], &rhs.root.children()[1..3]]; + assert_eq!( + tails.map(|side| side.iter().map(pair).collect::>()), + [vec![Some(103), Some(105)], vec![Some(102), Some(104)],] + ); let Node::Leaf { changed, .. } = &lhs.root.children()[3].node else { panic!("a leaf"); }; @@ -389,3 +401,40 @@ fn moves_that_cannot_be_carried_out_are_errors() { }) ); } + +#[test] +fn cuts_keep_highlights_on_their_piece_and_linked_states_keep_matches_open() { + let mut left = leaf(1, 1, 0, 6, &[]); + let Node::Leaf { + search_highlights, .. + } = &mut left.node + else { + unreachable!() + }; + search_highlights.push(Span { + line: 4, + start_column: 0, + end_column: 1, + }); + let mut state = both(vec![left], vec![in_state(leaf(2, 1, 0, 6, &[]), 1)]); + let RegionIds::Both(left_tail, right_tail) = state.cut(1, 3).unwrap() else { + unreachable!() + }; + assert!(!state.has_search_highlights(1).unwrap()); + assert!(state.has_search_highlights(left_tail).unwrap()); + assert!(state.has_search_highlights(right_tail).unwrap()); + state.set_collapsed(1, true).unwrap(); + state.set_collapsed(right_tail, true).unwrap(); + let (lhs, _) = sides_of(&state); + assert!(lhs.root.children()[0].visibility.collapsed); + assert!(!lhs.root.children()[1].visibility.collapsed); + state.link(&[1, left_tail]).unwrap(); + state.set_collapsed(2, true).unwrap(); + for source in state.sides.sides() { + assert!(source + .root + .children() + .iter() + .all(|region| !region.visibility.collapsed)); + } +} diff --git a/crates/diffr-core/src/plugin/cursor/tests.rs b/crates/diffr-core/src/plugin/cursor/tests.rs index a9bec537e..45bc5ec4d 100644 --- a/crates/diffr-core/src/plugin/cursor/tests.rs +++ b/crates/diffr-core/src/plugin/cursor/tests.rs @@ -66,7 +66,9 @@ fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> Region { tags: vec![], visibility: Visibility::default(), node: Node::Leaf { + search_highlights: vec![], alignment_id: alignment, + pair: None, changed: vec![], }, } diff --git a/crates/diffr-core/src/present.rs b/crates/diffr-core/src/present.rs index d1b87e558..cf0063e57 100644 --- a/crates/diffr-core/src/present.rs +++ b/crates/diffr-core/src/present.rs @@ -61,6 +61,7 @@ fn visible_changes(sides: &Pairing) -> LineCounts { Node::Leaf { alignment_id, changed, + .. } => { if other.contains(alignment_id) { visible.extend(changed.iter().map(|span| [span.line, span.line + 1])); @@ -156,7 +157,9 @@ mod visible_tests { label: String::new(), }, node: Node::Leaf { + search_highlights: Vec::new(), alignment_id: alignment, + pair: None, changed: changed .iter() .map(|&line| Span { diff --git a/crates/diffr-core/src/protocol/mod.rs b/crates/diffr-core/src/protocol/mod.rs index 1920c557c..d885eda30 100644 --- a/crates/diffr-core/src/protocol/mod.rs +++ b/crates/diffr-core/src/protocol/mod.rs @@ -154,6 +154,7 @@ pub enum FileStatus { Renamed, Copied, TypeChanged, + Unchanged, } /// One side of a git delta. @@ -303,8 +304,14 @@ pub enum Node { /// line, and they come in the same order on both sides. A leaf on /// one side only has a value no leaf on the other side carries. alignment_id: u32, + /// The `id` of the leaf on the other side with the same + /// `alignment_id`; absent when this leaf is added or deleted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pair: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] changed: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + search_highlights: Vec, }, /// A foldable region. Its range is the hull of its children. Fold { @@ -402,6 +409,8 @@ mod tests { visibility: Visibility::default(), node: Node::Leaf { alignment_id: index, + pair: None, + search_highlights: Vec::new(), changed, }, } diff --git a/crates/diffr-core/src/protocol/project.rs b/crates/diffr-core/src/protocol/project.rs index bfa467a4c..fea588bfe 100644 --- a/crates/diffr-core/src/protocol/project.rs +++ b/crates/diffr-core/src/protocol/project.rs @@ -237,6 +237,7 @@ fn regions(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> (Region, Region [&mut lhs[..], &mut rhs[..]], lhs_links.into_iter().chain(rhs_links), ); + name_partners(&mut lhs, &ids.partners); ( Region::root(ids.fresh_id(), lhs), Region::root(ids.fresh_id(), rhs), @@ -276,6 +277,16 @@ fn link(sides: [&mut [Region]; 2], links: impl IntoIterator) } } +/// Give each paired lhs leaf the `id` of its rhs partner. +fn name_partners(regions: &mut [Region], partners: &DftHashMap) { + for region in regions { + match &mut region.node { + Node::Leaf { pair, .. } => *pair = partners.get(®ion.id).copied(), + Node::Fold { children, .. } => name_partners(children, partners), + } + } +} + /// Folds the matcher left unpaired that the line alignment pairs: a left and /// a right fold inside one run of unchanged rows, over the same lines of it. /// The two sides of such a run are the same lines, so the folds are twins. @@ -383,6 +394,9 @@ struct Ids { /// The `(alignment_id, fold_state_id)` of every paired leaf numbered so /// far, by its `LeafKey`. leaves: DftHashMap, + /// The rhs partner of each paired lhs leaf, by the lhs leaf's `id`: known + /// only once the rhs is numbered, after the lhs leaves are built. + partners: DftHashMap, /// The `fold_state_id` of every fold numbered so far, by the fold of the /// syntax node it was built on. folds: DftHashMap, @@ -397,6 +411,7 @@ impl Ids { next_id: 1, next_alignment: 0, leaves: DftHashMap::default(), + partners: DftHashMap::default(), folds: DftHashMap::default(), aligned, } @@ -414,18 +429,21 @@ impl Ids { alignment } - /// A leaf's `(id, alignment_id, fold_state_id)`. - fn leaf(&mut self, key: Option) -> (u32, u32, u32) { + /// A leaf's `(id, alignment_id, fold_state_id, pair)`. The second of a + /// pair names the first, whose own `id` is its `fold_state_id`; the first + /// gets its `pair` from `partners` once both sides are built. + fn leaf(&mut self, key: Option) -> (u32, u32, u32, Option) { let id = self.fresh_id(); let Some(key) = key else { - return (id, self.fresh_alignment(), id); + return (id, self.fresh_alignment(), id, None); }; if let Some(&(alignment, state)) = self.leaves.get(&key) { - return (id, alignment, state); + self.partners.insert(state, id); + return (id, alignment, state, Some(state)); } let alignment = self.fresh_alignment(); self.leaves.insert(key, (alignment, id)); - (id, alignment, id) + (id, alignment, id, None) } /// A fold's `(id, fold_state_id)`. A fold whose opposite is already @@ -653,7 +671,7 @@ fn leaf_region( } } } - let (id, alignment_id, fold_state_id) = ids.leaf(leaf.key); + let (id, alignment_id, fold_state_id, pair) = ids.leaf(leaf.key); Region { id, fold_state_id, @@ -671,6 +689,8 @@ fn leaf_region( visibility: Visibility::default(), node: Node::Leaf { alignment_id, + pair, + search_highlights: Vec::new(), changed, }, } @@ -1227,6 +1247,16 @@ mod tests { alignments.into_iter().collect::>(), (0..=*lhs_leaves.keys().chain(rhs_leaves.keys()).max().unwrap()).collect::>() ); + // `pair` names the partner leaf, and nothing for a one-sided leaf. + let pair = |leaf: &Region| match leaf.node { + Node::Leaf { pair, .. } => pair, + Node::Fold { .. } => unreachable!("leaves only"), + }; + for (own, other) in [(&lhs_leaves, &rhs_leaves), (&rhs_leaves, &lhs_leaves)] { + for (id, leaf) in own { + assert_eq!(pair(leaf), other.get(id).map(|partner| partner.id)); + } + } let mut paired = 0; for (id, lhs_leaf) in &lhs_leaves { if let Some(rhs_leaf) = rhs_leaves.get(id) { diff --git a/crates/diffr-plugin-sdk/wit/plugin.wit b/crates/diffr-plugin-sdk/wit/plugin.wit index 9683768d1..8f5605903 100644 --- a/crates/diffr-plugin-sdk/wit/plugin.wit +++ b/crates/diffr-plugin-sdk/wit/plugin.wit @@ -20,6 +20,7 @@ interface types { renamed, copied, type-changed, + unchanged, } /// One side of a git delta, as the manifest names it. @@ -82,6 +83,7 @@ interface types { /// lines up with this one. alignment-id: u32, changed: list, + search-highlights: list, } /// A leaf tiles the file; a fold's range is the hull of its children. @@ -160,6 +162,8 @@ interface types { interface host { use types.{file-entry, region-view, side, region, region-ids, row-summary, move-error}; resource cursor { + /// Whether this region's fold state holds a search hit. + has-search-highlights: func(id: u32) -> result; file: func() -> file-entry; /// Current node, positioned by the host before each callback. id: func() -> u32; diff --git a/diffr-ts/src/contract.ts b/diffr-ts/src/contract.ts index 73302af7d..29879a1d9 100644 --- a/diffr-ts/src/contract.ts +++ b/diffr-ts/src/contract.ts @@ -128,7 +128,8 @@ export type StructuralFileStatus = | "modified" | "renamed" | "copied" - | "type_changed"; + | "type_changed" + | "unchanged"; export const StructuralFileStatusSchema: z.ZodType = z.lazy(() => @@ -139,6 +140,7 @@ export const StructuralFileStatusSchema: z.ZodType = z.literal("renamed"), z.literal("copied"), z.literal("type_changed"), + z.literal("unchanged"), ]), ); @@ -239,7 +241,15 @@ export const StructuralRegionSchema: z.ZodType = z.lazy(() => ); export type StructuralNode = - | { kind: "leaf"; alignment_id: number; changed?: StructuralSpan[] } + | { + kind: "leaf"; + alignment_id: number; + /** The partner leaf's `id` on the other side; absent when added or deleted. */ + pair?: number; + changed?: StructuralSpan[]; + /** The spans search hits cover. */ + search_highlights?: StructuralSpan[]; + } | { kind: "fold"; children: StructuralRegion[]; @@ -254,7 +264,9 @@ export const StructuralNodeSchema: z.ZodType = z.lazy(() => z.object({ kind: z.literal("leaf"), alignment_id: structuralU32, + pair: structuralU32.optional(), changed: z.array(StructuralSpanSchema).optional(), + search_highlights: z.array(StructuralSpanSchema).optional(), }), z.object({ kind: z.literal("fold"), diff --git a/plugins/classify/plugin.wasm b/plugins/classify/plugin.wasm index b1543a204..ca701631f 100644 Binary files a/plugins/classify/plugin.wasm and b/plugins/classify/plugin.wasm differ diff --git a/plugins/shape/context/plugin.wasm b/plugins/shape/context/plugin.wasm index 5f7e6cc2f..f328b05ba 100644 Binary files a/plugins/shape/context/plugin.wasm and b/plugins/shape/context/plugin.wasm differ diff --git a/plugins/shape/context/rust/src/lib.rs b/plugins/shape/context/rust/src/lib.rs index 677cd28ab..050fecca5 100644 --- a/plugins/shape/context/rust/src/lib.rs +++ b/plugins/shape/context/rust/src/lib.rs @@ -1,4 +1,5 @@ //! Keep changes and the context around them; fold every other stretch into one row. +//! In a file with search hits, the hits take the place of the changes. //! //! One walk does it. Entering an unchanged fold folds it to an outline. Entering a leaf //! cuts it where kept lines start and stop. Leaving a fold folds each run @@ -92,15 +93,20 @@ impl Context { /// Collapse a run of hidden siblings into one row, joined with its peers /// on the other side when they are hidden too. A run shorter than - /// `MIN_GAP` stays, and so does a lone region that already shows a - /// summary. + /// `MIN_GAP` stays unless it hides a change, and so does a lone region + /// that already shows a summary. fn fold_run(&self, cursor: &Cursor, run: &[u32]) -> Result<(), String> { let (Some(&first), Some(&last)) = (run.first(), run.last()) else { return Ok(()); }; let start = cursor.get(first)?.data; let lines = cursor.get(last)?.data.range.end.line - start.range.start.line; - if lines < MIN_GAP { + // With hits, changes away from them may fold. + let mut changes = false; + for &member in run { + changes |= cursor.has_changes(member)?; + } + if lines < MIN_GAP && !changes { return Ok(()); } let row = match run { @@ -110,12 +116,10 @@ impl Context { [only] => *only, _ => self.join(cursor, run)?, }; + let kind = if changes { "" } else { " unchanged" }; collapse(cursor, row, |region| { let range = cursor.get(region)?.data.range; - Ok(format!( - "{} unchanged lines", - range.end.line - range.start.line - )) + Ok(format!("{}{kind} lines", range.end.line - range.start.line)) }) } @@ -177,19 +181,25 @@ impl Context { /// The lines of a leaf that stay visible, its own and those its paired /// leaf keeps on the other side. fn kept(&self, cursor: &Cursor, id: u32) -> Result, String> { - let start = cursor.get(id)?.data.range.start.line; + let range = cursor.get(id)?.data.range; + let start = range.start.line; let mut kept = self.kept_on_side(cursor, id)?; - if let Some(peer) = cursor.paired_leaf(id)? { - let peer_start = cursor.get(peer)?.data.range.start.line; - let shift = |line: u32| line - peer_start + start; - kept.extend(self.kept_on_side(cursor, peer)?.into_iter().map(shift)); + match cursor.paired_leaf(id)? { + Some(peer) => { + let peer_start = cursor.get(peer)?.data.range.start.line; + let shift = |line: u32| line - peer_start + start; + kept.extend(self.kept_on_side(cursor, peer)?.into_iter().map(shift)); + } + None if hunk_holds_hit(cursor, id)? => kept.extend(start..range.end.line), + None => {} } Ok(kept) } /// Kept lines of a leaf from its own side: all of it when it or its - /// parent always shows; else the lines within `lines` of a change, and - /// the edges of the changed syntax around it. + /// parent always shows; else the lines within `lines` of a change (a + /// hit, in a file with hits), and the edges of the changed syntax + /// around it. fn kept_on_side(&self, cursor: &Cursor, id: u32) -> Result, String> { let data = cursor.get(id)?.data; let lines = data.range.start.line..data.range.end.line; @@ -207,11 +217,18 @@ impl Context { ..range.end.saturating_add(radius).min(lines.end) }; let mut kept = BTreeSet::new(); + let hits = has_hits(cursor, id)?; for leaf in self.near(cursor, id)? { let leaf = cursor.get(leaf)?.data; let Kind::Leaf(spans) = leaf.kind else { return Err(format!("leaves() returned the non-leaf {}", leaf.id)); }; + if hits { + for span in spans.search_highlights { + kept.extend(near(span.line..span.line + 1)); + } + continue; + } if cursor.paired_leaf(leaf.id)?.is_none() { kept.extend(near(leaf.range.start.line..leaf.range.end.line)); continue; @@ -376,9 +393,78 @@ fn nearest(cursor: &Cursor, id: u32, tag: &str) -> Result, String Ok(ancestors.into_iter().find(|ancestor| tagged(ancestor, tag))) } -/// Changed bytes or unpaired leaves anywhere in this region's fold state, -/// on either side. +/// Whether the file holds a search hit, on either side. +fn has_hits(cursor: &Cursor, id: u32) -> Result { + let root = match cursor.ancestors(id)?.last() { + Some(root) => root.id, + None => id, + }; + Ok(cursor.has_search_highlights(root)?) +} + +/// Whether an unpaired leaf shares its hunk with a hit on the other side: +/// a hit between the same unchanged paired neighbours. +fn hunk_holds_hit(cursor: &Cursor, id: u32) -> Result { + if !has_hits(cursor, id)? { + return Ok(false); + } + let RegionView { side, .. } = cursor.get(id)?; + let other = match side { + Side::Lhs => Side::Rhs, + Side::Rhs => Side::Lhs, + }; + let Some(other_text) = cursor.source(other) else { + return Ok(false); + }; + let text = cursor + .source(side) + .ok_or("a region's own side has a source")?; + let leaves = cursor.leaves(side, 0, text.lines().count() as u32); + let at = leaves + .iter() + .position(|&leaf| leaf == id) + .ok_or("a leaf is among its side's leaves")?; + let unchanged_peer = |leaf: u32| -> Result, String> { + match cursor.paired_leaf(leaf)? { + Some(peer) if !cursor.has_changes(leaf)? && !cursor.has_changes(peer)? => { + Ok(Some(cursor.get(peer)?.data)) + } + _ => Ok(None), + } + }; + let mut start = 0; + for &leaf in leaves[..at].iter().rev() { + if let Some(peer) = unchanged_peer(leaf)? { + start = peer.range.end.line; + break; + } + } + let mut end = other_text.lines().count() as u32; + for &leaf in &leaves[at + 1..] { + if let Some(peer) = unchanged_peer(leaf)? { + end = peer.range.start.line; + break; + } + } + // Neighbours out of order, as around moved code, bound no hunk. + if start >= end { + return Ok(false); + } + for leaf in cursor.leaves(other, start, end) { + if cursor.has_search_highlights(leaf)? { + return Ok(true); + } + } + Ok(false) +} + +/// What context grows around, anywhere in this region's fold state on +/// either side: search hits in a file with hits, else changed bytes or +/// unpaired leaves. fn changed(cursor: &Cursor, id: u32) -> Result { + if has_hits(cursor, id)? { + return Ok(cursor.has_search_highlights(id)?); + } for region in cursor.linked_regions(id)? { if cursor.has_changes(region)? { return Ok(true); diff --git a/plugins/shape/deleted-bodies/plugin.wasm b/plugins/shape/deleted-bodies/plugin.wasm index 9f56e8ca5..018a4283d 100644 Binary files a/plugins/shape/deleted-bodies/plugin.wasm and b/plugins/shape/deleted-bodies/plugin.wasm differ diff --git a/plugins/shape/removed-runs/plugin.wasm b/plugins/shape/removed-runs/plugin.wasm index 6a0ad72d2..f95f73b8e 100644 Binary files a/plugins/shape/removed-runs/plugin.wasm and b/plugins/shape/removed-runs/plugin.wasm differ diff --git a/plugins/shape/removed-runs/rust/src/lib.rs b/plugins/shape/removed-runs/rust/src/lib.rs index f1dc5918b..d7bf8b821 100644 --- a/plugins/shape/removed-runs/rust/src/lib.rs +++ b/plugins/shape/removed-runs/rust/src/lib.rs @@ -46,7 +46,7 @@ impl GuestPlugin for RemovedRuns { else { return Ok(true); }; - if data.visibility.collapsed { + if data.visibility.collapsed || cursor.has_search_highlights(data.id)? { return Ok(false); } if matches!(data.kind, Kind::Fold) { diff --git a/plugins/shape/summarize/plugin.wasm b/plugins/shape/summarize/plugin.wasm index e36a3e4fb..991eb4fc8 100644 Binary files a/plugins/shape/summarize/plugin.wasm and b/plugins/shape/summarize/plugin.wasm differ diff --git a/plugins/shape/summarize/rust/src/lib.rs b/plugins/shape/summarize/rust/src/lib.rs index 3e9a40171..617ca250b 100644 --- a/plugins/shape/summarize/rust/src/lib.rs +++ b/plugins/shape/summarize/rust/src/lib.rs @@ -242,6 +242,9 @@ fn docstring(cursor: &Cursor, id: u32) -> Result, String> { impl Summarize { fn eligible(&self, cursor: &Cursor, data: &Region) -> Result { + if cursor.has_search_highlights(data.id)? { + return Ok(false); + } if !matches!(data.kind, Kind::Fold) { return Ok(false); } diff --git a/plugins/shape/test-bodies/plugin.wasm b/plugins/shape/test-bodies/plugin.wasm index ef4fbdb64..de5fdbbbd 100644 Binary files a/plugins/shape/test-bodies/plugin.wasm and b/plugins/shape/test-bodies/plugin.wasm differ diff --git a/src/plugin/bindings.rs b/src/plugin/bindings.rs index d981657fc..1fd9476d2 100644 --- a/src/plugin/bindings.rs +++ b/src/plugin/bindings.rs @@ -53,6 +53,7 @@ impl From<&FileChange> for types::FileEntry { FileStatus::Renamed => types::FileStatus::Renamed, FileStatus::Copied => types::FileStatus::Copied, FileStatus::TypeChanged => types::FileStatus::TypeChanged, + FileStatus::Unchanged => types::FileStatus::Unchanged, }, tags: file.tags.clone(), } @@ -101,8 +102,17 @@ impl From for types::Region { cursor::Kind::Leaf { alignment_id, changed, + search_highlights, } => types::Kind::Leaf(types::Leaf { alignment_id, + search_highlights: search_highlights + .into_iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), changed: changed .into_iter() .map(|span| types::Span { diff --git a/src/plugin/tests/context.rs b/src/plugin/tests/context.rs index e36f213a3..a9e9dc118 100644 --- a/src/plugin/tests/context.rs +++ b/src/plugin/tests/context.rs @@ -246,6 +246,8 @@ fn a_fold_whose_matched_partner_holds_changes_stays_open() { visibility: Visibility::default(), node: Node::Leaf { alignment_id: alignment, + pair: None, + search_highlights: Vec::new(), changed: (start..end) .filter(|_| changed) .map(|line| Span { @@ -582,3 +584,46 @@ fn a_changed_binding_keeps_its_closer_between_two_rows() { ); assert!(open_leaf_lines(regions).contains(&14)); } + +/// Put a search hit on `line` of a side. +fn highlight(source: &mut Source, line: u32) { + fn visit(region: &mut Region, line: u32) { + let lines = region.range.lines(); + match &mut region.node { + Node::Leaf { + search_highlights, .. + } if lines.contains(&line) => search_highlights.push(Span { + line, + start_column: 0, + end_column: 1, + }), + Node::Leaf { .. } => {} + Node::Fold { children, .. } => children.iter_mut().for_each(|child| visit(child, line)), + } + } + visit(&mut source.root, line); +} + +/// Context around a hit on the new side's `line`. +fn shaped_around_hit(before: &str, after: &str, line: u32, lines: u32) -> Pairing { + let (file, mut sides) = project("a.rs", before, after); + let Pairing::Both { rhs, .. } = &mut sides else { + panic!("both sides"); + }; + highlight(rhs, line); + run("context", json!({ "lines": lines }), &file, &mut sides); + sides +} + +#[test] +fn near_a_hit_a_one_line_change_folds_but_a_one_line_gap_does_not() { + let before = "fn visitor() {\n let a = 1;\n let b = 2;\n search_token();\n let c = 3;\n let d = 4;\n let e = 5;\n finish();\n}\nconst OTHER: u32 = 1;\n"; + let after = before.replace("OTHER: u32 = 1", "OTHER: u32 = 2"); + let sides = shaped_around_hit(before, &after, 3, 1); + for source in [lhs(&sides), rhs(&sides)] { + assert_eq!( + open_leaf_lines(source.root.children()), + BTreeSet::from([0, 1, 2, 3, 4, 8]) + ); + } +} diff --git a/src/plugin/tests/deleted_bodies.rs b/src/plugin/tests/deleted_bodies.rs index 3546c5b35..0cd9a4d5b 100644 --- a/src/plugin/tests/deleted_bodies.rs +++ b/src/plugin/tests/deleted_bodies.rs @@ -25,6 +25,8 @@ fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> Region { visibility: Visibility::default(), node: Node::Leaf { alignment_id: alignment, + pair: None, + search_highlights: Vec::new(), changed: vec![], }, } diff --git a/src/plugin/tests/removed_runs.rs b/src/plugin/tests/removed_runs.rs index fbc66ac31..f08c4bd80 100644 --- a/src/plugin/tests/removed_runs.rs +++ b/src/plugin/tests/removed_runs.rs @@ -23,6 +23,8 @@ fn removed_leaf(id: u32, alignment: u32, start: u32, end: u32, changed: &[u32]) visibility: Visibility::default(), node: Node::Leaf { alignment_id: alignment, + pair: None, + search_highlights: Vec::new(), changed: changed .iter() .map(|&line| Span { @@ -82,6 +84,7 @@ fn shape(regions: &[Region]) -> Vec { let Node::Leaf { alignment_id, changed, + .. } = ®ion.node else { panic!("leaf expected"); diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 277005d78..1a0c7228b 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -175,6 +175,17 @@ fn next_child( } impl bindings::diffr::plugin::host::HostCursor for State { + fn has_search_highlights( + &mut self, + c: Resource, + id: u32, + ) -> wasmtime::Result> { + Ok(self + .table + .get(&c)? + .has_search_highlights(id) + .map_err(Into::into)) + } fn file(&mut self, c: Resource) -> wasmtime::Result { Ok((&self.table.get(&c)?.file).into()) } diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index f9edbf1d3..af49aa549 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -15,7 +15,7 @@ const visibility = z.object({ const problem = z.object({ code: z.string(), message: z.string() }); const fileChange = z.object({ file: pairing(fileRef), - status: z.enum(["added", "deleted", "modified", "renamed", "copied", "type_changed"]), + status: z.enum(["added", "deleted", "modified", "renamed", "copied", "type_changed", "unchanged"]), /** What the file is (`generated`, `vendored`, `docs`, `test`, or a `diffr-tags` attribute), sorted. */ tags: z.array(z.string()).default([]), }); @@ -45,6 +45,8 @@ export interface LeafRegion extends RegionBase { kind: "leaf"; /** Same value on the other side: the leaf whose rows line up with this one, one-to-one. Keys the row zip. */ alignment_id: number; + /** The partner leaf's `id` on the other side; absent when added or deleted. */ + pair?: number; } export interface FoldRegion extends RegionBase { kind: "fold"; @@ -64,7 +66,7 @@ const regionBase = z.object({ }); const region: z.ZodType = z.lazy(() => z.discriminatedUnion("kind", [ - regionBase.extend({ kind: z.literal("leaf"), alignment_id: uint, changed: z.array(span).default([]) }) + regionBase.extend({ kind: z.literal("leaf"), alignment_id: uint, pair: uint.optional(), changed: z.array(span).default([]) }) .transform((leaf) => ({ ...leaf, children: [] as Region[] })), regionBase.extend({ kind: z.literal("fold"), children: z.array(region), indent: sourcePos, syntax: z.object({ start: sourcePos, end: sourcePos }).optional() })