llm 1.3.8

A Rust library unifying multiple LLM backends.
Documentation
use crate::diff::{DiffHunk, DiffView, HunkDecision};

#[derive(Debug, Clone)]
pub struct DiffViewerState {
    pub diff: DiffView,
    pub file_index: usize,
    pub hunk_index: usize,
    pub scroll: u16,
}

impl DiffViewerState {
    pub fn new(diff: DiffView) -> Self {
        Self {
            diff,
            file_index: 0,
            hunk_index: 0,
            scroll: 0,
        }
    }

    pub fn current_hunk(&self) -> Option<&DiffHunk> {
        self.diff
            .files
            .get(self.file_index)
            .and_then(|file| file.hunks.get(self.hunk_index))
    }

    pub fn current_hunk_mut(&mut self) -> Option<&mut DiffHunk> {
        self.diff
            .files
            .get_mut(self.file_index)
            .and_then(|file| file.hunks.get_mut(self.hunk_index))
    }

    pub fn next_hunk(&mut self) {
        if let Some((file_idx, hunk_idx)) =
            next_hunk_index(&self.diff, self.file_index, self.hunk_index)
        {
            self.file_index = file_idx;
            self.hunk_index = hunk_idx;
            self.scroll = 0;
        }
    }

    pub fn prev_hunk(&mut self) {
        if let Some((file_idx, hunk_idx)) =
            prev_hunk_index(&self.diff, self.file_index, self.hunk_index)
        {
            self.file_index = file_idx;
            self.hunk_index = hunk_idx;
            self.scroll = 0;
        }
    }

    pub fn accept_current(&mut self) {
        if let Some(hunk) = self.current_hunk_mut() {
            hunk.set_decision(HunkDecision::Accepted);
        }
    }

    pub fn reject_current(&mut self) {
        if let Some(hunk) = self.current_hunk_mut() {
            hunk.set_decision(HunkDecision::Rejected);
        }
    }

    pub fn skip_current(&mut self) {
        if let Some(hunk) = self.current_hunk_mut() {
            hunk.set_decision(HunkDecision::Skipped);
        }
    }

    pub fn accept_all(&mut self) {
        for file in &mut self.diff.files {
            for hunk in &mut file.hunks {
                if hunk.decision == HunkDecision::Pending {
                    hunk.set_decision(HunkDecision::Accepted);
                }
            }
        }
    }

    pub fn scroll_up(&mut self, lines: u16) {
        self.scroll = self.scroll.saturating_sub(lines);
    }

    pub fn scroll_down(&mut self, lines: u16, height: u16) {
        let max = self.max_scroll(height);
        self.scroll = (self.scroll + lines).min(max);
    }

    pub fn max_scroll(&self, height: u16) -> u16 {
        let total = self.current_hunk_line_count();
        total.saturating_sub(height)
    }

    fn current_hunk_line_count(&self) -> u16 {
        self.current_hunk()
            .map(|hunk| hunk.lines.len() as u16 + 1)
            .unwrap_or(0)
    }
}

fn next_hunk_index(
    diff: &DiffView,
    file_index: usize,
    hunk_index: usize,
) -> Option<(usize, usize)> {
    let file = diff.files.get(file_index)?;
    if hunk_index + 1 < file.hunks.len() {
        return Some((file_index, hunk_index + 1));
    }
    let mut next_file = file_index + 1;
    while let Some(file) = diff.files.get(next_file) {
        if !file.hunks.is_empty() {
            return Some((next_file, 0));
        }
        next_file += 1;
    }
    None
}

fn prev_hunk_index(
    diff: &DiffView,
    file_index: usize,
    hunk_index: usize,
) -> Option<(usize, usize)> {
    if hunk_index > 0 {
        return Some((file_index, hunk_index - 1));
    }
    let mut prev_file = file_index.checked_sub(1)?;
    loop {
        let file = diff.files.get(prev_file)?;
        if !file.hunks.is_empty() {
            let idx = file.hunks.len().saturating_sub(1);
            return Some((prev_file, idx));
        }
        prev_file = prev_file.checked_sub(1)?;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diff::{DiffFile, DiffHunk, DiffLine, DiffView, HunkDecision, LineKind};

    fn sample_diff() -> DiffView {
        let hunk = DiffHunk {
            header: "@@ -1,1 +1,1 @@".to_string(),
            old_start: 1,
            new_start: 1,
            lines: vec![DiffLine {
                kind: LineKind::Add,
                content: "hello".to_string(),
            }],
            decision: HunkDecision::Pending,
        };
        DiffView {
            files: vec![DiffFile {
                old_path: "a.txt".to_string(),
                new_path: "a.txt".to_string(),
                hunks: vec![hunk],
            }],
        }
    }

    #[test]
    fn accepts_current_hunk() {
        let mut state = DiffViewerState::new(sample_diff());
        state.accept_current();
        let hunk = state.current_hunk().unwrap();
        assert_eq!(hunk.decision, HunkDecision::Accepted);
    }

    #[test]
    fn skips_empty_files_when_navigating() {
        let mut diff = sample_diff();
        diff.files.insert(
            0,
            DiffFile {
                old_path: "empty.txt".to_string(),
                new_path: "empty.txt".to_string(),
                hunks: Vec::new(),
            },
        );
        let mut state = DiffViewerState::new(diff);
        state.next_hunk();
        assert_eq!(state.file_index, 1);
    }
}