/**
* GET /repos — primary landing page (v3's "home").
*
* Renders the list of git repos served by the sync service, sorted by
* most-recent commit first. Each card shows name, description, commit
* count, branch count, last-commit time, pack size, and a 365-day
* activity sparkline (SVG polyline, ported from pear-git v1's
* renderActivityGraph()).
*
* PAGED. The page used to render every repo — 131 cards and 207KB of HTML on
* the live forge, each card carrying an inline SVG. This is the front door,
* and its cost grows with every repo hosted, so the first visit (the one with
* nothing cached) paid for the whole index before showing anything.
*
* Now the server renders PAGE_SIZE and the rest are pulled on demand by
* static/pgv3-repos.js against /api/repos/page, at a size the reader chooses.
* Ordering is untouched, so the first page is the part anyone actually wanted;
* anything below it is also reachable through the nav search box.
*
* The card markup lives here and ONLY here — the fragment route renders
* through renderRepoCards() too, so a later page cannot drift from the first.
*/
import { layout, escapeHtml, formatBytes, ASSET_QUERY } from '../helpers.js'
import { isAdmin } from '../../auth/check.js'
/** Repos rendered server-side on first paint. */
export const PAGE_SIZE = 25
/** Sizes offered by the "view more" control. `0` means "everything left". */
const MORE_SIZES = [25, 50, 100]
const CARD_STYLES = `
<style>
.repo-grid { display: flex; flex-direction: column; gap: 10px; }
.repo-card { display: flex; justify-content: space-between; align-items: center; gap: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px 20px; transition: border-color 0.15s; }
.repo-card:hover { border-color: #58a6ff; }
.repo-info { flex: 1; min-width: 0; }
.repo-name { font-size: 16px; font-weight: 600; color: #58a6ff; }
.repo-name a { color: inherit; text-decoration: none; }
.repo-name a:hover { text-decoration: underline; }
.repo-desc { color: #8b949e; font-size: 13px; margin-top: 4px; }
.repo-meta { display: flex; gap: 15px; font-size: 12px; color: #8b949e; margin-top: 6px; flex-wrap: wrap; }
.repo-activity { flex-shrink: 0; }
.repo-more { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
.repo-more select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 13px; }
.repo-more .meta { color: #8b949e; font-size: 12px; }
.repo-more[hidden] { display: none; }
</style>`
function timeAgo (tsSec) {
if (!tsSec) return ''
const s = Math.floor((Date.now() / 1000) - tsSec)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm ago'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h ago'
const d = Math.floor(h / 24)
if (d < 30) return d + 'd ago'
const mo = Math.floor(d / 30)
if (mo < 12) return mo + 'mo ago'
return Math.floor(mo / 12) + 'y ago'
}
const GRAPH_W = 360
const GRAPH_H = 32
const GRAPH_PAD = 1
const DAYS_PER_POINT = 7
/**
* 365-day activity sparkline. Ported from pear-git v1 (repo-manager-ui.js).
* Polygon fill under a polyline for the GitHub-style green sparkline.
*
* The daily buckets are collapsed to weekly before plotting. 365 points
* across a 360px-wide graph is ~1 point per pixel — sub-pixel detail that
* cannot be seen but that cost ~8KB of markup per repo (the coordinate
* string is emitted twice, once per shape). At 138 repos that was 92% of
* the landing page, sent uncompressed over the proxy link. Weekly buckets
* (~7px apart) plus integer coordinates give the same visible shape for
* roughly a tenth of the bytes.
*/
function renderActivityGraph (data) {
if (!data || !data.length) return ''
const weeks = []
for (let i = 0; i < data.length; i += DAYS_PER_POINT) {
let sum = 0
for (let j = i; j < i + DAYS_PER_POINT && j < data.length; j++) sum += data[j]
weeks.push(sum)
}
const max = Math.max(...weeks, 1)
const w = GRAPH_W
const h = GRAPH_H
const pad = GRAPH_PAD
const last = weeks.length - 1
const points = weeks.map((v, i) => {
// Guard last === 0: a single bucket would divide by zero and emit NaN.
const x = last === 0 ? 0 : Math.round((i / last) * w)
const y = Math.round(pad + (h - 2 * pad) * (1 - v / max))
return `${x},${y}`
}).join(' ')
const fillPoints = `0,${h} ${points} ${w},${h}`
return `<svg width="${w}" height="${h}" style="vertical-align:middle;" aria-hidden="true">` +
`<polygon points="${fillPoints}" fill="#238636" opacity="0.15"/>` +
`<polyline points="${points}" fill="none" stroke="#238636" stroke-width="1.5" stroke-linejoin="round"/>` +
`</svg>`
}
/**
* Sort most-recently-pushed first; repos with no commits sink to the bottom.
* Exported so the /repos page and the /api/repos/page fragment route order
* identically — a mismatch would make paging skip or repeat repos.
*
* NOT on `lastCommit.timestamp`: that only exists after enrichRepos() git-walks
* a repo's full history, so using it as the sort key forced walking every repo
* to pick a page of 25. It is also the wrong signal — force-pushing an old
* commit IS recent activity, and committer dates are clock-controlled, so one
* future-dated commit used to pin a repo to the top permanently.
*
* PREFER `lastRefUpdate` — the newest `timestamp` in the repo's replicated
* tracker ref-view (services/repo-ref-activity.js). Every backend derives the
* same number from the same autobase, so `7rnx.net/git` renders one order
* whichever backend the load balancer picks.
*
* FALL BACK to `lastUpdated`, the local ref/HEAD/packed-refs mtime that
* status() already has in memory. It is what a repo with no mounted tracker
* has, but it is a property of the MACHINE: on the live fleet all 135 repos
* carried a different value on the two backends (67 more than an hour apart),
* because a quiet repo's mtime records when THAT host cloned it and the
* 5-minute reconcile sweep re-stamps refs it materializes. Mixing the two
* scales is deliberate — both are unix ms, and a repo with no replicated
* signal should sit where its local evidence puts it rather than sink below
* everything that has one.
*
* Branch count stands in for "has commits" — free from status(), whereas a
* commit count is not. Reads correctly on both cheap and enriched records,
* since enrichRepos spreads the base fields through.
*/
function repoRecency (r) {
// Milliseconds from whichever signal the record carries. lastRefUpdate and
// lastUpdated are both unix ms; lastCommit.timestamp is git's %at in SECONDS
// (timeAgo treats it that way too), so it is scaled rather than compared raw.
if (r.lastRefUpdate) return r.lastRefUpdate
if (r.lastUpdated) return r.lastUpdated
if (r.lastCommit && r.lastCommit.timestamp) return r.lastCommit.timestamp * 1000
return 0
}
function repoHasHistory (r) {
// Branch count is the free proxy on a cheap record; lastCommit is the
// direct answer on an enriched one. Either is sufficient.
if (r.branches > 0) return true
return !!(r.lastCommit && r.lastCommit.timestamp)
}
export function sortReposByRecency (repos) {
return [...repos].sort((a, b) => {
const aHas = repoHasHistory(a)
const bHas = repoHasHistory(b)
if (aHas !== bHas) return aHas ? -1 : 1
const byRecency = repoRecency(b) - repoRecency(a)
if (byRecency !== 0) return byRecency
// TOTAL ORDER, or the backends still disagree. An exact tie left the
// comparator returning 0, so Array.sort kept INPUT order — and the input is
// sync.repos, a Map in readdir order, which is per-host. Ties are common
// now rather than accidental: one batch publish stamps one timestamp on
// every ref it touches, so proc-nodes and blender-bridge both read
// 1788551868967 and were the only 2 of 135 positions that disagreed across
// the two backends the hour this key shipped. Names are unique per daemon.
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
})
}
/**
* Slice one page out of an already-sorted list.
*
* `remaining` is what the client uses to decide whether to keep offering
* "view more", so an exhausted or past-the-end page must report 0 rather
* than a negative number. A limit of Infinity means "everything left".
*/
function pageOf (sorted, offset, limit) {
const start = Math.max(0, offset | 0)
const end = limit === Infinity ? sorted.length : start + Math.max(0, limit | 0)
const repos = sorted.slice(start, end)
const nextOffset = repos.length ? start + repos.length : start
return { repos, nextOffset, remaining: Math.max(0, sorted.length - nextOffset) }
}
/** One repo card. The single definition — both pages render through it. */
function renderRepoCard (r, prefix) {
return `
<div class="repo-card">
<div class="repo-info">
<div class="repo-name">
<a href="${prefix}/repos/${encodeURIComponent(r.name)}">${escapeHtml(r.name)}</a>
${r.serving ? '<span class="badge badge-open">serving</span>' : '<span class="badge badge-closed">idle</span>'}
</div>
${r.description ? `<div class="repo-desc">${escapeHtml(r.description)}</div>` : ''}
<div class="repo-meta">
<span>${r.commits || 0} commit${r.commits === 1 ? '' : 's'}</span>
<span>${r.branches || 0} branch${r.branches === 1 ? '' : 'es'}</span>
${r.lastCommit && r.lastCommit.timestamp ? `<span>${timeAgo(r.lastCommit.timestamp)}</span>` : ''}
${r.head ? `<span>HEAD ${escapeHtml(r.head)}</span>` : ''}
${r.packSize ? `<span>${formatBytes(r.packSize)}</span>` : ''}
</div>
</div>
${r.activity ? `<div class="repo-activity">${renderActivityGraph(r.activity)}</div>` : ''}
</div>`
}
/** Render a run of cards. Used by the first page AND the fragment route. */
export function renderRepoCards (repos, prefix = '') {
return repos.map((r) => renderRepoCard(r, prefix)).join('')
}
/**
* Fetch the repo list exactly the way the landing page does, so the fragment
* route pages over the SAME list. Shared rather than reimplemented: two
* different sources would silently drift and make paging lose repos.
*/
export async function loadRepoList (svc, ctx) {
const sync = svc._sync
if (!sync) return { repos: [], serving: 0, serverKey: null, active: false }
try {
// Deliberately the CHEAP listing: in-memory status, no git. This decides
// ordering and which page to show; only that page then gets enriched.
const result = await svc.dispatch('list-repos', {}, ctx || {})
return {
repos: result.repos || [],
serving: result.serving || 0,
serverKey: result.serverKey,
active: true
}
} catch {
// Fall back to basic status if detailed dispatch fails for any reason
const status = sync.status()
return {
repos: status.repos || [],
serving: status.serving || 0,
serverKey: status.serverKey,
active: true
}
}
}
/**
* Add the git-derived card fields (description, commits, lastCommit,
* activity) to exactly the repos being rendered.
*
* This is the whole point of the paging split: ordering comes from the cheap
* listing, so the expensive per-repo git walk is paid only for cards that
* actually reach the reader. The same shape the repo autocomplete already
* uses — rank on cheap fields, cap, then enrich.
*
* Degrades rather than fails: if the dispatch errors, cards still render
* from the cheap record, just without commit counts or a sparkline.
*/
async function enrichPage (svc, ctx, repos) {
if (!repos.length) return repos
try {
const result = await svc.dispatch(
'list-repos-detailed',
{ names: repos.map((r) => r.name) },
ctx || {}
)
const byName = new Map((result.repos || []).map((r) => [r.name, r]))
// Map over the page, not over the response: preserves the page's order
// and survives a repo disappearing between the two dispatches.
return repos.map((r) => byName.get(r.name) || r)
} catch {
return repos
}
}
/**
* One page of cards as an HTML fragment, for GET /api/repos/page.
* `limit` of 0 (or Infinity) means "everything left".
*/
export async function renderRepoPage (svc, ctx, { offset = 0, limit = PAGE_SIZE } = {}) {
const prefix = (ctx && ctx.prefix) || ''
const { repos } = await loadRepoList(svc, ctx)
const sorted = sortReposByRecency(repos)
const page = pageOf(sorted, offset, limit)
const enriched = await enrichPage(svc, ctx, page.repos)
return {
html: renderRepoCards(enriched, prefix),
count: enriched.length,
nextOffset: page.nextOffset,
remaining: page.remaining,
total: sorted.length
}
}
export const __test = { renderActivityGraph, GRAPH_W, GRAPH_H, DAYS_PER_POINT, pageOf }
export async function renderReposIndex (svc, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const sync = svc._sync
const active = !!sync
const { repos, serving, serverKey } = await loadRepoList(svc, ctx)
// Sort by most-recent activity desc; empty/quiet repos sink to the bottom.
// Ordering uses only cheap in-memory fields, so the git walk below is paid
// for PAGE_SIZE cards rather than the whole fleet.
const sorted = sortReposByRecency(repos)
const first = pageOf(sorted, 0, PAGE_SIZE)
const firstEnriched = await enrichPage(svc, ctx, first.repos)
// The reader picks how much to pull next. "all" is offered because on a
// fleet this size someone scanning for a repo they cannot name should not
// have to click five times — but it is never the default, since it is the
// one choice that reintroduces the whole payload.
const moreControl = first.remaining > 0
? `
<div class="repo-more" id="repo-more"
data-next-offset="${first.nextOffset}"
data-remaining="${first.remaining}"
data-total="${sorted.length}">
<button type="button" id="repo-more-btn">View more</button>
<select id="repo-more-size" aria-label="How many more repos to load">
${MORE_SIZES.map((n) => `<option value="${n}"${n === PAGE_SIZE ? ' selected' : ''}>next ${n}</option>`).join('')}
<option value="0">all remaining</option>
</select>
<span class="meta" id="repo-more-status">showing ${first.repos.length} of ${sorted.length}</span>
</div>`
: ''
const body = `
${CARD_STYLES}
<h1>repos</h1>
<div class="card">
<h2>Sync service</h2>
<div class="row"><div class="label">status</div><div>${active ? '<span class="badge badge-open">active</span>' : '<span class="badge badge-closed">inactive (no swarm)</span>'}</div></div>
${active ? `
<div class="row"><div class="label">server key</div><div class="meta mono" style="font-size:0.7rem">${escapeHtml(serverKey || '(none)')}</div></div>
<div class="row"><div class="label">repos</div><div>${repos.length}</div></div>
<div class="row"><div class="label">serving</div><div>${serving}</div></div>
` : `
<div class="meta">Enable swarm in the daemon config to activate the sync service.</div>
`}
</div>
${active && sorted.length > 0 ? `
<div class="repo-grid" id="repo-grid">
${renderRepoCards(firstEnriched, prefix)}
</div>
${moreControl}
<script src="${prefix}/static/pgv3-repos.js${ASSET_QUERY}" defer></script>
` : active ? `
<div class="card">
<h2>Repos</h2>
<div class="empty">
No repos yet. To add one, create a bare git repository in the daemon's <code>git-repos/</code> directory and call the <code>scan-repos</code> RPC over UDS:
<pre style="margin-top:0.6rem;background:#0d1117;padding:0.6rem;border-radius:4px;font-size:0.75rem">git init --bare ~/git-repos/<name>.git
pgh rpc --uds <sock> scan-repos
pgh rpc --uds <sock> serve-repo --name <name></pre>
See the <a href="${prefix}/docs">docs</a> for the full repo setup flow.
</div>
</div>` : ''}
${canAdmin ? `
<div class="card" id="join-repo">
<h2>Join a repo via invite</h2>
<div class="meta" style="margin-bottom:0.5rem">Paste a P2P invite code for a repo's issue tracker. This daemon pairs over the swarm (and, for contributor+, becomes a writer). No URL, no HTTP.</div>
<form action="${prefix}/repos/join" method="POST">
<div class="field"><label for="join-repo-invite">Invite code</label><textarea id="join-repo-invite" name="invitation" rows="3" placeholder="roompair1:…" required></textarea></div>
<button type="submit">Join</button>
</form>
</div>` : ''}
`
return layout('repos', '/repos', body, ctx)
}