OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / repo-browser.js

716 lines · javascript ref:
/**
 * Repo browser SSR pages.
 *
 *   GET /repos/:name              → repo overview (README + stats + clone box)
 *   GET /repos/:name/tree/*path   → file/directory browser
 *   GET /repos/:name/blob/*path   → file viewer
 *   GET /repos/:name/commits      → commit log
 *   GET /repos/:name/commit/:hash → single commit + collapsible per-file diff
 */

import { layout, escapeHtml, formatBytes, markdownBody, dirOf } from '../helpers.js'
import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'

// Shared styles for every repo-browser sub-page — dropped into the body
// once (first card block) so we don't duplicate them across templates.
// Small inline SVGs for the file browser — GitHub's folder + file glyphs
// stripped down. 16x16, recolored via currentColor so they pick up whatever
// the link colour is set to (folders inherit blue, files stay muted grey).
const ICON_FOLDER = '<svg class="file-icon file-icon-folder" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1Z"/></svg>'
const ICON_FILE = '<svg class="file-icon file-icon-file" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914ZM2 1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 12.25 16h-8.5A1.75 1.75 0 0 1 2 14.25Z"/></svg>'
const ICON_UP = '<svg class="file-icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M3.22 7.22a.75.75 0 0 1 1.06 0L7.25 10.19V1.75a.75.75 0 0 1 1.5 0v8.44l2.97-2.97a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 8.28a.75.75 0 0 1 0-1.06Z" transform="rotate(180 8 8)"/></svg>'

const REPO_STYLES = `
<style>
  /* File browser row aesthetic — matches GitHub dark: folders in blue,
     files with muted icon. Hover brightens the whole row. */
  .file-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0.6rem; border-radius: 4px; border-bottom: 1px solid #21262d; }
  .file-row:hover { background: #1c2128; }
  .file-row:last-child { border-bottom: none; }
  .file-row a { flex: 1; color: #c9d1d9; text-decoration: none; }
  .file-row a:hover { color: #58a6ff; text-decoration: underline; }
  .file-row.folder a { color: #58a6ff; }
  .file-row .meta { color: #8b949e; font-size: 0.75rem; white-space: nowrap; }
  .file-icon { flex-shrink: 0; display: inline-block; vertical-align: middle; }
  .file-icon-folder { color: #58a6ff; }
  .file-icon-file { color: #8b949e; }

  .ref-switcher { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: #8b949e; }
  .ref-switcher select { width: auto; min-width: 140px; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 0.25rem 0.5rem; border-radius: 6px; font-size: 0.8rem; }
  .clone-box { display: grid; grid-template-columns: 80px 1fr auto; gap: 8px; align-items: center; margin: 0.3rem 0; }
  .clone-box label { font-size: 0.7rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
  .clone-box input { font-family: 'SF Mono', 'Fira Code', Consolas, Monaco, monospace; font-size: 0.75rem; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 0.3rem 0.5rem; border-radius: 4px; width: 100%; }
  .clone-box button { font-size: 0.7rem; padding: 0.3rem 0.7rem; background: #21262d; border: 1px solid #30363d; color: #c9d1d9; border-radius: 4px; cursor: pointer; white-space: nowrap; }
  .clone-box button:hover { background: #30363d; }
  .clone-box button.copied { background: #238636; border-color: #238636; color: #fff; }
  .diff-file { border: 1px solid #30363d; border-radius: 6px; margin: 0.6rem 0; overflow: hidden; }
  .diff-file summary { list-style: none; padding: 0.5rem 0.8rem; background: #161b22; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
  .diff-file summary::-webkit-details-marker { display: none; }
  .diff-file summary::before { content: '▸'; color: #8b949e; font-size: 0.7rem; transition: transform 0.1s; }
  .diff-file[open] summary::before { transform: rotate(90deg); }
  .diff-file summary:hover { background: #1c2128; }
  .diff-file-path { font-family: monospace; font-size: 0.82rem; color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; flex: 1; }
  .diff-stats { font-size: 0.72rem; font-family: monospace; white-space: nowrap; }
  .diff-stats .add { color: #3fb950; }
  .diff-stats .del { color: #f85149; }
  .diff-body { background: #0d1117; overflow-x: auto; }
  .diff-body table { width: 100%; border-collapse: collapse; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 0.75rem; line-height: 1.45; }
  .diff-body td { padding: 0 8px; vertical-align: top; white-space: pre; }
  .diff-body td.gutter { width: 44px; text-align: right; color: #6e7681; background: #0d1117; user-select: none; border-right: 1px solid #21262d; }
  .diff-body tr.add td.gutter { background: #023a1a; color: #3fb950; }
  .diff-body tr.add td.code { background: #032d1422; color: #aff5c4; }
  .diff-body tr.del td.gutter { background: #3a0f12; color: #f85149; }
  .diff-body tr.del td.code { background: #2d0e0e22; color: #ffc1c1; }
  .diff-body tr.hunk td { background: #161b22; color: #79c0ff; padding: 0.2rem 8px; }
  .diff-body tr.hunk td.gutter { background: #161b22; color: #79c0ff; }

  /* Inline media + tabular file views (image / svg / csv) */
  .blob-image { text-align: center; padding: 1rem; background: #0d1117; }
  .blob-image img { max-width: 100%; height: auto; background: #fff; border-radius: 4px; }
  .csv-wrap { overflow-x: auto; }
  table.csv-table { border-collapse: collapse; font-size: 0.82rem; }
  table.csv-table th, table.csv-table td { border: 1px solid #30363d; padding: 0.3rem 0.6rem; text-align: left; white-space: nowrap; }
  table.csv-table th { background: #161b22; color: #c9d1d9; position: sticky; top: 0; }
  table.csv-table tr:nth-child(even) td { background: #0f141a; }
  .csv-note { color: #8b949e; font-size: 0.78rem; margin-top: 0.5rem; }
</style>`

