steid

@jamesgill /

feat: a file read by who last changed each line, and a way between the two views

`/{handle}/repos/{name}/blame/{rev}/-/{*path}`, shaped exactly like the tree
URL so editing `tree` to `blame` in the address bar works and a `%2F` in a
revision still survives as one segment.

The commit cell is one line tall on purpose. Anything taller and the blame and
the blob would disagree about where line 400 is, which is the one thing that
would stop them reading as two views of one file rather than two pages. It is
shown only on the first row of each run, because repeating the same commit
down forty adjacent rows is noise.

The age tint is a hairline down the left of each run, its opacity scaled by
the commit's age within *this file's* own history — a file nobody has touched
for two years still has a part that moved most recently, and that is the thing
worth seeing. Five steps, faint enough to read as texture; plain CSS in
`styles.css` because the colour is the primary token at a fraction of an
alpha, which is not a utility, and `color-mix` keeps it a token so it follows
the palette and both colour schemes.

`Code · Blame · Raw` goes in the header of both views, the active one marked
with the primary colour — the same rule the repository's tab strip follows.
Blame's own failure state is a page, not a 500: a read that ran out of time is
a statement about one file's history, and it offers the file rather than a
retry that would take just as long.

The sha links to `/commits/{sha}`, which does not exist yet. Rendered anyway,
by agreement with the commit page landing alongside this: a sha with nowhere
to go is the least useful thing on the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4XhTsWXrFyMeN1C12jqV9
JamesPatrickGill authored 15 hours agoparent5727ed7Browse filesf42a185906bc3d46fb6532a599a088c4ea0adf47

4 files changed+509 −13

