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::{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]
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()),
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]
166async 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]
177async 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]
199async 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]
251async 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)]
307pub(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]
318pub(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.
364pub(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.
376fn 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.
387fn 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)]
407mod 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}