// ── CSV / TSV → HTML table ─────────────────────────────────
// Cap the rendered rows so a multi-MB CSV can't produce gigabytes of HTML.
// The total row count is still reported accurately (parse is cheap; the
// whole file is already a string in memory by this point).
const CSV_ROW_CAP = 1000

// Minimal RFC-4180-ish parser: handles quoted fields, embedded delimiters /
// newlines, and "" escaped quotes. Good enough for viewing committed data.
function parseCsv (text, delimiter) {
  const rows = []
  let row = []
  let field = ''
  let inQuotes = false
  for (let i = 0; i < text.length; i++) {
    const c = text[i]
    if (inQuotes) {
      if (c === '"') {
        if (text[i + 1] === '"') { field += '"'; i++ } else inQuotes = false
      } else field += c
    } else if (c === '"') {
      inQuotes = true
    } else if (c === delimiter) {
      row.push(field); field = ''
    } else if (c === '\n') {
      row.push(field); rows.push(row); row = []; field = ''
    } else if (c === '\r') {
      // swallowed — the \n that follows closes the row
    } else field += c
  }
  if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row) }
  return rows
}

export function renderCsvTable (content, delimiter) {
  let rows
  try { rows = parseCsv(content, delimiter || ',') } catch { rows = null }
  if (!rows || rows.length === 0) return '<div class="empty">Empty or unparseable CSV</div>'
  const shown = rows.length > CSV_ROW_CAP ? rows.slice(0, CSV_ROW_CAP) : rows
  let html = '<div class="csv-wrap"><table class="csv-table">'
  for (let r = 0; r < shown.length; r++) {
    const cells = shown[r]
    html += '<tr>'
    for (let c = 0; c < cells.length; c++) {
      const tag = r === 0 ? 'th' : 'td'
      html += '<' + tag + '>' + escapeHtml(cells[c] == null ? '' : String(cells[c])) + '</' + tag + '>'
    }
    html += '</tr>'
  }
  html += '</table></div>'
  if (rows.length > CSV_ROW_CAP) {
    html += '<div class="csv-note">Showing first ' + CSV_ROW_CAP + ' of ' + rows.length + ' rows.</div>'
  }
  return html
}

const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg']

// ── Repo overview ─────────────────────────────────────────

