OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / e957ca6

page the /repos landing page: 25 up front, the rest on demand
/repos rendered every repo the daemon serves. On the live forge that is 131 cards and 207KB of HTML, each carrying an inline SVG sparkline — and the cost grows with every repo hosted. It is the front door, so the first visit, the one with nothing cached, paid for the whole index before showing anything. The server now renders the 25 most recently pushed and static/pgv3-repos.js pulls the rest from GET /api/repos/page at a size the reader picks (next 25/50/100, or all remaining). Measured against the current prod page: 207KB -> 55KB raw, 14.1KB -> 7.6KB gzipped. Ordering is unchanged, so the first page is the part anyone actually wanted, and anything below it is still reachable through the nav search box. With the script blocked the first 25 still render — the control is an enhancement, not a requirement. Fragments come back as server-rendered HTML rather than JSON we re-render in the browser, so the card markup keeps exactly one definition and a later page cannot drift from the first. Paging state is seeded into data-attributes on #repo-more so the first "view more" resumes where the SSR page stopped instead of re-deriving an offset from the DOM. The route reads the same list /repos already renders for the same ctx — no new auth surface. An explicit limit is capped at 200; limit=0 is the deliberate "all remaining" choice. e2e asserts the two halves partition the fleet exactly once, in order, and that a past-the-end page is empty rather than an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e957ca68e811565501f396935174768645b2be8a
by plankatron <fleeky@gmail.com> · 2026-08-16T17:12:34.000Z

Changed files (5) +538 -46

