aether-wisp 0.1.5

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::patch_renderer::{RenderedPatch, lang_hint_from_path, usize_to_u16_saturating};
use crate::components::app::git_diff_mode::PatchLineRef;
use crate::git_diff::{FileDiff, PatchLine, PatchLineKind};
use tui::{Line, ViewContext};

const SEPARATOR_WIDTH_U16: u16 = 3;

pub fn build_split_patch_base_lines(file: &FileDiff, width: usize, ctx: &ViewContext) -> RenderedPatch {
    let theme = &ctx.theme;
    let lang_hint = lang_hint_from_path(&file.path);
    let usable = width.saturating_sub(tui::GUTTER_WIDTH * 2 + tui::SEPARATOR_WIDTH);
    let left_content = usable / 2;
    let right_content = usable.saturating_sub(left_content);
    let left_panel_u16 = usize_to_u16_saturating(tui::GUTTER_WIDTH + left_content);
    let right_panel_u16 = usize_to_u16_saturating(tui::GUTTER_WIDTH + right_content);

    let mut patch_lines = Vec::new();
    let mut patch_refs = Vec::new();
    let mut anchor_insert_row_lookup = std::collections::HashMap::new();

    for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
        if hunk_idx > 0 {
            patch_lines.push(Line::default());
            patch_refs.push(None);
        }

        for row in pair_hunk_lines(&hunk.lines) {
            let anchor = row.anchor(hunk_idx);

            match row {
                PairedRow::Header { text, .. } => {
                    let mut line = Line::default();
                    line.push_with_style(text, tui::Style::fg(theme.info()).bold().bg_color(theme.code_bg()));
                    line.extend_bg_to_width(width);

                    if let Some(anchor) = anchor {
                        anchor_insert_row_lookup.insert(anchor, patch_lines.len() + 1);
                    }

                    patch_lines.push(line);
                    patch_refs.push(anchor);
                }
                PairedRow::Split { left, right } => {
                    let left_cell = left.as_ref().map(|side| tui::SplitDiffCell {
                        tag: match side.kind {
                            PatchLineKind::Removed => tui::DiffTag::Removed,
                            _ => tui::DiffTag::Context,
                        },
                        content: side.text.to_string(),
                        line_number: side.line_no,
                    });
                    let right_cell = right.as_ref().map(|side| tui::SplitDiffCell {
                        tag: match side.kind {
                            PatchLineKind::Added => tui::DiffTag::Added,
                            _ => tui::DiffTag::Context,
                        },
                        content: side.text.to_string(),
                        line_number: side.line_no,
                    });

                    let left_frame = tui::split_render_cell(left_cell.as_ref(), left_content, lang_hint, ctx);
                    let right_frame = tui::split_render_cell(right_cell.as_ref(), right_content, lang_hint, ctx);
                    let height = left_frame.lines().len().max(right_frame.lines().len());

                    let sep_line = Line::new(tui::SEPARATOR.to_string());
                    let sep_frame = tui::Frame::new(vec![sep_line; height]);
                    let row_frame = tui::Frame::hstack([
                        tui::FramePart::new(left_frame, left_panel_u16),
                        tui::FramePart::new(sep_frame, SEPARATOR_WIDTH_U16),
                        tui::FramePart::new(right_frame, right_panel_u16),
                    ]);

                    if let Some(anchor) = anchor {
                        anchor_insert_row_lookup.insert(anchor, patch_lines.len() + height);
                    }

                    patch_lines.extend(row_frame.into_lines());
                    patch_refs.push(anchor);
                    patch_refs.extend(std::iter::repeat_n(None, height.saturating_sub(1)));
                }
            }
        }
    }

    RenderedPatch::new(patch_lines, patch_refs, anchor_insert_row_lookup)
}

#[derive(Clone, Copy)]
struct SideInfo<'a> {
    kind: PatchLineKind,
    text: &'a str,
    line_no: Option<usize>,
    line_idx: usize,
}

enum PairedRow<'a> {
    Header { line_idx: usize, text: &'a str },
    Split { left: Option<SideInfo<'a>>, right: Option<SideInfo<'a>> },
}

impl PairedRow<'_> {
    fn anchor(&self, hunk_index: usize) -> Option<PatchLineRef> {
        match self {
            Self::Header { line_idx, .. } => Some(PatchLineRef { hunk_index, line_index: *line_idx }),
            Self::Split { left, right } => right
                .as_ref()
                .map(|side| PatchLineRef { hunk_index, line_index: side.line_idx })
                .or_else(|| left.as_ref().map(|side| PatchLineRef { hunk_index, line_index: side.line_idx })),
        }
    }
}

