grove

@11 Web pages: projects, main, changes, diffs, evidence, files; the daily twig in Rust

by claude for antonky · landed by antonky · · from #10 · files at @11

Files

src/api.rsmodified+6 −0
7373 }
7474
7575 pub fn handle(&self, req: Request) -> Response {
76 if req.path != "/api" && !req.path.starts_with("/api/") {
77 // The web pages: copy out what the page needs, then render without the lock.
78 let only = crate::web::wants(&req.path);
79 let snap = crate::web::Snapshot::of(self.store().state(), only.as_ref().map(|(o, n)| (o.as_str(), n.as_str())));
80 return crate::web::handle(&req, &snap, &self.objects);
81 }
7682 match self.route(&req) {
7783 Ok(r) | Err(r) => r,
7884 }
src/http.rsmodified+23 −10
2929
3030pub struct Response {
3131 pub status: u16,
32 pub content_type: &'static str,
32 pub content_type: String,
33 pub headers: Vec<(&'static str, String)>,
3334 pub body: Vec<u8>,
3435}
3536
3637impl Response {
3738 pub fn json(status: u16, v: &crate::json::Value) -> Response {
38 Response { status, content_type: "application/json", body: v.encode().into_bytes() }
39 Response::with(status, "application/json", v.encode().into_bytes())
3940 }
4041 pub fn bytes(body: Vec<u8>) -> Response {
41 Response { status: 200, content_type: "application/octet-stream", body }
42 Response::with(200, "application/octet-stream", body)
4243 }
44 pub fn with(status: u16, content_type: &str, body: Vec<u8>) -> Response {
45 Response { status, content_type: content_type.to_string(), headers: Vec::new(), body }
46 }
47 pub fn html(status: u16, page: String) -> Response {
48 Response::with(status, "text/html; charset=utf-8", page.into_bytes())
49 }
50 pub fn header(mut self, name: &'static str, value: impl Into<String>) -> Response {
51 self.headers.push((name, value.into()));
52 self
53 }
4354 pub fn error(status: u16, msg: &str) -> Response {
4455 Response::json(status, &crate::json::obj([("error", msg.into())]))
4556 }
4859fn reason(status: u16) -> &'static str {
4960 match status {
5061 200 => "OK",
62 302 => "Found",
5163 400 => "Bad Request",
5264 401 => "Unauthorized",
5365 403 => "Forbidden",
121133}
122134
123135fn write_response(stream: &mut TcpStream, r: &Response) -> io::Result<()> {
124 let head = format!(
125 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
126 r.status,
127 reason(r.status),
128 r.content_type,
129 r.body.len()
130 );
136 let mut head = format!("HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n", r.status, reason(r.status), r.content_type, r.body.len());
137 for (k, v) in &r.headers {
138 // Header values come from the server itself, but never let one end the head early.
139 if !v.contains(['\r', '\n']) {
140 head += &format!("{k}: {v}\r\n");
141 }
142 }
143 head += "Connection: close\r\n\r\n";
131144 stream.write_all(head.as_bytes())?;
132145 stream.write_all(&r.body)?;
133146 stream.flush()
src/lib.rsmodified+2 −0
1313pub mod log;
1414pub mod model;
1515pub mod objects;
16pub mod twig;
17pub mod web;
1618pub mod random;
1719pub mod store;
1820pub mod wire;
src/twig.rsadded+357 −0
1//! The grove mark: a twig grown fresh every UTC day.
2//!
3//! The same generator as the brand page (brand/grove-twig.html in the old grove repo), so a day
4//! gives the same twig everywhere. A sympodial twig: the leader zigzags, side shoots alternate
5//! and branch once more, every segment keeps a clear zone around it (drawn as a light halo), and
6//! lengths and starting direction come from the seed.
7
8type Pt = [f64; 2];
9
10struct Axis {
11 pts: Vec<Pt>,
12 order: u32,
13 parent: Option<usize>,
14}
15
16const NODES: u32 = 3;
17const ZIGZAG: f64 = 26.0;
18const UPWARD: f64 = 0.04;
19const BRANCH_ANGLE: f64 = 48.0;
20const LATERAL_RATIO: f64 = 0.85;
21const LATERAL_NODES: f64 = 0.9;
22const ORDERS: u32 = 2;
23const ANGLE_JITTER: f64 = 9.0;
24const MIN_LATERALS: usize = 3;
25pub const LINE_WIDTH: f64 = 3.9;
26const KEEPAWAY: f64 = 0.55;
27const MIN_ANGLE: f64 = 28.0;
28const FINAL_GAP: f64 = 3.0;
29const LEN_MIN: f64 = 0.7;
30const LEN_MAX: f64 = 1.35;
31const RAD: f64 = std::f64::consts::PI / 180.0;
32
33/// mulberry32, matching the JavaScript generator bit for bit.
34struct Rng(i32);
35
36impl Rng {
37 fn next(&mut self) -> f64 {
38 self.0 = self.0.wrapping_add(0x6d2b79f5u32 as i32);
39 let s = self.0;
40 let mut t = (s ^ ((s as u32) >> 15) as i32).wrapping_mul(1 | s);
41 t = t.wrapping_add((t ^ ((t as u32) >> 7) as i32).wrapping_mul(61 | t)) ^ t;
42 ((t ^ ((t as u32) >> 14) as i32) as u32) as f64 / 4294967296.0
43 }
44}
45
46// ── Geometry ──────────────────────────────────────────────
47
48fn sign(x: f64) -> f64 {
49 if x > 0.0 { 1.0 } else if x < 0.0 { -1.0 } else { 0.0 }
50}
51fn segments_cross(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {
52 let o = |p: Pt, q: Pt, r: Pt| sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
53 o(a, b, c) * o(a, b, d) < 0.0 && o(c, d, a) * o(c, d, b) < 0.0
54}
55fn pt_seg(p: Pt, a: Pt, b: Pt) -> f64 {
56 let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
57 let l2 = dx * dx + dy * dy;
58 let l2 = if l2 == 0.0 { 1e-9 } else { l2 };
59 let t = (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0);
60 (p[0] - a[0] - t * dx).hypot(p[1] - a[1] - t * dy)
61}
62fn seg_seg(a: Pt, b: Pt, c: Pt, d: Pt) -> f64 {
63 if segments_cross(a, b, c, d) {
64 return 0.0;
65 }
66 pt_seg(a, c, d).min(pt_seg(b, c, d)).min(pt_seg(c, a, b)).min(pt_seg(d, a, b))
67}
68fn same(p: Pt, q: Pt) -> bool {
69 (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6
70}
71fn angle_between(a: Pt, b: Pt, c: Pt, d: Pt) -> f64 {
72 let u = (b[1] - a[1]).atan2(b[0] - a[0]);
73 let v = (d[1] - c[1]).atan2(d[0] - c[0]);
74 let x = (u - v).abs() / RAD;
75 if x > 180.0 { 360.0 - x } else { x }
76}
77/// A new segment must stay `gap` away from everything it doesn't touch, and leave at least
78/// `min_angle` to segments it shares a point with.
79fn clear(a: Pt, b: Pt, segs: &[(Pt, Pt)], gap: f64, min_angle: f64) -> bool {
80 for &(c, d) in segs {
81 if same(a, c) {
82 if angle_between(a, b, c, d) < min_angle {
83 return false;
84 }
85 continue;
86 }
87 if same(a, d) {
88 if angle_between(a, b, d, c) < min_angle {
89 return false;
90 }
91 continue;
92 }
93 if seg_seg(a, b, c, d) < gap {
94 return false;
95 }
96 }
97 true
98}
99
100// ── Growth ────────────────────────────────────────────────
101
102struct Bud {
103 x: f64,
104 y: f64,
105 ang: f64,
106 len: f64,
107 order: u32,
108 nodes: u32,
109 side: f64,
110 parent: usize,
111}
112
113struct Grower {
114 r: Rng,
115 axes: Vec<Axis>,
116 segs: Vec<(Pt, Pt)>,
117 queue: std::collections::VecDeque<Bud>,
118 lateral_side: f64,
119}
120
121impl Grower {
122 fn jit(&mut self, amt: f64) -> f64 {
123 (self.r.next() - 0.5) * 2.0 * amt
124 }
125
126 // The leader grows to full length first; side buds wait in the queue and fit around it.
127 #[allow(clippy::too_many_arguments)]
128 fn axis(&mut self, mut x: f64, mut y: f64, mut ang: f64, len: f64, order: u32, nodes: u32, mut zig: f64, parent: Option<usize>) {
129 let gap = KEEPAWAY * 10.0;
130 // Axes are grown one at a time, so this one's index is the current count.
131 let me = self.axes.len();
132 let mut pts = vec![[x, y]];
133 for i in 0..nodes {
134 ang += zig * ZIGZAG * if i == 0 { 0.5 } else { 1.0 } + self.jit(ANGLE_JITTER);
135 ang += (-90.0 - ang) * UPWARD;
136 let l = len * (LEN_MIN + self.r.next() * (LEN_MAX - LEN_MIN));
137 let mut placed = None;
138 for off in [0.0, 7.0, -7.0, 14.0, -14.0, 21.0, -21.0, 28.0, -28.0, 36.0, -36.0] {
139 let a2: f64 = ang + off;
140 let (nx, ny) = (x + (a2 * RAD).cos() * l, y + (a2 * RAD).sin() * l);
141 if clear([x, y], [nx, ny], &self.segs, gap, MIN_ANGLE) {
142 placed = Some((nx, ny, a2));
143 break;
144 }
145 }
146 let Some((nx, ny, a2)) = placed else { break };
147 self.segs.push(([x, y], [nx, ny]));
148 (x, y, ang) = (nx, ny, a2);
149 pts.push([x, y]);
150 zig = -zig;
151 if i < nodes - 1 && order < ORDERS && self.r.next() < 1.0 {
152 self.lateral_side = -self.lateral_side;
153 let side = self.lateral_side;
154 let bud_ang = ang + side * (BRANCH_ANGLE + self.jit(ANGLE_JITTER));
155 let bud_nodes = (((nodes - i - 1) as f64) * LATERAL_NODES).round().max(1.0) as u32;
156 self.queue.push_back(Bud { x, y, ang: bud_ang, len: len * LATERAL_RATIO, order: order + 1, nodes: bud_nodes, side, parent: me });
157 }
158 }
159 if pts.len() >= 2 {
160 self.axes.push(Axis { pts, order, parent });
161 }
162 }
163}
164
165fn grow(seed: i32) -> Vec<Axis> {
166 let mut r = Rng(seed);
167 let base_angle = r.next() * 360.0;
168 let first_kink = if r.next() < 0.5 { 1.0 } else { -1.0 };
169 let lateral_side = if r.next() < 0.5 { 1.0 } else { -1.0 };
170 let mut g = Grower { r, axes: Vec::new(), segs: Vec::new(), queue: Default::default(), lateral_side };
171 g.axis(0.0, 0.0, base_angle, 10.0, 0, NODES, first_kink, None);
172 while let Some(b) = g.queue.pop_front() {
173 g.axis(b.x, b.y, b.ang, b.len, b.order, b.nodes, b.side, Some(b.parent));
174 }
175 g.axes
176}
177
178/// One side shoot, what grows from it, and the stretch of stem it grows from: a small shape that
179/// still reads at favicon size. The whole twig if no side shoot branches.
180fn section(axes: Vec<Axis>, seed: i32) -> Vec<Axis> {
181 let has_kids = |i: usize| axes.iter().any(|a| a.parent == Some(i));
182 let roots: Vec<usize> = (0..axes.len()).filter(|&i| axes[i].order == 1 && has_kids(i)).collect();
183 if roots.is_empty() {
184 return axes;
185 }
186 let root = roots[(Rng(seed ^ 0x5eed).next() * roots.len() as f64) as usize];
187 let mut keep = vec![false; axes.len()];
188 keep[root] = true;
189 for i in 0..axes.len() {
190 if axes[i].parent.is_some_and(|p| keep[p]) {
191 keep[i] = true;
192 }
193 }
194 let base = axes[root].pts[0];
195 let stem = axes[root].parent.and_then(|p| {
196 let pts = &axes[p].pts;
197 let at = pts.iter().position(|&q| same(q, base))?;
198 (at > 0).then(|| Axis { pts: vec![pts[at - 1], pts[at]], order: 0, parent: None })
199 });
200 let mut out: Vec<Axis> = stem.into_iter().collect();
201 out.extend(axes.into_iter().enumerate().filter(|(i, _)| keep[*i]).map(|(_, a)| a));
202 out
203}
204
205/// Fit the twig and its halo into the 64×64 box with a 2-unit margin. Returns the scale.
206fn fit(axes: &mut [Axis]) -> (f64, f64) {
207 let all = || axes.iter().flat_map(|a| a.pts.iter());
208 let (mut x0, mut x1, mut y0, mut y1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
209 for p in all() {
210 x0 = x0.min(p[0]);
211 x1 = x1.max(p[0]);
212 y0 = y0.min(p[1]);
213 y1 = y1.max(p[1]);
214 }
215 let halo_r = KEEPAWAY * 10.0;
216 let k = (64.0 - 4.0) / ((x1 - x0).max(y1 - y0).max(1e-6) + halo_r * 2.0);
217 let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
218 for a in axes.iter_mut() {
219 for p in a.pts.iter_mut() {
220 *p = [32.0 + (p[0] - cx) * k, 32.0 + (p[1] - cy) * k];
221 }
222 }
223 let aspect = (x1 - x0).min(y1 - y0) / (x1 - x0).max(y1 - y0);
224 (k, aspect)
225}
226
227// No crossings, enough shoots, clear gaps, no slivers.
228fn usable(seed: i32) -> bool {
229 let mut axes = grow(seed);
230 let (_, aspect) = fit(&mut axes);
231 let segs: Vec<(Pt, Pt, usize)> =
232 axes.iter().enumerate().flat_map(|(ai, ax)| ax.pts.windows(2).map(move |w| (w[0], w[1], ai))).collect();
233 for i in 0..segs.len() {
234 for j in i + 1..segs.len() {
235 if segs[i].2 != segs[j].2 && segments_cross(segs[i].0, segs[i].1, segs[j].0, segs[j].1) {
236 return false;
237 }
238 }
239 }
240 if axes.len() < MIN_LATERALS + 1 {
241 return false;
242 }
243 for i in 0..segs.len() {
244 for j in i + 1..segs.len() {
245 let ((a, b, _), (c, d, _)) = (segs[i], segs[j]);
246 if same(a, c) || same(a, d) || same(b, c) || same(b, d) {
247 let shared = if same(a, c) || same(a, d) { a } else { b };
248 let (p1, q1) = if same(shared, a) { (a, b) } else { (b, a) };
249 let (p2, q2) = if same(shared, c) { (c, d) } else { (d, c) };
250 let ang = angle_between(p1, q1, p2, q2);
251 if ang < MIN_ANGLE && ang > 0.5 {
252 return false;
253 }
254 continue;
255 }
256 if seg_seg(a, b, c, d) < LINE_WIDTH + FINAL_GAP {
257 return false;
258 }
259 }
260 }
261 aspect >= 0.42
262}
263
264// ── Daily ─────────────────────────────────────────────────
265
266/// Days since 1970-01-01 (UTC).
267pub fn day_number(unix_ms: i64) -> i64 {
268 unix_ms.div_euclid(86_400_000)
269}
270
271pub fn today() -> i64 {
272 day_number(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0))
273}
274
275fn daily_seed(day: i64) -> i32 {
276 for k in 0..60 {
277 let seed = (day * 7 + k * 104_729) as i32;
278 if usable(seed) {
279 return seed;
280 }
281 }
282 46
283}
284
285/// Today's shape: path data for each shoot in a 64×64 box, and the halo's stroke width.
286pub struct Shape {
287 pub shoots: Vec<String>,
288 pub halo_width: f64,
289}
290
291/// A number as JavaScript prints `+n.toFixed(2)`.
292fn num(n: f64) -> String {
293 let s = format!("{n:.2}");
294 let s = s.trim_end_matches('0').trim_end_matches('.');
295 if s == "-0" { "0".into() } else { s.to_string() }
296}
297
298/// A day's twig, or with `part` one section of it.
299pub fn shape(day: i64, part: bool) -> Shape {
300 let seed = daily_seed(day);
301 let mut axes = grow(seed);
302 if part {
303 axes = section(axes, seed);
304 }
305 let (k, _) = fit(&mut axes);
306 let shoots = axes
307 .iter()
308 .map(|a| a.pts.iter().enumerate().map(|(i, p)| format!("{}{} {}", if i == 0 { "M" } else { "L" }, num(p[0]), num(p[1]))).collect())
309 .collect();
310 Shape { shoots, halo_width: (KEEPAWAY * 10.0 * k * 2.0).max(1.0) }
311}
312
313/// An SVG of a day's twig. `halo` and `color` are any CSS colours.
314pub fn svg(day: i64, part: bool, color: &str, halo: &str, line_width: f64, size: Option<u32>) -> String {
315 let s = shape(day, part);
316 let dims = size.map(|n| format!(r#" width="{n}" height="{n}""#)).unwrap_or_default();
317 let mut out = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"{dims} viewBox="0 0 64 64" fill="none" aria-label="grove">"#);
318 for d in &s.shoots {
319 out += &format!(r#"<path d="{d}" stroke="{halo}" stroke-width="{}" stroke-linecap="round" stroke-linejoin="round"/>"#, num(s.halo_width));
320 }
321 for d in &s.shoots {
322 out += &format!(r#"<path d="{d}" stroke="{color}" stroke-width="{line_width}" stroke-linejoin="round"/>"#);
323 }
324 out + "</svg>"
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn rng_matches_javascript() {
333 // mulberry32(1) in JavaScript: 0.6270739405881613, 0.002735721180215478, 0.5274470399599522
334 let mut r = Rng(1);
335 assert_eq!(r.next(), 0.6270739405881613);
336 assert_eq!(r.next(), 0.002735721180215478);
337 assert_eq!(r.next(), 0.5274470399599522);
338 }
339
340 #[test]
341 fn shapes_match_the_brand_generator() {
342 // From ui/src/lib/grove-twig.ts in the old grove repo.
343 assert_eq!(shape(20727, false).shoots.join("|"), "M54.4 15.25L38.26 22.8L20.3 25.23L11.83 34.03|M38.26 22.8L38.6 36.74L35.63 48.75|M20.3 25.23L9.6 17.05|M38.6 36.74L48.58 44.51");
344 assert_eq!(shape(20727, true).shoots.join("|"), "M43.56 11.36L23.67 20.67|M23.67 20.67L24.1 37.84L20.44 52.64|M24.1 37.84L36.39 47.41");
345 assert_eq!(shape(20745, false).shoots.join("|"), "M10.5 27.5L27.86 32.54L36.83 41.51L53.5 48.48|M27.86 32.54L34.76 24.53L48.2 18.34|M36.83 41.51L36.48 52.19|M34.76 24.53L27.31 11.81");
346 }
347
348 #[test]
349 fn every_day_has_a_twig_and_a_section() {
350 for day in 20_700..20_760 {
351 let full = shape(day, false);
352 let part = shape(day, true);
353 assert!(full.shoots.len() >= MIN_LATERALS + 1);
354 assert!(!part.shoots.is_empty() && part.shoots.len() < full.shoots.len());
355 }
356 }
357}
src/web.rsadded+863 −0
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}
src/web/style.cssadded+141 −0
1@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap");
2
3:root {
4 --page: #faf8f5;
5 --surface: #ffffff;
6 --hover: #f3f0ea;
7 --ink: #2c2824;
8 --ink-2: #4a4540;
9 --muted: #7a746c;
10 --faint: #a09888;
11 --accent: #4d8a78;
12 --accent-subtle: #e8f2ee;
13 --halo: #d7e2dc;
14 --line: #d9d3ca;
15 --line-2: #e8e3db;
16 --add-bg: rgba(46, 160, 67, 0.09);
17 --add: #1a7f37;
18 --del-bg: rgba(248, 81, 73, 0.09);
19 --del: #cf222e;
20 --mono: "JetBrains Mono", Menlo, monospace;
21}
22
23* { box-sizing: border-box; }
24html { -webkit-text-size-adjust: 100%; }
25body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.5 var(--mono); font-feature-settings: "calt" 0; }
26a { color: inherit; text-decoration: none; }
27a:hover { text-decoration: underline; }
28h1, h2 { font-weight: 500; letter-spacing: -0.02em; margin: 0; }
29h2 { font-size: 13px; color: var(--muted); margin: 28px 0 8px; }
30pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: inherit; font-size: 12px; }
31.faint { color: var(--faint); }
32.wrap { max-width: 1440px; margin: 0 auto; padding: 0 24px; }
33main.wrap { padding-top: 24px; padding-bottom: 64px; }
34
35/* Top bar */
36.top { background: var(--page); border-bottom: 1px solid var(--line-2); }
37.top .wrap { height: 56px; display: flex; align-items: center; gap: 18px; }
38.brand { display: flex; align-items: center; gap: 6px; font-size: 22px; font-weight: 500; letter-spacing: -0.04em; }
39.brand:hover { text-decoration: none; }
40.crumbs { display: flex; align-items: center; gap: 8px; color: var(--muted); min-width: 0; white-space: nowrap; overflow: hidden; }
41.crumbs a:last-child, .crumbs span:last-child { color: var(--ink); }
42.sep { color: var(--faint); }
43
44.head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
45.title { font-size: 22px; }
46.tag { padding: 0 6px; font-size: 11px; color: var(--muted); border: 1px solid var(--line); }
47.meta { font-size: 12px; color: var(--muted); }
48.meta a, .who a { color: var(--ink-2); }
49.meta.line { margin: 6px 0 16px; }
50.agent { color: var(--accent); }
51.prose { white-space: pre-wrap; word-break: break-word; color: var(--ink-2); margin: 8px 0; max-width: 90ch; }
52.empty { padding: 18px 14px; color: var(--muted); background: var(--surface); border: 1px solid var(--line-2); }
53
54/* States */
55.state { font-size: 11px; padding: 0 6px; border: 1px solid currentColor; }
56.state.proposed { color: var(--accent); }
57.state.idea, .state.draft { color: var(--muted); }
58.state.landed { color: #6b4fa0; }
59.state.declined, .state.withdrawn { color: #a05050; }
60.behind { color: #a05050; }
61
62/* Tiles: one hairline between every tile. */
63.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 16px; }
64.tile { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 10px 12px; background: var(--surface); }
65.tile:hover { background: var(--hover); text-decoration: none; }
66.tile-name { color: var(--accent); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
67.tile-desc { font-size: 12px; color: var(--muted); line-height: 1.4; min-height: 2.8em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
68.tile-meta { display: flex; gap: 10px; font-size: 11px; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
69
70/* Project stats */
71.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 12px; }
72.stat { display: flex; flex-direction: column; padding: 10px 12px; background: var(--surface); }
73a.stat:hover { background: var(--hover); text-decoration: none; }
74.stat b { font-weight: 500; font-size: 18px; }
75.stat span { font-size: 11px; color: var(--muted); }
76.heat { display: grid; grid-template-columns: repeat(84, 1fr); gap: 2px; margin-top: 10px; }
77.heat i { aspect-ratio: 1; max-height: 14px; background: var(--line-2); }
78.heat .h1 { background: #cfe2da; }
79.heat .h2 { background: #9cc4b5; }
80.heat .h3 { background: #6fa592; }
81.heat .h4 { background: var(--accent); }
82
83.cols { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 24px; }
84@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
85
86/* Lists */
87.list { background: var(--surface); border: 1px solid var(--line-2); }
88.list > * { border-top: 1px solid var(--line-2); }
89.list > *:first-child { border-top: 0; }
90.list .empty { border: 0; }
91.row { display: flex; gap: 12px; padding: 8px 12px; align-items: baseline; }
92.row .n { color: var(--faint); min-width: 3.5ch; font-size: 12px; }
93.grow { flex: 1; min-width: 0; }
94.msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
95.tree a { display: flex; gap: 8px; padding: 6px 12px; }
96.tree a:hover { background: var(--hover); text-decoration: none; }
97.tree .icon { width: 1ch; color: var(--faint); }
98.tree .dir { color: var(--accent); }
99
100/* A change */
101.revs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 16px 0 8px; }
102.revs a { padding: 2px 8px; border: 1px solid var(--line); background: var(--surface); }
103.revs a.on { border-color: var(--ink); }
104.revs .meta { margin-left: 8px; }
105.steps { font-size: 12px; color: var(--ink-2); border-left: 2px solid var(--line); padding-left: 10px; margin: 8px 0; }
106.evidence { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
107.evidence figure { margin: 0; background: var(--surface); border: 1px solid var(--line-2); padding: 10px; min-width: 0; }
108.evidence figcaption { font-size: 12px; margin-bottom: 8px; }
109.evidence img { max-width: 100%; display: block; }
110.timeline { display: flex; flex-direction: column; gap: 6px; }
111.event { font-size: 12px; color: var(--muted); }
112.said { background: var(--surface); border: 1px solid var(--line-2); padding: 10px 12px; }
113.said .prose { margin: 4px 0 0; color: var(--ink); }
114
115/* Diffs and files */
116.filelist { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-bottom: 12px; font-size: 12px; }
117.filelist a { display: flex; gap: 8px; padding: 5px 10px; background: var(--surface); min-width: 0; }
118.filelist .path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
119.status { width: 1ch; }
120.status.added { color: var(--add); }
121.status.deleted { color: var(--del); }
122.status.modified { color: var(--muted); }
123.add { color: var(--add); }
124.del { color: var(--del); }
125.file { background: var(--surface); border: 1px solid var(--line-2); margin-bottom: 12px; }
126.file summary { display: flex; gap: 10px; padding: 7px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid var(--line-2); }
127.file summary .path { flex: 1; color: var(--ink); }
128table.diff { width: 100%; border-collapse: collapse; font-size: 12px; line-height: 1.55; }
129table.diff td { padding: 0 8px; vertical-align: top; }
130table.diff .ln { width: 1%; min-width: 4ch; text-align: right; color: var(--faint); user-select: none; }
131table.diff .code { white-space: pre-wrap; word-break: break-all; }
132table.diff tr.add { background: var(--add-bg); }
133table.diff tr.del { background: var(--del-bg); }
134table.diff tr.gap td { height: 10px; background: var(--page); }
135table.source { background: var(--surface); border: 1px solid var(--line-2); }
136.image { padding: 12px; background: var(--surface); border: 1px solid var(--line-2); }
137.image img { max-width: 100%; display: block; }
138.path-trail { font-size: 16px; display: flex; gap: 6px; flex-wrap: wrap; }
139.path-trail a:last-child { color: var(--ink); font-weight: 500; }
140
141@media (max-width: 640px) { .wrap { padding: 0 14px; } }