🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The commit diff viewer: shows the changes in a single commit.
//!
//! This module provides both a full commit diff page and reusable maud
//! components for displaying diffs, which can be shared with other views
//! like commit comparison pages.

use actix_web::HttpResponse;
use maud::{Markup, Render, html};

use crate::repositories::git;
use crate::views::Page;
use crate::views::components::{RepositoryChrome, RepositoryTab};
use crate::views::repositories::CommitSummary;

pub const MAX_DIFF_LINES_TO_AUTOPEN: usize = 100;

// ============================================================================
// Reusable maud components for displaying diffs
// ============================================================================

/// Renders a summary of diff statistics: file count, insertions, and deletions.
///
/// This component can be reused in commit lists, comparison pages, etc.
pub fn diff_stats_summary(files: &[git::DiffFile]) -> Markup {
    let total_files = files.len();
    let total_insertions: u64 = files.iter().map(|f| f.added).sum();
    let total_deletions: u64 = files.iter().map(|f| f.deleted).sum();

    html! {
        span class="stats" {
            (total_files)
            @if total_files == 1 {
                " file changed"
            } @else {
                " files changed"
            }
            ", "
            span class="insertions" { "+" (total_insertions) }
            ", "
            span class="deletions" { "−" (total_deletions) }
        }
    }
}

/// Renders a single file's diff: header with path and stats, plus hunks.
///
/// This component can be reused anywhere a file diff needs to be displayed.
pub fn file_diff(file: &git::DiffFile) -> Markup {
    let line_count = file
        .hunks
        .iter()
        .map(|f| f.lines.len())
        .fold(0, |acc, i| acc + i);
    html! {
        details class="file-diff" open[line_count<MAX_DIFF_LINES_TO_AUTOPEN] {
            summary {
                span class="file-path" {
                    @if file.old_path != file.new_path {
                        (file.old_path) " → " (file.new_path)
                    } @else {
                        (file.new_path)
                    }
                }
                span class="file-stats" {
                    span class="insertions" { "+" (file.added) }
                    span class="deletions" { "−" (file.deleted) }
                }
            }
            @if file.binary {
                p class="binary-notice" { "Binary file" }
            } @else {
                div class="hunks" {
                    @for hunk in &file.hunks {
                        div class="hunk" {
                            div class="hunk-header" { (hunk.header) }
                            @for line in &hunk.lines {
                                @match line.kind {
                                    git::DiffLineKind::Context => {
                                        div class="diff-line context" { " " (line.text) }
                                    }
                                    git::DiffLineKind::Added => {
                                        div class="diff-line added" { "+" (line.text) }
                                    }
                                    git::DiffLineKind::Removed => {
                                        div class="diff-line removed" { "−" (line.text) }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// Renders a list of file diffs.
///
/// Convenience wrapper around multiple file_diff calls.
pub fn file_diffs(files: &[git::DiffFile]) -> Markup {
    html! {
        @for file in files {
            (file_diff(file))
        }
    }
}

/// Renders a commit header: subject, hash, date, and author.
///
/// This component can be reused in commit lists, diff pages, etc.
pub fn commit_header(commit: &git::Commit) -> Markup {
    html! {
        div class="commit-header" {
            h3 { (commit.subject) }
            p class="commit-meta" {
                code class="hash" { (commit.hash) }
                code class="date" { (commit.date) }
                span class="author" { (commit.author) }
            }
        }
    }
}

/// Renders inline stats for a single commit: insertions and deletions.
///
/// This component is designed for use in commit lists where space is limited.
pub fn commit_stats_inline(commit: &CommitSummary) -> Markup {
    html! {
        @if commit.insertions > 0 || commit.deletions > 0 {
            span class="commit-stats" {
                span class="insertions" { "+" (commit.insertions) }
                " "
                span class="deletions" { "−" (commit.deletions) }
            }
        }
    }
}

// ============================================================================
// Full commit diff page
// ============================================================================

pub(super) struct CommitBody<'a> {
    owner: &'a str,
    slug: &'a str,
    visibility: &'a str,
    default_branch: &'a str,
    commit: &'a git::Commit,
    files: &'a [git::DiffFile],
    viewer: Option<&'a str>,
}

impl Render for CommitBody<'_> {
    fn render(&self) -> Markup {
        html! {
            (RepositoryChrome {
                owner: self.owner,
                slug: self.slug,
                visibility: self.visibility,
                default_branch: self.default_branch,
                current_tab: RepositoryTab::Commits,
                is_owner: self.viewer == Some(self.owner),
            })
            div class="commit-detail" {
                (commit_header(self.commit))
                p class="commit-links" {
                    a href=(format!(
                        "/~{}/{}/tree/{}",
                        self.owner, self.slug, self.commit.full_hash
                    )) {
                        "Browse the tree at this commit"
                    }
                }
                @if !self.files.is_empty() {
                    div class="commit-stats" {
                        (diff_stats_summary(self.files))
                    }
                    div class="commit-diff" {
                        (file_diffs(self.files))
                    }
                } @else {
                    p class="empty" { "No changes in this commit." }
                }
            }
        }
    }
}

/// The page showing the diff of a single commit.
pub fn commit(
    viewer: Option<&str>,
    owner: &str,
    slug: &str,
    visibility: &'static str,
    default_branch: &str,
    commit: &git::Commit,
    files: &[git::DiffFile],
) -> HttpResponse {
    Page {
        title: format!("~{owner}/{slug}: {} - Frogue", commit.subject),
        viewer: viewer.map(str::to_string),
        extra_head: None,
        body: CommitBody {
            owner,
            slug,
            visibility,
            default_branch,
            commit,
            files,
            viewer,
        },
    }
    .into_response()
}