| … | … | @@ -6,11 +6,30 @@ |
| 6 | 6 | * count, branch count, last-commit time, pack size, and a 365-day |
| 7 | 7 | * activity sparkline (SVG polyline, ported from pear-git v1's |
| 8 | 8 | * 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. | |
| 9 | 22 | */ |
| 10 | 23 | |
| 11 | import { layout, escapeHtml, formatBytes } from '../helpers.js' | |
| 24 | import { layout, escapeHtml, formatBytes, ASSET_QUERY } from '../helpers.js' | |
| 12 | 25 | import { isAdmin } from '../../auth/check.js' |
| 13 | 26 | |
| 27 | /** Repos rendered server-side on first paint. */ | |
| 28 | export const PAGE_SIZE = 25 | |
| 29 | ||
| 30 | /** Sizes offered by the "view more" control. `0` means "everything left". */ | |
| 31 | const MORE_SIZES = [25, 50, 100] | |
| 32 | ||
| 14 | 33 | const CARD_STYLES = ` |
| 15 | 34 | <style> |
| 16 | 35 | .repo-grid { display: flex; flex-direction: column; gap: 10px; } |
| … | … | @@ -23,6 +42,10 @@ const CARD_STYLES = ` |
| 23 | 42 | .repo-desc { color: #8b949e; font-size: 13px; margin-top: 4px; } |
| 24 | 43 | .repo-meta { display: flex; gap: 15px; font-size: 12px; color: #8b949e; margin-top: 6px; flex-wrap: wrap; } |
| 25 | 44 | .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; } | |
| 26 | 49 | </style>` |
| 27 | 50 | |
| 28 | 51 | function timeAgo (tsSec) { |
| … | … | @@ -85,7 +108,109 @@ function renderActivityGraph (data) { |
| 85 | 108 | `</svg>` |
| 86 | 109 | } |
| 87 | 110 | |
| 88 | export 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 | */ | |
| 116 | export 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 | */ | |
| 131 | function 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. */ | |
| 140 | function 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. */ | |
| 162 | export 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 | */ | |
| 171 | export 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 | */ | |
| 199 | export 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 | ||
| 213 | export const __test = { renderActivityGraph, GRAPH_W, GRAPH_H, DAYS_PER_POINT, pageOf } | |
| 89 | 214 | |
| 90 | 215 | export async function renderReposIndex (svc, ctx) { |
| 91 | 216 | const prefix = (ctx && ctx.prefix) || '' |
| … | … | @@ -93,32 +218,30 @@ export async function renderReposIndex (svc, ctx) { |
| 93 | 218 | const sync = svc._sync |
| 94 | 219 | const active = !!sync |
| 95 | 220 | |
| 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) | |
| 115 | 222 | |
| 116 | 223 | // 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 | : '' | |
| 122 | 245 | |
| 123 | 246 | const body = ` |
| 124 | 247 | ${CARD_STYLES} |
| … | … | @@ -137,26 +260,11 @@ ${CARD_STYLES} |
| 137 | 260 | </div> |
| 138 | 261 | |
| 139 | 262 | ${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)} | |
| 159 | 265 | </div> |
| 266 | ${moreControl} | |
| 267 | <script src="${prefix}/static/pgv3-repos.js${ASSET_QUERY}" defer></script> | |
| 160 | 268 | ` : active ? ` |
| 161 | 269 | <div class="card"> |
| 162 | 270 | <h2>Repos</h2> |
| … | … | @@ -52,7 +52,7 @@ import { renderStatus } from './pages/status.js' |
| 52 | 52 | import { renderOrgsIndex, renderOrgShow, renderOrgRepos, renderOrgMembers, renderOrgSettings, renderOrgError, renderOrgInviteResult } from './pages/orgs.js' |
| 53 | 53 | import { renderIssuesIndex, renderIssueShow, renderIssueError } from './pages/issues.js' |
| 54 | 54 | import { renderDeploy } from './pages/deploy.js' |
| 55 | import { renderReposIndex } from './pages/repos.js' | |
| 55 | import { renderReposIndex, renderRepoPage } from './pages/repos.js' | |
| 56 | 56 | import { |
| 57 | 57 | renderRepoOverview, renderRepoTree, renderRepoBlob, |
| 58 | 58 | renderRepoCommits, renderRepoCommit |
| … | … | @@ -304,6 +304,21 @@ export function registerRoutes (svc) { |
| 304 | 304 | return sendJson(res, result) |
| 305 | 305 | } |
| 306 | 306 | |
| 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 | ||
| 307 | 322 | if (req.method === 'GET' && url === '/api/search/code') { |
| 308 | 323 | const result = await svc.dispatch('search-code-all', { |
| 309 | 324 | query: query.q || '', |
| … | … | @@ -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 | })() |
| … | … | @@ -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 | ||
| 9 | import test from 'brittle' | |
| 10 | import process from '#node:process' | |
| 11 | import http from '#node:http' | |
| 12 | import { mkdtempSync } from '#node:fs' | |
| 13 | import { mkdir, writeFile } from '#node:fs/promises' | |
| 14 | import { join } from '#node:path' | |
| 15 | import { tmpdir } from '#node:os' | |
| 16 | import b4a from 'b4a' | |
| 17 | ||
| 18 | import { startPearGit } from '../index.js' | |
| 19 | import { execFileSync } from '../lib/_compat-exec.js' | |
| 20 | import { PAGE_SIZE } from '../lib/web/pages/repos.js' | |
| 21 | ||
| 22 | process.env.PGV3_USE_RAW_IDENTITY = '1' | |
| 23 | const 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. | |
| 27 | function 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 | ||
| 44 | const getJson = async (url) => { | |
| 45 | const r = await httpGet(url) | |
| 46 | return { status: r.status, body: JSON.parse(r.body) } | |
| 47 | } | |
| 48 | ||
| 49 | function 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. | |
| 64 | async 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 | ||
| 79 | const REPO_COUNT = 32 // more than one page, less than two | |
| 80 | ||
| 81 | test('/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 | }) |
| … | … | @@ -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 | ||
| 16 | import test from 'brittle' | |
| 17 | ||
| 18 | import { | |
| 19 | PAGE_SIZE, | |
| 20 | sortReposByRecency, | |
| 21 | renderRepoCards, | |
| 22 | __test | |
| 23 | } from '../lib/web/pages/repos.js' | |
| 24 | ||
| 25 | function 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 | ||
| 41 | test('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 | ||
| 58 | test('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 | ||
| 69 | test('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 | ||
| 88 | test('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 | ||
| 102 | test('"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 | ||
| 111 | test('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 | }) |