OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / repos.js

378 lines · javascript ref:
/**
 * 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/&lt;name&gt;.git
pgh rpc --uds &lt;sock&gt; scan-repos
pgh rpc --uds &lt;sock&gt; serve-repo --name &lt;name&gt;</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)
}