magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::{
    diff_review::{ReviewLineKind, ReviewSnapshot},
    output::sanitize_display_text,
    rendering::{
        DisplayLine,
        highlight::{SourceHighlightBudget, SourceHighlightJob},
    },
};
use std::{collections::BTreeMap, sync::Arc};
use unicode_width::UnicodeWidthStr;

pub(super) struct FileProjection {
    pub original: Arc<Vec<DisplayLine>>,
    pub current: Arc<Vec<DisplayLine>>,
    pub removed: Vec<bool>,
    pub widths: [usize; 2],
    pending: bool,
}

pub(super) struct TreeEntry {
    pub identity: String,
    pub label: String,
    pub file: Option<usize>,
    pub comment: Option<usize>,
}

pub(crate) struct PreparedSnapshot {
    source: ReviewSnapshot,
    pub(super) files_display: Vec<Arc<FileProjection>>,
    pub(super) tree: Vec<TreeEntry>,
    pub(super) tree_width: usize,
    pub(super) highlighting_pending: bool,
}

impl std::ops::Deref for PreparedSnapshot {
    type Target = ReviewSnapshot;
    fn deref(&self) -> &Self::Target {
        &self.source
    }
}

#[derive(Default)]
pub(super) struct ProjectionCache {
    root: std::path::PathBuf,
    files: BTreeMap<String, [SourceHighlightJob; 2]>,
    current_first: bool,
}

impl PreparedSnapshot {
    pub(super) fn prepare(
        source: ReviewSnapshot,
        previous: Option<&Self>,
        selected_path: Option<&str>,
        cancellation: &crate::cancellation::AgentCancellation,
        cache: &mut ProjectionCache,
    ) -> anyhow::Result<Self> {
        Self::with_budget(
            source,
            previous,
            selected_path,
            &SourceHighlightBudget::new(cancellation),
            cache,
            usize::MAX,
        )
    }

    pub(super) fn with_budget(
        source: ReviewSnapshot,
        previous: Option<&Self>,
        selected_path: Option<&str>,
        budget: &SourceHighlightBudget<'_>,
        cache: &mut ProjectionCache,
        max_lines: usize,
    ) -> anyhow::Result<Self> {
        budget.cancellation.check()?;
        if cache.root != source.root {
            cache.files.clear();
            cache.root = source.root.clone();
        }
        let paths: std::collections::BTreeSet<_> =
            source.files.iter().map(|file| file.path.as_str()).collect();
        cache.files.retain(|path, _| paths.contains(path.as_str()));
        let selected = selected_path
            .and_then(|path| source.files.iter().position(|file| file.path == path))
            .unwrap_or(0);
        let previous_files: BTreeMap<_, _> = previous
            .filter(|old| old.root == source.root)
            .into_iter()
            .flat_map(|old| old.files.iter().zip(&old.files_display))
            .map(|(file, display)| (file.path.as_str(), (file, display)))
            .collect();
        let mut files_display = vec![None; source.files.len()];
        let order = (selected..source.files.len().min(selected + 1))
            .chain((0..source.files.len()).filter(|index| *index != selected));
        for index in order {
            budget.cancellation.check()?;
            let file = &source.files[index];
            if let Some((old, display)) = previous_files.get(file.path.as_str())
                && !display.pending
                && old.original == file.original
                && old.current == file.current
                && old.rows.len() == file.rows.len()
                && old.rows.iter().zip(&file.rows).all(|(a, b)| {
                    a.old_line == b.old_line
                        && a.new_line == b.new_line
                        && a.kind == b.kind
                        && a.text == b.text
                })
            {
                files_display[index] = Some(Arc::clone(display));
                continue;
            }
            let language = std::path::Path::new(&file.path)
                .extension()
                .and_then(|s| s.to_str());
            let sources = [
                sanitize_display_text(&file.original),
                sanitize_display_text(&file.current),
            ];
            let jobs = cache.files.entry(file.path.clone()).or_insert_with(|| {
                [
                    SourceHighlightJob::new(sources[0].clone(), language),
                    SourceHighlightJob::new(sources[1].clone(), language),
                ]
            });
            for side in 0..2 {
                if !jobs[side].matches(&sources[side]) {
                    jobs[side] = SourceHighlightJob::new(sources[side].clone(), language);
                }
            }
            let first = usize::from(cache.current_first);
            for side in [first, 1 - first] {
                let slice = SourceHighlightBudget {
                    deadline: budget
                        .deadline
                        .min(std::time::Instant::now() + std::time::Duration::from_millis(150)),
                    cancellation: budget.cancellation,
                };
                jobs[side].advance(&slice, max_lines)?;
            }
            let original = jobs[0].display();
            let current = jobs[1].display();
            let mut removed = vec![false; original.len()];
            for row in &file.rows {
                if row.kind == ReviewLineKind::Removed
                    && let Some(index) = row.old_line.and_then(|line| line.checked_sub(1))
                    && let Some(removed) = removed.get_mut(index)
                {
                    *removed = true;
                }
            }
            let line_width = |line: &DisplayLine| {
                line.spans
                    .iter()
                    .map(|span| span.text.width())
                    .sum::<usize>()
            };
            let original_width = original.iter().map(line_width).max().unwrap_or(0);
            let diff_width = file
                .rows
                .iter()
                .filter_map(|row| {
                    if let Some(line) = row.new_line {
                        current.get(line.saturating_sub(1))
                    } else {
                        row.old_line
                            .and_then(|line| original.get(line.saturating_sub(1)))
                    }
                })
                .map(line_width)
                .max()
                .unwrap_or(0);
            files_display[index] = Some(Arc::new(FileProjection {
                original,
                current,
                removed,
                widths: [original_width, diff_width],
                pending: jobs.iter().any(SourceHighlightJob::pending),
            }));
        }
        cache.current_first = !cache.current_first;
        budget.cancellation.check()?;
        let tree = build_tree(&source);
        let tree_width = tree
            .iter()
            .map(|entry| {
                let status_width = if entry.comment.is_none()
                    && entry
                        .file
                        .and_then(|index| source.files.get(index))
                        .is_some_and(|file| file.change.is_some())
                {
                    2
                } else {
                    0
                };
                entry.label.width() + status_width
            })
            .max()
            .unwrap_or(0);
        let files_display: Vec<_> = files_display
            .into_iter()
            .map(|file| file.expect("every file projected"))
            .collect();
        let highlighting_pending = files_display.iter().any(|file| file.pending);
        Ok(Self {
            source,
            files_display,
            tree,
            tree_width,
            highlighting_pending,
        })
    }
}

