a73x

src/tui/widgets.rs

Ref:   Size: 31.1 KiB

use git2::{Oid, Repository};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};

use crate::event::{Action, ReviewVerdict};
use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};

use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode};

pub(crate) fn action_type_label(action: &Action) -> &str {
    match action {
        Action::IssueOpen { .. } => "Issue Open",
        Action::IssueComment { .. } => "Issue Comment",
        Action::IssueClose { .. } => "Issue Close",
        Action::IssueReopen => "Issue Reopen",
        Action::PatchCreate { .. } => "Patch Create",
        Action::PatchRevision { .. } => "Patch Revision",
        Action::PatchReview { .. } => "Patch Review",
        Action::PatchComment { .. } => "Patch Comment",
        Action::PatchInlineComment { .. } => "Inline Comment",
        Action::PatchClose { .. } => "Patch Close",
        Action::PatchMerge => "Patch Merge",
        Action::Merge => "Merge",
        Action::IssueEdit { .. } => "Issue Edit",
        Action::IssueLabel { .. } => "Issue Label",
        Action::IssueUnlabel { .. } => "Issue Unlabel",
        Action::IssueAssign { .. } => "Issue Assign",
        Action::IssueUnassign { .. } => "Issue Unassign",
        Action::IssueCommitLink { .. } => "Issue Commit Link",
    }
}

pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String {
    let short_oid = &oid.to_string()[..7];
    let action_label = action_type_label(&event.action);

    let mut detail = format!(
        "Commit:  {}\nAuthor:  {} <{}>\nDate:    {}\nType:    {}\n",
        short_oid, event.author.name, event.author.email, event.timestamp, action_label,
    );

    // Action-specific payload
    match &event.action {
        Action::IssueOpen { title, body, .. } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueComment { body } | Action::PatchComment { body } => {
            detail.push_str(&format!("\n{}\n", body));
        }
        Action::IssueClose { reason } | Action::PatchClose { reason } => {
            if let Some(r) = reason {
                detail.push_str(&format!("\nReason: {}\n", r));
            }
        }
        Action::PatchCreate {
            title,
            body,
            base_ref,
            branch,
            ..
        } => {
            detail.push_str(&format!("\nTitle:  {}\n", title));
            detail.push_str(&format!("Base:   {}\n", base_ref));
            detail.push_str(&format!("Branch: {}\n", branch));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchRevision { commit, tree, body } => {
            detail.push_str(&format!("\nCommit: {}\n", commit));
            detail.push_str(&format!("Tree:   {}\n", tree));
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\n{}\n", b));
                }
            }
        }
        Action::PatchReview { verdict, body, .. } => {
            detail.push_str(&format!("\nVerdict: {}\n", verdict));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchInlineComment { file, line, body, .. } => {
            detail.push_str(&format!("\nFile: {}:{}\n", file, line));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueEdit { title, body } => {
            if let Some(t) = title {
                detail.push_str(&format!("\nNew Title: {}\n", t));
            }
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\nNew Body: {}\n", b));
                }
            }
        }
        Action::IssueLabel { label } => {
            detail.push_str(&format!("\nLabel: {}\n", label));
        }
        Action::IssueUnlabel { label } => {
            detail.push_str(&format!("\nRemoved Label: {}\n", label));
        }
        Action::IssueAssign { assignee } => {
            detail.push_str(&format!("\nAssignee: {}\n", assignee));
        }
        Action::IssueUnassign { assignee } => {
            detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
        }
        Action::IssueCommitLink { commit } => {
            detail.push_str(&format!("\nCommit: {}\n", commit));
        }
        Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
    }

    detail
}

pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(frame.area());

    let main_area = chunks[0];
    let footer_area = chunks[1];

    let panes = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
        .split(main_area);

    render_list(frame, app, panes[0]);
    render_detail(frame, app, panes[1], repo);
    render_footer(frame, app, footer_area);
}

fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::ItemList {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    match app.list_mode {
        ListMode::Issues => {
            let visible = app.visible_issues();
            let items: Vec<ListItem> = visible
                .iter()
                .map(|i| {
                    let status = i.status.as_str();
                    let style = match i.status {
                        IssueStatus::Open => Style::default().fg(Color::Green),
                        IssueStatus::Closed => Style::default().fg(Color::Red),
                    };
                    ListItem::new(format!("{:.8}  {:6}  {}", i.id, status, i.title)).style(style)
                })
                .collect();

            let title = format!("Issues ({})", app.status_filter.label());

            let list = List::new(items)
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .title(title)
                        .border_style(border_style),
                )
                .highlight_style(
                    Style::default()
                        .bg(Color::DarkGray)
                        .add_modifier(Modifier::BOLD),
                )
                .highlight_symbol("> ");

            frame.render_stateful_widget(list, area, &mut app.list_state);
        }
        ListMode::Patches => {
            let visible = app.visible_patches();
            let items: Vec<ListItem> = visible
                .iter()
                .map(|p| {
                    let status = p.status.as_str();
                    let style = match p.status {
                        PatchStatus::Open => Style::default().fg(Color::Green),
                        PatchStatus::Closed => Style::default().fg(Color::Red),
                        PatchStatus::Merged => Style::default().fg(Color::Cyan),
                    };
                    ListItem::new(format!("{:.8}  {:6}  {}", p.id, status, p.title)).style(style)
                })
                .collect();

            let title = format!("Patches ({})", app.status_filter.label());

            let list = List::new(items)
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .title(title)
                        .border_style(border_style),
                )
                .highlight_style(
                    Style::default()
                        .bg(Color::DarkGray)
                        .add_modifier(Modifier::BOLD),
                )
                .highlight_symbol("> ");

            frame.render_stateful_widget(list, area, &mut app.patch_list_state);
        }
    }
}

fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Repository>) {
    let border_style = if app.pane == Pane::Detail {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    // Handle patch detail mode
    if app.mode == ViewMode::PatchDetail {
        let content = build_patch_detail_text(app);
        let block = Block::default()
            .borders(Borders::ALL)
            .title("Patch Detail")
            .border_style(border_style);
        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.patch_scroll, 0));
        frame.render_widget(para, area);
        return;
    }

    // Handle commit browser modes
    if app.mode == ViewMode::CommitList {
        let items: Vec<ListItem> = app
            .event_history
            .iter()
            .map(|(_oid, evt)| {
                let label = action_type_label(&evt.action);
                ListItem::new(format!(
                    "{} | {} | {}",
                    label, evt.author.name, evt.timestamp
                ))
            })
            .collect();

        let list = List::new(items)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Event History")
                    .border_style(border_style),
            )
            .highlight_style(
                Style::default()
                    .bg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, area, &mut app.event_list_state);
        return;
    }

    if app.mode == ViewMode::CommitDetail {
        let content = if let Some(idx) = app.event_list_state.selected() {
            if let Some((oid, evt)) = app.event_history.get(idx) {
                format_event_detail(oid, evt)
            } else {
                "No event selected.".to_string()
            }
        } else {
            "No event selected.".to_string()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title("Event Detail")
            .border_style(border_style);

        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.scroll, 0));

        frame.render_widget(para, area);
        return;
    }

    match app.list_mode {
        ListMode::Issues => {
            let visible = app.visible_issues();
            let selected_idx = app.list_state.selected().unwrap_or(0);
            let content: Text = match visible.get(selected_idx) {
                Some(issue) => build_issue_detail(issue, &app.patches, repo),
                None => Text::raw("No matches for current filter."),
            };

            let block = Block::default()
                .borders(Borders::ALL)
                .title("Issue Details")
                .border_style(border_style);

            let para = Paragraph::new(content)
                .block(block)
                .wrap(Wrap { trim: false })
                .scroll((app.scroll, 0));

            frame.render_widget(para, area);
        }
        ListMode::Patches => {
            let visible = app.visible_patches();
            let selected_idx = app.patch_list_state.selected().unwrap_or(0);
            let content: Text = match visible.get(selected_idx) {
                Some(patch) => build_patch_summary(patch),
                None => Text::raw("No patches for current filter."),
            };

            let block = Block::default()
                .borders(Borders::ALL)
                .title("Patch Details")
                .border_style(border_style);

            let para = Paragraph::new(content)
                .block(block)
                .wrap(Wrap { trim: false })
                .scroll((app.scroll, 0));

            frame.render_widget(para, area);
        }
    }
}