export async function renderRepoOverview (svc, ctx, repoName) {
  const prefix = (ctx && ctx.prefix) || ''
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx)

  const info = sync.info(repoName)
  if (!info) return errorPage('Repository "' + repoName + '" not found', ctx)

  const [stats, readme, branches, tags] = await Promise.all([
    sync.getStats(repoName).catch(() => null),
    sync.getReadme(repoName).catch(() => null),
    sync.getBranches(repoName).catch(() => []),
    sync.getTags(repoName).catch(() => [])
  ])

  // Fetch issue count for the Issues tab badge (per-repo scope).
  let issueCount
  try {
    const counts = await (await svc.issueRegistry.getForRepo(repoName)).counts()
    issueCount = counts.total
  } catch { issueCount = undefined }

  const body = `
${REPO_STYLES}
${REPO_TAB_STYLES}
<div class="card">
  <h2><a href="${prefix}/repos">&larr; repos</a> / ${escapeHtml(repoName)}</h2>
</div>

${renderRepoTabs(repoName, 'overview', { issues: issueCount, commits: stats ? stats.commits : undefined }, prefix)}

${renderCloneBox(repoName, sync.serverKey, svc._reposDir, svc._hostUser, prefix)}

${branches.length > 0 || tags.length > 0 ? `
<div class="card">
  <h2>Branches &amp; tags</h2>
  ${branches.map(b => '<a href="' + prefix + '/repos/' + enc(repoName) + '/tree?ref=' + enc(b.name) + '" class="badge badge-group" style="margin:0.15rem">' + escapeHtml(b.name) + '</a>').join('')}
  ${tags.map(t => '<a href="' + prefix + '/repos/' + enc(repoName) + '/tree?ref=' + enc(t) + '" class="badge badge-user" style="margin:0.15rem">' + escapeHtml(t) + '</a>').join('')}
</div>` : ''}

${readme ? `
<div class="card">
  <h2>${escapeHtml(readme.name)}</h2>
  ${markdownBody(readme.content, {
    id: 'readme-content',
    dir: dirOf(readme.name),
    ...mdBases(repoName, prefix)
  })}
</div>` : ''}
`
  return layout(repoName, '/repos', body, ctx)
}

// ── File tree ─────────────────────────────────────────────

export async function renderRepoTree (svc, ctx, repoName, treePath, ref) {
  const prefix = (ctx && ctx.prefix) || ''
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx)

  treePath = treePath || ''
  ref = ref || 'HEAD'
  const refParam = ref !== 'HEAD' ? '?ref=' + enc(ref) : ''

  let files, branches, tags
  try {
    [files, branches, tags] = await Promise.all([
      sync.listFiles(repoName, ref, treePath),
      sync.getBranches(repoName).catch(() => []),
      sync.getTags(repoName).catch(() => [])
    ])
  } catch (err) {
    return errorPage('Error listing files: ' + err.message, ctx)
  }

  // Sort: dirs first, then files
  files.sort((a, b) => {
    if (a.type === 'directory' && b.type !== 'directory') return -1
    if (a.type !== 'directory' && b.type === 'directory') return 1
    return a.name.localeCompare(b.name)
  })

  // Breadcrumb
  const parts = treePath ? treePath.split('/') : []
  let breadcrumb = `<a href="${prefix}/repos/${enc(repoName)}/tree${refParam}">${escapeHtml(repoName)}</a>`
  let cumPath = ''
  for (const part of parts) {
    cumPath += (cumPath ? '/' : '') + part
    breadcrumb += ' / <a href="' + prefix + '/repos/' + enc(repoName) + '/tree/' + enc(cumPath) + refParam + '">' + escapeHtml(part) + '</a>'
  }

  const switcher = renderRefSwitcher(repoName, ref, branches, tags, 'tree', treePath)

  const body = `
${REPO_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'files', {}, prefix)}
<div class="card">
  <h2>${breadcrumb}</h2>
  <div class="hstack" style="margin-bottom:0.5rem;flex-wrap:wrap;gap:1rem">
    ${switcher}
  </div>
</div>
<div class="card" style="padding:0.3rem 0.4rem">
  ${treePath ? `
  <div class="file-row">
    ${ICON_UP}
    <a href="${prefix}/repos/${enc(repoName)}/tree/${enc(parentPath(treePath))}${refParam}">..</a>
  </div>` : ''}
  ${files.map(f => {
    if (f.type === 'directory') {
      return `
  <div class="file-row folder">
    ${ICON_FOLDER}
    <a href="${prefix}/repos/${enc(repoName)}/tree/${enc(f.path)}${refParam}">${escapeHtml(f.name)}</a>
  </div>`
    }
    return `
  <div class="file-row">
    ${ICON_FILE}
    <a href="${prefix}/repos/${enc(repoName)}/blob/${enc(f.path)}${refParam}">${escapeHtml(f.name)}</a>
    <span class="meta">${f.size != null ? formatBytes(f.size) : ''}</span>
  </div>`
  }).join('')}
  ${files.length === 0 ? '<div class="empty" style="padding:0.8rem">(empty directory)</div>' : ''}
</div>
`
  return layout(repoName + '/' + (treePath || ''), '/repos', body, ctx)
}

