chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A repository's Fork button shows how many forks it has
crates/knotless-serve/src/web/actor.rs+4−2
| @@ -73,8 +73,10 @@ pub struct RepoView { | ||
/// How many accounts starred `did`'s repository with `key`, and how many | ||
/// forks of it there are, as `index` has them. | ||
fn counts(index: &Index, did: &str, key: &str) -> (Option<u64>, Option<u64>) { | ||
let forks = index.forks(did, key).ok().map(|f| f.len() as u64); | ||
(index.star_count(did, key).ok(), forks) | ||
( | ||
index.star_count(did, key).ok(), | ||
index.fork_count(did, key).ok(), | ||
) | ||
} | ||
| ||
/// What each private repository was last counted as mostly written in, | ||
crates/knotless-serve/src/web/views.rs+2−0
| @@ -33,6 +33,8 @@ pub struct RepoViewDetailed { | ||
pub card: String, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub star_count: Option<u64>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub fork_count: Option<u64>, | ||
/// What it publishes, in brief: none, with no releases. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub releases: Option<super::releases::ReleasesView>, | ||
crates/knotless-serve/src/index.rs+11−0
| @@ -4412,6 +4412,17 @@ impl Index { | ||
Ok(tips.into_iter().flatten().collect()) | ||
} | ||
| ||
/// How many forks of `did`'s repository `key` there are, leaving out | ||
/// those taken down, as [`Index::forks`] lists them. | ||
pub fn fork_count(&self, did: &str, key: &str) -> rusqlite::Result<u64> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT COUNT(*) FROM repo | ||
WHERE fork_did = ?1 AND fork_key = ?2 AND did NOT IN down AND uri NOT IN down", | ||
)?; | ||
stmt.query_row([did, key], |row| row.get(0)) | ||
} | ||
| ||
/// Forks of `did`'s repository `key`. | ||
pub fn forks(&self, did: &str, key: &str) -> rusqlite::Result<Vec<Listed>> { | ||
let db = self.readers.take(&self.db); | ||
crates/knotless-serve/src/web.rs+8−0
| @@ -1611,6 +1611,7 @@ impl Repo { | ||
open_issues: open.1, | ||
card: card_path(&self.href(""), &card), | ||
star_count: self.star_count(app), | ||
fork_count: self.fork_count(app), | ||
releases: releases::summary(app, self), | ||
viewer, | ||
private: self.in_space.is_some(), | ||
| @@ -1669,6 +1670,13 @@ impl Repo { | ||
index.star_count(&self.did, &self.key).ok() | ||
} | ||
| ||
/// How many forks of it there are: none for a private repository, | ||
/// whose forks are private too, or without an index. | ||
fn fork_count(&self, app: &App) -> Option<u64> { | ||
let index = app.index.as_ref().filter(|_| self.in_space.is_none())?; | ||
index.fork_count(&self.did, &self.key).ok() | ||
} | ||
| ||
/// What `viewer` has to do with it, as its `record` says, `offer` | ||
/// naming where its owner offered to move it, if anywhere. | ||
async fn viewer_state( | ||
docs/reference/api.md+3−2
| @@ -639,8 +639,9 @@ name it has now. | ||
| `dev.knotless.listWorkflows?repo=&ref=` | the workflows at a ref (the default branch), in file name order, and what runs each there: `push`, `pull`, `asked` (its owner may ask for it there by name), `error`; 404 for no such ref | | ||
| `dev.knotless.listRunners?repo=` | the runners the repository names, as accounts, for a public repository the minutes each counts for its owner (`dev.knotless.minutes`), and the key each publishes to seal secrets to (`dev.knotless.runnerKey`), as `keys`, `{runner, key}` | | ||
| ||
`getRepo`'s answer has `starCount`, how many accounts starred it, when the | ||
server keeps an index and the repository is public, and `viewer.star`, the | ||
`getRepo`'s answer has `starCount`, how many accounts starred it, and | ||
`forkCount`, how many forks of it there are, when the server keeps an | ||
index and the repository is public, and `viewer.star`, the | ||
asker's star, when it has an asker. With an index, a public repository | ||
that has published releases has `releases`: how many (`count`), the | ||
release to show under its name (`release`: the latest of the package | ||
harness/shots/fixtures.json+3−3
| @@ -249,7 +249,7 @@ | ||
"content-type": "application/json", | ||
"cache-control": "no-cache" | ||
}, | ||
"body": "{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.repo/scratch\",\"owner\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"},\"name\":\"scratch\",\"key\":\"3mz2z2irwxiwv\",\"defaultBranch\":\"main\",\"openPulls\":0,\"openIssues\":0,\"card\":\"/alice.test/scratch/card.png?v=8d5b3f136b93e209\",\"starCount\":0}" | ||
"body": "{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.repo/scratch\",\"owner\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"},\"name\":\"scratch\",\"key\":\"3mz2z2irwxiwv\",\"defaultBranch\":\"main\",\"openPulls\":0,\"openIssues\":0,\"card\":\"/alice.test/scratch/card.png?v=8d5b3f136b93e209\",\"starCount\":0,\"forkCount\":0}" | ||
}, | ||
"/xrpc/dev.knotless.getRepo?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally": { | ||
"status": 200, | ||
| @@ -257,7 +257,7 @@ | ||
"content-type": "application/json", | ||
"cache-control": "no-cache" | ||
}, | ||
"body": "{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.repo/tally\",\"owner\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"},\"name\":\"tally\",\"key\":\"3mz2z2gkqpvhk\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"links\":[{\"url\":\"https://tally.dev\"},{\"url\":\"https://docs.tally.dev\",\"title\":\"Docs\"}],\"defaultBranch\":\"main\",\"openPulls\":1,\"openIssues\":1,\"card\":\"/alice.test/tally/card.png?v=8da376836ab23847\",\"starCount\":1,\"releases\":{\"count\":4,\"release\":{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.release/3myvwh5dhhixv.wapcnu7vuujqxhmnqiofvhlb47.chxchoh4f7dbtvxkwytxvw22kj\",\"package\":\"tally\",\"version\":\"0.1.1\",\"latest\":true,\"createdAt\":\"2026-10-10T19:18:00.000Z\"},\"packages\":[{\"name\":\"tally\",\"latest\":\"0.1.1\",\"releaseCount\":3},{\"name\":\"tally-server\",\"latest\":\"0.1.0\",\"image\":true,\"releaseCount\":1}]}}" | ||
"body": "{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.repo/tally\",\"owner\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"},\"name\":\"tally\",\"key\":\"3mz2z2gkqpvhk\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"links\":[{\"url\":\"https://tally.dev\"},{\"url\":\"https://docs.tally.dev\",\"title\":\"Docs\"}],\"defaultBranch\":\"main\",\"openPulls\":1,\"openIssues\":1,\"card\":\"/alice.test/tally/card.png?v=8da376836ab23847\",\"starCount\":1,\"forkCount\":1,\"releases\":{\"count\":4,\"release\":{\"uri\":\"at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.release/3myvwh5dhhixv.wapcnu7vuujqxhmnqiofvhlb47.chxchoh4f7dbtvxkwytxvw22kj\",\"package\":\"tally\",\"version\":\"0.1.1\",\"latest\":true,\"createdAt\":\"2026-10-10T19:18:00.000Z\"},\"packages\":[{\"name\":\"tally\",\"latest\":\"0.1.1\",\"releaseCount\":3},{\"name\":\"tally-server\",\"latest\":\"0.1.0\",\"image\":true,\"releaseCount\":1}]}}" | ||
}, | ||
"/xrpc/dev.knotless.getRepo?repo=at%3A%2F%2Fbob.test%2Fdev.knotless.repo%2Ftally": { | ||
"status": 200, | ||
| @@ -265,7 +265,7 @@ | ||
"content-type": "application/json", | ||
"cache-control": "no-cache" | ||
}, | ||
"body": "{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.repo/tally\",\"owner\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"name\":\"tally\",\"key\":\"3mz2z2jedyqxn\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"defaultBranch\":\"main\",\"openPulls\":4,\"openIssues\":0,\"mergeQueue\":true,\"card\":\"/bob.test/tally/card.png?v=68776149d6ea2b31\",\"starCount\":0}" | ||
"body": "{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.repo/tally\",\"owner\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"name\":\"tally\",\"key\":\"3mz2z2jedyqxn\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"defaultBranch\":\"main\",\"openPulls\":4,\"openIssues\":0,\"mergeQueue\":true,\"card\":\"/bob.test/tally/card.png?v=68776149d6ea2b31\",\"starCount\":0,\"forkCount\":0}" | ||
}, | ||
"/xrpc/dev.knotless.getRepoSettings?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally": { | ||
"status": 200, | ||
lexicons/dev/knotless/defs.json+1−0
| @@ -125,6 +125,7 @@ | ||
"mergeQueue": { "type": "boolean", "description": "Whether its pages lead to its merge queue: a rule of its record has a branch take pulls through it, or an open pull waits in it still. Absent when neither." }, | ||
"card": { "type": "string", "description": "The path, on this appview, of the picture a link to its pages unfurls into: a new one whenever the picture changes." }, | ||
"starCount": { "type": "integer", "minimum": 0, "description": "How many accounts starred it, as the appview has indexed. Absent for a private repository, which cannot be starred, and from an appview that keeps no index." }, | ||
"forkCount": { "type": "integer", "minimum": 0, "description": "How many forks of it there are, as the appview has indexed. Absent for a private repository, whose forks are private too, and from an appview that keeps no index." }, | ||
"releases": { "type": "ref", "ref": "#releasesView", "description": "What it publishes, in brief, as the appview has indexed. Absent when it has published nothing, for a private repository, which cannot publish, and from an appview that keeps no index." }, | ||
"viewer": { "type": "ref", "ref": "#repoViewerState", "description": "What the account asking has to do with it; absent when no one asks." }, | ||
"private": { "type": "boolean", "description": "Whether it is private, in a space: shown only to its members, each logged in to this appview." }, | ||
web/src/lib/molecules/ForkButton.svelte+60−44
| @@ -11,7 +11,8 @@ | ||
* Forking `repo` into the account of whoever is logged in, `me` by | ||
* handle, under its name or another, as `knotless repo fork` does, then on | ||
* to the fork. One's own is not forked, so its button is disabled. For | ||
* anyone else, the way to log in first. | ||
* anyone else, the way to log in first. Beside it, how many forks of | ||
* it there are, where the server counts them, as Star's count is. | ||
*/ | ||
let { repo, me }: { repo: RepoViewDetailed; me?: string } = $props(); | ||
let open = $state(false); | ||
| @@ -20,6 +21,9 @@ | ||
let busy = $state(false); | ||
let blocked = $state(false); | ||
const mine = $derived(me === repo.owner.handle); | ||
const count = $derived(repo.forkCount); | ||
// The count as a few characters, 1.2K or 3M; its title keeps every digit. | ||
const short = new Intl.NumberFormat('en', { notation: 'compact' }); | ||
// Each time it opens, it starts from the repository's own name. | ||
$effect(() => { | ||
if (open) { | ||
| @@ -46,48 +50,60 @@ | ||
} | ||
</script> | ||
| ||
{#if mine} | ||
<button | ||
class="btn" | ||
type="button" | ||
disabled | ||
title="You own this repository: a fork is of someone else's"><Icon name="fork" />Fork</button | ||
> | ||
{:else if me} | ||
<details class="popover fork-menu" data-held={busy || undefined} bind:open> | ||
<summary class="btn"><Icon name="fork" />Fork</summary> | ||
<form class="menu form" onsubmit={submit}> | ||
<div class="menu-head">Fork into your account</div> | ||
<label for="fork-name">Name</label> | ||
<div class="owned"> | ||
<span class="mute">{me} /</span><input | ||
id="fork-name" | ||
name="name" | ||
maxlength="100" | ||
autocapitalize="none" | ||
spellcheck="false" | ||
required | ||
bind:value={name} | ||
/> | ||
</div> | ||
<!-- Asked only while it is open, not on every page with the button. --> | ||
{#if open}<NameCheck owner={me} {name} bind:blocked />{/if} | ||
<p class="hint"> | ||
{repo.private | ||
? `Every branch and tag is copied. The fork is private too, in a space of its own, and ${repo.owner.handle} may read it, to take pulls from it.` | ||
: 'Every branch and tag is copied, and the fork names where it came from.'} | ||
</p> | ||
{#if problem}<p class="problem" role="alert">{problem}</p>{/if} | ||
<div> | ||
<button | ||
class="btn primary" | ||
type="submit" | ||
disabled={busy || (open && blocked)} | ||
aria-busy={busy}>{busy ? 'Forking…' : 'Create fork'}</button | ||
> | ||
</div> | ||
</form> | ||
</details> | ||
{#snippet fork()} | ||
{#if mine} | ||
<button | ||
class="btn" | ||
type="button" | ||
disabled | ||
title="You own this repository: a fork is of someone else's"><Icon name="fork" />Fork</button | ||
> | ||
{:else if me} | ||
<details class="popover fork-menu" data-held={busy || undefined} bind:open> | ||
<summary class="btn"><Icon name="fork" />Fork</summary> | ||
<form class="menu form" onsubmit={submit}> | ||
<div class="menu-head">Fork into your account</div> | ||
<label for="fork-name">Name</label> | ||
<div class="owned"> | ||
<span class="mute">{me} /</span><input | ||
id="fork-name" | ||
name="name" | ||
maxlength="100" | ||
autocapitalize="none" | ||
spellcheck="false" | ||
required | ||
bind:value={name} | ||
/> | ||
</div> | ||
<!-- Asked only while it is open, not on every page with the button. --> | ||
{#if open}<NameCheck owner={me} {name} bind:blocked />{/if} | ||
<p class="hint"> | ||
{repo.private | ||
? `Every branch and tag is copied. The fork is private too, in a space of its own, and ${repo.owner.handle} may read it, to take pulls from it.` | ||
: 'Every branch and tag is copied, and the fork names where it came from.'} | ||
</p> | ||
{#if problem}<p class="problem" role="alert">{problem}</p>{/if} | ||
<div> | ||
<button | ||
class="btn primary" | ||
type="submit" | ||
disabled={busy || (open && blocked)} | ||
aria-busy={busy}>{busy ? 'Forking…' : 'Create fork'}</button | ||
> | ||
</div> | ||
</form> | ||
</details> | ||
{:else} | ||
<button class="btn" type="button" popovertarget={LOGIN}><Icon name="fork" />Fork</button> | ||
{/if} | ||
{/snippet} | ||
| ||
{#if count === undefined} | ||
{@render fork()} | ||
{:else} | ||
<button class="btn" type="button" popovertarget={LOGIN}><Icon name="fork" />Fork</button> | ||
<div class="counted"> | ||
{@render fork()}<span class="count" title="{count} {count === 1 ? 'fork' : 'forks'}" | ||
>{short.format(count)}</span | ||
> | ||
</div> | ||
{/if} | ||
web/src/lib/molecules/StarButton.svelte+1−1
| @@ -30,7 +30,7 @@ | ||
} | ||
</script> | ||
| ||
<div class="star"> | ||
<div class="star counted"> | ||
{#if logged} | ||
<button | ||
class="btn" | ||
web/src/app.css+10−4
| @@ -1831,12 +1831,17 @@ a.row:hover { | ||
white-space: nowrap; | ||
color: var(--mute); | ||
} | ||
.star { | ||
/* A button with its count joined on, as Star and Fork have. */ | ||
.counted { | ||
display: flex; | ||
} | ||
.star .btn { | ||
.counted > .btn, | ||
.counted > details > summary.btn { | ||
border-radius: var(--radius-md) 0 0 var(--radius-md); | ||
} | ||
.counted > details { | ||
display: flex; | ||
} | ||
.star .btn[aria-pressed='true'] .i { | ||
color: var(--yellow); | ||
} | ||
| @@ -1904,7 +1909,7 @@ a.row:hover { | ||
font-size: var(--text-xs); | ||
color: var(--danger); | ||
} | ||
.star .count { | ||
.counted > .count { | ||
display: inline-flex; | ||
align-items: center; | ||
height: var(--control); | ||
| @@ -7151,7 +7156,8 @@ textarea.field { | ||
justify-content: center; | ||
} | ||
.repo-actions :is(.fork-menu, .watch-menu) > summary, | ||
.repo-actions .star .btn { | ||
.repo-actions .counted > .btn, | ||
.repo-actions .counted > details { | ||
flex: 1 1 0; | ||
min-width: 0; | ||
justify-content: center; | ||