fn build_issue_detail(
    issue: &IssueState,
    patches: &[PatchState],
    repo: Option<&Repository>,
) -> Text<'static> {
    let status = issue.status.as_str();

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", issue.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" [{}]", status)),
        ]),
        Line::from(vec![
            Span::styled("Title:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.created_at.clone()),
        ]),
    ];

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

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

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

    if let Some(ref oid) = issue.closed_by {
        lines.push(Line::from(vec![
            Span::styled("Commit:  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{:.8}", oid),
                Style::default().fg(Color::Cyan),
            ),
        ]));
    }

    // Show patches that reference this issue via --fixes
    let fixing_patches: Vec<&PatchState> = patches
        .iter()
        .filter(|p| p.fixes.as_deref() == Some(&issue.id))
        .collect();
    if !fixing_patches.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Linked Patches ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for p in &fixing_patches {
            let status = match p.status {
                PatchStatus::Open => ("open", Color::Green),
                PatchStatus::Closed => ("closed", Color::Red),
                PatchStatus::Merged => ("merged", Color::Cyan),
            };
            lines.push(Line::from(vec![
                Span::styled(
                    format!("{:.8}", p.id),
                    Style::default().fg(Color::Yellow),
                ),
                Span::raw("  "),
                Span::styled(status.0, Style::default().fg(status.1)),
                Span::raw(format!("  {}", p.title)),
            ]));
        }
    }

    if !issue.body.is_empty() {
        lines.push(Line::raw(""));
        for l in issue.body.lines() {
            lines.push(Line::raw(l.to_string()));
        }
    }

    if !issue.comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for c in &issue.comments {
            lines.push(Line::raw(""));
            lines.push(Line::from(vec![
                Span::styled(
                    c.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    format!(" ({})", c.timestamp),
                    Style::default().fg(Color::DarkGray),
                ),
            ]));
            for l in c.body.lines() {
                lines.push(Line::raw(format!("  {}", l)));
            }
        }
    }

    if !issue.linked_commits.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Linked Commits ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for lc in &issue.linked_commits {
            let short_sha: String = lc.commit.chars().take(7).collect();
            let (subject, commit_author) = repo
                .and_then(|r| {
                    Oid::from_str(&lc.commit)
                        .ok()
                        .and_then(|oid| r.find_commit(oid).ok())
                        .map(|commit| {
                            let subject = commit
                                .summary()
                                .map(|s| crate::truncate_summary(s, 60))
                                .unwrap_or_default();
                            let author = commit.author().name().unwrap_or("unknown").to_string();
                            (subject, author)
                        })
                })
                .unwrap_or_else(|| (String::new(), String::new()));
            let line_text = if commit_author.is_empty() {
                format!(
                    "· linked {} (commit {} not in local repo) (linked by {}, {})",
                    short_sha, short_sha, lc.event_author.name, lc.event_timestamp
                )
            } else {
                format!(
                    "· linked {} \"{}\" by {} (linked by {}, {})",
                    short_sha, subject, commit_author, lc.event_author.name, lc.event_timestamp
                )
            };
            lines.push(Line::raw(line_text));
        }
    }

    Text::from(lines)
}

