OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / repo-viewer.js

646 lines · javascript ref:
/**
 * 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')
  }
}