fn pair_hunk_lines(lines: &[PatchLine]) -> Vec<PairedRow<'_>> {
    let mut rows = Vec::new();
    let mut index = 0;

    while index < lines.len() {
        let patch_line = &lines[index];
        match patch_line.kind {
            PatchLineKind::HunkHeader | PatchLineKind::Meta => {
                rows.push(PairedRow::Header { line_idx: index, text: &patch_line.text });
                index += 1;
            }
            PatchLineKind::Context => {
                rows.push(PairedRow::Split {
                    left: Some(SideInfo {
                        kind: patch_line.kind,
                        text: &patch_line.text,
                        line_no: patch_line.old_line_no,
                        line_idx: index,
                    }),
                    right: Some(SideInfo {
                        kind: patch_line.kind,
                        text: &patch_line.text,
                        line_no: patch_line.new_line_no,
                        line_idx: index,
                    }),
                });
                index += 1;
            }
            PatchLineKind::Removed => {
                let mut removed = Vec::new();
                while index < lines.len() && lines[index].kind == PatchLineKind::Removed {
                    removed.push(side_info(&lines[index], index));
                    index += 1;
                }

                let mut added = Vec::new();
                while index < lines.len() && lines[index].kind == PatchLineKind::Added {
                    added.push(side_info(&lines[index], index));
                    index += 1;
                }

                rows.extend(pair_changed_block(&removed, &added));
            }
            PatchLineKind::Added => {
                let mut added = Vec::new();
                while index < lines.len() && lines[index].kind == PatchLineKind::Added {
                    added.push(side_info(&lines[index], index));
                    index += 1;
                }
                rows.extend(pair_changed_block(&[], &added));
            }
        }
    }

    rows
}

fn side_info(line: &PatchLine, line_idx: usize) -> SideInfo<'_> {
    let line_no = match line.kind {
        PatchLineKind::Added => line.new_line_no,
        PatchLineKind::Removed | PatchLineKind::Context => line.old_line_no,
        PatchLineKind::HunkHeader | PatchLineKind::Meta => None,
    };

    SideInfo { kind: line.kind, text: &line.text, line_no, line_idx }
}