// ── File viewer ───────────────────────────────────────────

export async function renderRepoBlob (svc, ctx, repoName, filePath, ref) {
  const prefix = (ctx && ctx.prefix) || ''
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx)

  ref = ref || 'HEAD'

  const fileName = filePath.split('/').pop()
  const ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : ''
  const isImage = IMAGE_EXTS.includes(ext)
  const isCsv = ext === 'csv' || ext === 'tsv'

  let content, binary, branches, tags

  try {
    [binary, branches, tags] = await Promise.all([
      // Images go through the /raw route — never sniff or load their bytes
      // into a string here (corrupts binary data and bloats the page).
      isImage ? Promise.resolve(true) : sync.isBinary(repoName, filePath, ref),
      sync.getBranches(repoName).catch(() => []),
      sync.getTags(repoName).catch(() => [])
    ])
    content = (binary || isImage) ? null : await sync.readFile(repoName, filePath, ref)
  } catch (err) {
    return errorPage('Error reading file: ' + err.message, ctx)
  }

  const langMap = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c', cpp: 'cpp', cs: 'csharp', sh: 'bash', bash: 'bash', zsh: 'bash', yml: 'yaml', yaml: 'yaml', json: 'json', md: 'markdown', markdown: 'markdown', html: 'html', css: 'css', sql: 'sql', toml: 'toml', xml: 'xml', dockerfile: 'docker', makefile: 'makefile' }
  const lang = langMap[ext] || ext || 'text'
  // U39: also accept .markdown (not just .md)
  const isMarkdown = ['md', 'markdown'].includes(ext)

  const switcher = renderRefSwitcher(repoName, ref, branches, tags, 'blob', filePath)
  const rawUrl = prefix + '/repos/' + encodeURIComponent(repoName) + '/raw/' +
    filePath.split('/').map(encodeURIComponent).join('/') +
    (ref && ref !== 'HEAD' ? '?ref=' + encodeURIComponent(ref) : '')

  const metaLabel = isImage
    ? 'image'
    : isCsv
      ? 'csv'
      : binary
        ? 'binary'
        // NB: '\n', not '\\n' — this is plain JS, not a template
        // literal. The double-escaped form split on a literal
        // backslash-n and so reported "1 lines" for every file.
        : (content.split('\n').length + ' lines · ' + lang)

  const body = `
${REPO_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'files', {}, prefix)}
<div class="card">
  <h2>
    ${breadcrumbFor(repoName, filePath, prefix)}
  </h2>
  <div class="hstack" style="margin-bottom:0.5rem;flex-wrap:wrap;gap:1rem">
    <span class="meta">${metaLabel}</span>
    ${(isImage || binary) ? '<a class="meta" href="' + escapeHtml(rawUrl) + '" target="_blank" rel="noopener">view raw</a>' : ''}
    ${switcher}
  </div>
</div>
<div class="card">
  ${isImage
    ? '<div class="blob-image"><img src="' + escapeHtml(rawUrl) + '" alt="' + escapeHtml(fileName) + '" loading="lazy"></div>'
    : isCsv
      ? renderCsvTable(content, ext === 'tsv' ? '\t' : ',')
      : binary
        ? '<div class="empty">Binary file (' + escapeHtml(filePath) + ')</div>'
        : isMarkdown
          // Relative links resolve against the file's OWN directory, so
          // 'b.md' inside docs/a.md lands on docs/b.md rather than on the
          // repo root.
          ? markdownBody(content, {
              id: 'md-preview',
              dir: dirOf(filePath),
              ...mdBases(repoName, prefix)
            })
          : '<pre class="line-numbers"><code class="language-' + lang + '">' + escapeHtml(content) + '</code></pre><script>if(typeof Prism!=="undefined")Prism.highlightAll()</script>'
  }
</div>
`
  return layout(fileName, '/repos', body, ctx)
}

