~ 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 repository's Fork button shows how many forks it has

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/fork-count
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;