steid

@jamesgill /

f42a185feat: a file read by who last changed each line, and a way between the two views19h
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
12use std::time::SystemTime;
13
14use topcoat::{
15 Result,
16 context::Cx,
17 icon::{icon, iconify::iconify_icon},
18 router::{error::not_found, page, path_param},
8daff1cfeat: blame reads as code, the same way the blob does18h
19 view::{Unescaped, attributes, component, view},
f42a185feat: a file read by who last changed each line, and a way between the two views19h
20};
21
22use crate::{
00d9c06refactor: one place decides that git ran out of time18h
23 application::{Blame, BlameContent, BlameFile, BlameGroup, blame::AGE_STEPS, blame_file},
f42a185feat: a file read by who last changed each line, and a way between the two views19h
24 domain::{ObjectId, RefName, RepoPath},
8daff1cfeat: blame reads as code, the same way the blob does18h
25 infrastructure::highlight::{Source, SourceLine, source_lines},
f42a185feat: a file read by who last changed each line, and a way between the two views19h
26};
27
28use super::{
8804ad9feat: blame can change revision, like every other page under Code18h
29 browse::{
30 Switch, ago, crumbs, encode, raw_url, ref_links, refs_for, rev_switcher, size_of, tree_url,
31 },
00d9c06refactor: one place decides that git ran out of time18h
32 context::{current_actor, memberships, orgs, queries, repos, server_error, timed_out},
f42a185feat: a file read by who last changed each line, and a way between the two views19h
33 layout::wide,
34 repo::{Tab, repo_for, repo_header},
35};
36
37/// `{rev}` from the path, raw — validation is [`RefName`]'s job, as on the tree routes.
38#[path_param]
39struct Rev(str);
40
41/// `{*path}` from the path: every remaining segment, as one string.
42#[path_param]
43struct Path(str);
44
45#[page("/{handle}/repos/{name}/blame/{rev}/-/{*path}")]
46async fn blame_page(cx: &Cx) -> Result {
47 // A malformed revision or path is a page that does not exist rather than a bad
48 // request — the same answer the tree routes give.
49 let rev = RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?;
50 let path = RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?;
51
52 view! { blaming(rev: rev, path: path) }
53}
54
55/// What the page has to render, once the use case has answered.
56///
57/// Flattened into one enum before the view rather than matched in nested branches
58/// inside it: every one of these is a designed state, and a flat match is what makes it
59/// obvious when one has been left out.
60enum Body<'a> {
61 /// git was still walking history when the adapter's timeout cut it off.
62 TimedOut,
63 Ready(&'a Blame),
64 /// A file with no lines at all. Blame has nothing to say about it.
65 Empty,
66 Binary,
67 TooLarge(u64),
68}
69
70/// The blame page. A component rather than a plain function because `view!` needs the
71/// request context in scope.
72#[component]
73async fn blaming(cx: &Cx, rev: RefName, path: RepoPath) -> Result {
74 let repo = repo_for(cx).await?;
75
76 let outcome = blame_file(
77 &repo.handle,
78 &repo.name,
79 &rev,
80 &path,
81 &current_actor(cx).await?,
82 &orgs(cx),
83 &memberships(cx),
84 &repos(cx),
85 &queries(cx),
86 )
87 .await;
88
89 // A blame that ran out of time is the one failure this page answers with a page.
90 // It is a statement about *this file* — long history, many lines — not about the
91 // instance being broken, so a 500 would both look wrong and hide the reason.
92 let file: Option<BlameFile> = match outcome {
93 Ok(Some(file)) => Some(file),
94 Ok(None) => return Err(not_found().into()),
00d9c06refactor: one place decides that git ran out of time18h
95 Err(error) if timed_out(&error) => None,
f42a185feat: a file read by who last changed each line, and a way between the two views19h
96 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
97 };
98
99 let body = match &file {
100 None => Body::TimedOut,
101 Some(file) => match &file.content {
102 BlameContent::Binary => Body::Binary,
103 BlameContent::TooLarge => Body::TooLarge(file.size),
104 BlameContent::Ready(blame) if blame.is_empty() => Body::Empty,
105 BlameContent::Ready(blame) => Body::Ready(blame),
106 },
107 };
108
109 let handle = repo.handle.as_str();
110 let name = repo.name.as_str();
111 let blob = tree_url(handle, name, &rev, &path);
112
8804ad9feat: blame can change revision, like every other page under Code18h
113 // One more `git` process, and the reason blame shipped without a switcher: the
114 // header is shared with the tree and the log, and a file header with no way to
115 // change revision is the odd one out of the three. Switching keeps you on blame at
116 // the same path — [`Switch::Blame`].
117 let refs = refs_for(cx, &repo).await?;
118 let at = rev.as_str();
119 let known = refs.contains(&rev);
120 let switch = Switch::Blame(&path);
121 let branches = ref_links(handle, name, &refs.branches, at, &switch);
122 let tags = ref_links(handle, name, &refs.tags, at, &switch);
123
f42a185feat: a file read by who last changed each line, and a way between the two views19h
124 // Built here rather than inside the view: the message borrows a formatted string,
125 // and a temporary created inside `view!` does not outlive the branch that made it.
126 let oversized = match &body {
127 Body::TooLarge(size) => format!(
128 "This file is {}, which is too large to blame. Clone the repository to read it.",
129 size_of(*size),
130 ),
131 _ => String::new(),
132 };
133
134 view! {
135 wide(
8804ad9feat: blame can change revision, like every other page under Code18h
136 repo_header(
137 repo: &repo,
138 rev: at,
139 active: Tab::Code,
140 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
141 )
f42a185feat: a file read by who last changed each line, and a way between the two views19h
142
143 <div class="overflow-hidden rounded-lg border border-border">
144 <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
145 crumbs(handle: handle, name: name, rev: &rev, path: &path)
146 <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
147 match &file {
148 // Unknown after a timeout: the size came from the read that
149 // did finish, and there was none.
150 Some(file) => (size_of(file.size)),
151 None => "",
152 }
153 view_toggle(
154 handle: handle,
155 name: name,
156 rev: &rev,
157 path: &path,
158 active: FileTab::Blame,
159 )
160 </span>
161 </div>
162
163 match body {
164 Body::Ready(blame) => blame_rows(
165 handle: handle,
166 name: name,
167 path: &path,
168 blob: blob.as_str(),
169 blame: blame,
170 ),
171 Body::Empty => note(message: "This file is empty."),
172 Body::Binary => note(message: "This file cannot be blamed as text."),
173 Body::TooLarge(_) => note(message: oversized.as_str()),
00d9c06refactor: one place decides that git ran out of time18h
174 Body::TimedOut => took_too_long(blob: blob.as_str()),
f42a185feat: a file read by who last changed each line, and a way between the two views19h
175 }
176 </div>
177 )
178 }
179}
180
181/// Anything the page has instead of a table, in the blob page's own words and shape.
182#[component]
183async fn note(message: &str) -> Result {
184 view! {
185 <p class="px-4 py-6 text-center text-sm text-muted-foreground">(message)</p>
186 }
187}
188
189/// What a blame that could not finish says.
190///
191/// It offers the file itself rather than a retry: the read will take just as long the
192/// second time, and the reader came here to see the file.
193#[component]
00d9c06refactor: one place decides that git ran out of time18h
194async fn took_too_long(blob: &str) -> Result {
f42a185feat: a file read by who last changed each line, and a way between the two views19h
195 view! {
196 <div class="px-4 py-6 text-center">
197 <p class="text-sm text-muted-foreground">
198 "Blame took too long for this file."
199 </p>
200 <p class="mt-1 text-xs text-muted-foreground">
201 "Its history is long enough that walking it ran out of time. "
202 <a href=(blob) class="text-primary hover:underline">"Read the file instead"</a>
203 "."
204 </p>
205 </div>
206 }
207}
208
8daff1cfeat: blame reads as code, the same way the blob does18h
209/// One rendered line of the table.
210struct Row<'a> {
211 group: &'a BlameGroup,
212 /// The first line of a run, which is the only one that names its commit.
213 starts_run: bool,
214 /// Whether this row opens a run with a hairline — every run but the file's first.
215 ruled: bool,
216 /// 1-based, and what the `#L{n}` link points at.
217 number: usize,
218 code: SourceLine,
219}
220
221/// Where a line of a run sits in the file, 0-based.
222///
223/// One function because the reassembled text and the rendered rows must agree about it;
224/// two copies of `start_line + offset - 1` is exactly how they would stop agreeing.
225fn line_index(group: &BlameGroup, offset: usize) -> usize {
226 (group.start_line + offset).saturating_sub(1)
227}
228
f42a185feat: a file read by who last changed each line, and a way between the two views19h
229/// The blame table: commit, line number, code.
230///
231/// A table, and one row per line with the same type and leading as the blob's, so
232/// scrolling from one view to the other lands on the same lines in the same places. The
233/// commit cell is deliberately **one line tall** — anything taller would make the two
234/// views of a file disagree about where line 400 is.
8daff1cfeat: blame reads as code, the same way the blob does18h
235///
236/// The code cell goes through [`source_lines`], the same adapter and therefore the same
237/// classes, caps and plain fallback as the blob's. It is called **once for the whole
238/// file, not once per run**: `highlight.rs` carries parse state across lines — a block
239/// comment opens on one line and closes on another — and a blamed run is an arbitrary
240/// slice of the file, so highlighting a run on its own would start mid-language.
241/// Highlighting only ever changes what is inside the cell, so the row height is the
242/// blob's either way.
f42a185feat: a file read by who last changed each line, and a way between the two views19h
243#[component]
244async fn blame_rows(
245 handle: &str,
246 name: &str,
247 path: &RepoPath,
248 blob: &str,
249 blame: &Blame,
250) -> Result {
8daff1cfeat: blame reads as code, the same way the blob does18h
251 // Reassembled by line number rather than by walking the groups in order, so the
252 // colouring cannot drift from the numbering even if a run ever arrives out of
253 // sequence. Joined with `\n` and never a trailing one, so the line count is exactly
254 // `blame.line_count()` — the invariant `source_lines` checks before it trusts its
255 // own output.
256 let mut ordered = vec![""; blame.line_count()];
257 for group in &blame.groups {
258 for (offset, line) in group.lines.iter().enumerate() {
259 if let Some(slot) = ordered.get_mut(line_index(group, offset)) {
260 *slot = line.as_str();
261 }
262 }
263 }
264
265 let Source { lines, too_large } =
266 source_lines(path.file_name().unwrap_or_default(), &ordered.join("\n"));
267
268 // Flattened to one row per line before the view rather than nested `for`s inside
269 // it: the code cell now owns highlighted markup, and a local the view only borrows
270 // does not outlive it.
271 let mut rows: Vec<Row> = Vec::new();
272 let mut code = lines.into_iter();
273 for (index, group) in blame.groups.iter().enumerate() {
274 for offset in 0..group.lines.len() {
275 rows.push(Row {
276 group,
277 // The commit is shown once per run, and every run but the first opens
278 // with a hairline. Repeating the sha down forty adjacent rows is noise;
279 // saying it once is the whole reason blame is grouped.
280 starts_run: offset == 0,
281 ruled: offset == 0 && index > 0,
282 number: line_index(group, offset) + 1,
283 // An empty cell has no height, and a blame row must keep the blob's.
284 code: code
285 .next()
286 .unwrap_or_else(|| SourceLine::Plain(" ".to_owned())),
287 });
288 }
289 }
290
f42a185feat: a file read by who last changed each line, and a way between the two views19h
291 view! {
8daff1cfeat: blame reads as code, the same way the blob does18h
292 if too_large {
293 <p class="border-b border-border px-4 py-1.5 font-mono text-xs text-muted-foreground">
294 "Too large to highlight — shown as plain text."
295 </p>
296 }
297
f42a185feat: a file read by who last changed each line, and a way between the two views19h
298 <div class="overflow-x-auto">
299 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
300 <tbody>
8daff1cfeat: blame reads as code, the same way the blob does18h
301 for row in rows {
302 <tr class=(if row.ruled { "border-t border-border" } else { "" })>
303 // The age tint rides on the leftmost cell of every row in
304 // the run, so consecutive rows draw one unbroken edge. See
305 // `styles.css`.
306 <td class=(format!(
307 "w-px border-r border-border pl-2 pr-3 align-top blame-age blame-age-{}",
308 row.group.age.min(AGE_STEPS - 1),
309 ))>
310 if row.starts_run {
311 commit_cell(
312 handle: handle,
313 name: name,
314 path: path,
315 group: row.group,
316 )
317 }
318 </td>
319 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
320 <a
321 href=(format!("{blob}#L{}", row.number))
322 class="hover:text-foreground"
323 >((row.number).to_string())</a>
324 </td>
325 <td class="whitespace-pre px-4 align-top">
326 match row.code {
327 // Markup this codebase wrote: `highlight` escapes
328 // the source itself and emits nothing but spans.
329 SourceLine::Classed(html) => (Unescaped::new_unchecked(html)),
330 SourceLine::Plain(text) => (text),
331 }
332 </td>
333 </tr>
f42a185feat: a file read by who last changed each line, and a way between the two views19h
334 }
335 </tbody>
336 </table>
337 </div>
338 }
339}
340
341/// Who a run of lines came from, on the first row of the run and nowhere else.
342///
343/// Repeating the same commit down forty adjacent rows is noise; saying it once is the
344/// whole reason blame is grouped.
e141fa6fix: the commit column takes half an 800px window, so it narrows there18h
345///
346/// A fixed width, narrower on a small viewport: the code beside it is what the reader
347/// came for, and on an 800px window a 20rem column of attribution would take half of
348/// it. The summary truncates inside whichever width applies.
f42a185feat: a file read by who last changed each line, and a way between the two views19h
349#[component]
350async fn commit_cell(handle: &str, name: &str, path: &RepoPath, group: &BlameGroup) -> Result {
351 let commit = &group.commit;
352 // git names the file once per commit, so an empty one is a run it said nothing
353 // about rather than a rename from nowhere.
354 let moved = !commit.filename.is_empty() && commit.filename != path.as_str();
355
356 let mut about = format!(
357 "{} · {} · {}",
358 commit.author_name,
359 ago(commit.authored_at),
360 commit.summary,
361 );
362
363 // A boundary commit is where git stopped walking, so the lines it holds may be
364 // older than it is. Worth saying, not worth a mark on a row that has to stay one
365 // line tall.
366 if commit.boundary {
367 about.push_str(" · the oldest commit blame reached");
368 }
369
370 view! {
e141fa6fix: the commit column takes half an 800px window, so it narrows there18h
371 <div class="flex w-56 items-baseline gap-2 overflow-hidden lg:w-80">
f42a185feat: a file read by who last changed each line, and a way between the two views19h
372 <a
373 href=(commit_url(handle, name, &commit.id))
374 title=(about.as_str())
375 class="shrink-0 text-muted-foreground hover:text-foreground"
376 >(commit.id.short())</a>
377
378 if moved {
379 <span
380 class="shrink-0 self-center text-muted-foreground"
381 title=(format!("Moved from {}", commit.filename))
382 >
383 icon(
384 data: iconify_icon!("feather:corner-down-right"),
385 label: "Moved from another file",
386 attrs: attributes! { class="size-3" },
387 )
388 </span>
389 }
390
391 <span class="min-w-0 flex-1 truncate text-muted-foreground" title=(about.as_str())>
392 (&commit.summary)
393 </span>
394 <span class="shrink-0 text-muted-foreground">(short_ago(commit.authored_at))</span>
395 </div>
396 }
397}
398
399// --- The shared file header -------------------------------------------------------
400
401/// Which view of a file is being looked at.
402///
403/// Raw is not a variant: it downloads rather than displays, so it is never the view you
404/// are on.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub(super) enum FileTab {
407 Code,
408 Blame,
409}
410
411/// `Code · Blame · Raw`, in the file header of both views.
412///
413/// The active one is marked with the primary colour, which is the same rule the
414/// repository's tab strip follows: the primary colour says where you are, and nothing
415/// else on the header is coloured.
416#[component]
417pub(super) async fn view_toggle(
418 handle: &str,
419 name: &str,
420 rev: &RefName,
421 path: &RepoPath,
422 active: FileTab,
423) -> Result {
424 let item = |current| {
425 if current {
426 "text-primary"
427 } else {
428 "text-muted-foreground hover:text-foreground"
429 }
430 };
431
432 view! {
433 <span class="flex items-center gap-1.5">
434 <a
435 href=(tree_url(handle, name, rev, path))
436 class=(item(active == FileTab::Code))
437 >"Code"</a>
438 <span class="text-border">"·"</span>
439 <a
440 href=(blame_url(handle, name, rev, path))
441 class=(item(active == FileTab::Blame))
442 >"Blame"</a>
443 <span class="text-border">"·"</span>
444 // The way out for anything neither view can show — a binary, an oversized
445 // file — and the URL to hand to `curl`.
446 <a
447 href=(raw_url(handle, name, rev, path))
448 class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
449 >
450 icon(data: iconify_icon!("feather:download"), attrs: attributes! {
451 class="size-3.5"
452 })
453 "Raw"
454 </a>
455 </span>
456 }
457}
458
459// --- URLs -------------------------------------------------------------------------
460
461/// Blame's URL for a path at a revision. Shaped exactly like the tree's, and encoded
462/// the same way — the revision whole, the path with its slashes intact.
463pub(super) fn blame_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
464 format!(
465 "/{handle}/repos/{name}/blame/{}/-/{}",
466 encode(rev.as_str(), false),
467 encode(path.as_str(), true)
468 )
469}
470
471/// Where a commit's own page is.
472///
473/// Rendered before that page exists, by agreement: the sha is the most useful thing on
474/// a blame row and a sha with nowhere to go is the least.
475fn commit_url(handle: &str, name: &str, id: &ObjectId) -> String {
476 format!("/{handle}/repos/{name}/commits/{}", id.as_str())
477}
478
479// --- Formatting -------------------------------------------------------------------
480
481/// How long ago, in as few characters as it can be said.
482///
483/// The long form `ago` gives — "3 months ago" — is the tooltip. On the row itself the
484/// date shares one line with a sha and a summary, and the summary is what deserves the
485/// space.
486fn short_ago(time: SystemTime) -> String {
487 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
488 // A commit carries whoever made it's clock, so a future timestamp is a thing
489 // that happens rather than a thing to render as a negative.
490 return "now".to_owned();
491 };
492
493 let seconds = elapsed.as_secs();
494
495 match seconds {
496 0..=59 => "now".to_owned(),
497 60..=3599 => format!("{}m", seconds / 60),
498 3600..=86_399 => format!("{}h", seconds / 3600),
499 86_400..=2_591_999 => format!("{}d", seconds / 86_400),
500 2_592_000..=31_535_999 => format!("{}mo", seconds / 2_592_000),
501 _ => format!("{}y", seconds / 31_536_000),
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use std::time::Duration;
508
509 use super::*;
510
511 fn rev(value: &str) -> RefName {
512 RefName::new(value).expect("valid revision")
513 }
514
515 #[test]
516 fn a_blame_url_mirrors_the_tree_url_it_is_reached_from() {
517 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
518
519 assert_eq!(
520 blame_url("ada", "steid", &rev("main"), &path),
521 "/ada/repos/steid/blame/main/-/src/domain/repo.rs"
522 );
523 }
524
525 #[test]
526 fn a_revisions_slashes_stay_inside_one_segment() {
527 // Otherwise `feature/login` would look like a revision plus a path, which is the
528 // ambiguity the `/-/` separator exists to remove.
529 let path = RepoPath::new("README.md").expect("valid");
530
531 assert_eq!(
532 blame_url("ada", "steid", &rev("feature/login"), &path),
533 "/ada/repos/steid/blame/feature%2Flogin/-/README.md"
534 );
535 }
536
537 #[test]
538 fn a_commit_url_carries_the_whole_id() {
539 // Never the abbreviation: it is unambiguous today and need not stay so.
540 let id = ObjectId::from_trusted("0123456789abcdef0123456789abcdef01234567");
541
542 assert_eq!(
543 commit_url("ada", "steid", &id),
544 "/ada/repos/steid/commits/0123456789abcdef0123456789abcdef01234567"
545 );
546 }
547
548 #[test]
549 fn a_relative_date_fits_beside_a_sha() {
550 let now = SystemTime::now();
551 let since = |seconds| short_ago(now - Duration::from_secs(seconds));
552
553 assert_eq!(since(5), "now");
554 assert_eq!(since(600), "10m");
555 assert_eq!(since(7200), "2h");
556 assert_eq!(since(86_400 * 3), "3d");
557 assert_eq!(since(86_400 * 70), "2mo");
558 assert_eq!(since(86_400 * 400), "1y");
559 }
560
561 #[test]
562 fn a_commit_from_the_future_reads_as_now() {
563 assert_eq!(
564 short_ago(SystemTime::now() + Duration::from_secs(3600)),
565 "now"
566 );
567 }
568}