// ── Commit log ────────────────────────────────────────────

export async function renderRepoCommits (svc, ctx, repoName, ref) {
  const prefix = (ctx && ctx.prefix) || ''
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx)

  ref = ref || 'HEAD'
  const refParam = ref !== 'HEAD' ? '?ref=' + enc(ref) : ''
  let commits, branches, tags
  try {
    [commits, branches, tags] = await Promise.all([
      sync.getCommits(repoName, ref, 50),
      sync.getBranches(repoName).catch(() => []),
      sync.getTags(repoName).catch(() => [])
    ])
  } catch (err) {
    return errorPage('Error loading commits: ' + err.message, ctx)
  }

  const switcher = renderRefSwitcher(repoName, ref, branches, tags, 'commits', '')

  const body = `
${REPO_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'commits', {}, prefix)}
<div class="card">
  <h2><a href="${prefix}/repos/${enc(repoName)}">${escapeHtml(repoName)}</a> / commits</h2>
  <div class="hstack" style="margin-bottom:0.5rem;flex-wrap:wrap;gap:1rem">
    ${switcher}
  </div>
</div>
<div class="card">
  ${commits.map(c => `
  <div class="item">
    <div>
      <a href="${prefix}/repos/${enc(repoName)}/commit/${enc(c.hash)}" class="title">${escapeHtml(c.subject)}</a>
    </div>
    <div class="meta">${escapeHtml(c.shortHash)} by ${escapeHtml(c.author)} · ${escapeHtml(c.date.split('T')[0])}</div>
  </div>`).join('')}
  ${commits.length === 0 ? '<div class="empty">(no commits)</div>' : ''}
</div>
`
  return layout('commits · ' + repoName, '/repos', body, ctx)
}

// ── Single commit ─────────────────────────────────────────

export async function renderRepoCommit (svc, ctx, repoName, hash) {
  const prefix = (ctx && ctx.prefix) || ''
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx)

  let commit, diff
  try {
    commit = await sync.getCommit(repoName, hash)
    diff = await sync.getCommitDiff(repoName, hash)
  } catch (err) {
    return errorPage('Error loading commit: ' + err.message, ctx)
  }

  if (!commit) return errorPage('Commit not found: ' + hash, ctx)

  const parsedDiff = diff ? parseDiff(diff) : []
  const totalAdded = parsedDiff.reduce((s, f) => s + f.added, 0)
  const totalRemoved = parsedDiff.reduce((s, f) => s + f.removed, 0)

  const body = `
${REPO_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'commits', {}, prefix)}
<div class="card">
  <h2><a href="${prefix}/repos/${enc(repoName)}/commits">${escapeHtml(repoName)} commits</a> / ${escapeHtml(commit.shortHash)}</h2>
  <div class="item">
    <div class="title">${escapeHtml(commit.subject)}</div>
    ${commit.body ? '<div style="margin-top:0.3rem;white-space:pre-wrap">' + escapeHtml(commit.body) + '</div>' : ''}
    <div class="meta">${escapeHtml(commit.hash)}</div>
    <div class="meta">by ${escapeHtml(commit.author)} &lt;${escapeHtml(commit.email)}&gt; · ${escapeHtml(commit.date)}</div>
  </div>
</div>
${parsedDiff.length > 0 ? `
<div class="card">
  <h2>Changed files (${parsedDiff.length}) <span class="diff-stats" style="margin-left:0.5rem"><span class="add">+${totalAdded}</span> <span class="del">-${totalRemoved}</span></span></h2>
  ${parsedDiff.map((f, i) => renderDiffFile(f, i < 5)).join('')}
