aether-wisp 0.3.1

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::patch_renderer::RenderedPatch;
use super::split_patch_renderer::build_split_patch_base_lines;
use crate::components::app::git_diff_mode::QueuedComment;
use crate::components::common::CachedLayer;
use crate::components::review_comments::{CommentGroup, FrameSplice};
use crate::git_diff::FileDiff;
use tui::ViewContext;

#[derive(Clone, PartialEq, Eq)]
struct DiffLayerKey {
    document_revision: usize,
    width: u16,
    file_path: String,
    layout: DiffLayout,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum DiffLayout {
    Unified,
    Split,
}

#[derive(Clone, PartialEq, Eq)]
struct CommentLayerKey {
    revision: usize,
    width: u16,
    file_path: String,
}

pub struct GitDiffCompositor {
    diff_layer: CachedLayer<DiffLayerKey, RenderedPatch>,
    submitted_layer: CachedLayer<CommentLayerKey, Vec<FrameSplice>>,
    submitted_revision: usize,
}

impl GitDiffCompositor {
    pub fn new() -> Self {
        Self { diff_layer: CachedLayer::new(), submitted_layer: CachedLayer::new(), submitted_revision: 0 }
    }

    pub fn invalidate_diff_layer(&mut self) {
        self.diff_layer.reset();
    }

    pub fn invalidate_submitted_comments_layer(&mut self) {
        self.submitted_revision = self.submitted_revision.saturating_add(1);
        self.submitted_layer.reset();
    }

    pub fn invalidate_all(&mut self) {
        self.diff_layer.reset();
        self.invalidate_submitted_comments_layer();
    }

    pub fn ensure_diff_layer(
        &mut self,
        file: &FileDiff,
        width: u16,
        split_layout: bool,
        document_revision: usize,
        ctx: &ViewContext,
    ) {
        let key = DiffLayerKey {
            document_revision,
            width,
            file_path: file.path.clone(),
            layout: if split_layout { DiffLayout::Split } else { DiffLayout::Unified },
        };
        self.diff_layer.ensure(key, || {
            if split_layout {
                build_split_patch_base_lines(file, usize::from(width), ctx)
            } else {
                RenderedPatch::from_file_diff(file, usize::from(width), ctx)
            }
        });
    }

    pub fn ensure_submitted_layer(&mut self, file: &FileDiff, comments: &[&QueuedComment], ctx: &ViewContext) {
        let Some(rendered) = self.diff_layer.get() else {
            self.submitted_layer.reset();
            return;
        };

        let key =
            CommentLayerKey { revision: self.submitted_revision, width: ctx.size.width, file_path: file.path.clone() };
        self.submitted_layer.ensure(key, || {
            CommentGroup::splices_for(&rendered.surface, comments.iter().map(|comment| &comment.review), ctx)
        });
    }

    pub fn rendered_patch(&self) -> Option<&RenderedPatch> {
        self.diff_layer.get()
    }

    pub fn comment_splices(&self) -> &[FrameSplice] {
        self.submitted_layer.get().map_or(&[], Vec::as_slice)
    }
}

impl Default for GitDiffCompositor {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::components::app::git_diff_mode::GitDiffCommentContext;
    use crate::components::git_diff::PatchAnchor;
    use crate::components::review_comments::{CommentAnchor, ReviewComment};
    use crate::git_diff::{FileStatus, Hunk, PatchLine, PatchLineKind};

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

    fn make_file(lines: Vec<PatchLine>) -> FileDiff {
        FileDiff {
            old_path: Some("test.rs".to_string()),
            path: "test.rs".to_string(),
            status: FileStatus::Modified,
            hunks: vec![Hunk {
                header: "@@ -1,1 +1,1 @@".to_string(),
                old_start: 1,
                old_count: 1,
                new_start: 1,
                new_count: 1,
                lines,
            }],
            binary: false,
        }
    }

    fn queued(anchor: CommentAnchor<PatchAnchor>, comment: &str) -> QueuedComment {
        QueuedComment {
            review: ReviewComment::new(anchor, comment),
            context: GitDiffCommentContext {
                file_path: "test.rs".to_string(),
                line_text: "line".to_string(),
                line_number: Some(1),
                line_kind: PatchLineKind::Added,
            },
        }
    }

    fn initialize_layers(
        compositor: &mut GitDiffCompositor,
        file: &FileDiff,
        width: u16,
        comments: &[QueuedComment],
        document_revision: usize,
    ) {
        let ctx = context().with_width(width);
        let refs = comments.iter().collect::<Vec<_>>();
        compositor.ensure_diff_layer(file, width, false, document_revision, &ctx);
        compositor.ensure_submitted_layer(file, &refs, &ctx);
    }

    #[test]
    fn compositor_starts_empty() {
        let compositor = GitDiffCompositor::new();
        assert!(compositor.rendered_patch().is_none());
        assert!(compositor.comment_splices().is_empty());
    }

    #[test]
    fn comment_splices_preserve_input_order_for_same_anchor() {
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine {
                kind: PatchLineKind::Added,
                text: "new_line();".to_string(),
                old_line_no: None,
                new_line_no: Some(1),
            },
        ]);

        let anchor = CommentAnchor(PatchAnchor { hunk: 0, line: 1 });
        let comments = vec![queued(anchor, "alpha"), queued(anchor, "beta")];

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 80, &comments, 1);

        let splices = compositor.comment_splices();
        assert_eq!(splices.len(), 1);
        let rendered_text: Vec<String> = splices[0].frame.lines().iter().map(tui::Line::plain_text).collect();
        let alpha_pos = rendered_text.iter().position(|t| t.contains("alpha")).expect("alpha should render");
        let beta_pos = rendered_text.iter().position(|t| t.contains("beta")).expect("beta should render");
        assert!(alpha_pos < beta_pos, "comments should render in queue order");
    }

    #[test]
    fn comment_splice_uses_correct_after_row() {
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine {
                kind: PatchLineKind::Added,
                text: "new_line();".to_string(),
                old_line_no: None,
                new_line_no: Some(1),
            },
        ]);

        let anchor = CommentAnchor(PatchAnchor { hunk: 0, line: 1 });
        let comments = vec![queued(anchor, "a comment")];

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 80, &comments, 1);

        let splices = compositor.comment_splices();
        assert_eq!(splices.len(), 1);

        let rendered = compositor.rendered_patch().unwrap();
        let end_row = rendered.surface.end_row_for_anchor(anchor).expect("anchor end row should exist");
        assert_eq!(splices[0].after_row, end_row);
    }
}