fn build_patch_summary(patch: &PatchState) -> Text<'static> {
    let status_str = patch.status.as_str();
    let status_color = match patch.status {
        PatchStatus::Open => Color::Green,
        PatchStatus::Closed => Color::Red,
        PatchStatus::Merged => Color::Cyan,
    };

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", patch.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(status_str, Style::default().fg(status_color)),
        ]),
        Line::from(vec![
            Span::styled("Title:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Branch:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.branch.clone()),
        ]),
        Line::from(vec![
            Span::styled("Base:     ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.base_ref.clone()),
        ]),
        Line::from(vec![
            Span::styled("Revisions:", Style::default().fg(Color::DarkGray)),
            Span::raw(format!(" {}", patch.revisions.len())),
        ]),
    ];

    if let Some(ref fixes) = patch.fixes {
        lines.push(Line::from(vec![
            Span::styled("Fixes:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{:.8}", fixes)),
        ]));
    }

    if !patch.body.is_empty() {
        lines.push(Line::raw(""));
        for l in patch.body.lines() {
            lines.push(Line::raw(l.to_string()));
        }
    }

    lines.push(Line::raw(""));
    lines.push(Line::styled(
        "Press Enter to view full detail with diff",
        Style::default().fg(Color::DarkGray),
    ));

    Text::from(lines)
}

