~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A pull's card counts what it changes, and cards show when, not state

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/pull-card-stats
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

red can be 5 while added > 0, which leaves green with no square. Clamp it to 1..=4 when both sides are non-zero.

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.