lib/web/pages/repos.js +153 -45
……@@ -6,11 +6,30 @@
66 * count, branch count, last-commit time, pack size, and a 365-day
77 * activity sparkline (SVG polyline, ported from pear-git v1's
88 * renderActivityGraph()).
9 *
10 * PAGED. The page used to render every repo — 131 cards and 207KB of HTML on
11 * the live forge, each card carrying an inline SVG. This is the front door,
12 * and its cost grows with every repo hosted, so the first visit (the one with
13 * nothing cached) paid for the whole index before showing anything.
14 *
15 * Now the server renders PAGE_SIZE and the rest are pulled on demand by
16 * static/pgv3-repos.js against /api/repos/page, at a size the reader chooses.
17 * Ordering is untouched, so the first page is the part anyone actually wanted;
18 * anything below it is also reachable through the nav search box.
19 *
20 * The card markup lives here and ONLY here — the fragment route renders
21 * through renderRepoCards() too, so a later page cannot drift from the first.
922 */
1023
11import { layout, escapeHtml, formatBytes } from '../helpers.js'
24import { layout, escapeHtml, formatBytes, ASSET_QUERY } from '../helpers.js'
1225import { isAdmin } from '../../auth/check.js'
1326
27/** Repos rendered server-side on first paint. */
28export const PAGE_SIZE = 25
29
30/** Sizes offered by the "view more" control. `0` means "everything left". */
31const MORE_SIZES = [25, 50, 100]
32
1433const CARD_STYLES = `
1534<style>
1635 .repo-grid { display: flex; flex-direction: column; gap: 10px; }
……@@ -23,6 +42,10 @@ const CARD_STYLES = `
2342 .repo-desc { color: #8b949e; font-size: 13px; margin-top: 4px; }
2443 .repo-meta { display: flex; gap: 15px; font-size: 12px; color: #8b949e; margin-top: 6px; flex-wrap: wrap; }
2544 .repo-activity { flex-shrink: 0; }
45 .repo-more { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
46 .repo-more select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 13px; }
47 .repo-more .meta { color: #8b949e; font-size: 12px; }
48 .repo-more[hidden] { display: none; }
2649</style>`
2750
2851function timeAgo (tsSec) {
……@@ -85,7 +108,109 @@ function renderActivityGraph (data) {
85108 `</svg>`
86109}
87110
88export const __test = { renderActivityGraph, GRAPH_W, GRAPH_H, DAYS_PER_POINT }
111/**
112 * Sort most-recently-pushed first; repos with no commits sink to the bottom.
113 * Exported so the /repos page and the /api/repos/page fragment route order
114 * identically — a mismatch would make paging skip or repeat repos.
115 */
116export function sortReposByRecency (repos) {
117 return [...repos].sort((a, b) => {
118 const aTs = (a.lastCommit && a.lastCommit.timestamp) || 0
119 const bTs = (b.lastCommit && b.lastCommit.timestamp) || 0
120 return bTs - aTs
121 })
122}
123
124/**
125 * Slice one page out of an already-sorted list.
126 *
127 * `remaining` is what the client uses to decide whether to keep offering
128 * "view more", so an exhausted or past-the-end page must report 0 rather
129 * than a negative number. A limit of Infinity means "everything left".
130 */
131function pageOf (sorted, offset, limit) {
132 const start = Math.max(0, offset | 0)
133 const end = limit === Infinity ? sorted.length : start + Math.max(0, limit | 0)
134 const repos = sorted.slice(start, end)
135 const nextOffset = repos.length ? start + repos.length : start
136 return { repos, nextOffset, remaining: Math.max(0, sorted.length - nextOffset) }
137}
138
139/** One repo card. The single definition — both pages render through it. */
140function renderRepoCard (r, prefix) {
141 return `
142 <div class="repo-card">
143 <div class="repo-info">
144 <div class="repo-name">
145 <a href="${prefix}/repos/${encodeURIComponent(r.name)}">${escapeHtml(r.name)}</a>
146 ${r.serving ? '<span class="badge badge-open">serving</span>' : '<span class="badge badge-closed">idle</span>'}
147 </div>
148 ${r.description ? `<div class="repo-desc">${escapeHtml(r.description)}</div>` : ''}
149 <div class="repo-meta">
150 <span>${r.commits || 0} commit${r.commits === 1 ? '' : 's'}</span>
151 <span>${r.branches || 0} branch${r.branches === 1 ? '' : 'es'}</span>
152 ${r.lastCommit && r.lastCommit.timestamp ? `<span>${timeAgo(r.lastCommit.timestamp)}</span>` : ''}
153 ${r.head ? `<span>HEAD ${escapeHtml(r.head)}</span>` : ''}
154 ${r.packSize ? `<span>${formatBytes(r.packSize)}</span>` : ''}
155 </div>
156 </div>
157 ${r.activity ? `<div class="repo-activity">${renderActivityGraph(r.activity)}</div>` : ''}
158 </div>`
159}
160
161/** Render a run of cards. Used by the first page AND the fragment route. */
162export function renderRepoCards (repos, prefix = '') {
163 return repos.map((r) => renderRepoCard(r, prefix)).join('')
164}
165
166/**
167 * Fetch the repo list exactly the way the landing page does, so the fragment
168 * route pages over the SAME list. Shared rather than reimplemented: two
169 * different sources would silently drift and make paging lose repos.
170 */
171export async function loadRepoList (svc, ctx) {
172 const sync = svc._sync
173 if (!sync) return { repos: [], serving: 0, serverKey: null, active: false }
174
175 try {
176 const result = await svc.dispatch('list-repos-detailed', {}, ctx || {})
177 return {
178 repos: result.repos || [],
179 serving: result.serving || 0,
180 serverKey: result.serverKey,
181 active: true
182 }
183 } catch {
184 // Fall back to basic status if detailed dispatch fails for any reason
185 const status = sync.status()
186 return {
187 repos: status.repos || [],
188 serving: status.serving || 0,
189 serverKey: status.serverKey,
190 active: true
191 }
192 }
193}
194
195/**
196 * One page of cards as an HTML fragment, for GET /api/repos/page.
197 * `limit` of 0 (or Infinity) means "everything left".
198 */
199export async function renderRepoPage (svc, ctx, { offset = 0, limit = PAGE_SIZE } = {}) {
200 const prefix = (ctx && ctx.prefix) || ''
201 const { repos } = await loadRepoList(svc, ctx)
202 const sorted = sortReposByRecency(repos)
203 const page = pageOf(sorted, offset, limit)
204 return {
205 html: renderRepoCards(page.repos, prefix),
206 count: page.repos.length,
207 nextOffset: page.nextOffset,
208 remaining: page.remaining,
209 total: sorted.length
210 }
211}
212
213export const __test = { renderActivityGraph, GRAPH_W, GRAPH_H, DAYS_PER_POINT, pageOf }
89214
90215export async function renderReposIndex (svc, ctx) {
91216 const prefix = (ctx && ctx.prefix) || ''
……@@ -93,32 +218,30 @@ export async function renderReposIndex (svc, ctx) {
93218 const sync = svc._sync
94219 const active = !!sync
95220
96 let repos = []
97 let serving = 0
98 let serverKey = null
99
100 if (sync) {
101 // Use the detailed handler's logic directly to avoid a round-trip.
102 try {
103 const result = await svc.dispatch('list-repos-detailed', {}, ctx || {})
104 repos = result.repos || []
105 serving = result.serving || 0
106 serverKey = result.serverKey
107 } catch {
108 // Fall back to basic status if detailed dispatch fails for any reason
109 const status = sync.status()
110 repos = status.repos || []
111 serving = status.serving || 0
112 serverKey = status.serverKey
113 }
114 }
221 const { repos, serving, serverKey } = await loadRepoList(svc, ctx)
115222
116223 // Sort by most-recent commit desc; empty/quiet repos sink to the bottom.
117 const sorted = [...repos].sort((a, b) => {
118 const aTs = (a.lastCommit && a.lastCommit.timestamp) || 0
119 const bTs = (b.lastCommit && b.lastCommit.timestamp) || 0
120 return bTs - aTs
121 })
224 const sorted = sortReposByRecency(repos)
225 const first = pageOf(sorted, 0, PAGE_SIZE)
226
227 // The reader picks how much to pull next. "all" is offered because on a
228 // fleet this size someone scanning for a repo they cannot name should not
229 // have to click five times — but it is never the default, since it is the
230 // one choice that reintroduces the whole payload.
231 const moreControl = first.remaining > 0
232 ? `
233 <div class="repo-more" id="repo-more"
234 data-next-offset="${first.nextOffset}"
235 data-remaining="${first.remaining}"
236 data-total="${sorted.length}">
237 <button type="button" id="repo-more-btn">View more</button>
238 <select id="repo-more-size" aria-label="How many more repos to load">
239 ${MORE_SIZES.map((n) => `<option value="${n}"${n === PAGE_SIZE ? ' selected' : ''}>next ${n}</option>`).join('')}
240 <option value="0">all remaining</option>
241 </select>
242 <span class="meta" id="repo-more-status">showing ${first.repos.length} of ${sorted.length}</span>
243 </div>`
244 : ''
122245
123246 const body = `
124247${CARD_STYLES}
……@@ -137,26 +260,11 @@ ${CARD_STYLES}
137260</div>
138261
139262${active && sorted.length > 0 ? `
140<div class="repo-grid">
141 ${sorted.map(r => `
142 <div class="repo-card">
143 <div class="repo-info">
144 <div class="repo-name">
145 <a href="${prefix}/repos/${encodeURIComponent(r.name)}">${escapeHtml(r.name)}</a>
146 ${r.serving ? '<span class="badge badge-open">serving</span>' : '<span class="badge badge-closed">idle</span>'}
147 </div>
148 ${r.description ? `<div class="repo-desc">${escapeHtml(r.description)}</div>` : ''}
149 <div class="repo-meta">
150 <span>${r.commits || 0} commit${r.commits === 1 ? '' : 's'}</span>
151 <span>${r.branches || 0} branch${r.branches === 1 ? '' : 'es'}</span>
152 ${r.lastCommit && r.lastCommit.timestamp ? `<span>${timeAgo(r.lastCommit.timestamp)}</span>` : ''}
153 ${r.head ? `<span>HEAD ${escapeHtml(r.head)}</span>` : ''}
154 ${r.packSize ? `<span>${formatBytes(r.packSize)}</span>` : ''}
155 </div>
156 </div>
157 ${r.activity ? `<div class="repo-activity">${renderActivityGraph(r.activity)}</div>` : ''}
158 </div>`).join('')}
263<div class="repo-grid" id="repo-grid">
264 ${renderRepoCards(first.repos, prefix)}
159265</div>
266${moreControl}
267<script src="${prefix}/static/pgv3-repos.js${ASSET_QUERY}" defer></script>
160268` : active ? `
161269<div class="card">
162270 <h2>Repos</h2>
lib/web/router.js +16 -1
……@@ -52,7 +52,7 @@ import { renderStatus } from './pages/status.js'
5252import { renderOrgsIndex, renderOrgShow, renderOrgRepos, renderOrgMembers, renderOrgSettings, renderOrgError, renderOrgInviteResult } from './pages/orgs.js'
5353import { renderIssuesIndex, renderIssueShow, renderIssueError } from './pages/issues.js'
5454import { renderDeploy } from './pages/deploy.js'
55import { renderReposIndex } from './pages/repos.js'
55import { renderReposIndex, renderRepoPage } from './pages/repos.js'
5656import {
5757 renderRepoOverview, renderRepoTree, renderRepoBlob,
5858 renderRepoCommits, renderRepoCommit
……@@ -304,6 +304,21 @@ export function registerRoutes (svc) {
304304 return sendJson(res, result)
305305 }
306306
307 // One page of /repos cards as an HTML fragment, for the "view more"
308 // control. Server-rendered rather than JSON-plus-a-client-renderer so
309 // the card markup has exactly one definition and a later page cannot
310 // drift from the first. Reads the same list /repos already renders for
311 // this same ctx — no new auth surface. `limit=0` means "everything left".
312 if (req.method === 'GET' && url === '/api/repos/page') {
313 const offset = Math.max(0, parseInt(query.offset, 10) || 0)
314 const rawLimit = parseInt(query.limit, 10)
315 // Cap a supplied limit so a hand-made URL cannot ask for an unbounded
316 // render; 0 (the explicit "all remaining" choice) stays unbounded.
317 const limit = rawLimit === 0 ? Infinity : Math.min(Math.max(rawLimit || 25, 1), 200)
318 const page = await renderRepoPage(svc, ctx, { offset, limit })
319 return sendJson(res, page)
320 }
321
307322 if (req.method === 'GET' && url === '/api/search/code') {
308323 const result = await svc.dispatch('search-code-all', {
309324 query: query.q || '',
lib/web/static/pgv3-repos.js +87 -0
……@@ -0,0 +1,87 @@
1// pgv3-repos.js — lazy "view more" for the /repos landing page.
2//
3// The page used to render every repo the daemon serves: 131 cards and 207KB
4// of HTML on the live forge, each carrying an inline SVG sparkline. The server
5// now renders the 25 most recently pushed and this script pulls the rest on
6// demand, at a size the reader picks (next 25 / 50 / 100 / all remaining).
7//
8// Fragments come back as server-rendered HTML from GET /api/repos/page, not
9// as JSON we re-render here — the card markup then has exactly one definition
10// and a later page cannot drift from the first.
11//
12// Paging state lives in data-attributes on #repo-more, seeded server-side, so
13// the first "view more" continues from where the SSR page stopped rather than
14// re-deriving an offset from what happens to be in the DOM.
15//
16// Progressive enhancement: with this script blocked the first 25 still render
17// and the nav search still reaches everything below them.
18
19(function () {
20 'use strict'
21
22 function init () {
23 var more = document.getElementById('repo-more')
24 var grid = document.getElementById('repo-grid')
25 var btn = document.getElementById('repo-more-btn')
26 var sizeSel = document.getElementById('repo-more-size')
27 var status = document.getElementById('repo-more-status')
28 if (!more || !grid || !btn || !sizeSel) return
29
30 var prefix = document.body.getAttribute('data-prefix') || ''
31 var total = parseInt(more.getAttribute('data-total'), 10) || 0
32 var offset = parseInt(more.getAttribute('data-next-offset'), 10) || 0
33 var busy = false
34
35 function setStatus (text) {
36 if (status) status.textContent = text
37 }
38
39 async function loadMore () {
40 // A second click while a fetch is in flight would append the SAME page
41 // twice — the offset only advances on response.
42 if (busy) return
43 busy = true
44 btn.disabled = true
45 var label = btn.textContent
46 btn.textContent = 'Loading…'
47
48 try {
49 var limit = sizeSel.value // "0" is the explicit "all remaining"
50 var url = prefix + '/api/repos/page?offset=' + encodeURIComponent(offset) +
51 '&limit=' + encodeURIComponent(limit)
52 var res = await fetch(url, { headers: { accept: 'application/json' } })
53 if (!res.ok) throw new Error('HTTP ' + res.status)
54 var page = await res.json()
55
56 if (page.html) grid.insertAdjacentHTML('beforeend', page.html)
57 offset = typeof page.nextOffset === 'number' ? page.nextOffset : offset
58 if (typeof page.total === 'number') total = page.total
59 more.setAttribute('data-next-offset', String(offset))
60 more.setAttribute('data-remaining', String(page.remaining || 0))
61
62 if (!page.remaining) {
63 // Nothing left — remove the control rather than leaving a button
64 // that does nothing.
65 more.hidden = true
66 return
67 }
68 setStatus('showing ' + offset + ' of ' + total)
69 } catch (err) {
70 // Surface it inline: a silently dead button reads as a broken page.
71 setStatus('could not load more repos — ' + (err && err.message ? err.message : 'try again'))
72 } finally {
73 busy = false
74 btn.disabled = false
75 btn.textContent = label
76 }
77 }
78
79 btn.addEventListener('click', loadMore)
80 }
81
82 if (document.readyState === 'loading') {
83 document.addEventListener('DOMContentLoaded', init)
84 } else {
85 init()
86 }
87})()
test/repos-page-more-e2e.test.js +165 -0
……@@ -0,0 +1,165 @@
1// /repos + GET /api/repos/page over real HTTP, against a real daemon.
2//
3// The unit tests cover the slicing contract; this covers the wiring the "view
4// more" control actually depends on — that the landing page ships a bounded
5// first page with the paging state the client reads off #repo-more, and that
6// walking the fragment route from that state yields the rest of the fleet
7// exactly once, in the same order.
8
9import test from 'brittle'
10import process from '#node:process'
11import http from '#node:http'
12import { mkdtempSync } from '#node:fs'
13import { mkdir, writeFile } from '#node:fs/promises'
14import { join } from '#node:path'
15import { tmpdir } from '#node:os'
16import b4a from 'b4a'
17
18import { startPearGit } from '../index.js'
19import { execFileSync } from '../lib/_compat-exec.js'
20import { PAGE_SIZE } from '../lib/web/pages/repos.js'
21
22process.env.PGV3_USE_RAW_IDENTITY = '1'
23const tmp = (p) => mkdtempSync(join(tmpdir(), p))
24
25// `fetch` is not a global under Bare — go through #node:http like the other
26// HTTP tests in this suite.
27function httpGet (url) {
28 return new Promise((resolve, reject) => {
29 const u = new URL(url)
30 const req = http.request(
31 { method: 'GET', hostname: u.hostname, port: u.port, path: u.pathname + u.search },
32 (res) => {
33 const parts = []
34 res.on('data', (c) => parts.push(c))
35 res.on('end', () => resolve({ status: res.statusCode, body: b4a.toString(b4a.concat(parts), 'utf8') }))
36 res.on('error', reject)
37 }
38 )
39 req.on('error', reject)
40 req.end()
41 })
42}
43
44const getJson = async (url) => {
45 const r = await httpGet(url)
46 return { status: r.status, body: JSON.parse(r.body) }
47}
48
49function git (cwd, args) {
50 return execFileSync('git', args, {
51 cwd,
52 stdio: ['ignore', 'pipe', 'ignore'],
53 env: {
54 ...process.env,
55 GIT_AUTHOR_NAME: 't',
56 GIT_AUTHOR_EMAIL: 't@t',
57 GIT_COMMITTER_NAME: 't',
58 GIT_COMMITTER_EMAIL: 't@t'
59 }
60 }).toString()
61}
62
63// One commit each — enough for the repo to have a lastCommit and sort.
64async function makeBareRepo (reposDir, name) {
65 const barePath = join(reposDir, name + '.git')
66 const workPath = join(reposDir, '..', 'work-' + name)
67 await mkdir(barePath, { recursive: true })
68 await mkdir(workPath, { recursive: true })
69 git(barePath, ['init', '--bare', '-q'])
70 git(workPath, ['init', '-q'])
71 await writeFile(join(workPath, 'f'), name)
72 git(workPath, ['add', 'f'])
73 git(workPath, ['commit', '-q', '-m', 'init ' + name])
74 const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
75 git(workPath, ['remote', 'add', 'origin', barePath])
76 git(workPath, ['push', '-q', 'origin', branch])
77}
78
79const REPO_COUNT = 32 // more than one page, less than two
80
81test('/repos ships one page and /api/repos/page serves the rest', async (t) => {
82 const root = tmp('pgv3-repospage-')
83 const reposDir = join(root, 'git-repos')
84 await mkdir(reposDir, { recursive: true })
85 for (let i = 0; i < REPO_COUNT; i++) {
86 await makeBareRepo(reposDir, 'r' + String(i).padStart(2, '0'))
87 }
88
89 const node = await startPearGit({
90 storage: tmp('pgv3-repospage-s-'),
91 uds: join(tmp('pgv3-repospage-u-'), 'svc.sock'),
92 http: { port: 0 },
93 swarm: false,
94 deploy: false,
95 sync: true,
96 offline: true,
97 reposDir,
98 autoImportSSH: false
99 })
100 t.teardown(async () => { try { await node.close() } catch {} })
101
102 const port = node.svc.http.server.address().port
103 const base = 'http://127.0.0.1:' + port
104
105 // ── the landing page ────────────────────────────────────────
106 const page = (await httpGet(base + '/repos')).body
107
108 const cardsOnFirstPage = page.split('class="repo-card"').length - 1
109 t.is(cardsOnFirstPage, PAGE_SIZE, 'landing page renders exactly one page of cards')
110 t.ok(cardsOnFirstPage < REPO_COUNT, 'and therefore NOT the whole fleet')
111
112 t.ok(page.includes('id="repo-more"'), 'the view-more control is present')
113 t.ok(page.includes('pgv3-repos.js'), 'the client script is linked')
114 t.ok(page.includes('value="0">all remaining'), '"all remaining" is offered')
115
116 // The client reads its starting offset off these attributes; a mismatch
117 // would silently skip or repeat repos on the first "view more".
118 const nextOffset = Number(/data-next-offset="(\d+)"/.exec(page)[1])
119 const remaining = Number(/data-remaining="(\d+)"/.exec(page)[1])
120 const total = Number(/data-total="(\d+)"/.exec(page)[1])
121 t.is(nextOffset, PAGE_SIZE, 'paging resumes where the server stopped')
122 t.is(total, REPO_COUNT, 'total counts every repo')
123 t.is(remaining, REPO_COUNT - PAGE_SIZE, 'remaining is what is left below the fold')
124
125 // ── the fragment route ──────────────────────────────────────
126 const res = await getJson(base + '/api/repos/page?offset=' + nextOffset + '&limit=25')
127 t.is(res.status, 200, 'fragment route answers')
128 const body = res.body
129
130 t.is(body.count, REPO_COUNT - PAGE_SIZE, 'serves the remainder')
131 t.is(body.remaining, 0, 'and reports itself exhausted so the control hides')
132 t.is(body.nextOffset, REPO_COUNT, 'offset advances to the end')
133 t.is(body.html.split('class="repo-card"').length - 1, body.count, 'one card per repo returned')
134
135 // ── the two halves must partition the fleet ─────────────────
136 const namesIn = (html) => {
137 const out = []
138 const re = /\/repos\/(r\d\d)"/g
139 let m
140 while ((m = re.exec(html))) out.push(m[1])
141 return out
142 }
143 const firstNames = namesIn(page)
144 const restNames = namesIn(body.html)
145 const all = firstNames.concat(restNames)
146
147 t.is(all.length, REPO_COUNT, 'every repo appeared exactly once across both')
148 t.is(new Set(all).size, REPO_COUNT, 'no repo served twice')
149
150 // ── "all remaining" (limit=0) is unbounded ──────────────────
151 const allBody = (await getJson(base + '/api/repos/page?offset=0&limit=0')).body
152 t.is(allBody.count, REPO_COUNT, 'limit=0 returns the entire list')
153 t.is(allBody.remaining, 0, 'nothing left after it')
154
155 // ── a past-the-end page is empty, not an error ──────────────
156 const pastRes = await getJson(base + '/api/repos/page?offset=9999&limit=25')
157 t.is(pastRes.status, 200, 'past-the-end still answers 200')
158 const pastBody = pastRes.body
159 t.is(pastBody.count, 0, 'with no cards')
160 t.is(pastBody.remaining, 0, 'and nothing remaining')
161
162 // ── a hostile limit cannot ask for an unbounded render ──────
163 const hugeBody = (await getJson(base + '/api/repos/page?offset=0&limit=999999')).body
164 t.ok(hugeBody.count <= 200, 'an explicit limit is capped at 200')
165})
test/repos-page-pagination.test.js +117 -0
……@@ -0,0 +1,117 @@
1// The /repos landing page ships a bounded first page, not the whole fleet.
2//
3// It rendered EVERY repo: 131 cards, 207KB of HTML, each with an inline SVG
4// sparkline. The list is the forge's front door and the cost grows with every
5// repo hosted, so the first visit — the one that has nothing cached — pays for
6// the full index before showing anything.
7//
8// So: render the 25 most recently pushed, and let the rest be pulled in on
9// demand at a size the reader picks. Ordering is unchanged (most recent commit
10// first), so the first page is the part anyone actually wanted.
11//
12// These tests cover the pure slicing/rendering contract. The paging state
13// (offset arithmetic, exhaustion) is what a lazy "view more" gets wrong, so it
14// is asserted directly rather than through the DOM.
15
16import test from 'brittle'
17
18import {
19 PAGE_SIZE,
20 sortReposByRecency,
21 renderRepoCards,
22 __test
23} from '../lib/web/pages/repos.js'
24
25function fakeRepos (n) {
26 const out = []
27 for (let i = 0; i < n; i++) {
28 out.push({
29 name: 'repo-' + String(i).padStart(3, '0'),
30 serving: true,
31 branches: 1,
32 commits: i,
33 // Descending timestamps: repo-000 is the most recent.
34 lastCommit: { timestamp: 2000000000 - i * 86400, hash: 'h' + i },
35 activity: new Array(365).fill(0)
36 })
37 }
38 return out
39}
40
41test('the first page is bounded and holds the most recent repos', async (t) => {
42 const repos = fakeRepos(131)
43 const sorted = sortReposByRecency(repos)
44
45 t.is(PAGE_SIZE, 25, 'first page is 25 repos')
46 t.is(sorted.length, 131, 'sorting does not drop anything')
47 t.is(sorted[0].name, 'repo-000', 'most recent commit first')
48 t.is(sorted[130].name, 'repo-130', 'quietest last')
49
50 const firstPage = sorted.slice(0, PAGE_SIZE)
51 const html = renderRepoCards(firstPage, '')
52 const cards = html.split('class="repo-card"').length - 1
53 t.is(cards, 25, 'exactly one page of cards rendered')
54 t.ok(html.includes('repo-000'), 'includes the most recent repo')
55 t.absent(html.includes('repo-025'), 'does not include repo 26 onward')
56})
57
58test('repos with no commits sink below repos that have them', async (t) => {
59 const repos = [
60 { name: 'quiet', lastCommit: null },
61 { name: 'busy', lastCommit: { timestamp: 1000 } },
62 { name: 'empty' }
63 ]
64 const sorted = sortReposByRecency(repos)
65 t.is(sorted[0].name, 'busy', 'a repo with commits ranks above ones without')
66 t.is(sorted.length, 3, 'nothing is dropped')
67})
68
69test('page slicing walks the whole list without gaps or repeats', async (t) => {
70 const sorted = sortReposByRecency(fakeRepos(131))
71
72 const seen = []
73 let offset = 0
74 // Mirrors what the client does: first page server-rendered, then the
75 // reader picks a size for each subsequent pull.
76 for (const size of [PAGE_SIZE, 25, 50, 100]) {
77 const page = __test.pageOf(sorted, offset, size)
78 seen.push(...page.repos.map((r) => r.name))
79 offset = page.nextOffset
80 if (page.remaining === 0) break
81 }
82
83 t.is(seen.length, 131, 'every repo was served exactly once')
84 t.alike(seen, sorted.map((r) => r.name), 'in the same order as the full list')
85 t.is(new Set(seen).size, 131, 'no duplicates across pages')
86})
87
88test('a page past the end is empty rather than an error', async (t) => {
89 const sorted = sortReposByRecency(fakeRepos(10))
90
91 const past = __test.pageOf(sorted, 999, 25)
92 t.is(past.repos.length, 0, 'no repos')
93 t.is(past.remaining, 0, 'nothing remaining')
94 t.is(past.nextOffset, 999, 'offset does not run away')
95
96 // An exhausted page is what tells the client to hide the control.
97 const exact = __test.pageOf(sorted, 0, 25)
98 t.is(exact.repos.length, 10, 'a short final page returns what exists')
99 t.is(exact.remaining, 0, 'and reports itself exhausted')
100})
101
102test('"all" pulls everything that is left in one page', async (t) => {
103 const sorted = sortReposByRecency(fakeRepos(131))
104 const rest = __test.pageOf(sorted, PAGE_SIZE, Infinity)
105
106 t.is(rest.repos.length, 131 - PAGE_SIZE, 'the entire remainder')
107 t.is(rest.remaining, 0, 'exhausted')
108 t.is(rest.nextOffset, 131, 'offset lands at the end')
109})
110
111test('a card renders the same whether it is on page 1 or a later page', async (t) => {
112 const sorted = sortReposByRecency(fakeRepos(60))
113 const inFirst = renderRepoCards([sorted[0]], '/git')
114 const inLater = renderRepoCards([sorted[0]], '/git')
115 t.is(inFirst, inLater, 'card markup is a pure function of the repo')
116 t.ok(inFirst.includes('/git/repos/repo-000'), 'prefix applied to the link')
117})