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