#[derive(Default)]
struct Folder<'a> {
    folders: BTreeMap<&'a str, Folder<'a>>,
    files: BTreeMap<&'a str, usize>,
}

fn build_tree(snapshot: &ReviewSnapshot) -> Vec<TreeEntry> {
    let mut root = Folder::default();
    for (index, file) in snapshot.files.iter().enumerate() {
        let mut folder = &mut root;
        let mut parts = file.path.split('/').peekable();
        while let Some(part) = parts.next() {
            if parts.peek().is_some() {
                folder = folder.folders.entry(part).or_default();
            } else {
                folder.files.insert(part, index);
            }
        }
    }
    let mut entries = Vec::new();
    append_folder(&root, "", 0, snapshot, &mut entries);
    entries
}

fn append_folder(
    folder: &Folder<'_>,
    path: &str,
    depth: usize,
    snapshot: &ReviewSnapshot,
    entries: &mut Vec<TreeEntry>,
) {
    for (name, child) in &folder.folders {
        let path = format!("{path}{name}/");
        entries.push(TreeEntry {
            identity: format!("folder:{path}"),
            label: sanitize_display_text(&format!("{}{name}/", "  ".repeat(depth))),
            file: None,
            comment: None,
        });
        append_folder(child, &path, depth + 1, snapshot, entries);
    }
    for (name, index) in &folder.files {
        let file = &snapshot.files[*index];
        entries.push(TreeEntry {
            identity: format!("file:{}", file.path),
            label: sanitize_display_text(&format!("{}{name}", "  ".repeat(depth))),
            file: Some(*index),
            comment: None,
        });
        for (comment_index, comment) in snapshot
            .comments
            .iter()
            .enumerate()
            .filter(|(_, c)| c.path == file.path)
        {
            entries.push(TreeEntry {
                identity: format!("comment:{}", comment.id),
                label: sanitize_display_text(&format!(
                    "{}{} {}:{} {}",
                    "  ".repeat(depth + 1),
                    super::render::comment_marker(comment.stale, comment.resolved),
                    if comment.side == crate::diff_review::ReviewSide::Original {
                        "O"
                    } else {
                        "D"
                    },
                    comment.line,
                    comment.text.lines().next().unwrap_or("")
                )),
                file: Some(*index),
                comment: Some(comment_index),
            });
        }
    }
}