src/infrastructure/web/blame.rs+469 −0View file
@@ -0,0 +1,469 @@
1+//! Blame — the same file as the blob page, read by who last changed each line.
2+//!
3+//! One route, `/{handle}/repos/{name}/blame/{rev}/-/{*path}`, deliberately shaped like
4+//! the tree URL: the `/-/` separator means the same thing here, a `{rev}` may still
5+//! carry a `%2F`, and a reader editing `tree` to `blame` in the address bar lands
6+//! exactly where they expected.
7+//!
8+//! The two views share a header, so switching between them does not feel like leaving
9+//! the file. [`view_toggle`] is that header's control and is rendered by the blob page
10+//! too — it lives here because blame is the view that needed it to exist.
11+
12+use std::time::SystemTime;
13+
14+use topcoat::{
15+ Result,
16+ context::Cx,
17+ icon::{icon, iconify::iconify_icon},
18+ router::{error::not_found, page, path_param},
19+ view::{attributes, component, view},
20+};
21+
22+use crate::{
23+ application::{
24+ Blame, BlameContent, BlameFile, BlameGroup, Error, blame::AGE_STEPS, blame_file,
25+ },
26+ domain::{ObjectId, RefName, RepoPath},
27+};
28+
29+use super::{
30+ browse::{ago, crumbs, encode, raw_url, size_of, tree_url},
31+ context::{current_actor, memberships, orgs, queries, repos, server_error},
32+ layout::wide,
33+ repo::{Tab, repo_for, repo_header},
34+};
35+
36+/// `{rev}` from the path, raw — validation is [`RefName`]'s job, as on the tree routes.
37+#[path_param]
38+struct Rev(str);
39+
40+/// `{*path}` from the path: every remaining segment, as one string.
41+#[path_param]
42+struct Path(str);
43+
44+#[page("/{handle}/repos/{name}/blame/{rev}/-/{*path}")]
45+async fn blame_page(cx: &Cx) -> Result {
46+ // A malformed revision or path is a page that does not exist rather than a bad
47+ // request — the same answer the tree routes give.
48+ let rev = RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?;
49+ let path = RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?;
50+
51+ view! { blaming(rev: rev, path: path) }
52+}
53+
54+/// What the page has to render, once the use case has answered.
55+///
56+/// Flattened into one enum before the view rather than matched in nested branches
57+/// inside it: every one of these is a designed state, and a flat match is what makes it
58+/// obvious when one has been left out.
59+enum Body<'a> {
60+ /// git was still walking history when the adapter's timeout cut it off.
61+ TimedOut,
62+ Ready(&'a Blame),
63+ /// A file with no lines at all. Blame has nothing to say about it.
64+ Empty,
65+ Binary,
66+ TooLarge(u64),
67+}
68+
69+/// The blame page. A component rather than a plain function because `view!` needs the
70+/// request context in scope.
71+#[component]
72+async fn blaming(cx: &Cx, rev: RefName, path: RepoPath) -> Result {
73+ let repo = repo_for(cx).await?;
74+
75+ let outcome = blame_file(
76+ &repo.handle,
77+ &repo.name,
78+ &rev,
79+ &path,
80+ &current_actor(cx).await?,
81+ &orgs(cx),
82+ &memberships(cx),
83+ &repos(cx),
84+ &queries(cx),
85+ )
86+ .await;
87+
88+ // A blame that ran out of time is the one failure this page answers with a page.
89+ // It is a statement about *this file* — long history, many lines — not about the
90+ // instance being broken, so a 500 would both look wrong and hide the reason.
91+ let file: Option<BlameFile> = match outcome {
92+ Ok(Some(file)) => Some(file),
93+ Ok(None) => return Err(not_found().into()),
94+ Err(Error::GitQuery(error)) if error.is_timeout() => None,
95+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
96+ };
97+
98+ let body = match &file {
99+ None => Body::TimedOut,
100+ Some(file) => match &file.content {
101+ BlameContent::Binary => Body::Binary,
102+ BlameContent::TooLarge => Body::TooLarge(file.size),
103+ BlameContent::Ready(blame) if blame.is_empty() => Body::Empty,
104+ BlameContent::Ready(blame) => Body::Ready(blame),
105+ },
106+ };
107+
108+ let handle = repo.handle.as_str();
109+ let name = repo.name.as_str();
110+ let blob = tree_url(handle, name, &rev, &path);
111+
112+ // Built here rather than inside the view: the message borrows a formatted string,
113+ // and a temporary created inside `view!` does not outlive the branch that made it.
114+ let oversized = match &body {
115+ Body::TooLarge(size) => format!(
116+ "This file is {}, which is too large to blame. Clone the repository to read it.",
117+ size_of(*size),
118+ ),
119+ _ => String::new(),
120+ };
121+
122+ view! {
123+ wide(
124+ repo_header(repo: &repo, rev: rev.as_str(), active: Tab::Code)
125+
126+ <div class="overflow-hidden rounded-lg border border-border">
127+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
128+ crumbs(handle: handle, name: name, rev: &rev, path: &path)
129+ <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
130+ match &file {
131+ // Unknown after a timeout: the size came from the read that
132+ // did finish, and there was none.
133+ Some(file) => (size_of(file.size)),
134+ None => "",
135+ }
136+ view_toggle(
137+ handle: handle,
138+ name: name,
139+ rev: &rev,
140+ path: &path,
141+ active: FileTab::Blame,
142+ )
143+ </span>
144+ </div>
145+
146+ match body {
147+ Body::Ready(blame) => blame_rows(
148+ handle: handle,
149+ name: name,
150+ path: &path,
151+ blob: blob.as_str(),
152+ blame: blame,
153+ ),
154+ Body::Empty => note(message: "This file is empty."),
155+ Body::Binary => note(message: "This file cannot be blamed as text."),
156+ Body::TooLarge(_) => note(message: oversized.as_str()),
157+ Body::TimedOut => timed_out(blob: blob.as_str()),
158+ }
159+ </div>
160+ )
161+ }
162+}
163+
164+/// Anything the page has instead of a table, in the blob page's own words and shape.
165+#[component]
166+async fn note(message: &str) -> Result {
167+ view! {
168+ <p class="px-4 py-6 text-center text-sm text-muted-foreground">(message)</p>
169+ }
170+}
171+
172+/// What a blame that could not finish says.
173+///
174+/// It offers the file itself rather than a retry: the read will take just as long the
175+/// second time, and the reader came here to see the file.
176+#[component]
177+async fn timed_out(blob: &str) -> Result {
178+ view! {
179+ <div class="px-4 py-6 text-center">
180+ <p class="text-sm text-muted-foreground">
181+ "Blame took too long for this file."
182+ </p>
183+ <p class="mt-1 text-xs text-muted-foreground">
184+ "Its history is long enough that walking it ran out of time. "
185+ <a href=(blob) class="text-primary hover:underline">"Read the file instead"</a>
186+ "."
187+ </p>
188+ </div>
189+ }
190+}
191+
192+/// The blame table: commit, line number, code.
193+///
194+/// A table, and one row per line with the same type and leading as the blob's, so
195+/// scrolling from one view to the other lands on the same lines in the same places. The
196+/// commit cell is deliberately **one line tall** — anything taller would make the two
197+/// views of a file disagree about where line 400 is.
198+#[component]
199+async fn blame_rows(
200+ handle: &str,
201+ name: &str,
202+ path: &RepoPath,
203+ blob: &str,
204+ blame: &Blame,
205+) -> Result {
206+ view! {
207+ <div class="overflow-x-auto">
208+ <table class="w-full border-collapse font-mono text-xs leading-relaxed">
209+ <tbody>
210+ for (index, group) in blame.groups.iter().enumerate() {
211+ for (offset, line) in group.lines.iter().enumerate() {
212+ <tr class=(if offset == 0 && index > 0 {
213+ "border-t border-border"
214+ } else {
215+ ""
216+ })>
217+ // The age tint rides on the leftmost cell of every row
218+ // in the run, so consecutive rows draw one unbroken
219+ // edge. See `styles.css`.
220+ <td class=(format!(
221+ "w-px border-r border-border pl-2 pr-3 align-top blame-age blame-age-{}",
222+ group.age.min(AGE_STEPS - 1),
223+ ))>
224+ if offset == 0 {
225+ commit_cell(handle: handle, name: name, path: path, group: group)
226+ }
227+ </td>
228+ <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
229+ <a
230+ href=(format!("{blob}#L{}", group.start_line + offset))
231+ class="hover:text-foreground"
232+ >((group.start_line + offset).to_string())</a>
233+ </td>
234+ <td class="whitespace-pre px-4 align-top">
235+ (if line.is_empty() { " " } else { line.as_str() })
236+ </td>
237+ </tr>
238+ }
239+ }
240+ </tbody>
241+ </table>
242+ </div>
243+ }
244+}
245+
246+/// Who a run of lines came from, on the first row of the run and nowhere else.
247+///
248+/// Repeating the same commit down forty adjacent rows is noise; saying it once is the
249+/// whole reason blame is grouped.
250+#[component]
251+async fn commit_cell(handle: &str, name: &str, path: &RepoPath, group: &BlameGroup) -> Result {
252+ let commit = &group.commit;
253+ // git names the file once per commit, so an empty one is a run it said nothing
254+ // about rather than a rename from nowhere.
255+ let moved = !commit.filename.is_empty() && commit.filename != path.as_str();
256+
257+ let mut about = format!(
258+ "{} · {} · {}",
259+ commit.author_name,
260+ ago(commit.authored_at),
261+ commit.summary,
262+ );
263+
264+ // A boundary commit is where git stopped walking, so the lines it holds may be
265+ // older than it is. Worth saying, not worth a mark on a row that has to stay one
266+ // line tall.
267+ if commit.boundary {
268+ about.push_str(" · the oldest commit blame reached");
269+ }
270+
271+ view! {
272+ <div class="flex w-80 items-baseline gap-2 overflow-hidden">
273+ <a
274+ href=(commit_url(handle, name, &commit.id))
275+ title=(about.as_str())
276+ class="shrink-0 text-muted-foreground hover:text-foreground"
277+ >(commit.id.short())</a>
278+
279+ if moved {
280+ <span
281+ class="shrink-0 self-center text-muted-foreground"
282+ title=(format!("Moved from {}", commit.filename))
283+ >
284+ icon(
285+ data: iconify_icon!("feather:corner-down-right"),
286+ label: "Moved from another file",
287+ attrs: attributes! { class="size-3" },
288+ )
289+ </span>
290+ }
291+
292+ <span class="min-w-0 flex-1 truncate text-muted-foreground" title=(about.as_str())>
293+ (&commit.summary)
294+ </span>
295+ <span class="shrink-0 text-muted-foreground">(short_ago(commit.authored_at))</span>
296+ </div>
297+ }
298+}
299+
300+// --- The shared file header -------------------------------------------------------
301+
302+/// Which view of a file is being looked at.
303+///
304+/// Raw is not a variant: it downloads rather than displays, so it is never the view you
305+/// are on.
306+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307+pub(super) enum FileTab {
308+ Code,
309+ Blame,
310+}
311+
312+/// `Code · Blame · Raw`, in the file header of both views.
313+///
314+/// The active one is marked with the primary colour, which is the same rule the
315+/// repository's tab strip follows: the primary colour says where you are, and nothing
316+/// else on the header is coloured.
317+#[component]
318+pub(super) async fn view_toggle(
319+ handle: &str,
320+ name: &str,
321+ rev: &RefName,
322+ path: &RepoPath,
323+ active: FileTab,
324+) -> Result {
325+ let item = |current| {
326+ if current {
327+ "text-primary"
328+ } else {
329+ "text-muted-foreground hover:text-foreground"
330+ }
331+ };
332+
333+ view! {
334+ <span class="flex items-center gap-1.5">
335+ <a
336+ href=(tree_url(handle, name, rev, path))
337+ class=(item(active == FileTab::Code))
338+ >"Code"</a>
339+ <span class="text-border">"·"</span>
340+ <a
341+ href=(blame_url(handle, name, rev, path))
342+ class=(item(active == FileTab::Blame))
343+ >"Blame"</a>
344+ <span class="text-border">"·"</span>
345+ // The way out for anything neither view can show — a binary, an oversized
346+ // file — and the URL to hand to `curl`.
347+ <a
348+ href=(raw_url(handle, name, rev, path))
349+ class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
350+ >
351+ icon(data: iconify_icon!("feather:download"), attrs: attributes! {
352+ class="size-3.5"
353+ })
354+ "Raw"
355+ </a>
356+ </span>
357+ }
358+}
359+
360+// --- URLs -------------------------------------------------------------------------
361+
362+/// Blame's URL for a path at a revision. Shaped exactly like the tree's, and encoded
363+/// the same way — the revision whole, the path with its slashes intact.
364+pub(super) fn blame_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
365+ format!(
366+ "/{handle}/repos/{name}/blame/{}/-/{}",
367+ encode(rev.as_str(), false),
368+ encode(path.as_str(), true)
369+ )
370+}
371+
372+/// Where a commit's own page is.
373+///
374+/// Rendered before that page exists, by agreement: the sha is the most useful thing on
375+/// a blame row and a sha with nowhere to go is the least.
376+fn commit_url(handle: &str, name: &str, id: &ObjectId) -> String {
377+ format!("/{handle}/repos/{name}/commits/{}", id.as_str())
378+}
379+
380+// --- Formatting -------------------------------------------------------------------
381+
382+/// How long ago, in as few characters as it can be said.
383+///
384+/// The long form `ago` gives — "3 months ago" — is the tooltip. On the row itself the
385+/// date shares one line with a sha and a summary, and the summary is what deserves the
386+/// space.
387+fn short_ago(time: SystemTime) -> String {
388+ let Ok(elapsed) = SystemTime::now().duration_since(time) else {
389+ // A commit carries whoever made it's clock, so a future timestamp is a thing
390+ // that happens rather than a thing to render as a negative.
391+ return "now".to_owned();
392+ };
393+
394+ let seconds = elapsed.as_secs();
395+
396+ match seconds {
397+ 0..=59 => "now".to_owned(),
398+ 60..=3599 => format!("{}m", seconds / 60),
399+ 3600..=86_399 => format!("{}h", seconds / 3600),
400+ 86_400..=2_591_999 => format!("{}d", seconds / 86_400),
401+ 2_592_000..=31_535_999 => format!("{}mo", seconds / 2_592_000),
402+ _ => format!("{}y", seconds / 31_536_000),
403+ }
404+}
405+
406+#[cfg(test)]
407+mod tests {
408+ use std::time::Duration;
409+
410+ use super::*;
411+
412+ fn rev(value: &str) -> RefName {
413+ RefName::new(value).expect("valid revision")
414+ }
415+
416+ #[test]
417+ fn a_blame_url_mirrors_the_tree_url_it_is_reached_from() {
418+ let path = RepoPath::new("src/domain/repo.rs").expect("valid");
419+
420+ assert_eq!(
421+ blame_url("ada", "steid", &rev("main"), &path),
422+ "/ada/repos/steid/blame/main/-/src/domain/repo.rs"
423+ );
424+ }
425+
426+ #[test]
427+ fn a_revisions_slashes_stay_inside_one_segment() {
428+ // Otherwise `feature/login` would look like a revision plus a path, which is the
429+ // ambiguity the `/-/` separator exists to remove.
430+ let path = RepoPath::new("README.md").expect("valid");
431+
432+ assert_eq!(
433+ blame_url("ada", "steid", &rev("feature/login"), &path),
434+ "/ada/repos/steid/blame/feature%2Flogin/-/README.md"
435+ );
436+ }
437+
438+ #[test]
439+ fn a_commit_url_carries_the_whole_id() {
440+ // Never the abbreviation: it is unambiguous today and need not stay so.
441+ let id = ObjectId::from_trusted("0123456789abcdef0123456789abcdef01234567");
442+
443+ assert_eq!(
444+ commit_url("ada", "steid", &id),
445+ "/ada/repos/steid/commits/0123456789abcdef0123456789abcdef01234567"
446+ );
447+ }
448+
449+ #[test]
450+ fn a_relative_date_fits_beside_a_sha() {
451+ let now = SystemTime::now();
452+ let since = |seconds| short_ago(now - Duration::from_secs(seconds));
453+
454+ assert_eq!(since(5), "now");
455+ assert_eq!(since(600), "10m");
456+ assert_eq!(since(7200), "2h");
457+ assert_eq!(since(86_400 * 3), "3d");
458+ assert_eq!(since(86_400 * 70), "2mo");
459+ assert_eq!(since(86_400 * 400), "1y");
460+ }
461+
462+ #[test]
463+ fn a_commit_from_the_future_reads_as_now() {
464+ assert_eq!(
465+ short_ago(SystemTime::now() + Duration::from_secs(3600)),
466+ "now"
467+ );
468+ }
469+}
src/infrastructure/web/browse.rs+10 −13View file
@@ -46,6 +46,7 @@ use crate::{
4646 };
4747
4848 use super::{
49+ blame::{FileTab, view_toggle},
4950 commit::commit_url,
5051 context::{current_actor, memberships, orgs, queries, repos, server_error},
5152 layout::wide,
@@ -501,7 +502,7 @@ pub(super) fn encode(value: &str, keep_slash: bool) -> String {
501502 // --- Formatting -------------------------------------------------------------------
502503
503504 /// A file size, rounded for reading rather than for accounting.
504fn size_of(bytes: u64) -> String {
505+pub(super) fn size_of(bytes: u64) -> String {
505506 const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
506507
507508 if bytes < 1024 {
@@ -784,7 +785,7 @@ pub(super) async fn empty_repo(url: &str) -> Result {
784785 /// The main way anyone moves around a tree: the last component is the current page and
785786 /// is deliberately not a link, so the trail reads as a position rather than a menu.
786787 #[component]
787async fn crumbs(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
788+pub(super) async fn crumbs(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
788789 let parts: Vec<&str> = path.components().collect();
789790 let mut walked = RepoPath::root();
790791 let mut trail: Vec<(String, String)> = Vec::new();
@@ -964,17 +965,13 @@ pub(super) async fn blob(
964965 crumbs(handle: handle, name: name, rev: rev, path: path)
965966 <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
966967 (size_of(file.size))
967 // The way out for anything the page cannot show — a binary, an
968 // oversized file — and the URL to hand to `curl`.
969 <a
970 href=(raw_url(handle, name, rev, path))
971 class="inline-flex items-center gap-1 hover:text-foreground"
972 >
973 icon(data: iconify_icon!("feather:download"), attrs: attributes! {
974 class="size-3.5"
975 })
976 "Raw"
977 </a>
968+ view_toggle(
969+ handle: handle,
970+ name: name,
971+ rev: rev,
972+ path: path,
973+ active: FileTab::Code,
974+ )
978975 </span>
979976 </div>
980977
src/infrastructure/web/mod.rs+1 −0View file
@@ -2,6 +2,7 @@
22
33 pub mod api;
44 pub mod archive;
5+pub mod blame;
56 pub mod browse;
67 pub mod commit;
78 pub mod context;
styles.css+29 −0View file
@@ -266,3 +266,32 @@
266266 .diff-line-hunk:first-child td {
267267 border-top: 0;
268268 }
269+
270+/* Blame's age tint.
271+
272+ A hairline down the left of each run of lines, its opacity scaled by how
273+ recently that commit touched the file relative to the file's own oldest and
274+ newest. Five steps, deliberately faint: it should read as texture telling you
275+ which end of a file is moving, not as a heat map with values to read off it.
276+
277+ Plain CSS rather than Tailwind because the colour is the primary token at a
278+ fraction of an alpha, which is not a utility — and `color-mix` keeps it a
279+ token, so it follows a palette change and both colour schemes. */
280+.blame-age {
281+ border-left: 2px solid transparent;
282+}
283+.blame-age-0 {
284+ border-left-color: color-mix(in oklab, var(--primary) 5%, transparent);
285+}
286+.blame-age-1 {
287+ border-left-color: color-mix(in oklab, var(--primary) 9%, transparent);
288+}
289+.blame-age-2 {
290+ border-left-color: color-mix(in oklab, var(--primary) 14%, transparent);
291+}
292+.blame-age-3 {
293+ border-left-color: color-mix(in oklab, var(--primary) 21%, transparent);
294+}
295+.blame-age-4 {
296+ border-left-color: color-mix(in oklab, var(--primary) 30%, transparent);
297+}