tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::model::Issue;

pub struct DetailState {
    pub issue: Issue,
    pub scroll_offset: u16,
}

pub fn render(detail: &DetailState, f: &mut Frame, area: Rect) {
    let mut lines: Vec<Line<'static>> = Vec::new();

    let fields: Vec<(&str, String)> = vec![
        ("number", detail.issue.number.to_string()),
        ("title", detail.issue.title.clone()),
        ("status", detail.issue.status.to_string()),
        ("priority", detail.issue.priority.to_string()),
        ("created", detail.issue.created.clone()),
        ("updated", detail.issue.updated.clone()),
    ];

    for (key, value) in &fields {
        lines.push(Line::from(vec![
            Span::styled(format!("{key}: "), Style::default().fg(Color::DarkGray)),
            Span::raw(value.clone()),
        ]));
    }

    if let Some(rendered) = render_assignee(detail.issue.assignee.as_deref()) {
        lines.push(Line::from(vec![
            Span::styled("assignee: ", Style::default().fg(Color::DarkGray)),
            Span::raw(rendered),
        ]));
    }

    if let Some(ref estimate) = detail.issue.estimate {
        lines.push(Line::from(vec![
            Span::styled("estimate: ", Style::default().fg(Color::DarkGray)),
            Span::raw(estimate.clone()),
        ]));
    }

    if let Some(ref commit) = detail.issue.commit {
        lines.push(Line::from(vec![
            Span::styled("commit: ", Style::default().fg(Color::DarkGray)),
            Span::raw(commit.clone()),
        ]));
    }

    if !detail.issue.labels.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("labels: ", Style::default().fg(Color::DarkGray)),
            Span::raw(detail.issue.labels.join(", ")),
        ]));
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "---",
        Style::default().fg(Color::DarkGray),
    )));
    lines.push(Line::from(""));

    for line_text in detail.issue.body.lines() {
        lines.push(Line::from(Span::raw(line_text.to_string())));
    }

    let para = Paragraph::new(lines)
        .scroll((detail.scroll_offset, 0))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Detail [Esc=back e=edit]"),
        );

    f.render_widget(para, area);
}

/// Render an assignee for the detail view (FR-030):
/// - a full git identity (`Name <email>`) is shown verbatim (identity form
///   sniffed on the `<>` pair),
/// - a bare handle gets the cosmetic `@` prefix,
/// - an already `@`-prefixed value passes through unchanged,
/// - unassigned renders no line at all.
pub fn render_assignee(assignee: Option<&str>) -> Option<String> {
    let raw = assignee?;
    if raw.contains('<') && raw.contains('>') {
        return Some(raw.to_string());
    }
    if raw.starts_with('@') {
        return Some(raw.to_string());
    }
    Some(format!("@{raw}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::*;

    fn sample_issue() -> Issue {
        Issue {
            number: 1,
            title: "Test Issue".into(),
            status: Status::new("todo"),
            labels: vec!["bug".into()],
            priority: Priority::High,
            assignee: Some("alice".into()),
            estimate: Some("2".into()),
            commit: None,
            gitlab_id: None,
            linear_id: None,
            mission: None,
            task: None,
            created: "2026-01-01".into(),
            updated: "2026-01-01".into(),
            filename: "test.md".into(),
            body: "Line one\n\nLine two".into(),
        }
    }

    #[test]
    fn render_smoke_small_frame() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(10, 5);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 0,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn render_smoke_large_frame() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(80, 40);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 0,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn render_scroll_offset() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(80, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 5,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn assignee_render_identity_verbatim_bare_gets_at() {
        assert_eq!(render_assignee(None), None);
        assert_eq!(
            render_assignee(Some("Herbert Maschke <thyseus@pm.me>")),
            Some("Herbert Maschke <thyseus@pm.me>".to_string())
        );
        assert_eq!(render_assignee(Some("alice")), Some("@alice".to_string()));
        assert_eq!(render_assignee(Some("@alice")), Some("@alice".to_string()));
    }
}