grove
at @13
1//! The web interface: HTML pages rendered on the server, one request each, no scripts.
2//!
3//! What a person looks at to judge work: a project's main line, its changes with their
4//! revisions, diffs, evidence and discussion, and the files at any point. Only public projects
5//! are shown for now; signing in on the web comes later.
6
7use crate::diff::{self, Op};
8use crate::http::{Request, Response};
9use crate::model::{Actor, Change, ChangeState, Event, Project, State};
10use crate::objects::{Id, Kind, Objects};
11use crate::{hex, twig};
12use std::collections::HashMap;
13use std::fmt::Write as _;
14
15const STYLE: &str = include_str!("web/style.css");
16/// Files larger than this aren't diffed or shown inline.
17const MAX_TEXT: usize = 1 << 20;
18const CONTEXT: usize = 3;
19
20pub fn esc(s: &str) -> String {
21 let mut out = String::with_capacity(s.len());
22 for c in s.chars() {
23 match c {
24 '&' => out.push_str("&amp;"),
25 '<' => out.push_str("&lt;"),
26 '>' => out.push_str("&gt;"),
27 '"' => out.push_str("&quot;"),
28 '\'' => out.push_str("&#39;"),
29 c => out.push(c),
30 }
31 }
32 out
33}
34
35/// A path segment or query value, percent-encoded.
36pub fn enc(s: &str) -> String {
37 let mut out = String::new();
38 for b in s.bytes() {
39 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
40 out.push(b as char);
41 } else {
42 let _ = write!(out, "%{b:02X}");
43 }
44 }
45 out
46}
47
48/// A path, each segment encoded.
49fn enc_path(p: &str) -> String {
50 p.split('/').map(enc).collect::<Vec<_>>().join("/")
51}
52
53pub fn decode(s: &str) -> String {
54 let b = s.as_bytes();
55 let mut out = Vec::with_capacity(b.len());
56 let mut i = 0;
57 while i < b.len() {
58 if b[i] == b'%' && i + 2 < b.len() {
59 if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
60 out.push(v);
61 i += 3;
62 continue;
63 }
64 }
65 out.push(if b[i] == b'+' { b' ' } else { b[i] });
66 i += 1;
67 }
68 String::from_utf8_lossy(&out).into_owned()
69}
70
71/// A value from a query string or a form body (`a=1&b=2`), still encoded.
72pub fn param<'a>(pairs: &'a str, key: &str) -> Option<&'a str> {
73 pairs.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v))
74}
75
76fn query<'a>(req: &'a Request, key: &str) -> Option<&'a str> {
77 param(&req.query, key)
78}
79
80fn now_ms() -> i64 {
81 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
82}
83
84/// "3m ago", "2d ago".
85fn ago(at: i64, now: i64) -> String {
86 let s = ((now - at) / 1000).max(0);
87 match s {
88 0..=59 => "just now".into(),
89 60..=3599 => format!("{}m ago", s / 60),
90 3600..=86_399 => format!("{}h ago", s / 3600),
91 86_400..=2_591_999 => format!("{}d ago", s / 86_400),
92 _ => date(at),
93 }
94}
95
96/// Days since 1970-01-01 to (year, month, day). Howard Hinnant's civil_from_days.
97fn civil(days: i64) -> (i64, u32, u32) {
98 let z = days + 719_468;
99 let era = z.div_euclid(146_097);
100 let doe = z - era * 146_097;
101 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
102 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
103 let mp = (5 * doy + 2) / 153;
104 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
105 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
106 (yoe + era * 400 + i64::from(m <= 2), m, d)
107}
108
109fn date(at: i64) -> String {
110 let (y, m, d) = civil(at.div_euclid(86_400_000));
111 format!("{y}-{m:02}-{d:02}")
112}
113
114fn when(at: i64, now: i64) -> String {
115 let secs = at.div_euclid(1000);
116 let (h, mi) = (secs.rem_euclid(86_400) / 3600, secs.rem_euclid(3600) / 60);
117 format!(r#"<time title="{} {h:02}:{mi:02} UTC">{}</time>"#, date(at), ago(at, now))
118}
119
120fn plural(n: usize, one: &str, many: &str) -> String {
121 format!("{n} {}", if n == 1 { one } else { many })
122}
123
124/// Agent names by id, so actors read "claude for anton".
125struct Names(HashMap<String, String>);
126
127impl Names {
128 fn of(state: &State) -> Names {
129 Names(state.agents.iter().map(|a| (a.id.clone(), a.name.clone())).collect())
130 }
131 fn actor(&self, a: &Actor) -> String {
132 match &a.agent {
133 Some(id) => {
134 let name = self.0.get(id).map(String::as_str).unwrap_or(id);
135 format!(r#"<span class="who"><span class="agent">{}</span> for <a href="/{}">{}</a></span>"#, esc(name), enc(&a.person), esc(&a.person))
136 }
137 None => format!(r#"<span class="who"><a href="/{}">{}</a></span>"#, enc(&a.person), esc(&a.person)),
138 }
139 }
140}
141
142fn state_tag(s: ChangeState) -> String {
143 format!(r#"<span class="state {0}">{0}</span>"#, s.name())
144}
145
146// ── The page shell ────────────────────────────────────────
147
148fn page(who: Option<&str>, title: &str, crumbs: &[(String, String)], body: &str) -> Response {
149 let mark = twig::svg(twig::MARK, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40));
150 let mut trail = String::new();
151 for (i, (href, label)) in crumbs.iter().enumerate() {
152 let _ = write!(trail, "{}", if i == 0 { "" } else { r#"<span class="sep">/</span>"# });
153 if href.is_empty() {
154 let _ = write!(trail, "<span>{}</span>", esc(label));
155 } else {
156 let _ = write!(trail, r#"<a href="{href}">{}</a>"#, esc(label));
157 }
158 }
159 let account = match who {
160 Some(w) => format!(r#"<form class="account" method="post" action="/signout"><a href="/{}">{}</a><button>sign out</button></form>"#, enc(w), esc(w)),
161 None => String::new(),
162 };
163 let html = format!(
164 r#"<!doctype html>
165<html lang="en">
166<head>
167<meta charset="utf-8">
168<meta name="viewport" content="width=device-width, initial-scale=1">
169<title>{}</title>
170<link rel="icon" type="image/svg+xml" href="/favicon.svg">
171<link rel="stylesheet" href="/style.css?v={}">
172</head>
173<body>
174<header class="top"><div class="wrap"><a class="brand" href="/">{mark}<span>grove</span></a><nav class="crumbs">{trail}</nav>{account}</div></header>
175<main class="wrap">
176{body}
177</main>
178</body>
179</html>
180"#,
181 esc(title),
182 &hex::encode(&crate::blake3::hash(STYLE.as_bytes()))[..12],
183 );
184 Response::html(200, html)
185}
186
187fn not_found(who: Option<&str>, what: &str) -> Response {
188 let mut r = page(who, "not found · grove", &[], &format!(r#"<div class="empty">{}</div>"#, esc(what)));
189 r.status = 404;
190 r
191}
192
193fn project_crumbs(p: &Project) -> Vec<(String, String)> {
194 vec![(format!("/{}", enc(&p.owner)), p.owner.clone()), (format!("/{}/{}", enc(&p.owner), enc(&p.name)), p.name.clone())]
195}
196
197fn base_url(p: &Project) -> String {
198 format!("/{}/{}", enc(&p.owner), enc(&p.name))
199}
200
201// ── Routing ───────────────────────────────────────────────
202
203/// What a page needs from the store, copied out so rendering doesn't hold the store's lock.
204pub struct Snapshot {
205 pub projects: Vec<Project>,
206 names: Names,
207 owners: Vec<String>,
208 /// The signed-in person, if any.
209 viewer: Option<String>,
210 /// Of `projects`, the ones the viewer owns, and the ones they can contribute to.
211 owned: Vec<(String, String)>,
212 contributing: Vec<(String, String)>,
213}
214
215impl Snapshot {
216 /// Public projects, and the private ones the viewer owns or contributes to.
217 pub fn of(state: &State, only: Option<(&str, &str)>, viewer: Option<&str>) -> Snapshot {
218 let can_read = |p: &Project| p.public || viewer.is_some_and(|v| state.can_contribute(&p.owner, &p.name, v));
219 let projects: Vec<Project> =
220 state.projects.iter().filter(|p| only.is_none_or(|(o, n)| p.owner == o && p.name == n) && can_read(p)).cloned().collect();
221 let key = |p: &Project| (p.owner.clone(), p.name.clone());
222 let (owned, contributing) = match viewer {
223 Some(v) => (
224 projects.iter().filter(|p| state.owns(&p.owner, v)).map(key).collect(),
225 projects.iter().filter(|p| state.can_contribute(&p.owner, &p.name, v)).map(key).collect(),
226 ),
227 None => (Vec::new(), Vec::new()),
228 };
229 let owners = state.people.iter().map(|p| p.username.clone()).chain(state.groups.iter().map(|g| g.name.clone())).collect();
230 Snapshot { projects, names: Names::of(state), owners, viewer: viewer.map(str::to_string), owned, contributing }
231 }
232 fn who(&self) -> Option<&str> {
233 self.viewer.as_deref()
234 }
235 fn owns(&self, p: &Project) -> bool {
236 self.owned.iter().any(|(o, n)| *o == p.owner && *n == p.name)
237 }
238 fn contributes(&self, p: &Project) -> bool {
239 self.contributing.iter().any(|(o, n)| *o == p.owner && *n == p.name)
240 }
241}
242
243/// Which slice of the state a path needs: one project, or the list.
244pub fn wants(path: &str) -> Option<(String, String)> {
245 let segs: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
246 match segs.as_slice() {
247 [o, n, ..] => Some((decode(o), decode(n))),
248 _ => None,
249 }
250}
251
252pub fn handle(req: &Request, snap: &Snapshot, objects: &Objects) -> Response {
253 let segs: Vec<String> = req.path.trim_matches('/').split('/').filter(|s| !s.is_empty()).map(decode).collect();
254 let segs: Vec<&str> = segs.iter().map(String::as_str).collect();
255 let now = now_ms();
256 match segs.as_slice() {
257 [] => home(snap, objects, now),
258 ["style.css"] => Response::with(200, "text/css; charset=utf-8", STYLE.as_bytes().to_vec()).header("Cache-Control", "public, max-age=31536000, immutable"),
259 ["favicon.svg"] | ["favicon.ico"] => {
260 let svg = twig::svg(twig::MARK, true, "#4d8a78", "#d7e2dc", 6.0, None);
261 Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600")
262 }
263 ["mark.svg"] => {
264 let svg = twig::svg(twig::MARK, false, "#4d8a78", "#d7e2dc", twig::LINE_WIDTH, None);
265 Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600")
266 }
267 [owner] => owner_page(snap, owner, objects, now),
268 [o, n, rest @ ..] => {
269 let Some(p) = snap.projects.iter().find(|p| p.owner == *o && p.name == *n) else {
270 return not_found(snap.who(), &format!("no project {o}/{n}"));
271 };
272 match rest {
273 [] => project_page(p, snap, objects, now),
274 ["changes"] => changes_page(p, snap, now),
275 ["changes", c] => match c.parse::<u64>().ok().and_then(|c| p.change(c)) {
276 Some(c) => change_page(p, c, snap, objects, query(req, "r").and_then(|r| r.parse().ok()), query(req, "e").map(decode).as_deref(), now),
277 None => not_found(snap.who(), &format!("no change #{c} in {o}/{n}")),
278 },
279 [at] if at.starts_with('@') => match at[1..].parse::<u64>().ok().and_then(|n| p.point(n)) {
280 Some(pt) => point_page(p, pt.n, snap, objects, now),
281 None => not_found(snap.who(), &format!("no point {at} in {o}/{n}")),
282 },
283 ["files", path @ ..] => {
284 let at = query(req, "at").and_then(|a| a.trim_start_matches('@').parse().ok()).unwrap_or(p.tip());
285 files_page(p, at, &path.join("/"), objects, snap)
286 }
287 ["raw", snap_hex, path @ ..] => raw(p, snap_hex, &path.join("/"), objects),
288 ["evidence", blob] => evidence(p, blob, objects),
289 _ => not_found(snap.who(), "no such page"),
290 }
291 }
292 }
293}
294
295// ── Home and owners ───────────────────────────────────────
296
297fn last_activity(p: &Project) -> i64 {
298 let changes = p.changes.iter().flat_map(|c| c.timeline.last().map(|t| t.0));
299 p.points.last().map(|pt| pt.at).into_iter().chain(changes).max().unwrap_or(p.at)
300}
301
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
308const ICONS: [&str; 2] = ["icon.svg", "icon.png"];
309const READMES: [&str; 4] = ["README.md", "README", "readme.md", "README.txt"];
310
311fn 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
315fn 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.
325fn 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
341fn tiles(list: &[&Project], show_owner: bool, objects: &Objects, now: i64) -> String {
342 let mut out = String::from(r#"<div class="tiles">"#);
343 for p in list {
344 let open = p.changes.iter().filter(|c| c.state.is_open()).count();
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();
349 let _ = write!(
350 out,
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>"#,
352 base_url(p),
353 icon(p, objects, 44),
354 esc(&p.name),
355 ago(last_activity(p), now),
356 );
357 }
358 out + "</div>"
359}
360
361fn home(snap: &Snapshot, objects: &Objects, now: i64) -> Response {
362 let mut list: Vec<&Project> = snap.projects.iter().collect();
363 list.sort_by_key(|p| std::cmp::Reverse(last_activity(p)));
364 let body = if list.is_empty() {
365 r#"<div class="empty">No public projects yet.</div>"#.to_string()
366 } else {
367 format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, objects, now))
368 };
369 page(snap.who(), "grove", &[], &body)
370}
371
372fn owner_page(snap: &Snapshot, owner: &str, objects: &Objects, now: i64) -> Response {
373 if !snap.owners.iter().any(|o| o == owner) {
374 return not_found(snap.who(), &format!("no one called {owner}"));
375 }
376 let mut list: Vec<&Project> = snap.projects.iter().filter(|p| p.owner == owner).collect();
377 list.sort_by_key(|p| std::cmp::Reverse(last_activity(p)));
378 let body = if list.is_empty() {
379 format!(r#"<h1 class="title">{}</h1><div class="empty">No public projects.</div>"#, esc(owner))
380 } else {
381 format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, objects, now))
382 };
383 page(snap.who(), &format!("{owner} · grove"), &[], &body)
384}
385
386// ── A project ─────────────────────────────────────────────
387
388/// Activity per day for the last `days` days: points landed and things done on changes.
389fn activity(p: &Project, days: i64, now: i64) -> Vec<u32> {
390 let today = now.div_euclid(86_400_000);
391 let mut counts = vec![0u32; days as usize];
392 let mut add = |at: i64| {
393 let d = today - at.div_euclid(86_400_000);
394 if (0..days).contains(&d) {
395 counts[(days - 1 - d) as usize] += 1;
396 }
397 };
398 p.points.iter().for_each(|pt| add(pt.at));
399 p.changes.iter().flat_map(|c| &c.timeline).for_each(|t| add(t.0));
400 counts
401}
402
403fn heat(counts: &[u32]) -> String {
404 let max = counts.iter().copied().max().unwrap_or(0).max(1);
405 let mut out = String::from(r#"<div class="heat">"#);
406 for &c in counts {
407 let level = if c == 0 { 0 } else { 1 + (3 * c).div_ceil(max).min(3) };
408 let _ = write!(out, r#"<i class="h{level}" title="{c}"></i>"#);
409 }
410 out + "</div>"
411}
412
413fn project_page(p: &Project, snap: &Snapshot, objects: &Objects, now: i64) -> Response {
414 let names = &snap.names;
415 let base = base_url(p);
416 let open: Vec<&Change> = p.changes.iter().filter(|c| c.state.is_open()).collect();
417 let landed = p.changes.iter().filter(|c| c.state == ChangeState::Landed).count();
418
419 let mut body = format!(
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),
423 esc(&p.owner),
424 esc(&p.name),
425 if p.public { "" } else { r#" <span class="tag">private</span>"# }
426 );
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 );
438
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)));
446 if p.points.is_empty() {
447 body += r#"<div class="empty">Nothing on main yet.</div>"#;
448 }
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();
451 let _ = write!(
452 body,
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>"#,
454 pt.n,
455 esc(pt.message.lines().next().unwrap_or("")),
456 names.actor(&pt.author),
457 when(pt.at, now)
458 );
459 }
460 body += "</div></section>";
461 page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &[], &body)
462}
463
464fn change_list(list: &[&Change], base: &str, names: &Names, now: i64, empty: &str) -> String {
465 if list.is_empty() {
466 return format!(r#"<div class="list"><div class="empty">{empty}</div></div>"#);
467 }
468 let mut out = String::from(r#"<div class="list">"#);
469 for c in list {
470 let updated = c.timeline.last().map(|t| t.0).unwrap_or(c.at);
471 let _ = write!(
472 out,
473 r#"<div class="row"><a class="n" href="{base}/changes/{}">#{}</a><div class="grow"><div class="msg"><a href="{base}/changes/{}">{}</a></div><div class="meta">{} · {} · {} · {}</div></div></div>"#,
474 c.number,
475 c.number,
476 c.number,
477 esc(c.intent.lines().next().unwrap_or("")),
478 state_tag(c.state),
479 names.actor(&c.proposer),
480 plural(c.revisions.len(), "revision", "revisions"),
481 when(updated, now)
482 );
483 }
484 out + "</div>"
485}
486
487fn changes_page(p: &Project, snap: &Snapshot, now: i64) -> Response {
488 let names = &snap.names;
489 let base = base_url(p);
490 let mut body = format!(r#"<h1 class="title">Changes</h1>"#);
491 for (title, states) in [
492 ("Open", &[ChangeState::Proposed, ChangeState::Draft, ChangeState::Idea][..]),
493 ("Landed", &[ChangeState::Landed][..]),
494 ("Declined and withdrawn", &[ChangeState::Declined, ChangeState::Withdrawn][..]),
495 ] {
496 let list: Vec<&Change> = p.changes.iter().rev().filter(|c| states.contains(&c.state)).collect();
497 if list.is_empty() && title != "Open" {
498 continue;
499 }
500 let _ = write!(body, "<section><h2>{title} <span class=\"faint\">{}</span></h2>{}</section>", list.len(), change_list(&list, &base, names, now, "No open changes."));
501 }
502 let crumbs = project_crumbs(p);
503 page(snap.who(), &format!("changes · {}/{} · grove", p.owner, p.name), &crumbs, &body)
504}
505
506// ── Diffs ─────────────────────────────────────────────────
507
508enum FileChange {
509 Added(Id),
510 Deleted(Id),
511 Modified(Id, Id),
512}
513
514/// Files that differ between two snapshots, by path.
515fn compare(objects: &Objects, old: Option<&Id>, new: &Id) -> Result<Vec<(String, FileChange)>, String> {
516 let flat = |id: &Id| objects.flatten(id).map_err(|e| e.to_string());
517 let a: HashMap<String, Id> = match old {
518 Some(id) => flat(id)?.into_iter().map(|(p, _, b)| (p, b)).collect(),
519 None => HashMap::new(),
520 };
521 let b = flat(new)?;
522 let mut out = Vec::new();
523 for (path, _, blob) in &b {
524 match a.get(path) {
525 None => out.push((path.clone(), FileChange::Added(*blob))),
526 Some(old) if old != blob => out.push((path.clone(), FileChange::Modified(*old, *blob))),
527 _ => {}
528 }
529 }
530 let present: std::collections::HashSet<&String> = b.iter().map(|(p, _, _)| p).collect();
531 for (path, blob) in &a {
532 if !present.contains(path) {
533 out.push((path.clone(), FileChange::Deleted(*blob)));
534 }
535 }
536 out.sort_by(|x, y| x.0.cmp(&y.0));
537 Ok(out)
538}
539
540fn text_of(bytes: &[u8]) -> Option<&str> {
541 if bytes.len() > MAX_TEXT || bytes.contains(&0) {
542 return None;
543 }
544 std::str::from_utf8(bytes).ok()
545}
546
547/// One file's diff as table rows, with `CONTEXT` lines around each change.
548fn file_diff(old: &str, new: &str) -> (String, usize, usize) {
549 let (al, bl) = (diff::lines(old), diff::lines(new));
550 let ops = diff::diff(&al, &bl);
551 let (mut adds, mut dels) = (0, 0);
552 let mut nums = Vec::with_capacity(ops.len());
553 let (mut i, mut j) = (1usize, 1usize);
554 for (op, _) in &ops {
555 nums.push((i, j));
556 match op {
557 Op::Same => {
558 i += 1;
559 j += 1;
560 }
561 Op::Del => {
562 i += 1;
563 dels += 1;
564 }
565 Op::Add => {
566 j += 1;
567 adds += 1;
568 }
569 }
570 }
571 let changed: Vec<usize> = ops.iter().enumerate().filter(|(_, (op, _))| *op != Op::Same).map(|(k, _)| k).collect();
572 let near = |k: usize| changed.iter().any(|&c| c.abs_diff(k) <= CONTEXT);
573 let mut rows = String::new();
574 let mut skipped = false;
575 for (k, (op, line)) in ops.iter().enumerate() {
576 if *op == Op::Same && !near(k) {
577 skipped = true;
578 continue;
579 }
580 if skipped {
581 rows += r#"<tr class="gap"><td></td><td></td><td></td></tr>"#;
582 skipped = false;
583 }
584 let (a, b) = nums[k];
585 let (cls, an, bn) = match op {
586 Op::Same => ("", a.to_string(), b.to_string()),
587 Op::Del => ("del", a.to_string(), String::new()),
588 Op::Add => ("add", String::new(), b.to_string()),
589 };
590 let _ = write!(rows, r#"<tr class="{cls}"><td class="ln">{an}</td><td class="ln">{bn}</td><td class="code">{}</td></tr>"#, esc(line));
591 }
592 (rows, adds, dels)
593}
594
595fn diff_view(objects: &Objects, old: Option<&Id>, new: &Id, raw_base: &str) -> String {
596 let changes = match compare(objects, old, new) {
597 Ok(c) => c,
598 Err(e) => return format!(r#"<div class="empty">Couldn't read the snapshot: {}</div>"#, esc(&e)),
599 };
600 if changes.is_empty() {
601 return r#"<div class="empty">No file changes.</div>"#.into();
602 }
603 let read = |id: &Id| objects.get_blob(id).unwrap_or_default();
604 let mut summary = String::from(r#"<div class="filelist">"#);
605 let mut files = String::new();
606 for (k, (path, fc)) in changes.iter().enumerate() {
607 let (old_b, new_b, status) = match fc {
608 FileChange::Added(b) => (Vec::new(), read(b), "added"),
609 FileChange::Deleted(b) => (read(b), Vec::new(), "deleted"),
610 FileChange::Modified(a, b) => (read(a), read(b), "modified"),
611 };
612 let body;
613 let (mut adds, mut dels) = (0, 0);
614 match (text_of(&old_b), text_of(&new_b)) {
615 (Some(a), Some(b)) => {
616 let (rows, ad, de) = file_diff(a, b);
617 (adds, dels) = (ad, de);
618 body = format!(r#"<table class="diff">{rows}</table>"#);
619 }
620 _ if image_type(path).is_some() && !new_b.is_empty() => {
621 body = format!(r#"<div class="image"><img src="{raw_base}/{}" alt=""></div>"#, enc_path(path));
622 }
623 _ => body = format!(r#"<div class="empty">Binary or large file ({} → {} bytes).</div>"#, old_b.len(), new_b.len()),
624 }
625 let counts = format!(r#"<span class="add">+{adds}</span> <span class="del">−{dels}</span>"#);
626 let _ = write!(summary, r##"<a href="#f{k}"><span class="status {status}">{}</span><span class="path">{}</span>{counts}</a>"##, &status[..1], esc(path));
627 let _ = write!(files, r#"<details class="file" id="f{k}" open><summary><span class="path">{}</span><span class="faint">{status}</span>{counts}</summary>{body}</details>"#, esc(path));
628 }
629 summary + "</div>" + &files
630}
631
632// ── A change ──────────────────────────────────────────────
633
634fn change_page(p: &Project, c: &Change, snap: &Snapshot, objects: &Objects, r: Option<u32>, error: Option<&str>, now: i64) -> Response {
635 let names = &snap.names;
636 let base = base_url(p);
637 let rev = r.and_then(|n| c.revisions.iter().find(|x| x.number == n)).or_else(|| c.latest());
638 let mut body = format!(
639 r#"<div class="head"><h1 class="title"><span class="faint">#{}</span> {}</h1>{}</div>"#,
640 c.number,
641 esc(c.intent.lines().next().unwrap_or("")),
642 state_tag(c.state)
643 );
644 let rest: Vec<&str> = c.intent.lines().skip(1).collect();
645 if !rest.join("").trim().is_empty() {
646 let _ = write!(body, r#"<div class="prose">{}</div>"#, esc(rest.join("\n").trim()));
647 }
648 let landed = c.timeline.iter().find_map(|(_, _, e)| match e {
649 Event::Landed { revision, points } => Some((*revision, *points)),
650 _ => None,
651 });
652 let _ = write!(body, r#"<div class="meta line">proposed by {} · {}"#, names.actor(&c.proposer), when(c.at, now));
653 if let Some((rn, (a, b))) = landed {
654 let pts = if a == b { format!(r#"<a href="{base}/@{a}">@{a}</a>"#) } else { format!(r#"<a href="{base}/@{a}">@{a}</a>..<a href="{base}/@{b}">@{b}</a>"#) };
655 let _ = write!(body, " · r{rn} landed as {pts}");
656 }
657 body += "</div>";
658 if let Some(e) = error {
659 let _ = write!(body, r#"<div class="error">{}</div>"#, esc(e));
660 }
661 // The owner decides, in person.
662 if snap.owns(p) && c.state.is_open() {
663 let action = format!("{base}/changes/{}", c.number);
664 body += r#"<div class="decide">"#;
665 if c.state == ChangeState::Proposed {
666 let _ = write!(body, r#"<form method="post" action="{action}/land"><button class="primary">Land r{}</button></form>"#, c.latest().map(|r| r.number).unwrap_or(0));
667 }
668 let _ = write!(body, r#"<form method="post" action="{action}/decline"><button>Decline</button></form></div>"#);
669 }
670
671 if let Some(rev) = rev {
672 body += r#"<nav class="revs">"#;
673 for x in &c.revisions {
674 let cls = if x.number == rev.number { " class=\"on\"" } else { "" };
675 let _ = write!(body, r#"<a{cls} href="{base}/changes/{}?r={}">r{}</a>"#, c.number, x.number, x.number);
676 }
677 let _ = write!(
678 body,
679 r#"<span class="meta">r{} by {} · {} · built on <a href="{base}/@{}">@{}</a>{}</span></nav>"#,
680 rev.number,
681 names.actor(&rev.by),
682 when(rev.at, now),
683 rev.base,
684 rev.base,
685 if rev.base < p.tip() && c.state.is_open() { format!(r#" <span class="behind">main is at @{}</span>"#, p.tip()) } else { String::new() }
686 );
687 if let Some(note) = &rev.note {
688 let _ = write!(body, r#"<div class="prose">{}</div>"#, esc(note));
689 }
690 body += r#"<div class="steps">"#;
691 for s in &rev.steps {
692 let _ = write!(body, r#"<div>{}</div>"#, esc(s.message.lines().next().unwrap_or("")));
693 }
694 body += "</div>";
695
696 let ev: Vec<_> = c.evidence.iter().filter(|e| e.revision == rev.number).collect();
697 if !ev.is_empty() {
698 body += r#"<section><h2>Evidence</h2><div class="evidence">"#;
699 for e in ev {
700 let title = if e.title.is_empty() { e.media_type.clone() } else { e.title.clone() };
701 let inner = match (&e.blob, &e.url) {
702 (Some(b), _) if e.media_type.starts_with("image/") => {
703 format!(r#"<a href="{base}/evidence/{0}"><img src="{base}/evidence/{0}" alt="{1}"></a>"#, hex::encode(b), esc(&title))
704 }
705 (Some(b), _) => {
706 let text = objects.get_blob(b).ok().filter(|_| e.media_type.starts_with("text/") || e.media_type == "application/json");
707 match text.as_deref().and_then(text_of) {
708 Some(t) => format!(r#"<pre>{}</pre>"#, esc(t)),
709 None => format!(r#"<a href="{base}/evidence/{}">open</a>"#, hex::encode(b)),
710 }
711 }
712 (None, Some(u)) if u.starts_with("https://") || u.starts_with("http://") => format!(r#"<a href="{0}" rel="noreferrer noopener">{0}</a>"#, esc(u)),
713 (None, Some(u)) => esc(u),
714 _ => String::new(),
715 };
716 let _ = write!(body, r#"<figure><figcaption>{} <span class="faint">{} · {}</span></figcaption>{inner}</figure>"#, esc(&title), esc(&e.media_type), names.actor(&e.by));
717 }
718 body += "</div></section>";
719 }
720
721 if let Some(last) = rev.steps.last() {
722 let old = p.point(rev.base).map(|pt| pt.snapshot);
723 body += "<section><h2>Files</h2>";
724 body += &diff_view(objects, old.as_ref(), &last.snapshot, &format!("{base}/raw/{}", hex::encode(&last.snapshot)));
725 body += "</section>";
726 }
727 }
728
729 body += r#"<section><h2>Discussion</h2><div class="timeline">"#;
730 for (at, by, e) in &c.timeline {
731 let what = match e {
732 Event::Opened => "opened the change".to_string(),
733 Event::Revised(n) => format!(r#"sent <a href="{base}/changes/{}?r={n}">r{n}</a>"#, c.number),
734 Event::State(s) => format!("marked it {}", state_tag(*s)),
735 Event::Attached(i) => format!("attached {}", c.evidence.get(*i).map(|e| esc(if e.title.is_empty() { &e.media_type } else { &e.title })).unwrap_or_default()),
736 Event::Landed { revision, points: (a, b) } => format!(r#"landed r{revision} as <a href="{base}/@{a}">@{a}</a>{}"#, if a == b { String::new() } else { format!("..@{b}") }),
737 Event::Said(id) => {
738 let m = c.messages.iter().find(|m| m.id == *id);
739 let text = m.map(|m| esc(&m.body)).unwrap_or_default();
740 let _ = write!(body, r#"<div class="said"><div class="meta">{} · {}</div><div class="prose">{text}</div></div>"#, names.actor(by), when(*at, now));
741 continue;
742 }
743 };
744 let _ = write!(body, r#"<div class="event">{} {what} · {}</div>"#, names.actor(by), when(*at, now));
745 }
746 body += "</div>";
747 if snap.contributes(p) {
748 let _ = write!(
749 body,
750 r#"<form class="say" method="post" action="{base}/changes/{}/say"><textarea name="body" rows="4" required placeholder="Reply"></textarea><button>Send</button></form>"#,
751 c.number
752 );
753 }
754 body += "</section>";
755
756 let mut crumbs = project_crumbs(p);
757 crumbs.push((format!("{base}/changes"), "changes".into()));
758 page(snap.who(), &format!("#{} {} · {}/{}", c.number, c.intent.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body)
759}
760
761// ── A point on main ───────────────────────────────────────
762
763fn point_page(p: &Project, n: u64, snap: &Snapshot, objects: &Objects, now: i64) -> Response {
764 let base = base_url(p);
765 let pt = p.point(n).unwrap();
766 let prev = p.point(n - 1).map(|x| x.snapshot);
767 let mut body = format!(
768 r#"<div class="head"><h1 class="title"><span class="faint">@{}</span> {}</h1></div>"#,
769 n,
770 esc(pt.message.lines().next().unwrap_or(""))
771 );
772 let rest: Vec<&str> = pt.message.lines().skip(1).collect();
773 if !rest.join("").trim().is_empty() {
774 let _ = write!(body, r#"<div class="prose">{}</div>"#, esc(rest.join("\n").trim()));
775 }
776 let _ = write!(body, r#"<div class="meta line">by {} · landed by {} · {}"#, snap.names.actor(&pt.author), snap.names.actor(&pt.landed_by), when(pt.at, now));
777 if let Some(c) = pt.change {
778 let _ = write!(body, r##" · from <a href="{base}/changes/{c}">#{c}</a>"##);
779 }
780 let _ = write!(body, r#" · <a href="{base}/files?at={n}">files at @{n}</a></div><nav class="revs">"#);
781 if n > 1 {
782 let _ = write!(body, r#"<a href="{base}/@{}">← @{}</a>"#, n - 1, n - 1);
783 }
784 if n < p.tip() {
785 let _ = write!(body, r#"<a href="{base}/@{}">@{} →</a>"#, n + 1, n + 1);
786 }
787 body += "</nav><section><h2>Files</h2>";
788 body += &diff_view(objects, prev.as_ref(), &pt.snapshot, &format!("{base}/raw/{}", hex::encode(&pt.snapshot)));
789 body += "</section>";
790 let crumbs = project_crumbs(p);
791 page(snap.who(), &format!("@{n} {} · {}/{}", pt.message.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body)
792}
793
794// ── Files ─────────────────────────────────────────────────
795
796fn image_type(path: &str) -> Option<&'static str> {
797 let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
798 Some(match ext.as_str() {
799 "png" => "image/png",
800 "jpg" | "jpeg" => "image/jpeg",
801 "gif" => "image/gif",
802 "webp" => "image/webp",
803 "svg" => "image/svg+xml",
804 "ico" => "image/x-icon",
805 _ => return None,
806 })
807}
808
809/// The entry at `path` in a snapshot: a directory's tree id, or a file's blob id.
810fn lookup(objects: &Objects, root: &Id, path: &str) -> Option<(Kind, Id)> {
811 let mut cur = (Kind::Dir, *root);
812 for part in path.split('/').filter(|s| !s.is_empty()) {
813 if cur.0 != Kind::Dir {
814 return None;
815 }
816 let e = objects.get_tree(&cur.1).ok()?.into_iter().find(|e| e.name == part)?;
817 cur = (e.kind, e.id);
818 }
819 Some(cur)
820}
821
822fn files_page(p: &Project, at: u64, path: &str, objects: &Objects, snap: &Snapshot) -> Response {
823 let who = snap.who();
824 let base = base_url(p);
825 let Some(pt) = p.point(at) else { return not_found(who, "nothing on main yet") };
826 let Some((kind, id)) = lookup(objects, &pt.snapshot, path) else { return not_found(who, &format!("no {path} at @{at}")) };
827 let q = if at == p.tip() { String::new() } else { format!("?at={at}") };
828 let mut trail = format!(r#"<a href="{base}/files{q}">{}</a>"#, esc(&p.name));
829 let mut so_far = String::new();
830 for part in path.split('/').filter(|s| !s.is_empty()) {
831 so_far = if so_far.is_empty() { part.to_string() } else { format!("{so_far}/{part}") };
832 let _ = write!(trail, r#"<span class="sep">/</span><a href="{base}/files/{}{q}">{}</a>"#, enc_path(&so_far), esc(part));
833 }
834 let mut body = format!(r#"<div class="head"><div class="path-trail">{trail}</div><span class="meta">at <a href="{base}/@{at}">@{at}</a></span></div>"#);
835 if kind == Kind::Dir {
836 let mut entries = objects.get_tree(&id).unwrap_or_default();
837 entries.sort_by_key(|e| (e.kind != Kind::Dir, e.name.clone()));
838 body += r#"<div class="list tree">"#;
839 for e in entries {
840 let full = if path.is_empty() { e.name.clone() } else { format!("{path}/{}", e.name) };
841 let (icon, cls) = if e.kind == Kind::Dir { ("▸", "dir") } else { ("", "file") };
842 let _ = write!(body, r#"<a class="{cls}" href="{base}/files/{}{q}"><span class="icon">{icon}</span>{}</a>"#, enc_path(&full), esc(&e.name));
843 }
844 body += "</div>";
845 } else {
846 let bytes = objects.get_blob(&id).unwrap_or_default();
847 let raw = format!("{base}/raw/{}/{}", hex::encode(&pt.snapshot), enc_path(path));
848 if image_type(path).is_some() {
849 let _ = write!(body, r#"<div class="image"><img src="{raw}" alt=""></div>"#);
850 } else if let Some(text) = text_of(&bytes) {
851 body += r#"<table class="diff source">"#;
852 for (i, line) in diff::lines(text).iter().enumerate() {
853 let _ = write!(body, r#"<tr><td class="ln">{}</td><td class="code">{}</td></tr>"#, i + 1, esc(line));
854 }
855 body += "</table>";
856 } else {
857 let _ = write!(body, r#"<div class="empty">Binary or large file, {} bytes. <a href="{raw}">Download</a>.</div>"#, bytes.len());
858 }
859 }
860 let crumbs = project_crumbs(p);
861 page(who, &format!("{} · {}/{}", if path.is_empty() { "files" } else { path }, p.owner, p.name), &crumbs, &body)
862}
863
864/// Raw bytes may be anything someone stored; never let them run as a page on this site.
865fn sandboxed(r: Response) -> Response {
866 r.header("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox")
867 .header("X-Content-Type-Options", "nosniff")
868 .header("Cache-Control", "public, max-age=31536000, immutable")
869}
870
871fn raw(p: &Project, snap_hex: &str, path: &str, objects: &Objects) -> Response {
872 let Some(id) = hex::decode(snap_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found(None, "bad snapshot") };
873 // Only snapshots that belong to this project: points, and steps of its changes.
874 let known = p.points.iter().any(|pt| pt.snapshot == id) || p.changes.iter().flat_map(|c| &c.revisions).flat_map(|r| &r.steps).any(|s| s.snapshot == id);
875 if !known {
876 return not_found(None, "no such snapshot in this project");
877 }
878 match lookup(objects, &id, path) {
879 Some((k, blob)) if k != Kind::Dir => match objects.get_blob(&blob) {
880 Ok(bytes) => {
881 let ct = image_type(path).unwrap_or(if text_of(&bytes).is_some() { "text/plain; charset=utf-8" } else { "application/octet-stream" });
882 sandboxed(Response::with(200, ct, bytes))
883 }
884 Err(e) => Response::error(500, &e.to_string()),
885 },
886 _ => not_found(None, &format!("no file {path}")),
887 }
888}
889
890fn evidence(p: &Project, blob_hex: &str, objects: &Objects) -> Response {
891 let Some(id) = hex::decode(blob_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found(None, "bad id") };
892 let Some(e) = p.changes.iter().flat_map(|c| &c.evidence).find(|e| e.blob == Some(id)) else { return not_found(None, "no such evidence") };
893 match objects.get_blob(&id) {
894 Ok(bytes) => {
895 // Stored media types are whatever the agent said; keep them to something a browser
896 // can't run.
897 let ct = if e.media_type.starts_with("image/") || e.media_type.starts_with("video/") || e.media_type == "application/pdf" {
898 e.media_type.as_str()
899 } else if text_of(&bytes).is_some() {
900 "text/plain; charset=utf-8"
901 } else {
902 "application/octet-stream"
903 };
904 sandboxed(Response::with(200, ct, bytes))
905 }
906 Err(err) => Response::error(500, &err.to_string()),
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn escaping_and_encoding() {
916 assert_eq!(esc(r#"<a href="x">&'"#), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
917 assert_eq!(enc("a b/c"), "a%20b%2Fc");
918 assert_eq!(decode("a%20b%2Fc"), "a b/c");
919 assert_eq!(decode("100%"), "100%");
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"));
934 }
935
936 #[test]
937 fn dates() {
938 assert_eq!(date(0), "1970-01-01");
939 assert_eq!(date(1_790_878_823_448), "2026-10-01");
940 assert_eq!(civil(-1), (1969, 12, 31));
941 }
942
943 #[test]
944 fn diff_rows_keep_context() {
945 let a = (1..=20).map(|i| format!("{i}\n")).collect::<String>();
946 let b = a.replace("10\n", "ten\n");
947 let (rows, adds, dels) = file_diff(&a, &b);
948 assert_eq!((adds, dels), (1, 1));
949 assert!(rows.contains(">7<") && rows.contains(">13<") && !rows.contains(">5<"));
950 assert_eq!(rows.matches(r#"class="gap""#).count(), 1);
951 }
952}