chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A pull's card counts what it changes, and cards show when, not state
crates/knotless-serve/examples/cards.rs+37ā4
| @@ -56,11 +56,13 @@ fn cards() -> Vec<(&'static str, Card)> { | ||
avatar: None, | ||
name: "knotless".into(), | ||
pull: false, | ||
state: "open".into(), | ||
title: "Cloning a repository with ten thousand refs takes a minute on a slow PDS" | ||
.into(), | ||
author: "bob.example.com".into(), | ||
opened: Some("2026-11-01T09:30:00.000Z".into()), | ||
comments: 4, | ||
reviews: 0, | ||
size: None, | ||
}, | ||
), | ||
( | ||
| @@ -70,10 +72,37 @@ fn cards() -> Vec<(&'static str, Card)> { | ||
avatar: None, | ||
name: "knotless".into(), | ||
pull: true, | ||
state: "merged".into(), | ||
title: "Draw social cards in process".into(), | ||
author: "carol.test".into(), | ||
opened: Some("2026-11-01T09:30:00.000Z".into()), | ||
comments: 1, | ||
reviews: 2, | ||
size: Some(card::Size { | ||
commits: 3, | ||
files: 4, | ||
added: 181, | ||
removed: 18, | ||
}), | ||
}, | ||
), | ||
( | ||
"long", | ||
Card::Item { | ||
owner: "alice.bsky.social".into(), | ||
avatar: None, | ||
name: "knotless".into(), | ||
pull: true, | ||
title: "Keep a comment being written when the page reloads, and offer it back the next time the same pull is opened".into(), | ||
author: "erin.test".into(), | ||
opened: Some("2026-11-01T09:30:00.000Z".into()), | ||
comments: 0, | ||
reviews: 1, | ||
size: Some(card::Size { | ||
commits: 1, | ||
files: 4, | ||
added: 181, | ||
removed: 18, | ||
}), | ||
}, | ||
), | ||
( | ||
| @@ -83,10 +112,12 @@ fn cards() -> Vec<(&'static str, Card)> { | ||
avatar: None, | ||
name: "knotless".into(), | ||
pull: false, | ||
state: "closed".into(), | ||
title: "Titles with emoji š draw in color: š©š½āš» šÆšµ ā¤ļø š¦ not as boxes".into(), | ||
author: "dana.test".into(), | ||
opened: Some("2026-11-01T09:30:00.000Z".into()), | ||
comments: 2, | ||
reviews: 0, | ||
size: None, | ||
}, | ||
), | ||
] | ||
| @@ -170,10 +201,12 @@ fn main() { | ||
avatar: None, | ||
name: "knotless".into(), | ||
pull: i % 2 == 0, | ||
state: "open".into(), | ||
title: format!("Issue number {i}, one of many drawn at once"), | ||
author: "bob.example.com".into(), | ||
opened: Some("2026-11-01T09:30:00.000Z".into()), | ||
comments: i, | ||
reviews: 0, | ||
size: None, | ||
}; | ||
let svg = card.svg(); | ||
let cache = &cache; | ||
crates/knotless-serve/src/card.rs+180ā70
| @@ -20,10 +20,10 @@ use std::sync::{Arc, LazyLock, Mutex}; | ||
/// Icons, each a 16-unit path: GitHub's Octicons, as the pages draw them. | ||
mod icon { | ||
pub const ISSUE: &str = "M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"; | ||
pub const DONE: &str = "M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5ZM16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z"; | ||
pub const PULL: &str = "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"; | ||
pub const PULL_CLOSED: &str = "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"; | ||
pub const MERGE: &str = "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"; | ||
pub const COMMENTS: &str = "M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"; | ||
pub const REVIEW: &str = "M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 13H8.061l-2.574 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25v-8.5C0 1.784.784 1 1.75 1ZM1.5 2.75v8.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm5.28 1.72a.75.75 0 0 1 0 1.06L5.31 7l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L10.69 7 9.22 5.53a.75.75 0 0 1 0-1.06Z"; | ||
pub const FILES: &str = "M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"; | ||
} | ||
| ||
fn esc(s: &str) -> String { | ||
| @@ -350,14 +350,15 @@ fn avatar(out: &mut String, name: &str, picture: Option<&str>, (cx, cy, r): (f32 | ||
.unwrap(); | ||
} | ||
| ||
/// The color of a pull's or an issue's state, as the pages draw it. | ||
fn state_color(pull: bool, state: &str) -> &'static str { | ||
match (pull, state) { | ||
(_, "open") => "#1a7f37", | ||
(_, "merged") => "#7048b5", | ||
(true, _) => "#cf222e", | ||
(false, _) => "#636363", | ||
} | ||
/// The day `at`, a timestamp, falls on in UTC, as `November 1, 2026`; | ||
/// none if it is no timestamp. | ||
fn date(at: &str) -> Option<String> { | ||
let at: jiff::Timestamp = at.parse().ok()?; | ||
Some( | ||
at.to_zoned(jiff::tz::TimeZone::UTC) | ||
.strftime("%B %-d, %Y") | ||
.to_string(), | ||
) | ||
} | ||
| ||
fn plural(n: usize, one: &str, many: &str) -> String { | ||
| @@ -388,13 +389,45 @@ pub enum Card { | ||
avatar: Option<String>, | ||
name: String, | ||
pull: bool, | ||
state: String, | ||
title: String, | ||
author: String, | ||
/// When it was opened, as its record says. | ||
opened: Option<String>, | ||
comments: usize, | ||
/// A pull's reviews; none for an issue. | ||
reviews: usize, | ||
/// What a pull changes, if its commits could be read. | ||
size: Option<Size>, | ||
}, | ||
} | ||
| ||
/// What a pull changes, as its card counts it. | ||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
pub struct Size { | ||
pub commits: usize, | ||
pub files: usize, | ||
pub added: usize, | ||
pub removed: usize, | ||
} | ||
| ||
impl Size { | ||
/// Of five squares, how many are green for lines added and how many | ||
/// red for lines removed, as GitHub draws them; the rest are grey. | ||
/// Each side that changed anything shows at least one. | ||
fn squares(self) -> (usize, usize) { | ||
let all = self.added + self.removed; | ||
if all == 0 { | ||
crates/knotless-serve/src/card.rsline 419
| ||
return (0, 0); | ||
} | ||
let red = (5 * self.removed).div_ceil(all); | ||
(5 - red, red) | ||
} | ||
} | ||
| ||
const ADDED: &str = "#1a7f37"; | ||
const REMOVED: &str = "#cf222e"; | ||
const UNCHANGED: &str = "#d0d7de"; | ||
| ||
impl Card { | ||
/// The address of the picture it shows, if any. | ||
pub fn picture(&self) -> Option<&str> { | ||
| @@ -500,66 +533,47 @@ impl Card { | ||
avatar, | ||
name, | ||
pull, | ||
state, | ||
title, | ||
author, | ||
opened, | ||
comments, | ||
reviews, | ||
size, | ||
} => { | ||
top(&mut out, (owner, avatar.as_deref()), Some(name)); | ||
let head = wrap(title, 72.0, Weight::Semibold, wide, 3); | ||
// A pull's counts take a row above the last, so a title | ||
// three lines long is set smaller to leave it room. | ||
let mut head = wrap(title, 72.0, Weight::Semibold, wide, 3); | ||
let (mut set, mut at) = ((72.0, 86.0), 262.0); | ||
if *pull && head.len() == 3 { | ||
head = wrap(title, 58.0, Weight::Semibold, wide, 3); | ||
(set, at) = ((58.0, 68.0), 238.0); | ||
} | ||
lines( | ||
&mut out, | ||
&head, | ||
PAD, | ||
262.0, | ||
86.0, | ||
(72.0, Weight::Semibold, FG), | ||
); | ||
let color = state_color(*pull, state); | ||
let label = { | ||
let mut c = state.chars(); | ||
c.next() | ||
.map(|f| f.to_uppercase().chain(c).collect::<String>()) | ||
.unwrap_or_default() | ||
}; | ||
let path = match (*pull, state.as_str()) { | ||
(true, "merged") => icon::MERGE, | ||
(true, "open") => icon::PULL, | ||
(true, _) => icon::PULL_CLOSED, | ||
(false, "open") => icon::ISSUE, | ||
_ => icon::DONE, | ||
}; | ||
let pill = 70.0 + measure(&label, 30.0, Weight::Semibold) + 28.0; | ||
write!( | ||
out, | ||
r#"<rect x="{PAD}" y="{}" width="{pill}" height="60" rx="30" fill="{color}"/>"#, | ||
FOOT - 41.0, | ||
) | ||
.unwrap(); | ||
let label = [label]; | ||
lines( | ||
&mut out, | ||
&label, | ||
PAD + 70.0, | ||
FOOT, | ||
0.0, | ||
(30.0, Weight::Semibold, "#fff"), | ||
at, | ||
set.1, | ||
(set.0, Weight::Semibold, FG), | ||
); | ||
icon(&mut out, path, PAD + 26.0, FOOT - 27.0, 32.0, "#fff"); | ||
if *pull { | ||
counts(&mut out, *comments, *reviews, *size); | ||
} | ||
let mark = brand_width(34.0); | ||
let x = PAD + pill + 28.0; | ||
let said = plural(*comments, "comment", "comments"); | ||
let room = WIDTH as f32 - PAD - mark - 48.0 - x; | ||
let by = wrap( | ||
&format!("{author} Ā· {said}"), | ||
32.0, | ||
Weight::Regular, | ||
room, | ||
1, | ||
); | ||
lines(&mut out, &by, x, FOOT, 0.0, (32.0, Weight::Regular, MUTE)); | ||
// A pull's comments are counted above; its commits here. | ||
let mut said = vec![author.clone()]; | ||
said.extend(opened.as_deref().and_then(date)); | ||
said.extend(match (*pull, size) { | ||
(false, _) => Some(plural(*comments, "comment", "comments")), | ||
(true, Some(size)) => Some(plural(size.commits, "commit", "commits")), | ||
(true, None) => None, | ||
}); | ||
let room = WIDTH as f32 - PAD - mark - 48.0 - PAD; | ||
let by = wrap(&said.join(" Ā· "), 32.0, Weight::Regular, room, 1); | ||
lines(&mut out, &by, PAD, FOOT, 0.0, (32.0, Weight::Regular, MUTE)); | ||
brand(&mut out, WIDTH as f32 - PAD - mark, FOOT, 34.0); | ||
color.to_string() | ||
format!("hsl({}, 45%, 52%)", hue(owner)) | ||
} | ||
}; | ||
write!( | ||
| @@ -576,6 +590,66 @@ impl Card { | ||
} | ||
} | ||
| ||
/// Where a pull's counts sit, above the last row. | ||
const COUNTS: f32 = 456.0; | ||
| ||
/// A pull's counts, left to right as GitHub's cards draw them: its | ||
/// comments, reviews and files, then the lines it adds and removes and | ||
/// five squares of their share. | ||
fn counts(out: &mut String, comments: usize, reviews: usize, size: Option<Size>) { | ||
let (text, gap) = (30.0, 36.0); | ||
let mut x = PAD; | ||
let mut counted = vec![ | ||
(icon::COMMENTS, plural(comments, "comment", "comments")), | ||
(icon::REVIEW, plural(reviews, "review", "reviews")), | ||
]; | ||
if let Some(size) = size { | ||
counted.push((icon::FILES, plural(size.files, "file", "files"))); | ||
} | ||
for (path, said) in counted { | ||
icon(out, path, x, COUNTS - 25.0, 30.0, MUTE); | ||
x += 30.0 + 10.0; | ||
write!( | ||
out, | ||
r#"<text x="{x}" y="{COUNTS}" {}>{}</text>"#, | ||
style(text, Weight::Regular, MUTE), | ||
esc(&said) | ||
) | ||
.unwrap(); | ||
x += measure(&said, text, Weight::Regular) + gap; | ||
} | ||
let Some(size) = size else { | ||
return; | ||
}; | ||
for (said, fill) in [ | ||
(format!("+{}", size.added), ADDED), | ||
(format!("-{}", size.removed), REMOVED), | ||
] { | ||
write!( | ||
out, | ||
r#"<text x="{x}" y="{COUNTS}" {}>{said}</text>"#, | ||
style(text, Weight::Semibold, fill), | ||
) | ||
.unwrap(); | ||
x += measure(&said, text, Weight::Semibold) + 10.0; | ||
} | ||
let (green, red) = size.squares(); | ||
for i in 0..5 { | ||
let fill = match i { | ||
i if i < green => ADDED, | ||
i if i < green + red => REMOVED, | ||
_ => UNCHANGED, | ||
}; | ||
write!( | ||
out, | ||
r#"<rect x="{}" y="{}" width="18" height="18" rx="2" fill="{fill}"/>"#, | ||
x + 4.0 + 24.0 * i as f32, | ||
COUNTS - 19.0 | ||
) | ||
.unwrap(); | ||
} | ||
} | ||
| ||
/// A repository's languages along the foot, over its color, as GitHub's | ||
/// cards draw them: each as wide as its share of the bytes, its edges on | ||
/// whole pixels, so none shows a seam. | ||
| @@ -855,8 +929,11 @@ mod tests { | ||
assert_eq!(initial("_alice"), 'a'); | ||
assert_eq!(initial("ā¦"), '?'); | ||
assert!(hue("alice") < 360); | ||
assert_eq!(state_color(true, "closed"), "#cf222e"); | ||
assert_eq!(state_color(false, "closed"), "#636363"); | ||
assert_eq!( | ||
date("2026-11-01T23:59:59Z").as_deref(), | ||
Some("November 1, 2026") | ||
); | ||
assert_eq!(date("yesterday"), None); | ||
// Both weights are one family, so asking for 600 gets the bolder. | ||
let faces: Vec<(String, u16)> = FONTS | ||
.faces() | ||
| @@ -934,19 +1011,40 @@ mod tests { | ||
} | ||
} | ||
| ||
fn item(state: &str, pull: bool) -> Card { | ||
fn item(opened: Option<&str>, pull: bool) -> Card { | ||
Card::Item { | ||
owner: "alice.test".into(), | ||
avatar: None, | ||
name: "tracked".into(), | ||
pull, | ||
state: state.into(), | ||
title: "Broken <thing> & more".into(), | ||
author: "bob.test".into(), | ||
opened: opened.map(str::to_string), | ||
comments: 1, | ||
reviews: usize::from(pull), | ||
size: pull.then_some(Size { | ||
commits: 2, | ||
files: 3, | ||
added: 181, | ||
removed: 18, | ||
}), | ||
} | ||
} | ||
| ||
#[test] | ||
fn squares() { | ||
let size = |added, removed| Size { | ||
added, | ||
removed, | ||
..Size::default() | ||
}; | ||
assert_eq!(size(0, 0).squares(), (0, 0)); | ||
assert_eq!(size(181, 18).squares(), (4, 1)); | ||
assert_eq!(size(10, 0).squares(), (5, 0)); | ||
assert_eq!(size(0, 3).squares(), (0, 5)); | ||
assert_eq!(size(1, 1).squares(), (2, 3)); | ||
} | ||
| ||
#[test] | ||
fn every_card_draws() { | ||
let cards = [ | ||
| @@ -969,12 +1067,24 @@ mod tests { | ||
issues: 0, | ||
languages: Vec::new(), | ||
}, | ||
item("open", true), | ||
item("merged", true), | ||
item("closed", true), | ||
item("open", false), | ||
item("closed", false), | ||
item("", false), | ||
item(Some("2026-11-01T12:00:00.000Z"), true), | ||
item(None, true), | ||
item(Some("2026-11-01T12:00:00.000Z"), false), | ||
item(None, false), | ||
// A long title set smaller, over a pull whose commits were | ||
// not read. | ||
Card::Item { | ||
owner: "alice.test".into(), | ||
avatar: None, | ||
name: "tracked".into(), | ||
pull: true, | ||
title: "A title long enough that it wraps onto three lines at the size titles are set on a card".into(), | ||
author: "bob.test".into(), | ||
opened: Some("not a time".into()), | ||
comments: 0, | ||
reviews: 0, | ||
size: None, | ||
}, | ||
]; | ||
let mut versions = std::collections::HashSet::new(); | ||
for card in &cards { | ||
crates/knotless-serve/src/web.rs+52ā1
| @@ -2789,11 +2789,54 @@ async fn item_card(app: &App, repo: &Repo, kind: &str, thread: &Thread, author: | ||
avatar: avatar(app, &repo.did).await, | ||
name: repo.name.clone(), | ||
pull: kind == "pull", | ||
state: thread.item.state.clone(), | ||
title: thread.item.title.clone(), | ||
author: author.to_string(), | ||
opened: Some(thread.item.created_at.clone()), | ||
comments: thread.comments.len(), | ||
reviews: thread.item.reviews.len(), | ||
size: match kind { | ||
"pull" => pull_size(app, repo, &thread.item).await, | ||
_ => None, | ||
}, | ||
} | ||
} | ||
| ||
/// What a pull changes, for its card: its commits, the files it changes | ||
/// and the lines it adds and removes; none when its range cannot be read. | ||
async fn pull_size(app: &App, repo: &Repo, item: &Item) -> Option<card::Size> { | ||
let (base, head) = item.base.as_ref().zip(item.head.as_ref())?; | ||
let range = pull_range(app, repo, item, base, head).await; | ||
let (git, start, tip, _) = range.ok()?; | ||
let commits = format!("{start}..{tip}"); | ||
let commits = git.text(&["rev-list", "--count", &commits], None).await; | ||
let commits = commits.ok()?.parse().ok()?; | ||
let changed = format!("{start}...{tip}"); | ||
let stat = git.text(&["diff", "--shortstat", &changed], None).await; | ||
let (files, added, removed) = shortstat(&stat.ok()?); | ||
Some(card::Size { | ||
commits, | ||
files, | ||
added, | ||
removed, | ||
}) | ||
} | ||
| ||
/// The files, lines added and lines removed `git diff --shortstat` counts, | ||
/// as ` 4 files changed, 181 insertions(+), 18 deletions(-)`, each left | ||
/// out when none. | ||
fn shortstat(stat: &str) -> (usize, usize, usize) { | ||
let mut counted = (0, 0, 0); | ||
for part in stat.split(',') { | ||
let mut words = part.split_whitespace(); | ||
let n = words.next().and_then(|n| n.parse().ok()).unwrap_or(0); | ||
match words.next().unwrap_or_default() { | ||
w if w.starts_with("file") => counted.0 = n, | ||
w if w.starts_with("insertion") => counted.1 = n, | ||
w if w.starts_with("deletion") => counted.2 = n, | ||
_ => {} | ||
} | ||
} | ||
counted | ||
} | ||
| ||
static CARDS: LazyLock<card::Cache> = LazyLock::new(|| card::Cache::new(512)); | ||
| @@ -2951,6 +2994,14 @@ mod tests { | ||
assert!(shown.bytes().all(|b| b == b'a')); | ||
} | ||
| ||
#[test] | ||
fn shortstats() { | ||
let stat = " 4 files changed, 181 insertions(+), 18 deletions(-)\n"; | ||
assert_eq!(shortstat(stat), (4, 181, 18)); | ||
assert_eq!(shortstat(" 1 file changed, 1 deletion(-)"), (1, 0, 1)); | ||
assert_eq!(shortstat(""), (0, 0, 0)); | ||
} | ||
| ||
#[test] | ||
fn what_merges_brought() { | ||
assert_eq!(brought("m", Some("f")), "f..m"); | ||
docs/design/serve.md+8ā0
| @@ -1283,6 +1283,14 @@ and rasterizes it with resvg, using fonts compiled into the binary, so drawing | ||
needs no browser, no font files and no network. It reads only the account's | ||
picture, fetched with a 2 second limit. | ||
| ||
A pull's or an issue's card shows who opened it and the day they did. A pull's | ||
card also counts what GitHub's do: its comments, reviews and files, the | ||
lines it adds and removes with five squares of their share, and its commits. | ||
They are read from its mirror with `git rev-list --count` and `git diff | ||
--shortstat` over the range its diff shows. When that range cannot be read, | ||
as when its head's branch is gone, the card leaves the files, lines and | ||
commits out. | ||
| ||
A card's URL carries `?v=`, a hash of its SVG, so a card at its current version | ||
is immutable and changes as soon as its page does. At most one draws per core. | ||
A card drawn without its picture is not kept, so the next request tries again. | ||