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>
}
}