fn pair_changed_block<'a>(removed: &[SideInfo<'a>], added: &[SideInfo<'a>]) -> Vec<PairedRow<'a>> {
    let old: Vec<&str> = removed.iter().map(|side| side.text).collect();
    let new: Vec<&str> = added.iter().map(|side| side.text).collect();
    let diff = similar::TextDiff::from_slices(&old, &new);
    let mut rows = Vec::new();

    for op in diff.ops() {
        match *op {
            similar::DiffOp::Equal { old_index, new_index, len } => {
                for offset in 0..len {
                    rows.push(split_row(Some(removed[old_index + offset]), Some(added[new_index + offset])));
                }
            }
            similar::DiffOp::Delete { old_index, old_len, .. } => {
                for side in &removed[old_index..old_index + old_len] {
                    rows.push(split_row(Some(*side), None));
                }
            }
            similar::DiffOp::Insert { new_index, new_len, .. } => {
                for side in &added[new_index..new_index + new_len] {
                    rows.push(split_row(None, Some(*side)));
                }
            }
            similar::DiffOp::Replace { old_index, old_len, new_index, new_len } => {
                let pair_len = old_len.min(new_len);

                for offset in 0..pair_len {
                    rows.push(split_row(Some(removed[old_index + offset]), Some(added[new_index + offset])));
                }

                for side in &removed[old_index + pair_len..old_index + old_len] {
                    rows.push(split_row(Some(*side), None));
                }
                for side in &added[new_index + pair_len..new_index + new_len] {
                    rows.push(split_row(None, Some(*side)));
                }
            }
        }
    }

    rows
}

fn split_row<'a>(left: Option<SideInfo<'a>>, right: Option<SideInfo<'a>>) -> PairedRow<'a> {
    PairedRow::Split { left, right }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git_diff::{FileStatus, Hunk};

    fn pl(kind: PatchLineKind, text: &str, old: Option<usize>, new: Option<usize>) -> PatchLine {
        PatchLine { kind, text: text.to_string(), old_line_no: old, new_line_no: new }
    }

    fn test_file(hunks: Vec<Hunk>) -> FileDiff {
        FileDiff {
            old_path: Some("test.rs".to_string()),
            path: "test.rs".to_string(),
            status: FileStatus::Modified,
            hunks,
            binary: false,
        }
    }

    fn test_hunk(lines: Vec<PatchLine>) -> Hunk {
        Hunk { header: "@@ -1,3 +1,3 @@".to_string(), old_start: 1, old_count: 3, new_start: 1, new_count: 3, lines }
    }

    fn ctx() -> ViewContext {
        ViewContext::new((120, 24))
    }

    #[test]
    fn split_base_lines_has_insert_row_lookup() {
        let file = test_file(vec![test_hunk(vec![
            pl(PatchLineKind::HunkHeader, "@@ -1,3 +1,3 @@", None, None),
            pl(PatchLineKind::Removed, "old_a", Some(1), None),
            pl(PatchLineKind::Added, "new_a", None, Some(1)),
        ])]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        assert_eq!(result.line_ref_to_anchor_row_index.len(), 2);
        assert_eq!(result.line_ref_to_anchor_row_index[&PatchLineRef { hunk_index: 0, line_index: 0 }], 1);
    }

    #[test]
    fn split_base_lines_has_hunk_offsets() {
        let file = test_file(vec![
            test_hunk(vec![
                pl(PatchLineKind::HunkHeader, "@@ -1,1 +1,1 @@", None, None),
                pl(PatchLineKind::Context, "a", Some(1), Some(1)),
            ]),
            Hunk {
                header: "@@ -5,1 +5,1 @@".to_string(),
                old_start: 5,
                old_count: 1,
                new_start: 5,
                new_count: 1,
                lines: vec![
                    pl(PatchLineKind::HunkHeader, "@@ -5,1 +5,1 @@", None, None),
                    pl(PatchLineKind::Context, "b", Some(5), Some(5)),
                ],
            },
        ]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        assert_eq!(result.hunk_offsets.len(), 2);
        assert_eq!(result.hunk_offsets[0], 0);
    }

    #[test]
    fn split_pairs_removed_and_added() {
        let file = test_file(vec![test_hunk(vec![
            pl(PatchLineKind::HunkHeader, "@@ -1,3 +1,3 @@", None, None),
            pl(PatchLineKind::Removed, "old_a", Some(1), None),
            pl(PatchLineKind::Removed, "old_b", Some(2), None),
            pl(PatchLineKind::Added, "new_a", None, Some(1)),
            pl(PatchLineKind::Added, "new_b", None, Some(2)),
        ])]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        assert_eq!(result.lines.len(), 3);
        assert_eq!(result.line_refs.len(), 3);
        assert!(result.line_refs[1].is_some());
        assert!(result.line_refs[2].is_some());
    }

    #[test]
    fn split_spacer_between_hunks() {
        let file = test_file(vec![
            test_hunk(vec![
                pl(PatchLineKind::HunkHeader, "@@ -1,1 +1,1 @@", None, None),
                pl(PatchLineKind::Context, "a", Some(1), Some(1)),
            ]),
            Hunk {
                header: "@@ -5,1 +5,1 @@".to_string(),
                old_start: 5,
                old_count: 1,
                new_start: 5,
                new_count: 1,
                lines: vec![
                    pl(PatchLineKind::HunkHeader, "@@ -5,1 +5,1 @@", None, None),
                    pl(PatchLineKind::Context, "b", Some(5), Some(5)),
                ],
            },
        ]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        assert_eq!(result.line_refs.len(), 5);
        assert!(result.line_refs[2].is_none(), "spacer between hunks should have None ref");
    }

    #[test]
    fn split_refs_prefer_right_side() {
        let file = test_file(vec![test_hunk(vec![
            pl(PatchLineKind::HunkHeader, "@@ -1,1 +1,1 @@", None, None),
            pl(PatchLineKind::Removed, "old", Some(1), None),
            pl(PatchLineKind::Added, "new", None, Some(1)),
        ])]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        let paired_ref = result.line_refs[1].as_ref().expect("split row should have an anchor");
        assert_eq!(paired_ref.line_index, 2, "should reference the Added line");
    }

    #[test]
    fn moved_identical_line_is_aligned_on_same_row() {
        let file = test_file(vec![test_hunk(vec![
            pl(PatchLineKind::HunkHeader, "@@ -1,2 +1,2 @@", None, None),
            pl(PatchLineKind::Removed, "let before = old();", Some(1), None),
            pl(PatchLineKind::Removed, "shared_call();", Some(2), None),
            pl(PatchLineKind::Added, "shared_call();", None, Some(1)),
            pl(PatchLineKind::Added, "let after = new();", None, Some(2)),
        ])]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        let shared_row =
            result.lines.iter().skip(1).find(|line| line.plain_text().matches("shared_call();").count() == 2);
        assert!(shared_row.is_some(), "identical moved lines should be aligned onto the same split row");
    }

    #[test]
    fn replace_with_extra_added_line_keeps_prefix_aligned_and_overflow_unpaired() {
        let file = test_file(vec![test_hunk(vec![
            pl(PatchLineKind::HunkHeader, "@@ -1,2 +1,3 @@", None, None),
            pl(PatchLineKind::Removed, "shared_call();", Some(1), None),
            pl(PatchLineKind::Removed, "let old_value = foo();", Some(2), None),
            pl(PatchLineKind::Added, "shared_call();", None, Some(1)),
            pl(PatchLineKind::Added, "let new_value = bar();", None, Some(2)),
            pl(PatchLineKind::Added, "extra_call();", None, Some(3)),
        ])]);
        let result = build_split_patch_base_lines(&file, 100, &ctx());

        let shared_row =
            result.lines.iter().skip(1).find(|line| line.plain_text().matches("shared_call();").count() == 2);
        assert!(shared_row.is_some(), "shared prefix line should stay aligned as an unchanged pair");

        let overflow_row = result
            .lines
            .iter()
            .skip(1)
            .find(|line| line.plain_text().contains("extra_call();"))
            .expect("expected overflow added row");
        assert_eq!(
            overflow_row.plain_text().matches("extra_call();").count(),
            1,
            "overflow added line should remain unpaired"
        );
    }
}