steid

@jamesgill /

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