mod input;
mod render;
use crate::model::AnnotationContent;
use crate::ui::navigation;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlameAction {
None,
Back,
OpenDiff(String),
JumpToLog(String),
}
#[derive(Debug, Clone)]
pub struct BlameView {
content: AnnotationContent,
selected_index: usize,
scroll_offset: usize,
revision: Option<String>,
}
impl Default for BlameView {
fn default() -> Self {
Self::new()
}
}
impl BlameView {
pub fn new() -> Self {
Self {
content: AnnotationContent::default(),
selected_index: 0,
scroll_offset: 0,
revision: None,
}
}
pub fn set_content(&mut self, content: AnnotationContent, revision: Option<String>) {
self.content = content;
self.selected_index = 0;
self.scroll_offset = 0;
self.revision = revision;
}
pub fn revision(&self) -> Option<&str> {
self.revision.as_deref()
}
pub fn file_path(&self) -> &str {
&self.content.file_path
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
#[allow(dead_code)] pub fn line_count(&self) -> usize {
self.content.len()
}
pub fn selected_change_id(&self) -> Option<&str> {
self.content
.lines
.get(self.selected_index)
.map(|line| line.change_id.as_str())
}
pub fn selected_commit_id(&self) -> Option<&str> {
self.content
.lines
.get(self.selected_index)
.map(|line| line.commit_id.as_str())
}
pub fn move_down(&mut self) {
let max = self.content.len().saturating_sub(1);
self.selected_index = navigation::select_next(self.selected_index, max);
}
pub fn move_up(&mut self) {
self.selected_index = navigation::select_prev(self.selected_index);
}
pub fn move_to_top(&mut self) {
self.selected_index = 0;
}
pub fn move_to_bottom(&mut self) {
if !self.content.is_empty() {
self.selected_index = self.content.len() - 1;
}
}
fn calculate_scroll_offset(&self, visible_height: usize) -> usize {
navigation::adjust_scroll(self.selected_index, self.scroll_offset, visible_height)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AnnotationLine, ChangeId, CommitId};
fn make_test_content() -> AnnotationContent {
let mut content = AnnotationContent::new("test.rs".to_string());
for i in 1..=10 {
content.lines.push(AnnotationLine {
change_id: ChangeId::new(format!("change{:02}", i)),
commit_id: CommitId::new(format!("commit{:02}", i)),
author: "test".to_string(),
timestamp: "2026-01-30 10:00".to_string(),
line_number: i,
content: format!("line {}", i),
first_in_hunk: i == 1 || i == 5,
});
}
content
}
#[test]
fn test_blame_view_new() {
let view = BlameView::new();
assert!(view.is_empty());
assert_eq!(view.line_count(), 0);
}
#[test]
fn test_blame_view_set_content() {
let mut view = BlameView::new();
view.set_content(make_test_content(), None);
assert!(!view.is_empty());
assert_eq!(view.line_count(), 10);
assert_eq!(view.file_path(), "test.rs");
assert_eq!(view.revision(), None);
}
#[test]
fn test_blame_view_set_content_with_revision() {
let mut view = BlameView::new();
view.set_content(make_test_content(), Some("abc12345".to_string()));
assert_eq!(view.revision(), Some("abc12345"));
}
#[test]
fn test_blame_view_navigation() {
let mut view = BlameView::new();
view.set_content(make_test_content(), None);
assert_eq!(view.selected_index, 0);
view.move_down();
assert_eq!(view.selected_index, 1);
view.move_up();
assert_eq!(view.selected_index, 0);
view.move_up();
assert_eq!(view.selected_index, 0);
view.move_to_bottom();
assert_eq!(view.selected_index, 9);
view.move_down();
assert_eq!(view.selected_index, 9);
view.move_to_top();
assert_eq!(view.selected_index, 0);
}
#[test]
fn test_blame_view_selected_change_id() {
let mut view = BlameView::new();
view.set_content(make_test_content(), None);
assert_eq!(view.selected_change_id(), Some("change01"));
view.move_down();
assert_eq!(view.selected_change_id(), Some("change02"));
}
}