~ 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

The History button counts the commits, and the Commits tab goes

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/history-count
crates/knotless-serve/src/web/repo.rs+22−3
@@ -246,6 +246,9 @@ pub struct LastCommits {
path: String,
entries: Vec<LastCommit>,
commits: Vec<CommitView>,
/// How many commits the ref's history holds: at the root only.
#[serde(rename = "commitCount", skip_serializing_if = "Option::is_none")]
commit_count: Option<usize>,
}
/// `dev.knotless.getLastCommits`: found off the server's async threads, as
@@ -257,21 +260,37 @@ pub async fn get_last_commits(
let repo = found(&app, &p.repo).await?;
let (rev, path) = located(&repo, &p)?;
let (dir, at, from) = (repo.git.dir.clone(), rev.clone(), path.clone());
let walked =
tokio::task::spawn_blocking(move || crate::objects::open(&dir)?.last_changes(&at, &from));
let walked = tokio::task::spawn_blocking(move || walk(&dir, &at, &from));
let walked = walked.await.expect("a walk does not panic");
let (entries, commits) = walked.map_err(not_found)?;
let ((entries, commits), commit_count) = walked.map_err(not_found)?;
Ok(answer(LastCommits {
entries: entries
.into_iter()
.map(|(name, sha)| LastCommit { name, sha })
.collect(),
commits: shown(&app, &repo, commits).await,
commit_count,
refname: rev,
path,
}))
}
/// The last changes of the directory `path` at `rev`, in the mirror at
/// `dir`, and at the root, how many commits its history holds.
fn walk(
dir: &std::path::Path,
rev: &str,
path: &str,
) -> anyhow::Result<(crate::objects::LastChanges, Option<usize>)> {
let objects = crate::objects::open(dir)?;
let changes = objects.last_changes(rev, path)?;
let count = match path {
"" => Some(objects.count(rev)?),
_ => None,
};
Ok((changes, count))
}
/// `dev.knotless.getFile`'s answer: one file at a revision, an image, a
/// binary, too large to show, or text with the ranges its highlighting
/// paints, and for Markdown, made into HTML too.
crates/knotless-serve/src/objects.rs+33−0
@@ -82,6 +82,16 @@ impl<K: Hash + Eq + Clone, V: Clone> Kept<K, V> {
}
}
/// The histories whose length is kept, in each of the cache's two
/// generations: a tip and a number each, so many fit in little room.
const COUNTS_KEPT: usize = 16384;
/// How many commits each tip's history holds. A commit's history never
/// changes, so a count is kept for good, and for any repository that
/// asks, as `CHANGES` are.
static COUNTED: LazyLock<Mutex<Kept<ObjectId, usize>>> =
LazyLock::new(|| Mutex::new(Kept::new(COUNTS_KEPT)));
fn kept(tip: ObjectId, path: &str) -> Option<Changes> {
CHANGES.lock().unwrap().get(&(tip, path.to_string()))
}
@@ -310,6 +320,24 @@ impl Objects {
Ok(out)
}
/// How many commits the history of `rev` holds, its own among them.
/// The walk reads the mirror's commit-graph, not the packs, and is
/// made once for each tip.
pub fn count(&self, rev: &str) -> Result<usize> {
let tip = self.commit(rev)?.id;
if let Some(n) = COUNTED.lock().unwrap().get(&tip) {
return Ok(n);
}
let walk = self.0.rev_walk([tip]).sorting(Sorting::BreadthFirst);
let mut n = 0;
for info in walk.all()? {
info?;
n += 1;
}
COUNTED.lock().unwrap().put(tip, n);
Ok(n)
}
/// The newest commit to change each entry of the directory `path` at
/// `rev`, as `log` finds each entry's history, in the tree's order, and
/// each of those commits once, newest first.
@@ -635,6 +663,11 @@ mod tests {
names.sort();
assert_eq!(names, ["main", "side", "v1"]);
assert_eq!(o.branches().unwrap(), ["main", "side"]);
// one, two, three on side, the merge and vendor; again, as kept.
assert_eq!(o.count("main").unwrap(), 5);
assert_eq!(o.count("main").unwrap(), 5);
assert_eq!(o.count("side").unwrap(), 2);
assert!(o.count("no-such").is_err());
let tree = o.tree("main", "").unwrap();
let shown: Vec<(&str, &str, Option<u64>)> = tree
crates/knotless-serve/tests/web.rs+3−0
@@ -463,9 +463,12 @@ fn browsing_a_repository() {
assert_eq!(named(&last["entries"], "src")["sha"], tip.as_str());
assert_eq!(last["commits"][0]["sha"], tip.as_str());
assert_eq!(last["commits"][0]["message"], top["lastCommit"]["message"]);
// At the root, how long its history is; below it, not said.
assert!(last["commitCount"].as_u64().is_some_and(|n| n >= 1));
let src = [("repo", uri), ("ref", "feature/x"), ("path", "src")];
let last = site.json(&xrpc("getLastCommits", &src));
assert_eq!(names(&last["entries"]), ["main.rs"]);
assert!(last.get("commitCount").is_none());
let file = [("repo", uri), ("ref", "main"), ("path", "src/main.rs")];
let file = site.get(&xrpc("getLastCommits", &file));
assert_eq!(file.status().as_u16(), 404);
docs/design/serve.md+8−0
@@ -231,6 +231,14 @@ Details:
commit's history never changes; a push on top of a kept answer applies only
its own commits' changes. A page drawn on the server waits a quarter of a
second for it, and otherwise fills it in once it comes.
- At the root, the same answer says how many commits the ref's history holds,
which the History button shows: a walk of the whole history, read from the
mirror's commit-graph rather than the packs (see
[storage](storage.md)), and kept in memory by its tip, up to 32,768 of
them, as that history never changes. A folder's button says only History,
as counting the commits that touch one path would compare every commit's
tree with its parent's. There is no Commits tab: the history pages sit
under Code, and the History button is the way to them.
- A file's blame (`dev.knotless.getBlame`) runs `git blame --porcelain` on
the mirror, as gix has no blame of its own. Blame reads every version of a
file back to where each line began, so it is bounded: a binary file is
docs/reference/api.md+1−1
@@ -539,7 +539,7 @@ name it has now.
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, and `archivedAt` if its owner archived it |
| `dev.knotless.listReleases?repo=&package=&limit=&cursor=` | its releases ([packages.md](../design/packages.md#on-the-pages)), newest first, 30 a page by default, of one `package` if given: each with `latest` when it is its package's latest, `image` when its files are an OCI layout's, its notes as Markdown and as `html`, and its files' names, sizes and SHA-256; and every package it publishes, by name, with its latest version, how many releases it has and whether it is an image. None for a private repository or without an index |
| `dev.knotless.getTree?repo=&ref=&path=` | a folder, its README and the newest checks of its last commit |
| `dev.knotless.getLastCommits?repo=&ref=&path=` | the newest commit to change each entry of a folder |
| `dev.knotless.getLastCommits?repo=&ref=&path=` | the newest commit to change each entry of a folder, and at the root, how many commits the ref's history holds |
| `dev.knotless.getFile?repo=&ref=&path=` | a file, highlighted or rendered |
| `dev.knotless.getBlame?repo=&ref=&path=` | who last changed each line of a text file, as `git blame` finds it: its text, highlighted; its lines in runs (`hunks`), each with its commit and, where the commit's parent had the file, `previous`, that parent and the file's path there; and each commit once. 400 for a binary or a file over 256 KiB |
| `dev.knotless.listCommits?repo=&ref=&path=&limit=&cursor=&subjectOnly=` | history, 50 commits a page by default, with the newest checks of each; with `subjectOnly`, each message only to its first line |
harness/shots/fixtures.json+2−2
@@ -199,7 +199,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"ref\":\"main\",\"path\":\"\",\"entries\":[{\"name\":\".gitignore\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"Cargo.toml\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"README.md\",\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"},{\"name\":\"assets\",\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"},{\"name\":\"docs\",\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"},{\"name\":\"src\",\"sha\":\"1be297a440088f5808a7cdcf5254ac71c024458f\"}],\"commits\":[{\"sha\":\"1be297a440088f5808a7cdcf5254ac71c024458f\",\"parents\":[{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"committer\":{\"name\":\"alice.test\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"date\":\"2026-10-09T09:31:00+00:00\",\"committedAt\":\"2026-10-09T15:00:00+00:00\",\"message\":\"Count no lines in empty input\\n\\n`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.\\n\\nFixes [the empty file's count](https://knotless.dev/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwh7kezpru).\",\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwh7my3ltr\",\"title\":\"Count no lines in empty input\"}},{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\",\"parents\":[{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"committedAt\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\",\"parents\":[{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-06T10:02:00+00:00\",\"committedAt\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\",\"parents\":[{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-02T11:30:00+00:00\",\"committedAt\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-01T09:12:00+00:00\",\"committedAt\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}]}"
"body": "{\"ref\":\"main\",\"path\":\"\",\"entries\":[{\"name\":\".gitignore\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"Cargo.toml\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"README.md\",\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"},{\"name\":\"assets\",\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"},{\"name\":\"docs\",\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"},{\"name\":\"src\",\"sha\":\"1be297a440088f5808a7cdcf5254ac71c024458f\"}],\"commits\":[{\"sha\":\"1be297a440088f5808a7cdcf5254ac71c024458f\",\"parents\":[{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"committer\":{\"name\":\"alice.test\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"date\":\"2026-10-09T09:31:00+00:00\",\"committedAt\":\"2026-10-09T15:00:00+00:00\",\"message\":\"Count no lines in empty input\\n\\n`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.\\n\\nFixes [the empty file's count](https://knotless.dev/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwh7kezpru).\",\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwh7my3ltr\",\"title\":\"Count no lines in empty input\"}},{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\",\"parents\":[{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"committedAt\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\",\"parents\":[{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-06T10:02:00+00:00\",\"committedAt\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\",\"parents\":[{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-02T11:30:00+00:00\",\"committedAt\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-01T09:12:00+00:00\",\"committedAt\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}],\"commitCount\":7}"
},
"/xrpc/dev.knotless.getLastCommits?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally&ref=main%2Fsrc": {
"status": 200,
@@ -215,7 +215,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"ref\":\"main\",\"path\":\"\",\"entries\":[{\"name\":\".gitignore\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\".knotless\",\"sha\":\"4c4f3483836ee19f1f64f7976d1bc44ebc9db642\"},{\"name\":\"Cargo.toml\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"README.md\",\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"},{\"name\":\"assets\",\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"},{\"name\":\"docs\",\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"},{\"name\":\"src\",\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"commits\":[{\"sha\":\"4c4f3483836ee19f1f64f7976d1bc44ebc9db642\",\"parents\":[{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-20T12:00:00+00:00\",\"committedAt\":\"2026-10-20T12:00:00+00:00\",\"message\":\"Release from tags when asked\"},{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\",\"parents\":[{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-11T08:00:00+00:00\",\"committedAt\":\"2026-10-11T08:00:00+00:00\",\"message\":\"Show usage on --help\"},{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\",\"parents\":[{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"committedAt\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\",\"parents\":[{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-06T10:02:00+00:00\",\"committedAt\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\",\"parents\":[{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-02T11:30:00+00:00\",\"committedAt\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-01T09:12:00+00:00\",\"committedAt\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}]}"
"body": "{\"ref\":\"main\",\"path\":\"\",\"entries\":[{\"name\":\".gitignore\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\".knotless\",\"sha\":\"4c4f3483836ee19f1f64f7976d1bc44ebc9db642\"},{\"name\":\"Cargo.toml\",\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\"},{\"name\":\"README.md\",\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"},{\"name\":\"assets\",\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"},{\"name\":\"docs\",\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"},{\"name\":\"src\",\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"commits\":[{\"sha\":\"4c4f3483836ee19f1f64f7976d1bc44ebc9db642\",\"parents\":[{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-20T12:00:00+00:00\",\"committedAt\":\"2026-10-20T12:00:00+00:00\",\"message\":\"Release from tags when asked\"},{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\",\"parents\":[{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-11T08:00:00+00:00\",\"committedAt\":\"2026-10-11T08:00:00+00:00\",\"message\":\"Show usage on --help\"},{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\",\"parents\":[{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"committedAt\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\",\"parents\":[{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-06T10:02:00+00:00\",\"committedAt\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"4b29199f359ce31aa300ec409cdbdfa44d347ad5\",\"parents\":[{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"}],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-02T11:30:00+00:00\",\"committedAt\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":{\"name\":\"Alice Moreau\"},\"coauthors\":[],\"date\":\"2026-10-01T09:12:00+00:00\",\"committedAt\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}],\"commitCount\":8}"
},
"/xrpc/dev.knotless.getProfile?actor=alice.test": {
"status": 200,
lexicons/dev/knotless/getLastCommits.json+2−1
@@ -32,7 +32,8 @@
"type": "array",
"description": "Each commit an entry names, once.",
"items": { "type": "ref", "ref": "dev.knotless.defs#commitView" }
}
},
"commitCount": { "type": "integer", "minimum": 0, "description": "How many commits the ref's history holds, its own among them. Only when `path` is the root." }
}
}
},
web/src/lib/organisms/FileList.svelte+13−2
@@ -3,7 +3,7 @@
import type { Snippet } from 'svelte';
import type * as getTree from '#lib/lexicons/dev/knotless/getTree.ts';
import type { LastCommits } from '#lib/data.ts';
import { age, heat, repoHref, size, subject, urlPath, whenCommitted } from '#lib/format.ts';
import { age, heat, number, plural, repoHref, size, subject, urlPath, whenCommitted } from '#lib/format.ts';
import CheckMark from '#lib/molecules/CheckMark.svelte';
import CommitAuthor from '#lib/molecules/CommitAuthor.svelte';
import LangEdge from '#lib/molecules/LangEdge.svelte';
@@ -22,6 +22,8 @@
const href = (tail: string) => repoHref(authority, name, tail);
const prefix = $derived(path ? `${path}/` : '');
const history = $derived(href(`/commits/${urlPath(rev)}/${urlPath(path)}`).replace(/\/$/, ''));
/** At the root, once `changes` has it, how many commits its history holds. */
const counted = (changes: LastCommits) => changes?.commitCount;
/** The commit that last changed the entry `entry`, among `changes`. */
const changed = (changes: LastCommits, entry: string): CommitView | undefined => {
const sha = changes?.entries.find((e) => e.name === entry)?.sha;
@@ -39,7 +41,16 @@
<CheckMark {checks} />
<Sha sha={last.sha} href={href(`/commit/${last.sha}`)} />
<span class="mute hide-sm"><Time at={whenCommitted(last)} show={age} /></span>
<a class="btn" href={history}><Icon name="commit" />History</a>
{#await changes}
<a class="history" href={history} title="History" aria-label="History"><Icon name="history" /><span class="words">History</span></a>
{:then found}
{@const n = counted(found)}
{#if n === undefined}
<a class="history" href={history} title="History" aria-label="History"><Icon name="history" /><span class="words">History</span></a>
{:else}
<a class="history" href={history} title={plural(n, 'commit', 'commits')} aria-label={plural(n, 'commit', 'commits')}><Icon name="history" /><span class="words"><b>{number(n)}</b>{` ${n === 1 ? 'commit' : 'commits'}`}</span></a>
{/if}
{/await}
</div>
{/if}
<ul class="list files">
web/src/lib/organisms/RepoHeader.svelte+1−2
@@ -1,6 +1,6 @@
<script lang="ts" module>
import type { RepoViewDetailed } from '#lib/lexicons/dev/knotless/defs.ts';
export type Tab = 'code' | 'commits' | 'pulls' | 'issues' | 'releases' | 'checks' | 'settings';
export type Tab = 'code' | 'pulls' | 'issues' | 'releases' | 'checks' | 'settings';
</script>
<script lang="ts">
@@ -51,7 +51,6 @@
</div>
<nav class="tabs" data-sveltekit-reset="false" {@attach scrollRow(tab)}>
<TabLink href={href()} label="Code" icon="code" current={tab === 'code'} />
<TabLink href={href(`/commits/${urlPath(repo.defaultBranch)}`)} label="Commits" icon="commit" current={tab === 'commits'} />
<TabLink href={href('/issues')} label="Issues" icon="issue" count={repo.openIssues} current={tab === 'issues'} />
<TabLink href={href('/pulls')} label="Pulls" icon="pull" count={repo.openPulls} current={tab === 'pulls'} />
<TabLink href={href('/releases')} label="Releases" icon="tag" count={repo.releases?.count} current={tab === 'releases'} />
web/src/lib/icons.ts+3−2
@@ -63,13 +63,14 @@ import pkg from '@primer/octicons/build/svg/package-16.svg?raw';
import container from '@primer/octicons/build/svg/container-16.svg?raw';
import versions from '@primer/octicons/build/svg/versions-16.svg?raw';
import undo from '@primer/octicons/build/svg/undo-16.svg?raw';
import history from '@primer/octicons/build/svg/history-16.svg?raw';
/** What is drawn inside an icon's `<svg>`. */
const inside = (svg: string) => svg.replace(/^<svg[^>]*>|<\/svg>\s*$/g, '');
export const icons = Object.fromEntries(
Object.entries({ code, commit, pull, pullClosed, pullDraft, issue, done, merge, dir, file, branch, tag, chat, fork, repo, repoLocked, passed, failed, running, skipped, queue, checks, check, x, chevron, caret, search, clock, star, starred, people, organization, eye, eyeClosed, bell, sync, play, gear, person, mail, paintbrush, blocked, kebab, lock, smiley, sidebarCollapse, sidebarExpand, plus, personAdd, signOut, shield, book, pulse, link, key, package: pkg, container, versions, undo, archive, mention, sun, moon }).map(([k, v]) => [k, inside(v)])
) as Record<'code' | 'commit' | 'pull' | 'pullClosed' | 'pullDraft' | 'issue' | 'done' | 'merge' | 'dir' | 'file' | 'branch' | 'tag' | 'chat' | 'fork' | 'repo' | 'repoLocked' | 'passed' | 'failed' | 'running' | 'skipped' | 'queue' | 'checks' | 'check' | 'x' | 'chevron' | 'caret' | 'search' | 'clock' | 'star' | 'starred' | 'people' | 'organization' | 'eye' | 'eyeClosed' | 'bell' | 'sync' | 'play' | 'gear' | 'person' | 'mail' | 'paintbrush' | 'blocked' | 'kebab' | 'lock' | 'smiley' | 'sidebarCollapse' | 'sidebarExpand' | 'plus' | 'personAdd' | 'signOut' | 'shield' | 'book' | 'pulse' | 'link' | 'key' | 'package' | 'container' | 'versions' | 'undo' | 'archive' | 'mention' | 'sun' | 'moon', string>;
Object.entries({ code, commit, pull, pullClosed, pullDraft, issue, done, merge, dir, file, branch, tag, chat, fork, repo, repoLocked, passed, failed, running, skipped, queue, checks, check, x, chevron, caret, search, clock, star, starred, people, organization, eye, eyeClosed, bell, sync, play, gear, person, mail, paintbrush, blocked, kebab, lock, smiley, sidebarCollapse, sidebarExpand, plus, personAdd, signOut, shield, book, pulse, link, key, package: pkg, container, versions, undo, history, archive, mention, sun, moon }).map(([k, v]) => [k, inside(v)])
) as Record<'code' | 'commit' | 'pull' | 'pullClosed' | 'pullDraft' | 'issue' | 'done' | 'merge' | 'dir' | 'file' | 'branch' | 'tag' | 'chat' | 'fork' | 'repo' | 'repoLocked' | 'passed' | 'failed' | 'running' | 'skipped' | 'queue' | 'checks' | 'check' | 'x' | 'chevron' | 'caret' | 'search' | 'clock' | 'star' | 'starred' | 'people' | 'organization' | 'eye' | 'eyeClosed' | 'bell' | 'sync' | 'play' | 'gear' | 'person' | 'mail' | 'paintbrush' | 'blocked' | 'kebab' | 'lock' | 'smiley' | 'sidebarCollapse' | 'sidebarExpand' | 'plus' | 'personAdd' | 'signOut' | 'shield' | 'book' | 'pulse' | 'link' | 'key' | 'package' | 'container' | 'versions' | 'undo' | 'history' | 'archive' | 'mention' | 'sun' | 'moon', string>;
export type IconName = keyof typeof icons;
web/src/routes/[authority]/[repo]/blame/[...rest]/+page.svelte+1−1
web/src/routes/[authority]/[repo]/blob/[...rest]/+page.svelte+1−1
web/src/routes/[authority]/[repo]/commit/[rev]/+page.svelte+1−1
web/src/routes/[authority]/[repo]/commits/[...rest]/+page.svelte+1−1
web/src/app.css+8−4