</div>` : diff ? '<div class="card"><h2>diff</h2><div class="empty">(could not parse diff)</div></div>' : ''}
`
  return layout(commit.shortHash + ' · ' + repoName, '/repos', body, ctx)
}

// ── Helpers ───────────────────────────────────────────────

function enc (s) {
  return encodeURIComponent(s).replace(/%2F/g, '/')
}

/**
 * Base URLs the markdown renderer needs to turn a repo-relative link
 * into a forge URL:
 *
 *   linkBase    → the /blob/ HTML viewer (where a doc link should land)
 *   imgBase     → the /raw/ byte route (an <img> pointed at /blob/ breaks)
 *   siblingBase → /repos/, for a path that climbs above the repo root:
 *                 '../other' means the sibling REPO `other`.
 */
function mdBases (repoName, prefix) {
  const base = (prefix || '') + '/repos/' + encodeURIComponent(repoName)
  return {
    linkBase: base + '/blob/',
    imgBase: base + '/raw/',
    siblingBase: (prefix || '') + '/repos/'
  }
}

/**
 * U10: segmented breadcrumb for a file path. Each directory segment is
 * an individual link so the user can jump to any ancestor without using
 * Back. Matches the GitHub-dark file viewer header pattern:
 *   repoName / docs / sub / guide.md
 *               ↑       ↑      (each a link to /repos/:name/tree/:prefix)
 */
function breadcrumbFor (repoName, filePath, prefix) {
  const segments = filePath.split('/')
  const fileName = segments.pop()
  let html = `<a href="${prefix}/repos/${enc(repoName)}/tree">${escapeHtml(repoName)}</a>`
  let cumulative = ''
  for (const seg of segments) {
    cumulative += (cumulative ? '/' : '') + seg
    html += ` / <a href="${prefix}/repos/${enc(repoName)}/tree/${enc(cumulative)}">${escapeHtml(seg)}</a>`
  }
  html += ` / ${escapeHtml(fileName)}`
  return html
}

function parentPath (p) {
  const parts = p.split('/')
  parts.pop()
  return parts.join('/')
}

/**
 * Clone URL card with SSH + HTTP + punch:// entries.
 *
 * - SSH uses host OpenSSH (no in-process SSH server) and the gitolite-style
 *   scp SHORT form `user@host:repo.git` — the wrapper resolves the bare repo
 *   NAME against its own reposDir (git-shell-parse rejects multi-segment
 *   paths), so the absolute server path is neither needed nor accepted. The
 *   hostname is filled in client-side so proxied / aliased hosts render right.
 * - HTTP is resolved client-side from `window.location.origin` so reverse
 *   proxies and alternate hostnames work without server knowledge.
 * - P2P uses the sync service's serverKey directly (requires git-remote-punch).
 */
