/**
* 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">← 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 & 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)} <${escapeHtml(commit.email)}> · ${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)
}