travelagent 1.11.1

Agent-first TUI code review tool
//! Full-file viewer pane renderer (`:view` / `t`).
//!
//! Draws the whole current file in the main content area instead of the diff.
//! Two sub-modes, driven by [`crate::app::ViewerRender`]:
//!
//! - **Raw** — syntax-highlighted source with a line-number gutter and a
//!   cursor-line highlight. Built from the cached file body via the shared
//!   [`travelagent_core::syntax::SyntaxHighlighter`] (same engine the diff
//!   renderer uses).
//! - **Rendered** — markdown drawn with [`crate::ui::markdown::render_markdown`]
//!   (the same renderer used for PR descriptions / comments). Read-only; no
//!   gutter, no cursor — there's no stable source-line ↔ rendered-row map.
//!
//! Content is read from disk by [`crate::app::App::refresh_viewer_content`]
//! before this runs; if that fails (binary/deleted/remote/IO) the caller shows
//! the diff instead, so this function can assume the cache is populated.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

use travelagent_core::syntax::SyntaxHighlighter;

use crate::app::{App, FocusedPanel, ViewerRender};
use crate::ui::app_layout::diff_stat_title;
use crate::ui::styles;

/// Render the viewer pane into `area`. Assumes `app.viewer` content cache is
/// already populated for the current file.
pub(super) fn render_viewer_pane(frame: &mut Frame, app: &mut App, area: Rect) {
    let focused = app.nav.focused_panel == FocusedPanel::Diff;

    let path_label = app
        .current_file_path()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "Overview".to_string());
    let mode_label = match app.viewer.render_mode() {
        ViewerRender::Raw => "Raw",
        ViewerRender::Rendered => "Rendered",
    };
    let title = format!(" Viewer ({mode_label}) \u{2014} {path_label} ");

    let block = Block::default()
        .title(title)
        .title_top(diff_stat_title(app).right_aligned())
        .borders(Borders::ALL)
        .style(styles::panel_style(&app.theme))
        .border_style(styles::border_style(&app.theme, focused));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    app.viewer
        .set_viewport(inner.height as usize, inner.width as usize);
    // The viewport metrics just changed; keep the cursor on-screen before we
    // compute the scroll window.
    app.viewer.ensure_cursor_visible();

    match app.viewer.render_mode() {
        ViewerRender::Raw => render_raw(frame, app, inner),
        ViewerRender::Rendered => render_rendered(frame, app, inner),
    }
}

/// Raw view: line-number gutter + syntax-highlighted source, cursor line tinted.
fn render_raw(frame: &mut Frame, app: &App, inner: Rect) {
    let Some(content) = app.viewer.content() else {
        // Should not happen (caller refreshes first), but degrade gracefully.
        let msg = Paragraph::new("(no content)").style(Style::default().fg(app.theme.fg_dim));
        frame.render_widget(msg, inner);
        return;
    };

    let theme = &app.theme;
    let total = content.lines.len();
    let scroll = app.viewer.scroll_offset();
    let cursor = app.viewer.cursor();
    let height = inner.height as usize;

    // Highlight the whole file once; `None` (unknown syntax) falls back to
    // plain text. We only build `Line`s for the visible window to bound work.
    let highlighter = SyntaxHighlighter::default();
    let highlighted = highlighter.highlight_file_lines(&content.path, &content.lines);

    // Gutter width: enough columns for the largest visible line number.
    let gutter_w = total.max(1).to_string().len();

    let scroll_x = app.viewer.scroll_x();
    let wrap = app.viewer.wrap_lines();

    let mut lines: Vec<Line> = Vec::with_capacity(height.min(total.saturating_sub(scroll)));
    for idx in scroll..(scroll + height).min(total) {
        let lineno = idx + 1;
        let is_cursor = idx == cursor;

        let gutter_style = if is_cursor {
            Style::default().fg(theme.cursor_color)
        } else {
            Style::default().fg(theme.fg_dim)
        };
        let mut spans: Vec<Span> = vec![Span::styled(
            format!("{lineno:>gutter_w$} \u{2502} "),
            gutter_style,
        )];

        // Content spans: highlighted if available, else raw text.
        match highlighted.as_ref().and_then(|h| h.get(idx)) {
            Some(Some(hl)) => {
                for (hint, text) in hl {
                    spans.push(Span::styled(
                        text.clone(),
                        styles::style_hint_to_ratatui(*hint),
                    ));
                }
            }
            _ => {
                let raw = content.lines.get(idx).cloned().unwrap_or_default();
                spans.push(Span::styled(raw, Style::default().fg(theme.fg_primary)));
            }
        }

        let mut line = Line::from(spans);
        // Tint the cursor line's background for a clear "you are here" cue.
        if is_cursor {
            line = line.style(Style::default().bg(theme.bg_highlight));
        }
        // Horizontal scroll only matters when not wrapping; the gutter stays
        // pinned (we only shift the content by rebuilding with an offset would
        // be more work than it's worth — ratatui's Paragraph scroll handles
        // the simple case, so we lean on wrap=false + Paragraph::scroll).
        lines.push(line);
    }

    let mut para = Paragraph::new(lines).style(Style::default().fg(theme.fg_primary));
    if wrap {
        para = para.wrap(Wrap { trim: false });
    } else if scroll_x > 0 {
        para = para.scroll((0, scroll_x as u16));
    }
    frame.render_widget(para, inner);
}

/// Rendered view: markdown → ratatui, scrolled to the viewer offset. Read-only.
fn render_rendered(frame: &mut Frame, app: &App, inner: Rect) {
    let Some(content) = app.viewer.content() else {
        let msg = Paragraph::new("(no content)").style(Style::default().fg(app.theme.fg_dim));
        frame.render_widget(msg, inner);
        return;
    };

    let body = content.lines.join("\n");
    let rendered = crate::ui::markdown::render_markdown(&body, &app.theme, inner.width as usize);

    // Markdown rows don't map 1:1 to source lines, so the cursor/gutter from
    // raw view doesn't apply. We scroll by source-line offset as a best-effort
    // approximation (same trade-off the help popup makes) via Paragraph::scroll.
    let scroll = app
        .viewer
        .scroll_offset()
        .min(rendered.len().saturating_sub(1)) as u16;
    let para = Paragraph::new(rendered)
        .style(Style::default().fg(app.theme.fg_primary))
        .wrap(Wrap { trim: false })
        .scroll((scroll, 0));
    frame.render_widget(para, inner);
}