function renderCloneBox (repoName, serverKey, reposDir, hostUser, prefix) {
  // Include the server's mount prefix (e.g. /git) — without it,
  // https://host/<repo>.git misses the reverse-proxy route. With prefix
  // unset (direct daemon access), '' + '/' + repo + '.git' is identical
  // to the prefix-less form.
  const httpPath = (prefix || '') + '/' + encodeURIComponent(repoName) + '.git'
  const p2p = serverKey ? 'punch://' + serverKey + '/' + repoName : null
  // SSH scp SHORT form `user@host:repo.git`. The wrapper resolves the bare
  // repo NAME against its reposDir, so we expose only the name — the old
  // absolute-path form (ssh://user@host/abs/repo.git) was actually REJECTED by
  // git-shell-parse's single-segment charset gate. reposDir is now unused for
  // the URL (kept in the signature). Gate on hostUser (the service account).
  const sshShort = hostUser ? repoName + '.git' : null

  return `
<div class="card">
  <h2>Clone</h2>
  ${sshShort ? `
  <div class="clone-box">
    <label for="clone-ssh">SSH</label>
    <input readonly id="clone-ssh" value="">
    <button type="button" onclick="pgv3Copy('clone-ssh', this)">copy</button>
  </div>` : ''}
  <div class="clone-box">
    <label for="clone-http">HTTP</label>
    <input readonly id="clone-http" value="">
    <button type="button" onclick="pgv3Copy('clone-http', this)">copy</button>
  </div>
  ${p2p ? `
  <div class="clone-box">
    <label for="clone-p2p">P2P</label>
    <input readonly id="clone-p2p" value="${escapeHtml(p2p)}">
    <button type="button" onclick="pgv3Copy('clone-p2p', this)">copy</button>
  </div>` : ''}
  <div class="meta" style="margin-top:0.5rem;font-size:0.7rem">
    <code>git clone</code> any URL. The SSH username${hostUser ? ` (<code>${escapeHtml(hostUser)}</code>)` : ''} is a shared service account — like GitHub's <code>git@</code>, you're identified by your <strong>key</strong>, not the name (enroll it with <code>pgh ssh-key add</code>). The <a href="${prefix}/docs/ssh-access">ssh-access docs</a> show a <code>~/.ssh/config</code> alias so you can clone <code>forge:${escapeHtml(repoName)}.git</code>. For P2P, run <code>pgh clone ${escapeHtml(repoName)}</code> — or install the <code>git-remote-punch</code> helper to <code>git clone</code> the <code>punch://</code> URL directly.
  </div>
  <script>
    (function(){
      var httpInput = document.getElementById('clone-http')
      if (httpInput) httpInput.value = window.location.origin + ${JSON.stringify(httpPath)}
      var sshInput = document.getElementById('clone-ssh')
      if (sshInput) sshInput.value = ${JSON.stringify(hostUser || '')} + '@' + window.location.hostname + ':' + ${JSON.stringify(repoName + '.git')}
    })()
    window.pgv3Copy = window.pgv3Copy || function(id, btn){
      var el = document.getElementById(id)
      if (!el) return
      el.select()
      try { document.execCommand('copy') } catch {}
      if (navigator.clipboard) { try { navigator.clipboard.writeText(el.value) } catch {} }
      var t = btn.textContent
      btn.textContent = 'copied!'
      btn.classList.add('copied')
      setTimeout(function(){ btn.textContent = t; btn.classList.remove('copied') }, 1500)
    }
  </script>
</div>`
}

/**
 * Small `<select>` that jumps to the equivalent URL on another ref.
 * pageType is 'tree' | 'blob' | 'commits' — determines the target path.
 * For 'blob', switching to a different ref keeps the same file path.
 */
function renderRefSwitcher (repoName, currentRef, branches, tags, pageType, pathArg) {
  if (branches.length === 0 && tags.length === 0) return ''

  const branchNames = branches.map(b => (typeof b === 'string' ? b : b.name))
  const opts = []
  for (const b of branchNames) opts.push({ value: b, label: b, group: 'Branches' })
  for (const t of tags) opts.push({ value: t, label: t, group: 'Tags' })

  // If currentRef isn't HEAD or a branch/tag, add it as-is so it's selectable.
  if (currentRef !== 'HEAD' && !branchNames.includes(currentRef) && !tags.includes(currentRef)) {
    opts.unshift({ value: currentRef, label: currentRef + ' (current)', group: 'Current' })
  }

  const byGroup = { Branches: [], Tags: [], Current: [] }
  for (const o of opts) byGroup[o.group].push(o)

  const groupsHtml = ['Current', 'Branches', 'Tags']
    .filter(g => byGroup[g].length > 0)
    .map(g => `<optgroup label="${g}">` +
      byGroup[g].map(o => `<option value="${escapeHtml(o.value)}"${o.value === currentRef ? ' selected' : ''}>${escapeHtml(o.label)}</option>`).join('') +
      '</optgroup>').join('')

  const dataAttrs =
    'data-repo="' + escapeHtml(repoName) + '" ' +
    'data-page="' + pageType + '" ' +
    'data-path="' + escapeHtml(pathArg || '') + '"'

  return `
<span class="ref-switcher">
  <span>ref:</span>
  <select ${dataAttrs} onchange="pgv3SwitchRef(this)">
    ${groupsHtml}
  </select>
  <script>
    window.pgv3SwitchRef = window.pgv3SwitchRef || function(sel){
      var repo = sel.dataset.repo
      var page = sel.dataset.page
      var path = sel.dataset.path
      var ref = sel.value
      var pfx = document.body.dataset.prefix || ''
      var base = pfx + '/repos/' + encodeURIComponent(repo)
      var url
      if (page === 'tree') url = base + '/tree' + (path ? '/' + path : '')
      else if (page === 'blob') url = base + '/blob/' + path
      else if (page === 'commits') url = base + '/commits'
      else url = base
      if (ref !== 'HEAD') url += '?ref=' + encodeURIComponent(ref)
      window.location.href = url
    }
  </script>
</span>`
}

