/**
* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// Initial search if query provided
if (currentQuery) {
performSearch();
}
</script>
</body>
</html>
`
}
export { renderTreePage, renderFilePage, renderCommitPage, renderSubmodulePage, renderSearchPage }