fn build_patch_detail_text(app: &App) -> Text<'static> {
    let patch = match &app.current_patch {
        Some(p) => p,
        None => return Text::raw("No patch loaded."),
    };

    let status_str = patch.status.as_str();
    let status_color = match patch.status {
        PatchStatus::Open => Color::Green,
        PatchStatus::Closed => Color::Red,
        PatchStatus::Merged => Color::Cyan,
    };

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", patch.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(status_str, Style::default().fg(status_color)),
        ]),
        Line::from(vec![
            Span::styled("Title:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Branch:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.branch.clone()),
        ]),
        Line::from(vec![
            Span::styled("Base:     ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.base_ref.clone()),
        ]),
        Line::from(vec![
            Span::styled("Revisions:", Style::default().fg(Color::DarkGray)),
            Span::raw(format!(" {}", patch.revisions.len())),
        ]),
    ];

    if let Some(ref fixes) = patch.fixes {
        lines.push(Line::from(vec![
            Span::styled("Fixes:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{:.8}", fixes)),
        ]));
    }

    // Staleness warning (stored in status_msg or computed text)
    if let Some(ref warning) = app.status_msg {
        if warning.contains("behind") {
            lines.push(Line::raw(""));
            lines.push(Line::styled(
                warning.clone(),
                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
            ));
        }
    }

    // Revision list
    if !patch.revisions.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Revisions ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for (i, rev) in patch.revisions.iter().enumerate() {
            let short = if rev.commit.len() >= 8 {
                &rev.commit[..8]
            } else {
                &rev.commit
            };
            let marker = if i == app.patch_revision_idx {
                "> "
            } else {
                "  "
            };
            let label = if i == 0 { " (initial)" } else { "" };
            lines.push(Line::from(vec![
                Span::raw(format!("{}r{}  {}  {}{}", marker, rev.number, short, rev.timestamp, label)),
            ]));
        }
    }

    // Reviews
    if !patch.reviews.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Reviews ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for review in &patch.reviews {
            let verdict_str = review.verdict.as_str();
            let verdict_color = match review.verdict {
                ReviewVerdict::Approve => Color::Green,
                ReviewVerdict::RequestChanges => Color::Yellow,
                ReviewVerdict::Comment => Color::White,
                ReviewVerdict::Reject => Color::Red,
            };
            let rev_label = review
                .revision
                .map(|r| format!(" (r{})", r))
                .unwrap_or_default();
            lines.push(Line::from(vec![
                Span::styled(
                    review.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!(" {} ", review.timestamp)),
                Span::styled(verdict_str, Style::default().fg(verdict_color)),
                Span::raw(rev_label),
            ]));
            if !review.body.is_empty() {
                for l in review.body.lines() {
                    lines.push(Line::raw(format!("  {}", l)));
                }
            }
        }
    }

    // Inline comments
    if !patch.inline_comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Inline Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for ic in &patch.inline_comments {
            let rev_label = ic
                .revision
                .map(|r| format!(" (r{})", r))
                .unwrap_or_default();
            lines.push(Line::from(vec![
                Span::styled(
                    ic.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!(" {}:{}", ic.file, ic.line)),
                Span::raw(rev_label),
            ]));
            for l in ic.body.lines() {
                lines.push(Line::raw(format!("  {}", l)));
            }
        }
    }

    // Thread comments
    if !patch.comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for c in &patch.comments {
            lines.push(Line::raw(""));
            lines.push(Line::from(vec![
                Span::styled(
                    c.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    format!(" ({})", c.timestamp),
                    Style::default().fg(Color::DarkGray),
                ),
            ]));
            for l in c.body.lines() {
                lines.push(Line::raw(format!("  {}", l)));
            }
        }
    }

    // Diff header
    lines.push(Line::raw(""));
    let diff_mode = if app.patch_interdiff_mode {
        let rev_idx = app.patch_revision_idx;
        if rev_idx > 0 {
            let from_rev = patch.revisions.get(rev_idx - 1).map(|r| r.number).unwrap_or(0);
            let to_rev = patch.revisions.get(rev_idx).map(|r| r.number).unwrap_or(0);
            format!("--- Interdiff r{} -> r{} ---", from_rev, to_rev)
        } else {
            "--- Diff vs base (no previous revision) ---".to_string()
        }
    } else {
        let rev_num = patch.revisions.get(app.patch_revision_idx).map(|r| r.number).unwrap_or(1);
        format!("--- Diff r{} vs base ---", rev_num)
    };
    lines.push(Line::styled(
        diff_mode,
        Style::default()
            .fg(Color::Green)
            .add_modifier(Modifier::BOLD),
    ));

    // Diff content
    if app.patch_diff.is_empty() {
        lines.push(Line::raw("(no diff available)"));
    } else {
        for l in app.patch_diff.lines() {
            let style = if l.starts_with('+') {
                Style::default().fg(Color::Green)
            } else if l.starts_with('-') {
                Style::default().fg(Color::Red)
            } else if l.starts_with("@@") {
                Style::default().fg(Color::Cyan)
            } else if l.starts_with("diff ") {
                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            lines.push(Line::styled(l.to_string(), style));
        }
    }

    Text::from(lines)
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    match app.input_mode {
        InputMode::Search => {
            let max_query_len = (area.width as usize).saturating_sub(12);
            let display_query = if app.search_query.len() > max_query_len {
                &app.search_query[app.search_query.len() - max_query_len..]
            } else {
                &app.search_query
            };
            let text = format!(" Search: {}_", display_query);
            let style = Style::default().bg(Color::Blue).fg(Color::White);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateTitle => {
            let max_len = (area.width as usize).saturating_sub(22);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Title: {}_", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateBody => {
            let max_len = (area.width as usize).saturating_sub(21);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Body: {}_  (Esc: skip)", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::Normal => {}
    }

    // Show status message if present
    if let Some(ref msg) = app.status_msg {
        let para =
            Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black));
        frame.render_widget(para, area);
        return;
    }

    let text = match app.mode {
        ViewMode::CommitList => " j/k:navigate  Enter:detail  Esc:back  q:quit".to_string(),
        ViewMode::CommitDetail => " j/k:scroll  Esc:back  q:quit".to_string(),
        ViewMode::PatchDetail => " j/k:scroll  Esc:back  [/]:revision  d:interdiff  q:quit".to_string(),
        ViewMode::Details => {
            let list_hint = match app.list_mode {
                ListMode::Issues => "P:patches",
                ListMode::Patches => "P:issues",
            };
            let filter_hint = match app.status_filter {
                StatusFilter::Open => "a:show all",
                StatusFilter::All => "a:closed",
                StatusFilter::Closed => "a:open only",
            };
            let mode_hint = match app.list_mode {
                ListMode::Issues => "  c:events  p:patch",
                ListMode::Patches => "  Enter:view patch",
            };
            format!(
                " j/k:navigate  Tab:pane  {}  {}{}  /:search  r:refresh  q:quit",
                list_hint, filter_hint, mode_hint
            )
        }
    };
    let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
    frame.render_widget(para, area);
}