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},
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.
e141fa6fix: the commit column takes half an 800px window, so it narrows there19h
250///
251/// A fixed width, narrower on a small viewport: the code beside it is what the reader
252/// came for, and on an 800px window a 20rem column of attribution would take half of
253/// 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
254#[component]
255async fn commit_cell(handle: &str, name: &str, path: &RepoPath, group: &BlameGroup) -> Result {
256 let commit = &group.commit;
257 // git names the file once per commit, so an empty one is a run it said nothing
258 // about rather than a rename from nowhere.
259 let moved = !commit.filename.is_empty() && commit.filename != path.as_str();
260
261 let mut about = format!(
262 "{} · {} · {}",
263 commit.author_name,
264 ago(commit.authored_at),
265 commit.summary,
266 );
267
268 // A boundary commit is where git stopped walking, so the lines it holds may be
269 // older than it is. Worth saying, not worth a mark on a row that has to stay one
270 // line tall.
271 if commit.boundary {
272 about.push_str(" · the oldest commit blame reached");
273 }
274
275 view! {
e141fa6fix: the commit column takes half an 800px window, so it narrows there19h
276 <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
277 <a
278 href=(commit_url(handle, name, &commit.id))
279 title=(about.as_str())
280 class="shrink-0 text-muted-foreground hover:text-foreground"
281 >(commit.id.short())</a>
282
283 if moved {
284 <span
285 class="shrink-0 self-center text-muted-foreground"
286 title=(format!("Moved from {}", commit.filename))
287 >
288 icon(
289 data: iconify_icon!("feather:corner-down-right"),
290 label: "Moved from another file",
291 attrs: attributes! { class="size-3" },
292 )
293 </span>
294 }
295
296 <span class="min-w-0 flex-1 truncate text-muted-foreground" title=(about.as_str())>
297 (&commit.summary)
298 </span>
299 <span class="shrink-0 text-muted-foreground">(short_ago(commit.authored_at))</span>
300 </div>
301 }
302}
303
304// --- The shared file header -------------------------------------------------------
305
306/// Which view of a file is being looked at.
307///
308/// Raw is not a variant: it downloads rather than displays, so it is never the view you
309/// are on.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub(super) enum FileTab {
312 Code,
313 Blame,
314}
315
316/// `Code · Blame · Raw`, in the file header of both views.
317///
318/// The active one is marked with the primary colour, which is the same rule the
319/// repository's tab strip follows: the primary colour says where you are, and nothing
320/// else on the header is coloured.
321#[component]
322pub(super) async fn view_toggle(
323 handle: &str,
324 name: &str,
325 rev: &RefName,
326 path: &RepoPath,
327 active: FileTab,
328) -> Result {
329 let item = |current| {
330 if current {
331 "text-primary"
332 } else {
333 "text-muted-foreground hover:text-foreground"
334 }
335 };
336
337 view! {
338 <span class="flex items-center gap-1.5">
339 <a
340 href=(tree_url(handle, name, rev, path))
341 class=(item(active == FileTab::Code))
342 >"Code"</a>
343 <span class="text-border">"·"</span>
344 <a
345 href=(blame_url(handle, name, rev, path))
346 class=(item(active == FileTab::Blame))
347 >"Blame"</a>
348 <span class="text-border">"·"</span>
349 // The way out for anything neither view can show — a binary, an oversized
350 // file — and the URL to hand to `curl`.
351 <a
352 href=(raw_url(handle, name, rev, path))
353 class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
354 >
355 icon(data: iconify_icon!("feather:download"), attrs: attributes! {
356 class="size-3.5"
357 })
358 "Raw"
359 </a>
360 </span>
361 }
362}
363
364// --- URLs -------------------------------------------------------------------------
365
366/// Blame's URL for a path at a revision. Shaped exactly like the tree's, and encoded
367/// the same way — the revision whole, the path with its slashes intact.
368pub(super) fn blame_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
369 format!(
370 "/{handle}/repos/{name}/blame/{}/-/{}",
371 encode(rev.as_str(), false),
372 encode(path.as_str(), true)
373 )
374}
375
376/// Where a commit's own page is.
377///
378/// Rendered before that page exists, by agreement: the sha is the most useful thing on
379/// a blame row and a sha with nowhere to go is the least.
380fn commit_url(handle: &str, name: &str, id: &ObjectId) -> String {
381 format!("/{handle}/repos/{name}/commits/{}", id.as_str())
382}
383
384// --- Formatting -------------------------------------------------------------------
385
386/// How long ago, in as few characters as it can be said.
387///
388/// The long form `ago` gives — "3 months ago" — is the tooltip. On the row itself the
389/// date shares one line with a sha and a summary, and the summary is what deserves the
390/// space.
391fn short_ago(time: SystemTime) -> String {
392 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
393 // A commit carries whoever made it's clock, so a future timestamp is a thing
394 // that happens rather than a thing to render as a negative.
395 return "now".to_owned();
396 };
397
398 let seconds = elapsed.as_secs();
399
400 match seconds {
401 0..=59 => "now".to_owned(),
402 60..=3599 => format!("{}m", seconds / 60),
403 3600..=86_399 => format!("{}h", seconds / 3600),
404 86_400..=2_591_999 => format!("{}d", seconds / 86_400),
405 2_592_000..=31_535_999 => format!("{}mo", seconds / 2_592_000),
406 _ => format!("{}y", seconds / 31_536_000),
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use std::time::Duration;
413
414 use super::*;
415
416 fn rev(value: &str) -> RefName {
417 RefName::new(value).expect("valid revision")
418 }
419
420 #[test]
421 fn a_blame_url_mirrors_the_tree_url_it_is_reached_from() {
422 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
423
424 assert_eq!(
425 blame_url("ada", "steid", &rev("main"), &path),
426 "/ada/repos/steid/blame/main/-/src/domain/repo.rs"
427 );
428 }
429
430 #[test]
431 fn a_revisions_slashes_stay_inside_one_segment() {
432 // Otherwise `feature/login` would look like a revision plus a path, which is the
433 // ambiguity the `/-/` separator exists to remove.
434 let path = RepoPath::new("README.md").expect("valid");
435
436 assert_eq!(
437 blame_url("ada", "steid", &rev("feature/login"), &path),
438 "/ada/repos/steid/blame/feature%2Flogin/-/README.md"
439 );
440 }
441
442 #[test]
443 fn a_commit_url_carries_the_whole_id() {
444 // Never the abbreviation: it is unambiguous today and need not stay so.
445 let id = ObjectId::from_trusted("0123456789abcdef0123456789abcdef01234567");
446
447 assert_eq!(
448 commit_url("ada", "steid", &id),
449 "/ada/repos/steid/commits/0123456789abcdef0123456789abcdef01234567"
450 );
451 }
452
453 #[test]
454 fn a_relative_date_fits_beside_a_sha() {
455 let now = SystemTime::now();
456 let since = |seconds| short_ago(now - Duration::from_secs(seconds));
457
458 assert_eq!(since(5), "now");
459 assert_eq!(since(600), "10m");
460 assert_eq!(since(7200), "2h");
461 assert_eq!(since(86_400 * 3), "3d");
462 assert_eq!(since(86_400 * 70), "2mo");
463 assert_eq!(since(86_400 * 400), "1y");
464 }
465
466 #[test]
467 fn a_commit_from_the_future_reads_as_now() {
468 assert_eq!(
469 short_ago(SystemTime::now() + Duration::from_secs(3600)),
470 "now"
471 );
472 }
473}