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