/**
 * Parse a unified diff into { file, added, removed, hunks: [...] } blocks.
 * Each hunk has { header, lines: [{type: 'ctx'|'add'|'del', oldLine, newLine, content}] }.
 * Handles binary-file markers by emitting a file with added=removed=0 and empty hunks.
 */
function parseDiff (text) {
  const files = []
  let current = null
  let hunk = null
  let oldLine = 0
  let newLine = 0

  const lines = text.split('\n')
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]

    const fileStart = line.match(/^diff --git a\/(.+) b\/(.+)$/)
    if (fileStart) {
      if (current) files.push(current)
      current = { file: fileStart[2] || fileStart[1], added: 0, removed: 0, hunks: [] }
      hunk = null
      continue
    }
    if (!current) continue

    // Skip metadata lines (index, mode changes, ---, +++, etc.)
    if (line.startsWith('index ') || line.startsWith('new file') ||
        line.startsWith('deleted file') || line.startsWith('similarity') ||
        line.startsWith('rename ') || line.startsWith('--- ') || line.startsWith('+++ ') ||
        line.startsWith('Binary files ')) {
      continue
    }

    const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/)
    if (hunkMatch) {
      hunk = { header: line, lines: [] }
      current.hunks.push(hunk)
      oldLine = parseInt(hunkMatch[1], 10)
      newLine = parseInt(hunkMatch[2], 10)
      continue
    }
    if (!hunk) continue

    if (line.startsWith('+')) {
      hunk.lines.push({ type: 'add', oldLine: null, newLine: newLine++, content: line.slice(1) })
      current.added++
    } else if (line.startsWith('-')) {
      hunk.lines.push({ type: 'del', oldLine: oldLine++, newLine: null, content: line.slice(1) })
      current.removed++
    } else if (line.startsWith(' ')) {
      hunk.lines.push({ type: 'ctx', oldLine: oldLine++, newLine: newLine++, content: line.slice(1) })
    }
    // trailing "\ No newline at end of file" and other junk — skip silently
  }
  if (current) files.push(current)
  return files
}

/**
 * Render one parsed-diff file as a `<details>` block. `openByDefault`
 * expands the first few files so the user doesn't have to click every
 * one on a small commit.
 */
function renderDiffFile (f, openByDefault) {
  const rows = []
  for (const h of f.hunks) {
    rows.push(`<tr class="hunk"><td class="gutter">…</td><td class="gutter">…</td><td class="code">${escapeHtml(h.header)}</td></tr>`)
    for (const l of h.lines) {
      rows.push(
        `<tr class="${l.type}">` +
          `<td class="gutter">${l.oldLine != null ? l.oldLine : ''}</td>` +
          `<td class="gutter">${l.newLine != null ? l.newLine : ''}</td>` +
          `<td class="code">${escapeHtml(l.content)}</td>` +
        '</tr>'
      )
    }
  }

  return `
<details class="diff-file"${openByDefault ? ' open' : ''}>
  <summary>
    <span class="diff-file-path">${escapeHtml(f.file)}</span>
    <span class="diff-stats"><span class="add">+${f.added}</span> <span class="del">-${f.removed}</span></span>
  </summary>
  <div class="diff-body">
    ${rows.length > 0 ? '<table>' + rows.join('') + '</table>' : '<div class="empty" style="padding:0.8rem">(no content changes — binary or rename)</div>'}
  </div>
</details>`
}

function errorPage (msg, ctx) {
  const body = `<div class="card"><h2>Error</h2><div class="empty">${escapeHtml(msg)}</div></div>`
  return layout('Error', '/repos', body, ctx)
}