| | |
| 146 | 146 | // ── The page shell ──────────────────────────────────────── |
| 147 | 147 | |
| 148 | 148 | fn page(who: Option<&str>, title: &str, crumbs: &[(String, String)], body: &str) -> Response { |
| 149 | | let day = twig::today(); |
| 150 | | let mark = twig::svg(day, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40)); |
| 149 | let mark = twig::svg(twig::MARK, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40)); |
| 151 | 150 | let mut trail = String::new(); |
| 152 | 151 | for (i, (href, label)) in crumbs.iter().enumerate() { |
| 153 | 152 | let _ = write!(trail, "{}", if i == 0 { "" } else { r#"<span class="sep">/</span>"# }); |
| | |
| 168 | 167 | <meta charset="utf-8"> |
| 169 | 168 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 170 | 169 | <title>{}</title> |
| 171 | | <link rel="icon" type="image/svg+xml" href="/favicon.svg?d={day}"> |
| 170 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| 172 | 171 | <link rel="stylesheet" href="/style.css?v={}"> |
| 173 | 172 | </head> |
| 174 | 173 | <body> |
| | |
| 255 | 254 | let segs: Vec<&str> = segs.iter().map(String::as_str).collect(); |
| 256 | 255 | let now = now_ms(); |
| 257 | 256 | match segs.as_slice() { |
| 258 | | [] => home(snap, now), |
| 257 | [] => home(snap, objects, now), |
| 259 | 258 | ["style.css"] => Response::with(200, "text/css; charset=utf-8", STYLE.as_bytes().to_vec()).header("Cache-Control", "public, max-age=31536000, immutable"), |
| 260 | 259 | ["favicon.svg"] | ["favicon.ico"] => { |
| 261 | | let svg = twig::svg(twig::today(), true, "#4d8a78", "#d7e2dc", 6.0, None); |
| 260 | let svg = twig::svg(twig::MARK, true, "#4d8a78", "#d7e2dc", 6.0, None); |
| 262 | 261 | Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600") |
| 263 | 262 | } |
| 264 | 263 | ["mark.svg"] => { |
| 265 | | let svg = twig::svg(twig::today(), false, "#4d8a78", "#d7e2dc", twig::LINE_WIDTH, None); |
| 264 | let svg = twig::svg(twig::MARK, false, "#4d8a78", "#d7e2dc", twig::LINE_WIDTH, None); |
| 266 | 265 | Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600") |
| 267 | 266 | } |
| 268 | | [owner] => owner_page(snap, owner, now), |
| 267 | [owner] => owner_page(snap, owner, objects, now), |
| 269 | 268 | [o, n, rest @ ..] => { |
| 270 | 269 | let Some(p) = snap.projects.iter().find(|p| p.owner == *o && p.name == *n) else { |
| 271 | 270 | return not_found(snap.who(), &format!("no project {o}/{n}")); |
| | |
| 300 | 299 | p.points.last().map(|pt| pt.at).into_iter().chain(changes).max().unwrap_or(p.at) |
| 301 | 300 | } |
| 302 | 301 | |
| 303 | | fn tiles(list: &[&Project], show_owner: bool, now: i64) -> String { |
| 302 | // ── What a project says about itself ───────────────────── |
| 303 | // |
| 304 | // Both come from the files on main, so they change the way everything else does: a project's |
| 305 | // icon is `icon.svg` or `icon.png` at its root, and its description is the README's first |
| 306 | // paragraph. Without an icon, a project gets a twig grown from its name. |
| 307 | |
| 308 | const ICONS: [&str; 2] = ["icon.svg", "icon.png"]; |
| 309 | const READMES: [&str; 4] = ["README.md", "README", "readme.md", "README.txt"]; |
| 310 | |
| 311 | fn root_files(p: &Project, objects: &Objects) -> Vec<crate::objects::Entry> { |
| 312 | p.points.last().and_then(|pt| objects.get_tree(&pt.snapshot).ok()).unwrap_or_default() |
| 313 | } |
| 314 | |
| 315 | fn icon(p: &Project, objects: &Objects, size: u32) -> String { |
| 316 | let root = root_files(p, objects); |
| 317 | if let (Some(name), Some(pt)) = (ICONS.iter().find(|n| root.iter().any(|e| e.name == **n && e.kind != Kind::Dir)), p.points.last()) { |
| 318 | return format!(r#"<img class="icon" src="{}/raw/{}/{name}" width="{size}" height="{size}" alt="">"#, base_url(p), hex::encode(&pt.snapshot)); |
| 319 | } |
| 320 | let svg = twig::svg(twig::day_for(&format!("{}/{}", p.owner, p.name)), false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(size)); |
| 321 | format!(r#"<span class="icon">{svg}</span>"#) |
| 322 | } |
| 323 | |
| 324 | /// The README's first paragraph that isn't a heading, as plain text. |
| 325 | fn description(p: &Project, objects: &Objects) -> Option<String> { |
| 326 | let root = root_files(p, objects); |
| 327 | let readme = READMES.iter().find_map(|n| root.iter().find(|e| e.name == *n && e.kind != Kind::Dir))?; |
| 328 | let bytes = objects.get_blob(&readme.id).ok()?; |
| 329 | let text = text_of(&bytes)?; |
| 330 | let para: Vec<&str> = text |
| 331 | .split("\n\n") |
| 332 | .map(str::trim) |
| 333 | .find(|b| !b.is_empty() && !b.starts_with('#') && !b.starts_with("```") && !b.starts_with('<') && !b.starts_with("!["))? |
| 334 | .lines() |
| 335 | .map(str::trim) |
| 336 | .collect(); |
| 337 | let plain: String = para.join(" ").chars().filter(|c| !matches!(c, '*' | '`' | '_')).collect(); |
| 338 | Some(plain) |
| 339 | } |
| 340 | |
| 341 | fn tiles(list: &[&Project], show_owner: bool, objects: &Objects, now: i64) -> String { |
| 304 | 342 | let mut out = String::from(r#"<div class="tiles">"#); |
| 305 | 343 | for p in list { |
| 306 | 344 | let open = p.changes.iter().filter(|c| c.state.is_open()).count(); |
| 307 | | let latest = p.points.last().map(|pt| pt.message.lines().next().unwrap_or("").to_string()).unwrap_or_default(); |
| 308 | | let owner = if show_owner { format!(r#"<span class="faint">{}/</span>"#, esc(&p.owner)) } else { String::new() }; |
| 345 | let owner = if show_owner { format!(r#"<div class="tile-owner">{}</div>"#, esc(&p.owner)) } else { String::new() }; |
| 346 | let open = if open > 0 { format!(r#"<span class="tile-open">{}</span>"#, plural(open, "open change", "open changes")) } else { String::new() }; |
| 347 | let private = if p.public { "" } else { r#"<span class="tag">private</span>"# }; |
| 348 | let desc = description(p, objects).map(|d| format!(r#"<div class="tile-desc">{}</div>"#, esc(&d))).unwrap_or_default(); |
| 309 | 349 | let _ = write!( |
| 310 | 350 | out, |
| 311 | | r#"<a class="tile" href="{}"><div class="tile-name">{owner}{}</div><div class="tile-desc">{}</div><div class="tile-meta"><span>@{}</span><span>{}</span><span class="faint">{}</span></div></a>"#, |
| 351 | r#"<a class="tile" href="{}"><div class="tile-head">{}<div class="tile-title">{owner}<div class="tile-name">{}{private}</div></div></div>{desc}<div class="tile-meta">{open}<span>{}</span></div></a>"#, |
| 312 | 352 | base_url(p), |
| 353 | icon(p, objects, 44), |
| 313 | 354 | esc(&p.name), |
| 314 | | esc(&latest), |
| 315 | | p.tip(), |
| 316 | | if open > 0 { plural(open, "open change", "open changes") } else { "no open changes".into() }, |
| 317 | 355 | ago(last_activity(p), now), |
| 318 | 356 | ); |
| 319 | 357 | } |
| 320 | 358 | out + "</div>" |
| 321 | 359 | } |
| 322 | 360 | |
| 323 | | fn home(snap: &Snapshot, now: i64) -> Response { |
| 361 | fn home(snap: &Snapshot, objects: &Objects, now: i64) -> Response { |
| 324 | 362 | let mut list: Vec<&Project> = snap.projects.iter().collect(); |
| 325 | 363 | list.sort_by_key(|p| std::cmp::Reverse(last_activity(p))); |
| 326 | 364 | let body = if list.is_empty() { |
| 327 | 365 | r#"<div class="empty">No public projects yet.</div>"#.to_string() |
| 328 | 366 | } else { |
| 329 | | format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, now)) |
| 367 | format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, objects, now)) |
| 330 | 368 | }; |
| 331 | 369 | page(snap.who(), "grove", &[], &body) |
| 332 | 370 | } |
| 333 | 371 | |
| 334 | | fn owner_page(snap: &Snapshot, owner: &str, now: i64) -> Response { |
| 372 | fn owner_page(snap: &Snapshot, owner: &str, objects: &Objects, now: i64) -> Response { |
| 335 | 373 | if !snap.owners.iter().any(|o| o == owner) { |
| 336 | 374 | return not_found(snap.who(), &format!("no one called {owner}")); |
| 337 | 375 | } |
| | |
| 340 | 378 | let body = if list.is_empty() { |
| 341 | 379 | format!(r#"<h1 class="title">{}</h1><div class="empty">No public projects.</div>"#, esc(owner)) |
| 342 | 380 | } else { |
| 343 | | format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, now)) |
| 381 | format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, objects, now)) |
| 344 | 382 | }; |
| 345 | | page(snap.who(), &format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body) |
| 383 | page(snap.who(), &format!("{owner} · grove"), &[], &body) |
| 346 | 384 | } |
| 347 | 385 | |
| 348 | 386 | // ── A project ───────────────────────────────────────────── |
| | |
| 364 | 402 | |
| 365 | 403 | fn heat(counts: &[u32]) -> String { |
| 366 | 404 | let max = counts.iter().copied().max().unwrap_or(0).max(1); |
| 367 | | let mut out = String::from(r#"<div class="heat" title="activity, last 12 weeks">"#); |
| 405 | let mut out = String::from(r#"<div class="heat">"#); |
| 368 | 406 | for &c in counts { |
| 369 | 407 | let level = if c == 0 { 0 } else { 1 + (3 * c).div_ceil(max).min(3) }; |
| 370 | 408 | let _ = write!(out, r#"<i class="h{level}" title="{c}"></i>"#); |
| | |
| 375 | 413 | fn project_page(p: &Project, snap: &Snapshot, objects: &Objects, now: i64) -> Response { |
| 376 | 414 | let names = &snap.names; |
| 377 | 415 | let base = base_url(p); |
| 378 | | let count = |s: ChangeState| p.changes.iter().filter(|c| c.state == s).count(); |
| 379 | 416 | let open: Vec<&Change> = p.changes.iter().filter(|c| c.state.is_open()).collect(); |
| 380 | | let mut people: Vec<&str> = p.points.iter().map(|pt| pt.author.person.as_str()).chain(p.changes.iter().flat_map(|c| c.timeline.iter().map(|t| t.1.person.as_str()))).collect(); |
| 381 | | people.sort(); |
| 382 | | people.dedup(); |
| 383 | | let mut agents: Vec<&str> = p.points.iter().filter_map(|pt| pt.author.agent.as_deref()).chain(p.changes.iter().flat_map(|c| c.timeline.iter().filter_map(|t| t.1.agent.as_deref()))).collect(); |
| 384 | | agents.sort(); |
| 385 | | agents.dedup(); |
| 386 | | let files = p.points.last().and_then(|pt| objects.flatten(&pt.snapshot).ok()).map(|f| f.len()).unwrap_or(0); |
| 417 | let landed = p.changes.iter().filter(|c| c.state == ChangeState::Landed).count(); |
| 387 | 418 | |
| 388 | | let stat = |n: String, label: &str, href: Option<String>| match href { |
| 389 | | Some(h) => format!(r#"<a class="stat" href="{h}"><b>{n}</b><span>{label}</span></a>"#), |
| 390 | | None => format!(r#"<div class="stat"><b>{n}</b><span>{label}</span></div>"#), |
| 391 | | }; |
| 392 | 419 | let mut body = format!( |
| 393 | | r#"<div class="head"><h1 class="title">{}<span class="faint"> / </span>{}</h1>{}</div>"#, |
| 420 | r#"<div class="project-head">{}<div><a class="owner" href="/{}">{}</a><h1 class="title">{}{}</h1></div></div>"#, |
| 421 | icon(p, objects, 56), |
| 422 | enc(&p.owner), |
| 394 | 423 | esc(&p.owner), |
| 395 | 424 | esc(&p.name), |
| 396 | | if p.public { "" } else { r#"<span class="tag">private</span>"# } |
| 425 | if p.public { "" } else { r#" <span class="tag">private</span>"# } |
| 397 | 426 | ); |
| 398 | | body += r#"<div class="stats">"#; |
| 399 | | body += &stat(format!("@{}", p.tip()), "main", None); |
| 400 | | body += &stat(open.len().to_string(), "open", Some(format!("{base}/changes"))); |
| 401 | | body += &stat(count(ChangeState::Landed).to_string(), "landed", Some(format!("{base}/changes"))); |
| 402 | | body += &stat(count(ChangeState::Declined).to_string(), "declined", Some(format!("{base}/changes"))); |
| 403 | | body += &stat(people.len().to_string(), if people.len() == 1 { "person" } else { "people" }, None); |
| 404 | | body += &stat(agents.len().to_string(), if agents.len() == 1 { "agent" } else { "agents" }, None); |
| 405 | | body += &stat(files.to_string(), "files", Some(format!("{base}/files"))); |
| 406 | | body += &stat(ago(last_activity(p), now), "last activity", None); |
| 407 | | body += "</div>"; |
| 408 | | body += &heat(&activity(p, 84, now)); |
| 427 | if let Some(d) = description(p, objects) { |
| 428 | let _ = write!(body, r#"<p class="lede">{}</p>"#, esc(&d)); |
| 429 | } |
| 430 | let _ = write!( |
| 431 | body, |
| 432 | r#"<div class="facts"><a href="{base}/@{0}">main @{0}</a><a href="{base}/changes">{1}</a><a href="{base}/changes">{2} landed</a><a href="{base}/files">files</a><span>active {3}</span></div>"#, |
| 433 | p.tip(), |
| 434 | plural(open.len(), "open change", "open changes"), |
| 435 | landed, |
| 436 | ago(last_activity(p), now), |
| 437 | ); |
| 409 | 438 | |
| 410 | | body += r#"<div class="cols"><section><h2>Main</h2><div class="list">"#; |
| 439 | if !open.is_empty() { |
| 440 | body += "<section><h2>Open changes</h2>"; |
| 441 | body += &change_list(&open, &base, names, now, ""); |
| 442 | body += "</section>"; |
| 443 | } |
| 444 | |
| 445 | let _ = write!(body, r#"<section><div class="section-head"><h2>Main</h2><div class="activity">{}<span>last 12 weeks</span></div></div><div class="list">"#, heat(&activity(p, 84, now))); |
| 411 | 446 | if p.points.is_empty() { |
| 412 | 447 | body += r#"<div class="empty">Nothing on main yet.</div>"#; |
| 413 | 448 | } |
| 414 | | for pt in p.points.iter().rev().take(60) { |
| 415 | | let change = pt.change.map(|c| format!(r##"<a class="faint" href="{base}/changes/{c}">#{c}</a>"##)).unwrap_or_default(); |
| 449 | for pt in p.points.iter().rev().take(30) { |
| 450 | let change = pt.change.map(|c| format!(r##" · <a href="{base}/changes/{c}">#{c}</a>"##)).unwrap_or_default(); |
| 416 | 451 | let _ = write!( |
| 417 | 452 | body, |
| 418 | | r#"<div class="row"><a class="n" href="{base}/@{}">@{}</a><div class="grow"><div class="msg"><a href="{base}/@{}">{}</a> {change}</div><div class="meta">{} · {}</div></div></div>"#, |
| 453 | r#"<div class="row"><a class="n" href="{base}/@{0}">@{0}</a><div class="grow msg"><a href="{base}/@{0}">{1}</a></div><div class="meta side">{2}{change} · {3}</div></div>"#, |
| 419 | 454 | pt.n, |
| 420 | | pt.n, |
| 421 | | pt.n, |
| 422 | 455 | esc(pt.message.lines().next().unwrap_or("")), |
| 423 | 456 | names.actor(&pt.author), |
| 424 | 457 | when(pt.at, now) |
| 425 | 458 | ); |
| 426 | 459 | } |
| 427 | | body += "</div></section><section><h2>Open changes</h2>"; |
| 428 | | body += &change_list(&open, &base, names, now, "No open changes."); |
| 429 | | body += "</section></div>"; |
| 430 | | page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &project_crumbs(p), &body) |
| 460 | body += "</div></section>"; |
| 461 | page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &[], &body) |
| 431 | 462 | } |
| 432 | 463 | |
| 433 | 464 | fn change_list(list: &[&Change], base: &str, names: &Names, now: i64, empty: &str) -> String { |
| | |
| 468 | 499 | } |
| 469 | 500 | let _ = write!(body, "<section><h2>{title} <span class=\"faint\">{}</span></h2>{}</section>", list.len(), change_list(&list, &base, names, now, "No open changes.")); |
| 470 | 501 | } |
| 471 | | let mut crumbs = project_crumbs(p); |
| 472 | | crumbs.push((String::new(), "changes".into())); |
| 502 | let crumbs = project_crumbs(p); |
| 473 | 503 | page(snap.who(), &format!("changes · {}/{} · grove", p.owner, p.name), &crumbs, &body) |
| 474 | 504 | } |
| 475 | 505 | |
| | |
| 725 | 755 | |
| 726 | 756 | let mut crumbs = project_crumbs(p); |
| 727 | 757 | crumbs.push((format!("{base}/changes"), "changes".into())); |
| 728 | | crumbs.push((String::new(), format!("#{}", c.number))); |
| 729 | 758 | page(snap.who(), &format!("#{} {} · {}/{}", c.number, c.intent.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 730 | 759 | } |
| 731 | 760 | |
| | |
| 758 | 787 | body += "</nav><section><h2>Files</h2>"; |
| 759 | 788 | body += &diff_view(objects, prev.as_ref(), &pt.snapshot, &format!("{base}/raw/{}", hex::encode(&pt.snapshot))); |
| 760 | 789 | body += "</section>"; |
| 761 | | let mut crumbs = project_crumbs(p); |
| 762 | | crumbs.push((String::new(), format!("@{n}"))); |
| 790 | let crumbs = project_crumbs(p); |
| 763 | 791 | page(snap.who(), &format!("@{n} {} · {}/{}", pt.message.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 764 | 792 | } |
| 765 | 793 | |
| | |
| 829 | 857 | let _ = write!(body, r#"<div class="empty">Binary or large file, {} bytes. <a href="{raw}">Download</a>.</div>"#, bytes.len()); |
| 830 | 858 | } |
| 831 | 859 | } |
| 832 | | let mut crumbs = project_crumbs(p); |
| 833 | | crumbs.push((String::new(), "files".into())); |
| 860 | let crumbs = project_crumbs(p); |
| 834 | 861 | page(who, &format!("{} · {}/{}", if path.is_empty() { "files" } else { path }, p.owner, p.name), &crumbs, &body) |
| 835 | 862 | } |
| 836 | 863 | |
| | |
| 891 | 918 | assert_eq!(decode("a%20b%2Fc"), "a b/c"); |
| 892 | 919 | assert_eq!(decode("100%"), "100%"); |
| 893 | 920 | assert_eq!(enc_path("src/a b.rs"), "src/a%20b.rs"); |
| 921 | } |
| 922 | |
| 923 | #[test] |
| 924 | fn description_is_the_first_plain_paragraph() { |
| 925 | let dir = crate::testdir::TestDir::new("web-desc"); |
| 926 | let objects = Objects::open(dir.path()).unwrap(); |
| 927 | let readme = objects.put_blob(b"# grove\n\n<p>badge</p>\n\nWhere *agents*\nbuild `software`.\n\nMore.\n").unwrap(); |
| 928 | let tree = objects.put_tree(&[crate::objects::Entry { name: "README.md".into(), kind: Kind::File, id: readme }]).unwrap(); |
| 929 | let mut p = Project { owner: "o".into(), name: "n".into(), public: true, points: Vec::new(), changes: Vec::new(), contributors: Vec::new(), at: 0 }; |
| 930 | assert_eq!(description(&p, &objects), None); |
| 931 | p.points.push(crate::model::Point { n: 1, snapshot: tree, message: "m".into(), author: Actor::person("o"), landed_by: Actor::person("o"), change: None, step: None, at: 0 }); |
| 932 | assert_eq!(description(&p, &objects).as_deref(), Some("Where agents build software.")); |
| 933 | assert!(icon(&p, &objects, 44).contains("<svg")); |
| 894 | 934 | } |
| 895 | 935 | |
| 896 | 936 | #[test] |