Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/pages/blame.rs

//! Blame — who wrote each line, and whether the commit that wrote it is signed.
//!
//! gunnar answers `/api/repos/{ns}/{name}/blame` by running its own blame walk
//! (`Refs.Blame`) and, for each distinct commit, its own chain walk
//! (`Admin.VerifyChain`) — so every line here carries the FULL oid of the commit
//! that last touched it and that commit's signature verdict. The page computes
//! neither; it colours each line by its commit and renders the verdict in words.
//!
//! # This is half of the chain view
//!
//! Link 1 of the chain is "which commit wrote the line" and link 2 is "the
//! signature on that commit". A per-file blame is exactly those two, once per
//! line. So a line whose commit is `proven` names its signer; one whose commit
//! is `unproven` or `broken` is a **named hole** — the oid is real and shown,
//! and the page says the provenance stops there rather than drawing a tick.

use std::collections::HashMap;

use leptos::prelude::*;
use leptos_router::hooks::{use_params_map, use_query_map};

use crate::api::segment;
use crate::blame::{stopped_line, Blame};
use crate::pages::resolved;
use crate::ui::{Badge, PageHeader, Panel, Tone};

/// The first eight of an oid — enough to read, and what a person compares.
fn short(oid: &str) -> String {
    oid.chars().take(8).collect()
}

/// A stable colour per commit, from its position in the distinct-commit list.
///
/// Position, not a hash of the oid: two adjacent commits should look different,
/// and hues spread by golden angle do that where a hash would not.
fn hue_for(commits: &[String]) -> HashMap<String, i32> {
    commits
        .iter()
        .enumerate()
        .map(|(i, oid)| (oid.clone(), (i as i32 * 137) % 360))
        .collect()
}

/// The signature verdict's word and colour. The word does the work.
fn signature_badge(verdict: &str) -> (Tone, &'static str) {
    match verdict {
        "proven" => (Tone::Pass, "signed"),
        "broken" => (Tone::Bad, "BROKEN"),
        // A hole: the commit is unsigned, or its signature did not check out.
        _ => (Tone::Warn, "unsigned"),
    }
}

#[component]
pub fn BlameView() -> impl IntoView {
    let params = use_params_map();
    let query = use_query_map();
    let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
    let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
    let rev = Memo::new(move |_| query.read().get("rev").unwrap_or_default());
    let path = Memo::new(move |_| query.read().get("path").unwrap_or_default());

    let typed_rev = RwSignal::new(String::new());
    let typed_path = RwSignal::new(String::new());
    Effect::new(move |_| typed_rev.set(rev.get()));
    Effect::new(move |_| typed_path.set(path.get()));

    let navigate = leptos_router::hooks::use_navigate();
    let go = move || {
        let target = format!(
            "/{}/{}/blame?rev={}&path={}",
            segment(&namespace.get_untracked()),
            segment(&name.get_untracked()),
            crate::auth::percent_encode(typed_rev.get_untracked().trim()),
            crate::auth::percent_encode(typed_path.get_untracked().trim()),
        );
        navigate(&target, Default::default());
    };
    let go_click = go.clone();

    // Only ask once a path is set: a blame with no file is not a question.
    let blame = LocalResource::new(move || {
        let ns = segment(&namespace.get());
        let nm = segment(&name.get());
        let rev = rev.get();
        let path = path.get();
        async move {
            if path.trim().is_empty() {
                return None;
            }
            let url = format!(
                "/api/repos/{}/{}/blame?rev={}&path={}",
                ns,
                nm,
                crate::auth::percent_encode(&rev),
                crate::auth::percent_encode(&path),
            );
            Some(crate::api::get::<Blame>(&url).await)
        }
    });

    view! {
        <PageHeader
            title=Signal::derive(move || format!("{}/{}", namespace.get(), name.get()))
        />
        <crate::pages::repo::RepoTabs/>

        <div class="stack">
            <Panel title="Blame">
                <div class="panel-body row" style="align-items:flex-end">
                    <div class="field">
                        <label for="rev">"Revision"</label>
                        <input
                            id="rev"
                            type="text"
                            placeholder="branch, tag or commit"
                            prop:value=move || typed_rev.get()
                            on:input=move |ev| typed_rev.set(event_target_value(&ev))
                        />
                    </div>
                    <div class="field" style="flex:1">
                        <label for="path">"File"</label>
                        <input
                            id="path"
                            type="text"
                            placeholder="src/lib.rs"
                            prop:value=move || typed_path.get()
                            on:input=move |ev| typed_path.set(event_target_value(&ev))
                            on:keydown={
                                let go = go.clone();
                                move |ev: leptos::ev::KeyboardEvent| {
                                    if ev.key() == "Enter" {
                                        go();
                                    }
                                }
                            }
                        />
                    </div>
                    <button class="btn primary" on:click=move |_| go_click()>"Blame"</button>
                </div>
            </Panel>

            {move || match blame.get().flatten() {
                None => view! {
                    <Panel title="No file yet">
                        <p class="panel-body truncation">
                            "Name a file above to see who wrote each of its lines."
                        </p>
                    </Panel>
                }.into_any(),
                some => resolved(some, None, |blame: Blame| {
                    if blame.lfs {
                        return view! {
                            <Panel title=format!("{} · stored in Git LFS", blame.path)>
                                <div class="panel-body" data-blame-lfs="true">
                                    <crate::ui::Hint
                                        line="This file is stored in Git LFS: there are no lines to blame."
                                        more="The repository holds a small pointer to the file, not its \
                                              lines, so blaming it would only name who last moved the \
                                              pointer. Its history lists the commits that changed it."
                                    />
                                </div>
                            </Panel>
                        }
                        .into_any();
                    }
                    let hues = hue_for(&blame.commits);
                    let stopped = stopped_line(&blame);
                    view! {
                        <Panel title=format!("{} · {} lines", blame.path, blame.lines.len())>
                            {stopped.map(|words| view! {
                                <p class="panel-body truncation">{words}</p>
                            })}
                            <div class="table-wrap">
                                <table class="blame">
                                    <caption class="vh">"Blame"</caption>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Line"</th>
                                            <th scope="col">"Commit"</th>
                                            <th scope="col">"Author"</th>
                                            <th scope="col">"Signature"</th>
                                            <th scope="col">"Source"</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {blame.lines.into_iter().map(|line| {
                                            let (tone, word) = signature_badge(&line.signature);
                                            let hue = hues.get(&line.commit).copied().unwrap_or(0);
                                            let style = format!(
                                                "border-left:4px solid hsl({hue}deg 60% 55%)"
                                            );
                                            let signer = line.signer.clone().unwrap_or_default();
                                            let signer_title = signer.clone();
                                            view! {
                                                <tr
                                                    data-blame-line=line.number.to_string()
                                                    data-blame-commit=line.commit.clone()
                                                    data-blame-signature=line.signature.clone()
                                                    style=style
                                                >
                                                    <td class="num">{line.number}</td>
                                                    <td><code title=line.commit.clone()>{short(&line.commit)}</code></td>
                                                    <td>{line.author}</td>
                                                    <td>
                                                        <Badge tone=tone label=word.to_owned()/>
                                                        " "
                                                        <span class="truncation" title=signer_title>{signer}</span>
                                                    </td>
                                                    <td>
                                                        {if line.binary {
                                                            view! { <em class="truncation">"binary"</em> }.into_any()
                                                        } else {
                                                            view! { <code class="src">{line.text}</code> }.into_any()
                                                        }}
                                                    </td>
                                                </tr>
                                            }
                                        }).collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        </Panel>
                    }
                    .into_any()
                })
            }}
        </div>
    }
}