/**
* Repository Viewer Routes — HTML pages for browsing repos, files, commits
*/
import fs from 'bare-fs'
import path from 'bare-path'
import z32 from 'z32'
import b4a from 'b4a'
import { html, notFound, escapeHtml } from '../response.js'
import { renderRepoViewer } from '../repo-viewer-ui.js'
import { renderIdentityScript } from '../identity-ui.js'
import { detectLanguage, getPrismAssets, isBinaryContent } from '../syntax.js'
import { parseDiff, getDiffStats } from '../diff-parser.js'
import { buildBreadcrumb } from '../breadcrumb.js'
import { renderTreePage, renderFilePage, renderCommitPage, renderSubmodulePage } from '../repo-pages-ui.js'
export function registerRepoViewerRoutes(router) {
router.get('/api/repos/:repoName/raw', handleRawFile)
// Static assets must come before the /:username/:repoName catch-all
router.get('/anproto-client.js', serveANProtoClient)
router.get('/vendor/prism-components/:filename', serveVendorFile)
router.get('/vendor/:filename', serveVendorFile)
// Repo viewer routes (catch-all patterns — must be last)
router.get('/:username/:repoName/commit/:hash', handleViewCommit)
router.get('/:username/:repoName/tree/*path', handleViewTree)
router.get('/:username/:repoName/blob/*path', handleViewFile)
router.get('/:username/:repoName', serveRepo)
}
async function serveRepo(req, res, server) {
const { repos, backendManager, linkedRepos, repoRegistry } = server.repoData
const { p2pEnabled, serverIdentity } = server.p2p
const { pathPrefix, bindAddresses, port, externalUrl } = server.config
try {
const { username, repoName } = req.params
const url = new URL(req.url, `http://${req.headers.host}`)
const selectedBranch = url.searchParams.get('branch') || 'HEAD'
// Check if repo exists locally
const hasRepo = repos.some(r => r.name === repoName)
if (!hasRepo) {
// Check for ?remote= override (from catalog links)
const remoteKey = url.searchParams.get('remote')
if (remoteKey) {
return serveRemoteRepo(req, res, server, username, repoName, remoteKey)
}
// Check if it's a remote repo in a federated group
return serveRemoteRepo(req, res, server, username, repoName)
}
const [files, commits, readme, stats, branches] = await Promise.all([
backendManager.listFiles(repoName, selectedBranch, ''),
backendManager.getCommits(repoName, selectedBranch, 20),
backendManager.getReadme(repoName, selectedBranch),
backendManager.getStats(repoName),
backendManager.getBranches(repoName)
])
// Derive punch:// URL from server identity
const punchUrl = p2pEnabled && serverIdentity
? `punch://${serverIdentity.getPublicKeyHex()}/${repoName}`
: null
console.log(`[Repo ${repoName}] Files: ${files.length}, Commits: ${commits.length}, Branches: ${branches.length}`)
// Gather documentation files (root .md files + docs/ directory recursive)
const docFiles = files.filter(f => f.type === 'file' && f.name.toLowerCase().endsWith('.md'))
const hasDocsDir = files.some(f => f.type === 'directory' && f.name.toLowerCase() === 'docs')
if (hasDocsDir) {
try {
const docsContents = await backendManager.listFiles(repoName, selectedBranch, 'docs')
const subDirs = []
for (const f of docsContents) {
if (f.type === 'file') {
docFiles.push({ ...f, path: f.path || `docs/${f.name}` })
} else if (f.type === 'directory') {
subDirs.push(f.path || `docs/${f.name}`)
}
}
// Recurse into subdirectories (architecture/, phases/, etc.)
for (const subDir of subDirs) {
try {
const subContents = await backendManager.listFiles(repoName, selectedBranch, subDir)
for (const f of subContents) {
if (f.type === 'file') {
docFiles.push({ ...f, path: f.path || `${subDir}/${f.name}` })
}
}
} catch (err) {
// subdirectory listing failed, skip
}
}
} catch (err) {
// docs/ listing failed, skip
}
}
// Format README
let readmeHtml = null
let readmeIsMarkdown = false
if (readme) {
if (readme.isMarkdown) {
// Pass raw markdown - will be rendered client-side by marked.js
readmeHtml = readme.content
readmeIsMarkdown = true
} else {
readmeHtml = `<pre>${escapeHtml(readme.content)}</pre>`
}
}
// Check if repo is linked (Autobase collaboration overlay)
const isLinked = linkedRepos && linkedRepos.has(repoName)
let linkedWriters = []
if (isLinked) {
const linked = linkedRepos.get(repoName)
linkedWriters = await linked.listWriters().catch(() => [])
}
// Fetch issues, labels, and counts from LinkedRepo Autobase
const linkedRepo = linkedRepos.get(repoName)
let issues = []
let labels = []
let issueCounts = { open: 0, closed: 0, total: 0 }
if (linkedRepo) {
const [rawIssues, rawLabels, rawCounts] = await Promise.all([
linkedRepo.listIssues(),
linkedRepo.listLabels(),
linkedRepo.getIssueCounts()
])
// Normalize: template expects issue.number (LinkedRepo uses issue.id)
issues = rawIssues.map(i => ({ ...i, number: i.id }))
labels = rawLabels
issueCounts = rawCounts
}
const serverKeyHex = p2pEnabled && serverIdentity
? serverIdentity.getPublicKeyHex()
: null
// Fetch driveKey if repo has a Hyperdrive (enables pear-link URL)
const driveKeyHex = await backendManager.getDriveKey(repoName).catch(err => { console.error('[Repo] getDriveKey error:', err.message); return null })
// Encode driveKey as z-base-32 for pear-link ecosystem standard
const driveKeyZ32 = driveKeyHex ? z32.encode(b4a.from(driveKeyHex, 'hex')) : null
// Pick best LAN address for HTTP fallback display
let httpHost = 'localhost'
if (bindAddresses) {
const lanAddr = bindAddresses.find(a => a !== 'localhost' && a !== '127.0.0.1' && a !== '::1' && a !== '0.0.0.0' && a !== '::')
httpHost = lanAddr || 'localhost'
}
const p2pInfo = serverKeyHex ? {
serverKey: serverKeyHex,
driveKey: driveKeyZ32,
enabled: true,
httpHost,
httpPort: port || 5005,
pathPrefix: pathPrefix || '',
externalUrl: externalUrl || null
} : null
// Check for release branch and last deploy info
const hasRelease = branches.some(b => {
const name = typeof b === 'string' ? b : b.name
return name === 'release' || name === 'refs/heads/release'
})
let lastDeploy = null
if (hasRelease) {
try {
const repoData = await repoRegistry.get(repoName)
if (repoData && repoData.deploy) {
lastDeploy = {
timestamp: repoData.deploy.lastDeploy,
commitHash: repoData.deploy.lastCommitHash || repoData.deploy.lastVersion
}
}
} catch {}
}
const content = renderRepoViewer({ name: repoName, username, selectedBranch, punchUrl }, {
files,
commits,
issues,
labels,
issueCounts,
readme: readmeHtml,
readmeIsMarkdown,
docFiles,
branches,
p2pInfo,
isLinked,
linkedWriters,
pathPrefix: pathPrefix || '',
hasRelease,
lastDeploy,
stats: {
commits: stats.commits,
branches: branches.length,
issues: issues.length
}
})
html(res, content)
} catch (err) {
console.error('Serve repo error:', err)
html(res, 'Internal Server Error', 500)
}
}
/**
* Serve a remote repo — browse files over P2P from the federated server
* @param {string} [overridePeerKey] - Explicit server key from ?remote= param
*/
async function serveRemoteRepo(req, res, server, username, repoName, overridePeerKey) {
const { namespaceRegistry } = server.namespaceData
const { serverIdentity, browseClient } = server.p2p
const { pathPrefix } = server.config
// Find which group(s) contain this repo
let foundGroup = null
let federationPeers = []
if (namespaceRegistry) {
try {
const namespaces = await namespaceRegistry.list()
for (const ns of namespaces) {
if (ns.repos && ns.repos.includes(repoName)) {
foundGroup = ns
break
}
}
} catch (err) {
console.error('[RemoteRepo] Error looking up groups:', err)
}
}
// With ?remote= override, we don't require the repo to be in a shared group
if (!foundGroup && !overridePeerKey) {
notFound(res, 'Repository not found')
return
}
// Use a synthetic group for display if none found
if (!foundGroup) {
foundGroup = { name: username || 'remote', displayName: username || 'Remote' }
}
// Get federation peer keys from namespace registry writers
const selfKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null
if (namespaceRegistry) {
try {
const writers = await namespaceRegistry.listWriters()
for (const w of writers) {
if (w.removedAt) continue
if (w.serverKey && w.serverKey !== selfKey) {
federationPeers.push({ serverKey: w.serverKey, role: w.role })
}
}
} catch (err) {
console.error('[RemoteRepo] Error getting writers:', err)
}
}
// Override peer key takes priority (from ?remote= catalog links)
if (overridePeerKey && !federationPeers.some(p => p.serverKey === overridePeerKey)) {
federationPeers.unshift({ serverKey: overridePeerKey, role: 'federation' })
}
const primaryPeerKey = federationPeers.length > 0 ? federationPeers[0].serverKey : null
// Try P2P browsing if we have a browse client and a peer key
if (browseClient && primaryPeerKey) {
try {
const url = new URL(req.url, `http://${req.headers.host}`)
const selectedBranch = url.searchParams.get('branch') || 'HEAD'
const key = primaryPeerKey
console.log(`[RemoteRepo] P2P browse ${repoName} via ${key.slice(0, 8)}...`)
// Single request for all repo page data (one connection, one round-trip)
const pageData = await browseClient.browse(key, repoName, {
command: 'repoPage',
branch: selectedBranch,
path: '',
count: 20
})
if (pageData.error) throw new Error(pageData.error)
const { files, commits, readme, stats, branches } = pageData.data
// Derive punch:// URL
const punchUrl = `punch://${key}/${repoName}`
// Format README
let readmeHtml = null
let readmeIsMarkdown = false
if (readme) {
if (readme.isMarkdown) {
readmeHtml = readme.content
readmeIsMarkdown = true
} else {
readmeHtml = `<pre>${escapeHtml(readme.content)}</pre>`
}
}
const p2pInfo = {
serverKey: key,
enabled: true,
remote: true,
httpHost: 'remote',
httpPort: 0,
pathPrefix: pathPrefix || ''
}
const content = renderRepoViewer({ name: repoName, username, selectedBranch, punchUrl, remote: true }, {
files: files || [],
commits: commits || [],
issues: [],
labels: [],
issueCounts: { open: 0, closed: 0 },
readme: readmeHtml,
readmeIsMarkdown,
branches: branches || [],
p2pInfo,
isLinked: false,
linkedWriters: [],
pathPrefix: pathPrefix || '',
stats: {
commits: (stats && stats.commits) || 0,
branches: (branches || []).length,
issues: 0
}
})
html(res, content)
return
} catch (err) {
console.log(`[RemoteRepo] P2P browse failed for ${repoName}: ${err.message}`)
// Fall through to landing page
}
}
// Fallback: show landing page with clone info
const punchUrl = primaryPeerKey ? `punch://${primaryPeerKey}/${repoName}` : null
const content = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(repoName)} - Remote Repository</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117; color: #c9d1d9; padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.nav { display: flex; gap: 15px; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 1px solid #30363d; }
.nav a { padding: 8px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
.nav a:hover { background: #21262d; text-decoration: none; }
.remote-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: #30363d; color: #8b949e; margin-left: 10px; vertical-align: middle; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 24px; margin-bottom: 16px; }
.clone-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; display: flex; align-items: center; gap: 10px; font-family: monospace; font-size: 14px; color: #58a6ff; }
.clone-box input { flex: 1; background: transparent; border: none; color: #58a6ff; font-family: monospace; font-size: 14px; outline: none; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; }
.btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
</style>
</head>
<body>
${renderIdentityScript(pathPrefix)}
<div class="container">
<div class="nav">
<a href="${pathPrefix}/">Repos</a>
<a href="${pathPrefix}/groups/${escapeHtml(foundGroup.name)}">${escapeHtml(foundGroup.displayName || foundGroup.name)}</a>
</div>
<h1 style="font-size: 28px; margin-bottom: 6px;">${escapeHtml(repoName)} <span class="remote-badge">remote - offline</span></h1>
<p style="color: #8b949e; margin-bottom: 24px;">
Could not reach the remote server over P2P. The server hosting this repo may be offline.
</p>
<div class="card">
<h2 style="font-size: 18px; margin-bottom: 16px;">Clone via P2P</h2>
${punchUrl ? `
<div class="clone-box">
<input readonly value="git clone ${escapeHtml(punchUrl)}" id="clone-punch">
<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(document.getElementById('clone-punch').value)" style="padding: 4px 12px; font-size: 12px;">Copy</button>
</div>
` : '<p style="color: #8b949e;">No federation peers found.</p>'}
</div>
</div>
</body>
</html>`
html(res, content)
}
/**
* View directory/tree with breadcrumb navigation
*/
async function handleViewTree(req, res, server) {
const { backendManager, repoRegistry } = server.repoData
const { pathPrefix } = server.config
try {
const { username, repoName, path: dirPath } = req.params
// Check if path is a submodule
const submoduleInfo = await backendManager.getSubmoduleInfo(repoName, dirPath)
if (submoduleInfo) {
const submoduleRepoName = backendManager.parseRepoNameFromURL(submoduleInfo.url)
if (submoduleRepoName) {
const hasRepo = await repoRegistry.hasRepo(submoduleRepoName)
if (hasRepo) {
res.statusCode = 302
res.setHeader('Location', `${pathPrefix || ''}/${username}/${submoduleRepoName}`)
res.end()
return
}
}
return handleSubmoduleNotAvailable(req, res, server, username, repoName, dirPath, submoduleInfo)
}
const files = await backendManager.listFiles(repoName, 'HEAD', dirPath)
// Build breadcrumb using utility
const breadcrumb = buildBreadcrumb({
username,
repoName,
filePath: dirPath,
pathPrefix: pathPrefix || '',
type: 'tree'
})
const content = renderTreePage(username, repoName, dirPath, files, breadcrumb, pathPrefix || '')
html(res, content)
} catch (err) {
console.error('View tree error:', err)
html(res, 'Internal Server Error', 500)
}
}
/**
* View file with syntax highlighting
*/
async function handleViewFile(req, res, server) {
const { backendManager } = server.repoData
const { pathPrefix } = server.config
try {
const { username, repoName, path: fullPath } = req.params
// fullPath is "branch/path/to/file" — split into ref and file path
// Try common branch names first, fall back to treating first segment as ref
const parts = fullPath.split('/')
let ref = 'HEAD'
let filePath = fullPath
// Check if first segment is a known branch
try {
const branches = await backendManager.getBranches(repoName)
const branchNames = branches.map(b => b.name || b)
// Try matching longest branch name first (handles branches with slashes)
let matched = false
for (const branch of branchNames.sort((a, b) => b.length - a.length)) {
if (fullPath.startsWith(branch + '/')) {
ref = branch
filePath = fullPath.slice(branch.length + 1)
matched = true
break
}
}
if (!matched && parts.length > 1) {
ref = parts[0]
filePath = parts.slice(1).join('/')
}
} catch {
// Can't get branches — use first segment as ref
if (parts.length > 1) {
ref = parts[0]
filePath = parts.slice(1).join('/')
}
}
const content = await backendManager.readFile(repoName, filePath, ref)
// Detect language from file extension
const language = detectLanguage(filePath)
// Check if binary
const isBinary = isBinaryContent(content)
// Count lines
const lineCount = content ? content.split('\n').length : 0
const htmlContent = renderFilePage(repoName, filePath, content, pathPrefix || '', {
username,
language,
lineCount,
isBinary
})
html(res, htmlContent)
} catch (err) {
console.error('View file error:', err)
if (err.message.includes('Failed to read file')) {
notFound(res, 'File not found')
return
}
html(res, 'Internal Server Error', 500)
}
}
/**
* Get raw file content (for docs tab AJAX loading)
*/
async function handleRawFile(req, res, server) {
const { backendManager } = server.repoData
try {
const { repoName } = req.params
const url = new URL(req.url, `http://${req.headers.host}`)
const filePath = url.searchParams.get('path')
const branch = url.searchParams.get('branch') || 'HEAD'
if (!filePath) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path parameter' }))
return
}
const content = await backendManager.readFile(repoName, filePath, branch)
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end(content)
} catch (err) {
if (err.message.includes('Failed to read file')) {
res.statusCode = 404
res.end('File not found')
return
}
res.statusCode = 500
res.end('Internal Server Error')
}
}
/**
* View commit with diff
*/
async function handleViewCommit(req, res, server) {
const { backendManager } = server.repoData
const { pathPrefix } = server.config
try {
const { username, repoName, hash } = req.params
// Get commit info and diff
const commitData = await backendManager.getCommitDiff(repoName, hash)
if (!commitData) {
notFound(res, 'Commit not found')
return
}
// Parse the diff
const files = parseDiff(commitData.diff)
const stats = getDiffStats(files)
const htmlContent = renderCommitPage(username, repoName, commitData.info, files, stats, pathPrefix || '')
html(res, htmlContent)
} catch (err) {
console.error('View commit error:', err)
if (err.message.includes('not found') || err.message.includes('unknown revision')) {
notFound(res, 'Commit not found')
return
}
html(res, 'Internal Server Error', 500)
}
}
/**
* Handle submodule not available
*/
function handleSubmoduleNotAvailable(req, res, server, username, repoName, dirPath, submoduleInfo) {
const { pathPrefix } = server.config
const prefix = pathPrefix || ''
const pathParts = dirPath.split('/').filter(p => p)
let breadcrumbs = `<a href="${prefix}/${username}/${repoName}" style="color: #58a6ff; text-decoration: none;">${repoName}</a>`
let currentPath = ''
for (const part of pathParts) {
currentPath += (currentPath ? '/' : '') + part
breadcrumbs += ` / <a href="${prefix}/${username}/${repoName}/tree/${currentPath}" style="color: #58a6ff; text-decoration: none;">${part}</a>`
}
const content = renderSubmodulePage(username, repoName, dirPath, submoduleInfo, breadcrumbs, prefix)
html(res, content)
}
/**
* Serve ANProto client library
*/
async function serveANProtoClient(req, res, server) {
try {
const filePath = path.join(process.cwd(), 'public', 'anproto-client.js')
const content = await fs.promises.readFile(filePath, 'utf8')
res.statusCode = 200
res.setHeader('Content-Type', 'application/javascript')
res.setHeader('Cache-Control', 'public, max-age=3600')
res.end(content)
} catch (err) {
console.error('[Static] Failed to serve anproto-client.js:', err)
res.statusCode = 500
res.end('Failed to load resource')
}
}
/**
* Serve vendored JS/CSS libraries (no CDN dependency)
*/
async function serveVendorFile (req, res, server) {
const { filename } = req.params
// Only allow .js and .css files, no path traversal
if (!/^prism-[\w-]+\.min\.(js|css)$/.test(filename) &&
!['marked.min.js', 'prism.min.js', 'prism-tomorrow.min.css', 'prism-line-numbers.min.js', 'prism-line-numbers.min.css', 'prism-autoloader.min.js', 'noble-ed25519.js', 'noble-sha512.js', 'noble-crypto.js'].includes(filename)) {
res.statusCode = 404
res.end('Not found')
return
}
try {
// Determine subdirectory from URL path
const isComponent = req.url.includes('/prism-components/')
const vendorDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'vendor')
const filePath = isComponent ? path.join(vendorDir, 'prism-components', filename) : path.join(vendorDir, filename)
const content = await fs.promises.readFile(filePath)
const ext = filename.split('.').pop()
const types = { js: 'application/javascript', css: 'text/css' }
res.statusCode = 200
res.setHeader('Content-Type', types[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=86400')
res.end(content)
} catch (err) {
console.error(`[Static] Failed to serve vendor/${filename}:`, err.message)
res.statusCode = 500
res.end('Failed to load resource')
}
}