steid

@jamesgill /

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