travelagent 1.11.1

Agent-first TUI code review tool
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
};

use crate::app::App;
use crate::ui::styles;

pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
    // Local mode: no commits panel content, render a placeholder and bail
    // before we grab a mutable borrow on the remote state.
    if app.remote().is_none() {
        let theme = &app.theme;
        let paragraph = Paragraph::new(vec![Line::from(Span::styled(
            "  No commits",
            styles::dim_style(theme),
        ))])
        .style(styles::panel_style(theme));
        frame.render_widget(paragraph, area);
        return;
    }

    // Precompute everything that touches `theme` before we mutably borrow
    // the remote state (app.theme and app.mode can't be borrowed at the
    // same time since both live on `App`).
    let panel_style = styles::panel_style(&app.theme);
    let dim_style = styles::dim_style(&app.theme);
    let selected_style = styles::selected_style(&app.theme);
    let fg_secondary = app.theme.fg_secondary;
    let fg_dim = app.theme.fg_dim;

    let r = app.remote_mut().expect("remote mode checked above");

    let lines = if r.pr_commits.is_empty() {
        vec![Line::from(Span::styled("  No commits", dim_style))]
    } else {
        // Clamp cursor
        if r.pr_commits_cursor >= r.pr_commits.len() {
            r.pr_commits_cursor = r.pr_commits.len().saturating_sub(1);
        }

        let viewport_height = area.height.saturating_sub(2) as usize; // header + padding
        let scroll_offset = if r.pr_commits_cursor >= viewport_height {
            r.pr_commits_cursor - viewport_height + 1
        } else {
            0
        };

        r.pr_commits
            .iter()
            .enumerate()
            .skip(scroll_offset)
            .take(viewport_height.max(1))
            .map(|(i, commit)| {
                let is_selected = i == r.pr_commits_cursor;
                let marker = if is_selected { "\u{25b6} " } else { "  " };
                let date = commit.time.format("%Y-%m-%d");

                let style = if is_selected {
                    selected_style
                } else {
                    Style::default()
                };

                Line::from(vec![
                    Span::styled(marker.to_string(), style),
                    Span::styled(
                        format!("{} ", commit.short_id),
                        Style::default()
                            .fg(fg_secondary)
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(format!("{:<50} ", truncate(&commit.summary, 50)), style),
                    Span::styled(
                        format!("{:<12} ", truncate(&commit.author, 12)),
                        Style::default().fg(fg_dim),
                    ),
                    Span::styled(date.to_string(), Style::default().fg(fg_dim)),
                ])
            })
            .collect()
    };

    let paragraph = Paragraph::new(lines).style(panel_style);
    frame.render_widget(paragraph, area);
}

fn truncate(s: &str, max_len: usize) -> String {
    if s.chars().count() <= max_len {
        s.to_string()
    } else {
        let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect();
        format!("{truncated}\u{2026}")
    }
}

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

    #[test]
    fn truncate_short_string_unchanged() {
        assert_eq!(truncate("hello", 10), "hello");
    }

    #[test]
    fn truncate_exact_length_unchanged() {
        assert_eq!(truncate("hello", 5), "hello");
    }

    #[test]
    fn truncate_long_string() {
        assert_eq!(truncate("hello world", 5), "hell\u{2026}");
    }

    #[test]
    fn truncate_handles_multibyte_chars() {
        assert_eq!(truncate("こんにちは世界", 5), "こんにち\u{2026}");
    }
}