quinjet 0.0.52

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

#[expect(
    clippy::too_many_lines,
    reason = "the draw pass reads better as one top-to-bottom pass"
)]
pub(super) fn conversation_rows(app: &App, width: usize, theme: &Theme) -> Vec<ContentRow> {
    let mut rows = Vec::new();
    let Some(pull_request) = app.selected_pull_request() else {
        if let Some(error) = app.pull_request_error.as_deref() {
            rows.push(ContentRow::text(
                "  This pull request could not be opened",
                Style::default()
                    .fg(theme.error)
                    .add_modifier(Modifier::BOLD),
            ));
            rows.push(ContentRow::blank());
            for (_, text) in wrap_prose(error, width.saturating_sub(2)) {
                rows.push(ContentRow::text(
                    format!("  {text}"),
                    Style::default().fg(theme.error),
                ));
            }
            rows.push(ContentRow::blank());
            rows.push(ContentRow::text(
                "  Press r to try again, or o to choose another repository",
                Style::default().fg(theme.muted),
            ));
            return rows;
        }
        rows.push(ContentRow::text(
            if app.pull_request_loading {
                "  Fetching pull-request metadata…"
            } else {
                "  Enter a pull-request number to open one"
            },
            Style::default().fg(theme.muted),
        ));
        return rows;
    };

    let state = if pull_request.is_draft {
        "DRAFT"
    } else {
        pull_request.state.as_str()
    };
    rows.push(ContentRow::wide(Line::from(vec![
        Span::styled(format!("#{}  ", pull_request.number), Link::style(theme)),
        Span::styled(pull_request.title.clone(), Link::style(theme)),
    ])));
    rows.push(ContentRow::wide(Line::from(vec![
        Span::styled(
            format!("{:<DETAIL_LABEL_WIDTH$}", "State"),
            Style::default().fg(theme.muted),
        ),
        Span::styled(
            format!("{state}{}  ·  ", pull_request_action_summary(pull_request)),
            Style::default().fg(theme.text),
        ),
        Span::styled(
            format!("@{}", pull_request.author),
            app.account_open_target(&pull_request.author).map_or_else(
                || Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
                |_| Link::style(theme),
            ),
        ),
        Span::styled(
            format!(
                "  ·  opened {}  ·  updated {}",
                format_relative_timestamp(&pull_request.created_at),
                format_relative_timestamp(&pull_request.updated_at)
            ),
            Style::default().fg(theme.text),
        ),
    ])));
    rows.push(ContentRow::wide(Line::from(vec![
        Span::styled(
            format!("{:<DETAIL_LABEL_WIDTH$}", "Source"),
            Style::default().fg(theme.muted),
        ),
        Span::styled(
            pull_request.head_label(),
            app.pull_request_head_branch_open_target()
                .map_or_else(|| Style::default().fg(theme.text), |_| Link::style(theme)),
        ),
        Span::styled(
            if pull_request.is_cross_repository {
                "  ·  fork"
            } else {
                ""
            },
            Style::default().fg(theme.text),
        ),
    ])));
    rows.push(ContentRow::wide(Line::from(vec![
        Span::styled(
            format!("{:<DETAIL_LABEL_WIDTH$}", "Destination"),
            Style::default().fg(theme.muted),
        ),
        Span::styled(
            pull_request.base_label(),
            app.pull_request_base_branch_open_target()
                .map_or_else(|| Style::default().fg(theme.text), |_| Link::style(theme)),
        ),
    ])));
    rows.push(ContentRow::wide(Line::from(vec![
        Span::styled(
            format!("{:<DETAIL_LABEL_WIDTH$}", "Changes"),
            Style::default().fg(theme.muted),
        ),
        Span::styled(
            format!(
                "{} file{}  ",
                pull_request.changed_files,
                if pull_request.changed_files == 1 {
                    ""
                } else {
                    "s"
                }
            ),
            Style::default().fg(theme.text),
        ),
        Span::styled(
            format!("+{}", pull_request.additions),
            Style::default()
                .fg(theme.added)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled("  ", Style::default()),
        Span::styled(
            format!("-{}", pull_request.deletions),
            Style::default()
                .fg(theme.removed)
                .add_modifier(Modifier::BOLD),
        ),
    ])));
    rows.push(ContentRow::wide(check_summary_line(app, theme)));
    rows.push(ContentRow::wide(link_detail_line(
        "URL",
        pull_request.url.clone(),
        theme,
    )));

    rows.push(ContentRow::blank());
    rows.push(ContentRow::plain(section_rule("Description", width, theme)));
    if pull_request.description.trim().is_empty() {
        rows.push(ContentRow::text(
            "  No description provided",
            Style::default().fg(theme.muted),
        ));
    } else {
        push_prose(&mut rows, &pull_request.description, " ", width, theme);
    }

    rows.push(ContentRow::blank());
    rows.push(ContentRow::plain(section_rule(
        "Conversation",
        width,
        theme,
    )));
    if let Some(error) = app.pull_request_conversation_error.as_deref() {
        rows.push(ContentRow::text(
            format!("  {error}"),
            Style::default().fg(theme.error),
        ));
        return rows;
    }
    if app.pull_request_conversation.entries.is_empty() {
        rows.push(ContentRow::text(
            if app.pull_request_conversation_loading {
                "  Loading the conversation…"
            } else {
                "  No activity yet"
            },
            Style::default().fg(theme.muted),
        ));
        return rows;
    }
    if app.pull_request_conversation.truncated {
        rows.push(ContentRow::text(
            "  Older activity was omitted to keep this view bounded",
            Style::default().fg(theme.muted),
        ));
    }
    for entry in &app.pull_request_conversation.entries {
        rows.push(ContentRow::blank());
        push_conversation_entry(&mut rows, entry, width, app, theme);
    }
    rows
}

fn pull_request_action_summary(pull_request: &PullRequest) -> String {
    let action = &pull_request.action_state;
    let mut parts = Vec::new();
    if !action.merge_queue_entry_id.is_empty() {
        parts.push(format!(
            "queue {} {}",
            action.merge_queue_position,
            action.merge_queue_state.to_ascii_lowercase()
        ));
    } else if !action.auto_merge_method.is_empty() {
        parts.push(format!(
            "auto-merge {}",
            action.auto_merge_method.to_ascii_lowercase()
        ));
    }
    if action.is_locked {
        parts.push("locked".to_owned());
    }
    if !action.review_decision.is_empty() {
        parts.push(
            action
                .review_decision
                .to_ascii_lowercase()
                .replace('_', " "),
        );
    }
    if parts.is_empty() {
        String::new()
    } else {
        format!("  ·  {}", parts.join("  ·  "))
    }
}

pub(super) fn push_conversation_entry(
    rows: &mut Vec<ContentRow>,
    entry: &ConversationEntry,
    width: usize,
    app: &App,
    theme: &Theme,
) {
    let (icon, color, action) = conversation_marker(entry, theme);
    let stamp = format_relative_timestamp(&entry.timestamp);
    let stamp = if stamp.is_empty() {
        String::new()
    } else {
        format!("  ·  {stamp}")
    };
    let reserved = 3 + entry.actor.width() + stamp.width();
    let action = truncate_end(&action, width.saturating_sub(reserved));
    rows.push(ContentRow::plain(Line::from(vec![
        Span::styled(format!("{icon} "), Style::default().fg(color)),
        Span::styled(
            format!("@{}", entry.actor),
            app.account_open_target(&entry.actor).map_or_else(
                || Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
                |_| Link::style(theme),
            ),
        ),
        Span::styled(
            format!(" {action}"),
            if entry.url.is_empty() {
                Style::default().fg(theme.muted)
            } else {
                Link::style(theme)
            },
        ),
        Span::styled(stamp, Style::default().fg(theme.muted)),
    ])));

    if !entry.context.is_empty() {
        for line in entry.context.lines().take(8) {
            let style = match line.as_bytes().first() {
                Some(b'+') => Style::default().fg(theme.added),
                Some(b'-') => Style::default().fg(theme.removed),
                _ => Style::default().fg(theme.muted),
            };
            rows.push(ContentRow::wide(Line::from(vec![
                Span::styled(" │ ▏ ", Style::default().fg(theme.border)),
                Span::styled(line.to_owned(), style),
            ])));
        }
    }
    if entry.kind != ConversationKind::Opened
        && entry.kind.has_body()
        && !entry.body.trim().is_empty()
    {
        push_prose(rows, &entry.body, "", width, theme);
    }
}

pub(super) fn conversation_marker(
    entry: &ConversationEntry,
    theme: &Theme,
) -> (&'static str, Color, String) {
    match entry.kind {
        ConversationKind::Opened => (
            "",
            theme.accent,
            format!("opened this pull request from {}", entry.detail),
        ),
        ConversationKind::Comment => ("", theme.text, "commented".to_owned()),
        ConversationKind::Review => {
            let (icon, color) = match entry.detail.to_ascii_lowercase().as_str() {
                "approved" => ("", theme.success),
                "changes_requested" => ("×", theme.error),
                _ => ("", theme.accent),
            };
            (
                icon,
                color,
                format!("reviewed · {}", entry.detail.to_lowercase()),
            )
        }
        ConversationKind::ReviewComment => (
            "",
            theme.modified,
            format!("commented on {}", entry.detail),
        ),
        ConversationKind::Commit => ("", theme.muted, format!("pushed {}", entry.detail)),
        ConversationKind::ForcePush => (
            "",
            theme.modified,
            format!(
                "force-pushed{}",
                if entry.reference.is_empty() {
                    String::new()
                } else {
                    format!(" to {}", short_oid(&entry.reference))
                }
            ),
        ),
        ConversationKind::Merged => ("", theme.success, "merged this pull request".to_owned()),
        ConversationKind::Closed => ("×", theme.removed, "closed this pull request".to_owned()),
        ConversationKind::Reopened => ("", theme.accent, "reopened this pull request".to_owned()),
        ConversationKind::Labeled => (
            "",
            theme.muted,
            format!("added the {} label", entry.detail),
        ),
        ConversationKind::Unlabeled => (
            "",
            theme.muted,
            format!("removed the {} label", entry.detail),
        ),
        ConversationKind::Renamed => (
            "",
            theme.muted,
            format!("renamed this from {}", entry.detail),
        ),
        ConversationKind::ReadyForReview => {
            ("", theme.accent, "marked this ready for review".to_owned())
        }
        ConversationKind::ConvertedToDraft => {
            ("", theme.muted, "converted this to a draft".to_owned())
        }
        ConversationKind::ReviewRequested => (
            "",
            theme.muted,
            format!("requested a review from {}", entry.detail),
        ),
        ConversationKind::ReviewRequestRemoved => (
            "",
            theme.muted,
            format!("removed the review request for {}", entry.detail),
        ),
        ConversationKind::Assigned => ("", theme.muted, format!("assigned {}", entry.detail)),
        ConversationKind::Unassigned => ("", theme.muted, format!("unassigned {}", entry.detail)),
        ConversationKind::CrossReferenced => (
            "",
            theme.muted,
            format!("referenced this from #{}", entry.detail),
        ),
        ConversationKind::HeadRefDeleted => {
            ("", theme.muted, "deleted the source branch".to_owned())
        }
        ConversationKind::HeadRefRestored => {
            ("", theme.muted, "restored the source branch".to_owned())
        }
        ConversationKind::BaseRefChanged => (
            "",
            theme.modified,
            "changed the destination branch".to_owned(),
        ),
        ConversationKind::Other => ("·", theme.muted, "updated this pull request".to_owned()),
    }
}