OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-pages-ui.js

1038 lines · javascript ref:
/**
 * Repo Pages UI — server-rendered HTML for file tree, file viewer,
 * commit detail, submodule info, and search results.
 *
 * Extracted from routes/repos.js to separate rendering from routing.
 */

import { escapeHtml } from './response.js'
import { renderIdentityScript } from './identity-ui.js'
import { detectLanguage, getPrismAssets } from './syntax.js'
import { buildBreadcrumb } from './breadcrumb.js'
import { getBreadcrumbStyles } from './breadcrumb.js'
import { parseDiff, getDiffStats, getStatusLabel } from './diff-parser.js'

function formatBytes(bytes) {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}

// Template rendering functions

function renderTreePage(username, repoName, dirPath, files, breadcrumb, pathPrefix) {
  // Render breadcrumb from array
  const breadcrumbHtml = breadcrumb.map((part, i) => {
    if (part.isCurrent) {
      return `<span class="breadcrumb-current">${escapeHtml(part.name)}</span>`
    }
    return `<a href="${part.url}" class="breadcrumb-link">${escapeHtml(part.name)}</a>`
  }).join('<span class="breadcrumb-sep">/</span>')

  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>${escapeHtml(dirPath || repoName)} - ${escapeHtml(repoName)}</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      padding: 20px;
      margin: 0;
    }
    .container { max-width: 1200px; margin: 0 auto; }
    ${getBreadcrumbStyles()}
    .breadcrumb-link { color: #58a6ff; text-decoration: none; }
    .breadcrumb-link:hover { text-decoration: underline; }
    .breadcrumb-sep { color: #8b949e; margin: 0 6px; }
    .breadcrumb-current { color: #c9d1d9; font-weight: 500; }

    .file-list { background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
    .file-item {
      padding: 12px 16px;
      border-bottom: 1px solid #30363d;
      display: flex;
      align-items: center;
      gap: 12px;
      cursor: pointer;
      transition: background 0.2s;
    }
    .file-item:hover { background: #21262d; }
    .file-item:last-child { border-bottom: none; }
    .file-icon { font-size: 20px; }
    .file-name { flex: 1; color: #58a6ff; text-decoration: none; }
    .file-size { color: #8b949e; font-size: 12px; min-width: 80px; text-align: right; }
  </style>
</head>
<body>
  ${renderIdentityScript(pathPrefix)}
  <div class="container">
    <nav class="breadcrumb">
      <span style="font-size: 20px; margin-right: 8px;">📁</span>
      ${breadcrumbHtml}
    </nav>
    <div class="file-list">
      ${files.map(file => {
        let icon = '📄'
        if (file.type === 'directory') icon = '📁'
        else if (file.type === 'submodule') icon = '📦'

        let targetUrl
        let size = ''

        if (file.type === 'directory' || file.type === 'submodule') {
          targetUrl = `${pathPrefix}/${username}/${repoName}/tree/${file.path}`
          size = file.type === 'submodule' ? 'submodule' : ''
        } else {
          targetUrl = `${pathPrefix}/${username}/${repoName}/blob/${file.path}`
          size = file.size ? formatBytes(file.size) : ''
        }

        return `
          <div class="file-item" onclick="window.location.href='${targetUrl}'">
            <div class="file-icon">${icon}</div>
            <div class="file-name">${escapeHtml(file.name)}</div>
            <div class="file-size">${size}</div>
          </div>
        `
      }).join('')}
    </div>
  </div>
</body>
</html>
  `
}


function renderFilePage(repoName, filePath, content, pathPrefix, options = {}) {
  const { username = 'user', language = 'plaintext', lineCount = 0, isBinary = false } = options
  const prismAssets = getPrismAssets(language, pathPrefix)
  const isMarkdown = filePath.toLowerCase().endsWith('.md')

  // Build breadcrumb
  const breadcrumb = buildBreadcrumb({
    username,
    repoName,
    filePath,
    pathPrefix,
    type: 'blob'
  })

  const breadcrumbHtml = breadcrumb.map((part, i) => {
    if (part.isCurrent) {
      return `<span class="breadcrumb-current">${escapeHtml(part.name)}</span>`
    }
    return `<a href="${part.url}" class="breadcrumb-link">${escapeHtml(part.name)}</a>`
  }).join('<span class="breadcrumb-sep">/</span>')

  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>${escapeHtml(filePath)} - ${escapeHtml(repoName)}</title>
  ${isMarkdown ? `<script src="${pathPrefix}/vendor/marked.min.js"></script>` : ''}
  <!-- Prism.js CSS -->
  ${prismAssets.css.map(url => `<link rel="stylesheet" href="${url}">`).join('\n  ')}
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      padding: 20px;
      margin: 0;
    }
    .container { max-width: 1400px; margin: 0 auto; }
    ${getBreadcrumbStyles()}
    .breadcrumb-link { color: #58a6ff; text-decoration: none; }
    .breadcrumb-link:hover { text-decoration: underline; }
    .breadcrumb-sep { color: #8b949e; margin: 0 6px; }
    .breadcrumb-current { color: #c9d1d9; font-weight: 500; }

    .file-header {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px 6px 0 0;
      padding: 12px 16px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .file-info { display: flex; align-items: center; gap: 12px; }
    .file-path { font-weight: 500; }
    .file-meta { color: #8b949e; font-size: 13px; }
    .file-actions { display: flex; gap: 8px; }
    .btn {
      background: #21262d;
      color: #c9d1d9;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 6px 12px;
      font-size: 13px;
      cursor: pointer;
      text-decoration: none;
    }
    .btn:hover { background: #30363d; }
    .btn.active { background: #388bfd33; border-color: #58a6ff; color: #58a6ff; }

    .file-content {
      background: #0d1117;
      border: 1px solid #30363d;
      border-top: none;
      border-radius: 0 0 6px 6px;
      overflow: auto;
    }

    /* Prism overrides for dark theme */
    pre[class*="language-"] {
      margin: 0;
      padding: 16px;
      background: #0d1117 !important;
      font-size: 13px;
      line-height: 1.5;
    }
    code[class*="language-"] {
      font-family: 'SF Mono', 'Fira Code', Consolas, Monaco, monospace;
    }
    .line-numbers .line-numbers-rows {
      border-right: 1px solid #30363d;
      padding-right: 12px;
    }
    .line-numbers-rows > span:before {
      color: #484f58;
    }

    /* Binary file notice */
    .binary-notice {
      padding: 40px;
      text-align: center;
      color: #8b949e;
    }
    .binary-notice-icon { font-size: 48px; margin-bottom: 16px; }

    /* Markdown rendered view */
    .markdown-body {
      padding: 24px 32px;
      line-height: 1.6;
    }
    .markdown-body h1 { font-size: 24px; margin: 24px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
    .markdown-body h2 { font-size: 20px; margin: 24px 0 16px; padding-bottom: 6px; border-bottom: 1px solid #30363d; }
    .markdown-body h3 { font-size: 18px; margin: 20px 0 12px; }
    .markdown-body h4 { font-size: 16px; margin: 16px 0 8px; }
    .markdown-body p { margin: 12px 0; }
    .markdown-body ul, .markdown-body ol { margin: 12px 0; padding-left: 2em; }
    .markdown-body li { margin: 4px 0; }
    .markdown-body code {
      background: #161b22;
      padding: 2px 6px;
      border-radius: 3px;
      font-family: 'SF Mono', 'Fira Code', Consolas, Monaco, monospace;
      font-size: 85%;
    }
    .markdown-body pre {
      background: #161b22;
      padding: 16px;
      border-radius: 6px;
      overflow-x: auto;
      margin: 16px 0;
    }
    .markdown-body pre code {
      background: none;
      padding: 0;
      font-size: 13px;
    }
    .markdown-body table {
      border-collapse: collapse;
      margin: 16px 0;
      width: 100%;
    }
    .markdown-body th, .markdown-body td {
      border: 1px solid #30363d;
      padding: 8px 12px;
      text-align: left;
    }
    .markdown-body th {
      background: #161b22;
      font-weight: 600;
    }
    .markdown-body blockquote {
      border-left: 3px solid #30363d;
      margin: 16px 0;
      padding: 4px 16px;
      color: #8b949e;
    }
    .markdown-body a { color: #58a6ff; text-decoration: none; }
    .markdown-body a:hover { text-decoration: underline; }
    .markdown-body img { max-width: 100%; }
    .markdown-body hr { border: none; border-top: 1px solid #30363d; margin: 24px 0; }
    .markdown-body strong { font-weight: 600; }
  </style>
</head>
<body>
  ${renderIdentityScript(pathPrefix)}
  <div class="container">
    <nav class="breadcrumb">
      <span style="font-size: 20px; margin-right: 8px;">📄</span>
      ${breadcrumbHtml}
    </nav>

    <div class="file-header">
      <div class="file-info">
        <span class="file-path">${escapeHtml(filePath.split('/').pop())}</span>
        <span class="file-meta">${lineCount} lines · ${language}</span>
      </div>
      <div class="file-actions">
        ${isMarkdown ? '<button class="btn active" id="btn-preview" onclick="toggleView(\'preview\')">Preview</button><button class="btn" id="btn-source" onclick="toggleView(\'source\')">Source</button>' : ''}
        <button class="btn" onclick="copyFile()">📋 Copy</button>
        <a href="${pathPrefix}/${username}/${repoName}/raw/${escapeHtml(filePath)}" class="btn">Raw</a>
      </div>
    </div>

    <div class="file-content">
      ${isBinary ? `
        <div class="binary-notice">
          <div class="binary-notice-icon">📦</div>
          <div>Binary file not shown</div>
        </div>
      ` : `
        ${isMarkdown ? `<div class="markdown-body" id="md-preview"></div>` : ''}
        <div id="source-view" ${isMarkdown ? 'style="display:none"' : ''}>
          <pre class="line-numbers"><code class="language-${language}">${escapeHtml(content)}</code></pre>
        </div>
      `}
    </div>
  </div>

  <!-- Prism.js -->
  ${prismAssets.js.map(url => `<script src="${url}"></script>`).join('\n  ')}
  <script>
    // Re-highlight after load
    if (typeof Prism !== 'undefined') {
      Prism.highlightAll();
    }

    ${isMarkdown ? `
    // Render markdown
    (function() {
      var raw = ${JSON.stringify(content)};
      if (typeof marked !== 'undefined') {
        marked.setOptions({ breaks: false, gfm: true });
        var parsed = marked.parse(raw);
        var scriptRe = new RegExp('<scr' + 'ipt[\\\\s\\\\S]*?<\\\\/scr' + 'ipt>', 'gi');
        parsed = parsed.replace(scriptRe, '');
        parsed = parsed.replace(new RegExp('<ifr' + 'ame[\\\\s\\\\S]*?<\\\\/ifr' + 'ame>', 'gi'), '');
        parsed = parsed.replace(new RegExp('<ifr' + 'ame[^>]*/?>', 'gi'), '');
        parsed = parsed.replace(/\\bon\\w+\\s*=/gi, 'data-removed=');
        parsed = parsed.replace(/javascript\\s*:/gi, '');
        document.getElementById('md-preview').innerHTML = parsed;
      }
    })();

    function toggleView(mode) {
      var preview = document.getElementById('md-preview');
      var source = document.getElementById('source-view');
      var btnPreview = document.getElementById('btn-preview');
      var btnSource = document.getElementById('btn-source');
      if (mode === 'source') {
        preview.style.display = 'none';
        source.style.display = 'block';
        btnPreview.classList.remove('active');
        btnSource.classList.add('active');
      } else {
        preview.style.display = 'block';
        source.style.display = 'none';
        btnPreview.classList.add('active');
        btnSource.classList.remove('active');
      }
    }
    ` : ''}

    function copyFile() {
      var code = document.querySelector('#source-view code') || document.querySelector('code');
      if (code) {
        navigator.clipboard.writeText(code.textContent).then(function() {
          var btns = document.querySelectorAll('.file-actions .btn');
          var btn = btns[btns.length - 2];
          btn.textContent = '✓ Copied!';
          setTimeout(function() { btn.textContent = '📋 Copy'; }, 2000);
        });
      }
    }
  </script>
</body>
</html>
  `
}

/**
 * Render commit detail page with diff
 */

function renderCommitPage(username, repoName, info, files, stats, pathPrefix) {
  const filesHtml = files.map(file => {
    const statusInfo = getStatusLabel(file.status)
    const language = detectLanguage(file.newPath || file.oldPath)

    // Render hunks
    const hunksHtml = file.hunks.map(hunk => {
      const linesHtml = hunk.lines.map(line => {
        let lineClass = 'diff-ctx'
        let prefix = ' '
        if (line.type === 'add') {
          lineClass = 'diff-add'
          prefix = '+'
        } else if (line.type === 'del') {
          lineClass = 'diff-del'
          prefix = '-'
        }

        const oldNo = line.oldLineNo !== null ? line.oldLineNo : ''
        const newNo = line.newLineNo !== null ? line.newLineNo : ''

        return `<tr class="${lineClass}">
          <td class="diff-line-no">${oldNo}</td>
          <td class="diff-line-no">${newNo}</td>
          <td class="diff-line-content"><span class="diff-prefix">${prefix}</span>${escapeHtml(line.content)}</td>
        </tr>`
      }).join('')

      return `
        <div class="diff-hunk">
          <div class="diff-hunk-header">${escapeHtml(hunk.header)}</div>
          <table class="diff-table">
            <tbody>${linesHtml}</tbody>
          </table>
        </div>
      `
    }).join('')

    return `
      <div class="diff-file" id="file-${escapeHtml(file.newPath || file.oldPath)}">
        <div class="diff-file-header" onclick="toggleDiff(this)">
          <div class="diff-file-info">
            <span class="diff-file-status" style="background: ${statusInfo.bgColor}">${statusInfo.label}</span>
            <span class="diff-file-name">${escapeHtml(file.newPath || file.oldPath)}</span>
          </div>
          <div class="diff-file-stats">
            <span class="diff-add-count">+${file.additions}</span>
            <span class="diff-del-count">-${file.deletions}</span>
          </div>
        </div>
        <div class="diff-file-content">
          ${file.isBinary ? '<div class="diff-binary">Binary file changed</div>' : hunksHtml}
        </div>
      </div>
    `
  }).join('')

  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Commit ${info.shortHash} - ${escapeHtml(repoName)}</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      padding: 20px;
      margin: 0;
    }
    .container { max-width: 1400px; margin: 0 auto; }
    .back-link { color: #58a6ff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 20px; }
    .back-link:hover { text-decoration: underline; }

    /* Commit info */
    .commit-header {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 20px;
      margin-bottom: 20px;
    }
    .commit-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
    .commit-body { color: #8b949e; white-space: pre-wrap; margin: 16px 0; padding: 16px; background: #0d1117; border-radius: 6px; }
    .commit-meta { display: flex; gap: 20px; font-size: 14px; color: #8b949e; flex-wrap: wrap; }
    .commit-meta-item { display: flex; align-items: center; gap: 6px; }
    .commit-hash { font-family: monospace; background: #21262d; padding: 2px 8px; border-radius: 4px; }

    /* Stats summary */
    .commit-stats {
      display: flex;
      gap: 16px;
      margin-top: 16px;
      padding-top: 16px;
      border-top: 1px solid #30363d;
    }
    .stat-item { font-size: 14px; }
    .stat-files { color: #c9d1d9; }
    .stat-additions { color: #3fb950; }
    .stat-deletions { color: #f85149; }

    /* Diff files */
    .diff-file {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      margin-bottom: 16px;
      overflow: hidden;
    }
    .diff-file-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 12px 16px;
      background: #161b22;
      border-bottom: 1px solid #30363d;
      cursor: pointer;
    }
    .diff-file-header:hover { background: #21262d; }
    .diff-file-info { display: flex; align-items: center; gap: 12px; }
    .diff-file-status {
      font-size: 11px;
      font-weight: 600;
      padding: 2px 8px;
      border-radius: 4px;
      color: white;
    }
    .diff-file-name { font-family: monospace; font-size: 13px; }
    .diff-file-stats { display: flex; gap: 12px; font-size: 13px; font-family: monospace; }
    .diff-add-count { color: #3fb950; }
    .diff-del-count { color: #f85149; }

    .diff-file-content { display: block; }
    .diff-file.collapsed .diff-file-content { display: none; }

    .diff-hunk { border-top: 1px solid #30363d; }
    .diff-hunk:first-child { border-top: none; }
    .diff-hunk-header {
      padding: 8px 16px;
      background: rgba(56, 139, 253, 0.1);
      color: #8b949e;
      font-family: monospace;
      font-size: 12px;
    }
    .diff-table {
      width: 100%;
      border-collapse: collapse;
      font-family: monospace;
      font-size: 12px;
      line-height: 1.5;
    }
    .diff-line-no {
      width: 50px;
      padding: 0 8px;
      text-align: right;
      color: #484f58;
      user-select: none;
      border-right: 1px solid #30363d;
    }
    .diff-line-content {
      padding: 0 16px;
      white-space: pre-wrap;
      word-break: break-all;
    }
    .diff-prefix {
      display: inline-block;
      width: 16px;
      user-select: none;
    }
    .diff-ctx { background: #0d1117; }
    .diff-add { background: rgba(46, 160, 67, 0.15); }
    .diff-add .diff-line-content { color: #3fb950; }
    .diff-del { background: rgba(248, 81, 73, 0.15); }
    .diff-del .diff-line-content { color: #f85149; }

    .diff-binary {
      padding: 20px;
      text-align: center;
      color: #8b949e;
      font-style: italic;
    }
  </style>
</head>
<body>
  ${renderIdentityScript(pathPrefix)}
  <div class="container">
    <a href="${pathPrefix}/${username}/${repoName}" class="back-link">
      ← Back to ${escapeHtml(repoName)}
    </a>

    <div class="commit-header">
      <div class="commit-title">${escapeHtml(info.subject)}</div>
      ${info.body ? `<div class="commit-body">${escapeHtml(info.body)}</div>` : ''}
      <div class="commit-meta">
        <div class="commit-meta-item">
          <span>👤</span>
          <span>${escapeHtml(info.author)}</span>
        </div>
        <div class="commit-meta-item">
          <span>📅</span>
          <span>${info.date}</span>
        </div>
        <div class="commit-meta-item">
          <span>🔗</span>
          <span class="commit-hash">${info.hash}</span>
        </div>
        ${info.parents.length > 0 ? `
          <div class="commit-meta-item">
            <span>⬆️</span>
            <span>Parent: ${info.parents.map(p => `<a href="${pathPrefix}/${username}/${repoName}/commit/${p}" style="color: #58a6ff;">${p.slice(0, 7)}</a>`).join(', ')}</span>
          </div>
        ` : ''}
      </div>
      <div class="commit-stats">
        <span class="stat-item stat-files">${stats.filesChanged} files changed</span>
        <span class="stat-item stat-additions">+${stats.additions} additions</span>
        <span class="stat-item stat-deletions">-${stats.deletions} deletions</span>
      </div>
    </div>

    <div class="diff-files">
      ${filesHtml}
    </div>
  </div>

  <script>
    function toggleDiff(header) {
      const file = header.closest('.diff-file');
      file.classList.toggle('collapsed');
    }
  </script>
</body>
</html>
  `
}


function renderSubmodulePage(username, repoName, dirPath, submoduleInfo, breadcrumbs, pathPrefix) {
  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>${dirPath} - ${repoName}</title>
  <style>
    body { font-family: system-ui; background: #0d1117; color: #c9d1d9; padding: 20px; }
    .container { max-width: 1200px; margin: 0 auto; }
    .breadcrumb { margin-bottom: 20px; font-size: 18px; }
    .submodule-notice {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 40px;
      text-align: center;
    }
    .submodule-icon { font-size: 64px; margin-bottom: 20px; }
    .submodule-title { font-size: 24px; margin-bottom: 10px; }
    .submodule-info { color: #8b949e; margin: 20px 0; line-height: 1.6; }
    .submodule-url {
      background: #0d1117;
      padding: 10px;
      border-radius: 4px;
      font-family: monospace;
      font-size: 12px;
      margin: 20px 0;
    }
    .instruction {
      background: #1f6feb;
      color: white;
      padding: 15px;
      border-radius: 6px;
      margin-top: 20px;
      font-family: monospace;
      font-size: 14px;
      text-align: left;
    }
    .back-link { color: #58a6ff; text-decoration: none; margin-top: 20px; display: inline-block; }
  </style>
</head>
<body>
  ${renderIdentityScript(pathPrefix)}
  <div class="container">
    <div class="breadcrumb">📁 ${breadcrumbs}</div>
    <div class="submodule-notice">
      <div class="submodule-icon">📦</div>
      <div class="submodule-title">Git Submodule</div>
      <div class="submodule-info">
        This directory is a <strong>git submodule</strong>, which is an external repository linked to this one.
        <br><br>
        The submodule repository is not currently available in the registry.
      </div>
      <div class="submodule-url">${submoduleInfo.url}</div>
      <div class="submodule-info"><strong>To view this submodule's files:</strong></div>
      <div class="instruction">
        # Clone the parent repository with submodules initialized<br>
        git clone --recursive https://7rnx.net/git/${repoName}.git<br>
        <br>
        # Or initialize submodules in an existing clone<br>
        cd ${repoName}<br>
        git submodule update --init ${dirPath}
      </div>
      <a href="${pathPrefix}/${username}/${repoName}" class="back-link">← Back to repository root</a>
    </div>
  </div>
</body>
</html>
  `
}

// ========================================
// Search Handlers
// ========================================

/**
 * Search repositories by name, description, or category
 * GET /api/repos/search?q=query&limit=20
 */

function renderSearchPage(query, searchType, repoFilter, allRepos, pathPrefix) {
  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Search${query ? ': ' + escapeHtml(query) : ''} - Pear Git</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: 1000px; margin: 0 auto; }
    .back-link { color: #58a6ff; text-decoration: none; margin-bottom: 20px; display: inline-block; }
    .back-link:hover { text-decoration: underline; }

    h1 { font-size: 24px; margin-bottom: 20px; }

    .search-form {
      display: flex;
      gap: 10px;
      margin-bottom: 20px;
      flex-wrap: wrap;
    }
    .search-input {
      flex: 1;
      min-width: 300px;
      padding: 12px 16px;
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      color: #c9d1d9;
      font-size: 16px;
    }
    .search-input:focus { outline: none; border-color: #58a6ff; }
    .search-btn {
      padding: 12px 24px;
      background: #238636;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 16px;
    }
    .search-btn:hover { background: #2ea043; }

    .search-tabs {
      display: flex;
      gap: 5px;
      margin-bottom: 20px;
      background: #161b22;
      padding: 4px;
      border-radius: 6px;
      width: fit-content;
    }
    .search-tab {
      padding: 8px 16px;
      background: transparent;
      border: none;
      color: #8b949e;
      cursor: pointer;
      border-radius: 4px;
      font-size: 14px;
    }
    .search-tab.active { background: #238636; color: white; }
    .search-tab:hover:not(.active) { color: #c9d1d9; }

    .repo-filter {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 20px;
    }
    .repo-filter select {
      padding: 8px 12px;
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      color: #c9d1d9;
    }

    .results-info {
      color: #8b949e;
      margin-bottom: 20px;
      font-size: 14px;
    }

    .result-item {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      margin-bottom: 15px;
      overflow: hidden;
    }
    .result-header {
      padding: 12px 16px;
      border-bottom: 1px solid #30363d;
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .result-repo {
      color: #58a6ff;
      font-weight: 500;
      text-decoration: none;
    }
    .result-repo:hover { text-decoration: underline; }
    .result-file {
      color: #8b949e;
      font-family: monospace;
      font-size: 13px;
    }

    .code-matches {
      font-family: monospace;
      font-size: 12px;
    }
    .code-line {
      display: flex;
      padding: 2px 16px;
      border-bottom: 1px solid #21262d;
    }
    .code-line:last-child { border-bottom: none; }
    .code-line:hover { background: #0d1117; }
    .code-line.match { background: #1c3d1f; }
    .line-num {
      color: #8b949e;
      min-width: 50px;
      text-align: right;
      padding-right: 16px;
      user-select: none;
    }
    .line-content {
      white-space: pre-wrap;
      word-break: break-all;
    }
    .highlight { background: #5e4b00; color: #f0e68c; padding: 1px 2px; border-radius: 2px; }

    .repo-result {
      padding: 16px;
      display: flex;
      align-items: center;
      gap: 15px;
    }
    .repo-result:hover { background: #0d1117; cursor: pointer; }
    .repo-name { color: #58a6ff; font-weight: 600; font-size: 16px; }
    .repo-desc { color: #8b949e; font-size: 14px; margin-top: 4px; }
    .repo-meta { color: #8b949e; font-size: 12px; display: flex; gap: 15px; margin-top: 8px; }

    .loading { color: #8b949e; padding: 40px; text-align: center; }
    .no-results { color: #8b949e; padding: 40px; text-align: center; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
  </style>
</head>
<body>
  <div class="container">
    <a href="${pathPrefix}/" class="back-link">← Back to repositories</a>
    <h1>Search</h1>

    <form class="search-form" onsubmit="doSearch(event)">
      <input type="text" class="search-input" id="search-query" placeholder="Search repositories and code..." value="${escapeHtml(query)}">
      <button type="submit" class="search-btn">Search</button>
    </form>

    <div class="search-tabs">
      <button class="search-tab ${searchType === 'repo' ? 'active' : ''}" onclick="switchType('repo')">Repositories</button>
      <button class="search-tab ${searchType === 'code' ? 'active' : ''}" onclick="switchType('code')">Code</button>
    </div>

    <div id="code-filter" style="display: ${searchType === 'code' ? 'flex' : 'none'};" class="repo-filter">
      <label>Filter by repository:</label>
      <select id="repo-filter" onchange="updateRepoFilter()">
        <option value="">All repositories</option>
        ${allRepos.map(r => `<option value="${r.name}" ${repoFilter === r.name ? 'selected' : ''}>${r.name}</option>`).join('')}
      </select>
    </div>

    <div id="results-info" class="results-info"></div>
    <div id="results-container"></div>
  </div>

  <script>
    const pathPrefix = '${pathPrefix}';
    let currentType = '${searchType}';
    let currentQuery = '${escapeHtml(query).replace(/'/g, "\\'")}';
    let currentRepo = '${escapeHtml(repoFilter).replace(/'/g, "\\'")}';

    function doSearch(e) {
      if (e) e.preventDefault();
      const query = document.getElementById('search-query').value.trim();
      if (query.length < 2) {
        document.getElementById('results-container').innerHTML = '<div class="no-results">Please enter at least 2 characters</div>';
        return;
      }
      currentQuery = query;
      updateUrl();
      performSearch();
    }

    function switchType(type) {
      currentType = type;
      document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
      document.querySelector(\`.search-tab:nth-child(\${type === 'repo' ? 1 : 2})\`).classList.add('active');
      document.getElementById('code-filter').style.display = type === 'code' ? 'flex' : 'none';
      updateUrl();
      if (currentQuery) performSearch();
    }

    function updateRepoFilter() {
      currentRepo = document.getElementById('repo-filter').value;
      updateUrl();
      if (currentQuery) performSearch();
    }

    function updateUrl() {
      const params = new URLSearchParams();
      if (currentQuery) params.set('q', currentQuery);
      params.set('type', currentType);
      if (currentRepo && currentType === 'code') params.set('repo', currentRepo);
      history.replaceState(null, '', pathPrefix + '/search?' + params.toString());
    }

    async function performSearch() {
      const container = document.getElementById('results-container');
      const info = document.getElementById('results-info');
      container.innerHTML = '<div class="loading">Searching...</div>';
      info.textContent = '';

      try {
        if (currentType === 'repo') {
          const response = await fetch(pathPrefix + '/api/repos/search?q=' + encodeURIComponent(currentQuery));
          const data = await response.json();
          renderRepoResults(data);
        } else {
          const url = pathPrefix + '/api/search/code?q=' + encodeURIComponent(currentQuery) +
            (currentRepo ? '&repo=' + encodeURIComponent(currentRepo) : '');
          const response = await fetch(url);
          const data = await response.json();
          renderCodeResults(data);
        }
      } catch (err) {
        container.innerHTML = '<div class="no-results">Search failed: ' + err.message + '</div>';
      }
    }

    function renderRepoResults(data) {
      const container = document.getElementById('results-container');
      const info = document.getElementById('results-info');

      if (!data.repos || data.repos.length === 0) {
        container.innerHTML = '<div class="no-results">No repositories found</div>';
        info.textContent = '';
        return;
      }

      info.textContent = \`Found \${data.repos.length} repositories\`;

      container.innerHTML = data.repos.map(repo => \`
        <div class="result-item" onclick="window.location.href='\${pathPrefix}/\${repo.namespace || 'local'}/\${repo.name}'">
          <div class="repo-result">
            <div style="flex: 1;">
              <div class="repo-name">\${escapeHtml(repo.name)}</div>
              \${repo.description ? \`<div class="repo-desc">\${escapeHtml(repo.description)}</div>\` : ''}
              <div class="repo-meta">
                \${repo.category ? \`<span>\${escapeHtml(repo.category)}</span>\` : ''}
                <span>\${repo.commits} commits</span>
                <span>\${repo.branches} branches</span>
              </div>
            </div>
          </div>
        </div>
      \`).join('');
    }

    function renderCodeResults(data) {
      const container = document.getElementById('results-container');
      const info = document.getElementById('results-info');

      if (!data.results || data.results.length === 0) {
        container.innerHTML = '<div class="no-results">No code matches found</div>';
        info.textContent = data.reposSearched ? \`Searched \${data.reposSearched} repositories\` : '';
        return;
      }

      info.textContent = \`Found \${data.totalMatches} matches in \${data.results.length} repositories\`;

      container.innerHTML = data.results.map(result => {
        // Group matches by file
        const byFile = {};
        result.matches.forEach(m => {
          if (!byFile[m.file]) byFile[m.file] = [];
          byFile[m.file].push(m);
        });

        return Object.entries(byFile).map(([file, matches]) => \`
          <div class="result-item">
            <div class="result-header">
              <a href="\${pathPrefix}/local/\${result.repo}" class="result-repo">\${result.repo}</a>
              <span class="result-file">/\${escapeHtml(file)}</span>
            </div>
            <div class="code-matches">
              \${matches.slice(0, 5).map(m => \`
                <a href="\${pathPrefix}/local/\${result.repo}/blob/\${encodeURIComponent(file)}#L\${m.line}" class="code-line \${m.isMatch ? 'match' : ''}" style="text-decoration: none; color: inherit;">
                  <span class="line-num">\${m.line}</span>
                  <span class="line-content">\${highlightMatch(escapeHtml(m.content), currentQuery)}</span>
                </a>
              \`).join('')}
              \${matches.length > 5 ? \`<div class="code-line" style="color: #8b949e;">... and \${matches.length - 5} more matches</div>\` : ''}
            </div>
          </div>
        \`).join('');
      }).join('');
    }

    function highlightMatch(text, query) {
      if (!query) return text;
      const escaped = query.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
      const regex = new RegExp('(' + escaped + ')', 'gi');
      return text.replace(regex, '<span class="highlight">$1</span>');
    }

    function escapeHtml(text) {
      if (!text) return '';
      return String(text)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
    }

    // Initial search if query provided
    if (currentQuery) {
      performSearch();
    }
  </script>
</body>
</html>
  `
}

export { renderTreePage, renderFilePage, renderCommitPage, renderSubmodulePage, renderSearchPage }