import { renderIssueModal } from './issue-modal-ui.js'
import { renderPage } from './page-layout.js'
import { escapeHtml } from './response.js'
/**
* Render enhanced repository viewer page
*/
export function renderRepoViewer(repo, options = {}) {
const { files = [], commits = [], readme = null, readmeIsMarkdown = false, docFiles = [], issues = [], branches = [], p2pInfo = null, labels = [], issueCounts = {}, isLinked = false, linkedWriters = [], pathPrefix = '', hasRelease = false, lastDeploy = null } = options
// Generate P2P URLs if enabled
// punch:// = identity-addressed P2P (ANProto Noise key, eventually Keet-derived)
// git+pear:// = Hyperdrive-addressed git clone (requires driveKey)
// pear:// = Hyperdrive content-addressed (Pear runtime viewer)
const punchUrl = p2pInfo?.serverKey ? `punch://${p2pInfo.serverKey}/${repo.name}` : null
const cloneUrl = p2pInfo?.driveKey ? `git+pear://${p2pInfo.driveKey}/${repo.name}` : null
// pear-link URL points to the Hyperdrive root (the path is within the drive, not a repo name)
// Format: pear://[<fork>.<length>.]<keyOrAlias>[.<dhash>]<path>
const pearLinkUrl = p2pInfo?.driveKey ? `pear://${p2pInfo.driveKey}` : null
const PAGE_CSS = `
* { 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: 1200px; margin: 0 auto; }
.header {
border-bottom: 1px solid #30363d;
padding-bottom: 20px;
margin-bottom: 20px;
}
h1 { font-size: 32px; margin-bottom: 10px; }
.meta {
color: #8b949e;
display: flex;
gap: 20px;
font-size: 14px;
}
.tabs {
display: flex;
gap: 5px;
border-bottom: 1px solid #30363d;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
background: transparent;
border: none;
color: #8b949e;
cursor: pointer;
border-radius: 6px 6px 0 0;
font-size: 14px;
}
.tab.active {
background: #161b22;
border: 1px solid #30363d;
border-bottom: none;
color: #c9d1d9;
}
.tab:hover { color: #c9d1d9; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.file-list, .commit-list, .issue-list {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.file-item, .commit-item, .issue-item {
padding: 12px 16px;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: 10px;
}
.file-item:last-child, .commit-item:last-child, .issue-item:last-child {
border-bottom: none;
}
.file-item:hover, .commit-item:hover, .issue-item:hover {
background: #0d1117;
cursor: pointer;
}
.file-icon, .commit-icon { width: 20px; text-align: center; }
.file-name { flex: 1; color: #58a6ff; }
.file-size, .commit-date { color: #8b949e; font-size: 12px; }
.commit-hash {
font-family: monospace;
background: #0d1117;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
color: #58a6ff;
}
.commit-message { flex: 1; }
.commit-author { color: #8b949e; font-size: 12px; }
.readme {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 30px;
line-height: 1.6;
}
.readme h1 { font-size: 24px; margin: 24px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
.readme h2 { font-size: 20px; margin: 24px 0 16px; padding-bottom: 6px; border-bottom: 1px solid #30363d; }
.readme h3 { font-size: 18px; margin: 20px 0 12px; }
.readme h4 { font-size: 16px; margin: 16px 0 8px; }
.readme p { margin: 12px 0; }
.readme ul, .readme ol { margin: 12px 0; padding-left: 2em; }
.readme li { margin: 4px 0; }
.readme code {
background: #0d1117;
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 85%;
}
.readme pre {
background: #0d1117;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
}
.readme pre code {
background: none;
padding: 0;
font-size: 13px;
line-height: 1.45;
}
.readme table {
border-collapse: collapse;
margin: 16px 0;
width: 100%;
}
.readme th, .readme td {
border: 1px solid #30363d;
padding: 8px 12px;
text-align: left;
}
.readme th {
background: #0d1117;
font-weight: 600;
}
.readme blockquote {
border-left: 3px solid #30363d;
margin: 16px 0;
padding: 4px 16px;
color: #8b949e;
}
.readme a { color: #58a6ff; text-decoration: none; }
.readme a:hover { text-decoration: underline; }
.readme img { max-width: 100%; }
.readme hr { border: none; border-top: 1px solid #30363d; margin: 24px 0; }
.readme strong { font-weight: 600; }
.docs-layout {
display: flex;
gap: 20px;
min-height: 400px;
}
.docs-sidebar {
width: 250px;
flex-shrink: 0;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
overflow-y: auto;
max-height: 80vh;
}
.docs-sidebar h3 {
font-size: 13px;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #30363d;
}
.doc-file-item {
padding: 8px 10px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
color: #c9d1d9;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s;
}
.doc-file-item:hover {
background: #21262d;
}
.doc-file-item.active {
background: #1f6feb;
color: white;
}
.doc-file-item .doc-icon {
font-size: 14px;
flex-shrink: 0;
}
.doc-file-item .doc-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-file-item .doc-dir {
color: #8b949e;
font-size: 11px;
}
.docs-content {
flex: 1;
min-width: 0;
}
.docs-content .readme {
min-height: 300px;
}
.docs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #8b949e;
font-size: 14px;
}
.clone-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
margin-bottom: 20px;
}
.clone-box h3 { margin-bottom: 15px; font-size: 16px; }
.clone-url {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.clone-url input {
flex: 1;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 12px;
color: #c9d1d9;
font-family: monospace;
font-size: 12px;
}
.clone-url button {
background: #238636;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.clone-url button:hover { background: #2ea043; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #8b949e;
}
.empty-state-icon { font-size: 48px; margin-bottom: 10px; }
.issue-item {
display: flex;
align-items: flex-start;
gap: 10px;
}
.issue-number {
background: #0d1117;
padding: 4px 8px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
color: #8b949e;
}
.issue-title { flex: 1; font-weight: 500; }
.issue-comments {
color: #8b949e;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
min-width: 60px;
margin-right: 10px;
}
.issue-status {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.issue-status.open { background: #238636; color: white; }
.issue-status.closed { background: #8250df; color: white; }
button.primary {
background: #238636;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-bottom: 20px;
}
button.primary:hover { background: #2ea043; }
.back-link {
color: #58a6ff;
text-decoration: none;
margin-bottom: 20px;
display: inline-block;
}
.back-link:hover { text-decoration: underline; }
.branch-selector {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.branch-selector label {
color: #8b949e;
font-size: 14px;
}
.branch-selector select {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 12px;
color: #c9d1d9;
font-size: 14px;
cursor: pointer;
min-width: 200px;
}
.branch-selector select:hover {
border-color: #58a6ff;
}
.branch-icon {
font-size: 16px;
}
.peers-table {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
overflow: hidden;
}
.peers-header {
display: grid;
grid-template-columns: 1fr 100px 180px;
gap: 15px;
padding: 12px 16px;
background: #0d1117;
border-bottom: 1px solid #30363d;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: #8b949e;
}
.peers-row {
display: grid;
grid-template-columns: 1fr 100px 180px;
gap: 15px;
padding: 12px 16px;
border-bottom: 1px solid #30363d;
align-items: center;
}
.peers-row:last-child { border-bottom: none; }
.peers-row:hover { background: #0d1117; }
.peer-user { font-weight: 500; }
.peer-key {
font-family: monospace;
font-size: 11px;
color: #8b949e;
}
.peer-role {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.peer-role.owner { background: #238636; color: white; }
.peer-role.writer { background: #1f6feb; color: white; }
.peer-role.mirror { background: #6e7681; color: white; }
.invite-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
margin-top: 20px;
}
.invite-box h3 { margin-bottom: 12px; font-size: 16px; }
.invite-result {
margin-top: 12px;
padding: 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
font-family: monospace;
font-size: 12px;
word-break: break-all;
display: none;
}
.invite-result.visible { display: block; }
`
const content = `
<div class="header">
<h1>📦 ${escapeHtml(repo.name)}${isLinked ? ' <span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 13px; font-weight: 600; background: #1f6feb; color: white; vertical-align: middle; margin-left: 8px;">Linked</span>' : ''}</h1>
<div class="meta">
<span>🌿 ${escapeHtml(repo.selectedBranch || 'HEAD')}</span>
<span>📝 ${commits.length} commits shown</span>
<span>📅 ${commits[0] ? new Date(commits[0].timestamp * 1000).toLocaleDateString() : 'N/A'}</span>
${isLinked ? `<span style="color: #58a6ff;">🔗 ${linkedWriters.length} peer${linkedWriters.length !== 1 ? 's' : ''}</span>` : ''}
</div>
</div>
${branches.length > 0 ? `
<div class="branch-selector">
<span class="branch-icon">🌿</span>
<label for="branch-select">Branch:</label>
<select id="branch-select" onchange="switchBranch(this.value)">
${branches.map(branch => {
const branchName = branch.name.replace(/^remotes\/[^/]+\//, '').replace(/^\*\s*/, '').trim()
const isSelected = repo.selectedBranch === branchName ||
(repo.selectedBranch === 'HEAD' && branch.current) ||
(!repo.selectedBranch && branch.current)
return `<option value="${escapeHtml(branchName)}" ${isSelected ? 'selected' : ''}>${escapeHtml(branchName)}</option>`
}).join('')}
</select>
<span style="color: #8b949e; font-size: 12px;">${branches.length} branch${branches.length !== 1 ? 'es' : ''}</span>
</div>
` : ''}
<div class="clone-box">
<h3>Clone this repository</h3>
${punchUrl ? `
<div class="clone-url">
<input readonly value="git clone ${escapeHtml(punchUrl)}" id="clone-punch" title="P2P clone via server identity">
<button onclick="copyClone('clone-punch')">Copy</button>
</div>
${cloneUrl ? `
<p style="color: #8b949e; font-size: 11px; margin-top: 8px; margin-bottom: 4px;">Hyperdrive (content-addressed):</p>
<div class="clone-url">
<input readonly value="${escapeHtml(cloneUrl)}" id="clone-p2p" title="P2P git clone via Hyperdrive">
<button onclick="copyClone('clone-p2p')">Copy</button>
</div>
` : ''}
<p style="color: #8b949e; font-size: 11px; margin-top: 8px; margin-bottom: 4px;">HTTP${p2pInfo?.externalUrl ? 'S' : ' (LAN fallback)'}:</p>
<div class="clone-url">
<input readonly value="git clone ${p2pInfo?.externalUrl ? escapeHtml(p2pInfo.externalUrl) + '/' + escapeHtml(repo.username || 'fleeky') + '/' + escapeHtml(repo.name) + '.git' : 'http://' + escapeHtml(p2pInfo?.httpHost || 'localhost') + ':' + escapeHtml(String(p2pInfo?.httpPort || '5005')) + escapeHtml(p2pInfo?.pathPrefix || '') + '/' + escapeHtml(repo.name) + '.git'}" id="clone-http" title="${p2pInfo?.externalUrl ? 'HTTPS clone' : 'HTTP clone (LAN only)'}">
<button onclick="copyClone('clone-http')">Copy</button>
</div>
<p style="color: #8b949e; font-size: 11px; margin-top: 6px;">
Add petname: <code style="background: #161b22; padding: 2px 4px; border-radius: 3px;">pgh remote add myname ${p2pInfo?.serverKey?.slice(0, 16)}...</code>
</p>
` : (repo.punchUrl ? `
<div class="clone-url">
<input readonly value="git clone ${escapeHtml(repo.punchUrl)}" id="clone-p2p">
<button onclick="copyClone('clone-p2p')">Copy</button>
</div>
` : `
<div style="margin-top: 10px; padding: 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px;">
<p style="color: #8b949e; font-size: 13px;">P2P not enabled — start server with --network</p>
</div>
`)}
</div>
<!-- Repo Linking Controls (admin only) -->
<div id="linking-controls" style="display: none; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
${!isLinked ? `
<button class="primary" onclick="linkRepo('${escapeHtml(repo.name)}')" id="link-repo-btn" style="margin-bottom: 0;">🔗 Link Repo</button>
<span style="color: #8b949e; font-size: 13px;">Enable multi-server collaboration via Autobase</span>
` : `
<span style="color: #58a6ff; font-size: 14px; font-weight: 500;">🔗 Linked</span>
<button style="background: #da3633; color: white; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="unlinkRepo('${escapeHtml(repo.name)}')">Unlink</button>
`}
<div style="flex: 1;"></div>
<button style="background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="toggleJoinForm()">Join with Invite</button>
</div>
<!-- Join form -->
<div id="join-form" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d;">
<div style="display: flex; gap: 8px; align-items: center;">
<input type="text" id="join-invite-hex" placeholder="Paste invite hex from another server..." style="flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9; font-family: monospace; font-size: 12px;">
<button class="primary" onclick="joinWithInvite('${escapeHtml(repo.name)}')" style="margin-bottom: 0;">Join</button>
</div>
</div>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('files')">📁 Files</button>
<button class="tab" onclick="switchTab('commits')">📝 Commits</button>
<button class="tab" onclick="switchTab('issues')">🐛 Issues (${issues.length})</button>
${docFiles.length > 0 ? `<button class="tab" onclick="switchTab('docs')">📖 Docs (${docFiles.length})</button>` : ''}
${isLinked ? `<button class="tab" onclick="switchTab('peers')">🔗 Peers (${linkedWriters.length})</button>` : ''}
${hasRelease ? `<button class="tab" onclick="switchTab('releases')">📦 Releases</button>` : ''}
</div>
<div id="tab-files" class="tab-content active">
${renderFilesTab(files, readme, readmeIsMarkdown)}
</div>
<div id="tab-commits" class="tab-content">
${renderCommitsTab(commits)}
</div>
<div id="tab-issues" class="tab-content">
${renderIssuesTab(issues, repo.name, labels, issueCounts)}
</div>
${docFiles.length > 0 ? `
<div id="tab-docs" class="tab-content">
${renderDocsTab(docFiles, repo.name)}
</div>
` : ''}
${isLinked ? `
<div id="tab-peers" class="tab-content">
${renderPeersTab(linkedWriters, repo.name)}
</div>
` : ''}
${hasRelease ? `
<div id="tab-releases" class="tab-content">
${renderReleasesTab(repo, lastDeploy, pathPrefix)}
</div>
` : ''}
</div>
`
const scripts = `
function getBasePath() {
return window.__PATH_PREFIX__ || '';
}
// Show linking controls for admin users (nav handles admin link + user badge)
async function showLinkingControls() {
if (window.identityReady) {
await window.identityReady;
}
try {
const response = await window.fetch(getBasePath() + '/api/user/identities');
if (!response.ok) return;
const data = await response.json();
const hasAdmin = data.permissions.global.includes('admin') ||
data.permissions.repos.some(r => r.permissions.includes('admin'));
if (hasAdmin) {
const linkCtrl = document.getElementById('linking-controls');
if (linkCtrl) linkCtrl.style.display = 'block';
}
} catch (err) {
console.error('Failed to check admin for linking controls:', err);
}
}
showLinkingControls();
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
// Auto-load docs overview (or first doc) when docs tab is opened
if (tab === 'docs' && !window.__docsLoaded) {
window.__docsLoaded = true;
var overview = document.querySelector('#tab-docs .doc-overview');
var firstDoc = overview || document.querySelector('#tab-docs .doc-file-item');
if (firstDoc) firstDoc.click();
}
}
function copyClone(id) {
const input = document.getElementById(id);
const text = input.value;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text);
} else {
input.select();
document.execCommand('copy');
}
event.target.textContent = 'Copied!';
setTimeout(() => {
event.target.textContent = 'Copy';
}, 2000);
}
function switchBranch(branchName) {
// Reload page with branch parameter
const url = new URL(window.location);
url.searchParams.set('branch', branchName);
window.location.href = url.toString();
}
function viewIssue(number) {
window.location.href = window.location.pathname + '/issues/' + number;
}
function viewFile(filePath, isDirectory) {
const prefix = isDirectory ? '/tree/' : '/blob/';
window.location.href = window.location.pathname + prefix + filePath;
}
async function loadDoc(el, filePath, repoName) {
// Update active state in sidebar
document.querySelectorAll('.doc-file-item').forEach(function(item) { item.classList.remove('active'); });
el.classList.add('active');
var placeholder = document.getElementById('docs-placeholder');
var rendered = document.getElementById('docs-rendered');
if (placeholder) placeholder.style.display = 'none';
rendered.style.display = 'block';
rendered.innerHTML = '<div style="color: #8b949e; padding: 20px;">Loading...</div>';
try {
var response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/raw?path=' + encodeURIComponent(filePath));
if (!response.ok) throw new Error('Failed to load file');
var text = await response.text();
if (filePath.toLowerCase().endsWith('.md') && typeof marked !== 'undefined') {
marked.setOptions({ breaks: false, gfm: true, headerIds: true });
var parsed = marked.parse(text);
// Sanitize
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, '');
rendered.innerHTML = '<h3 style="margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-size: 13px;">' + filePath.replace(/</g, '<') + '</h3>' + parsed;
} else {
rendered.innerHTML = '<h3 style="margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-size: 13px;">' + filePath.replace(/</g, '<') + '</h3><pre style="white-space: pre-wrap;">' + text.replace(/</g, '<').replace(/>/g, '>') + '</pre>';
}
} catch (err) {
rendered.innerHTML = '<div style="color: #da3633; padding: 20px;">Failed to load document: ' + err.message + '</div>';
}
}
async function createInvite(repoName) {
const btn = event.target;
const resultEl = document.getElementById('invite-result');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const response = await window.fetch(getBasePath() + \`/api/repos/\${encodeURIComponent(repoName)}/invite\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'writer' })
});
const data = await response.json();
if (response.ok && data.invite) {
resultEl.textContent = data.invite;
resultEl.classList.add('visible');
const copyBtn = document.getElementById('copy-invite-btn');
if (copyBtn) copyBtn.style.display = 'inline-block';
} else {
alert('Failed to create invite: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Error: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '+ Create Invite';
}
}
function copyInvite() {
const el = document.getElementById('invite-result');
navigator.clipboard.writeText(el.textContent).then(() => {
const btn = event.target;
btn.textContent = '✓ Copied!';
setTimeout(() => { btn.textContent = '📋 Copy'; }, 2000);
});
}
function toggleJoinForm() {
const form = document.getElementById('join-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
async function linkRepo(repoName) {
if (!confirm('Link this repo for multi-server collaboration?')) return;
const btn = document.getElementById('link-repo-btn');
btn.disabled = true;
btn.textContent = 'Linking...';
try {
const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (response.ok) {
alert('Repo linked! Invite: ' + (data.invite || '(none)'));
window.location.reload();
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.textContent = '🔗 Link Repo';
}
} catch (err) {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = '🔗 Link Repo';
}
}
async function unlinkRepo(repoName) {
if (!confirm('Unlink this repo? This removes the Autobase overlay.')) return;
try {
const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/unlink', {
method: 'DELETE'
});
if (response.ok) {
alert('Repo unlinked');
window.location.reload();
} else {
const data = await response.json();
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Error: ' + err.message);
}
}
async function joinWithInvite(repoName) {
const inviteHex = document.getElementById('join-invite-hex').value.trim();
if (!inviteHex) { alert('Paste an invite hex first'); return; }
try {
const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite: inviteHex })
});
const data = await response.json();
if (response.ok) {
alert('Joined! Bootstrap key: ' + (data.bootstrapKey || '(unknown)'));
window.location.reload();
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Error: ' + err.message);
}
}
async function migrateToP2P() {
if (!confirm('This will enable P2P cloning for this repository. Continue?')) {
return;
}
const repoName = window.location.pathname.split('/').filter(Boolean).pop();
try {
event.target.disabled = true;
event.target.textContent = 'Migrating...';
const response = await window.fetch(getBasePath() + \`/api/repos/\${repoName}/migrate\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
strategy: 'hyperdrive',
options: {}
})
});
const result = await response.json();
if (response.ok) {
alert(\`✓ P2P enabled!\\n\\nClone with:\\ngit clone \${result.punchUrl}\`);
window.location.reload();
} else {
alert(\`Error: \${result.error}\`);
event.target.disabled = false;
event.target.textContent = 'Enable P2P Clone';
}
} catch (err) {
alert(\`Error: \${err.message}\`);
event.target.disabled = false;
event.target.textContent = 'Enable P2P Clone';
}
}
`
// Issue modal needs to be in the content, not after scripts
const fullContent = content + '\n' + renderIssueModal()
return renderPage(escapeHtml(repo.name), fullContent, {
pathPrefix,
activePage: 'repo',
css: PAGE_CSS,
scripts,
head: `<script src="${pathPrefix}/vendor/marked.min.js"></script>`
})
}
function renderFilesTab(files, readme, readmeIsMarkdown) {
let content = ''
// Show README if present
if (readme) {
if (readmeIsMarkdown) {
// Render markdown client-side with marked.js
const escapedMd = readme
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
content += `
<div class="readme" id="readme-content">
<pre style="white-space: pre-wrap;">Loading...</pre>
</div>
<script>
(function() {
const raw = ${JSON.stringify(readme).replace(/<\//g, '<\\/')};
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: false,
gfm: true,
headerIds: true
});
// Sanitize HTML output to prevent XSS from malicious READMEs
var parsed = marked.parse(raw);
// Note: avoid literal close-script in regex to not break HTML parser
var scriptRe = new RegExp('<scr' + 'ipt[\\\\s\\\\S]*?<\\\\/scr' + 'ipt>', 'gi');
parsed = parsed.replace(scriptRe, '');
var iframeRe = new RegExp('<ifr' + 'ame[\\\\s\\\\S]*?<\\\\/ifr' + 'ame>', 'gi');
parsed = parsed.replace(iframeRe, '');
parsed = parsed.replace(new RegExp('<ifr' + 'ame[^>]*/?>', 'gi'), '');
parsed = parsed.replace(new RegExp('<object[\\\\s\\\\S]*?</object>', 'gi'), '');
parsed = parsed.replace(new RegExp('<embed[^>]*/?>','gi'), '');
parsed = parsed.replace(new RegExp('<style[\\\\s\\\\S]*?</style>', 'gi'), '');
parsed = parsed.replace(/\\bon\\w+\\s*=/gi, 'data-removed=');
parsed = parsed.replace(/javascript\\s*:/gi, '');
parsed = parsed.replace(/vbscript\\s*:/gi, '');
parsed = parsed.replace(/data\\s*:[^image]/gi, '');
document.getElementById('readme-content').innerHTML = parsed;
} else {
document.getElementById('readme-content').innerHTML = '<pre style="white-space: pre-wrap;">' + raw.replace(/</g, '<').replace(/>/g, '>') + '</pre>';
}
})();
</script>
<div style="height: 30px;"></div>
`
} else {
content += `
<div class="readme">
<pre style="white-space: pre-wrap;">${escapeHtml(readme)}</pre>
</div>
<div style="height: 30px;"></div>
`
}
}
// Show files list
if (files.length === 0) {
content += `
<div class="empty-state">
<div class="empty-state-icon">📂</div>
<div>No files available yet</div>
<div style="font-size: 12px; margin-top: 10px;">Files will appear here once the git repository is fully imported</div>
</div>
`
} else {
content += `
<div>
<h2 style="margin-bottom: 15px;">📁 Files</h2>
<div class="file-list">
${files.map(file => {
let icon = '📄'
if (file.type === 'directory') icon = '📁'
else if (file.type === 'submodule') icon = '📦'
const isNavigable = file.type === 'directory' || file.type === 'submodule'
const onclick = `viewFile(${escapeHtml(JSON.stringify(file.path))}, ${isNavigable})`
return `
<div class="file-item" onclick="${onclick}">
<div class="file-icon">${icon}</div>
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-size">${file.size ? formatBytes(file.size) : file.type === 'submodule' ? 'submodule' : ''}</div>
</div>
`
}).join('')}
</div>
</div>
`
}
return content
}
function renderDocsTab(docFiles, repoName) {
if (docFiles.length === 0) {
return `
<div class="empty-state">
<div class="empty-state-icon">📖</div>
<div>No documentation files found</div>
</div>
`
}
// Human-readable name from filename
function prettyName(filename) {
return filename
.replace(/\.md$/i, '')
.replace(/\.txt$/i, '')
.replace(/[_-]/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
}
// Human-readable directory labels
const dirLabels = {
'docs': 'Reference',
'docs/architecture': 'Architecture',
'docs/phases': 'Phase History'
}
// Group files: root .md, docs/ root, docs/architecture/, docs/phases/
const rootFiles = docFiles.filter(f => !f.path.includes('/'))
const dirFiles = {}
docFiles.filter(f => f.path.includes('/')).forEach(f => {
// Get the directory path (e.g. "docs" or "docs/architecture")
const parts = f.path.split('/')
parts.pop() // remove filename
const dir = parts.join('/')
if (!dirFiles[dir]) dirFiles[dir] = []
dirFiles[dir].push(f)
})
// Find docs/README.md for auto-load
const docsReadme = docFiles.find(f => f.path.toLowerCase() === 'docs/readme.md')
const hasDocsReadme = !!docsReadme
// Sort directory entries: docs/ first, then alphabetically
const sortedDirs = Object.keys(dirFiles).sort((a, b) => {
if (a === 'docs') return -1
if (b === 'docs') return 1
return a.localeCompare(b)
})
// Within each group, put README.md first, then sort alphabetically
for (const dir of sortedDirs) {
dirFiles[dir].sort((a, b) => {
if (a.name.toLowerCase() === 'readme.md') return -1
if (b.name.toLowerCase() === 'readme.md') return 1
return a.name.localeCompare(b.name)
})
}
return `
<div class="docs-layout">
<div class="docs-sidebar">
<h3>Documentation</h3>
${rootFiles.length > 0 ? `
${rootFiles.map(f => `
<div class="doc-file-item" onclick="loadDoc(this, ${escapeHtml(JSON.stringify(f.path))}, '${escapeHtml(repoName)}')">
<span class="doc-icon">${f.name.toLowerCase().endsWith('.md') ? '📄' : '📋'}</span>
<span class="doc-name">${escapeHtml(prettyName(f.name))}</span>
</div>
`).join('')}
` : ''}
${sortedDirs.map(dir => `
<div style="margin-top: 12px;">
<div style="font-size: 12px; color: #8b949e; padding: 4px 10px; font-weight: 500;">📁 ${escapeHtml(dirLabels[dir] || dir.split('/').pop())}</div>
${dirFiles[dir].map(f => `
<div class="doc-file-item${hasDocsReadme && f.path.toLowerCase() === 'docs/readme.md' ? ' doc-overview' : ''}" onclick="loadDoc(this, ${escapeHtml(JSON.stringify(f.path))}, '${escapeHtml(repoName)}')">
<span class="doc-icon">${f.name.toLowerCase() === 'readme.md' ? '📖' : f.name.toLowerCase().endsWith('.md') ? '📄' : '📋'}</span>
<span class="doc-name">${f.name.toLowerCase() === 'readme.md' ? 'Overview' : escapeHtml(prettyName(f.name))}</span>
</div>
`).join('')}
</div>
`).join('')}
</div>
<div class="docs-content">
<div class="docs-empty" id="docs-placeholder">
Select a document from the sidebar to view it
</div>
<div class="readme" id="docs-rendered" style="display: none;"></div>
</div>
</div>
`
}
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]
}
function renderCommitsTab(commits) {
if (commits.length === 0) {
return `
<div class="empty-state">
<div class="empty-state-icon">📝</div>
<div>No commits available yet</div>
</div>
`
}
return `
<div class="commit-list">
${commits.map(commit => `
<div class="commit-item">
<div class="commit-hash">${commit.shortHash || commit.hash.substring(0, 7)}</div>
<div class="commit-message">${escapeHtml(commit.subject || commit.message)}</div>
<div class="commit-author">${escapeHtml(commit.author)}</div>
<div class="commit-date">${new Date(commit.timestamp * 1000).toLocaleDateString()}</div>
</div>
`).join('')}
</div>
`
}
function renderIssuesTab(issues, repoName, labels = [], issueCounts = {}) {
// Create label map for quick lookup
const labelMap = {}
labels.forEach(l => { labelMap[l.name] = l })
return `
<div style="display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; align-items: center;">
<button class="primary" onclick="openIssueModal()" style="margin-bottom: 0;">+ New Issue</button>
<!-- Status filter -->
<div style="display: flex; gap: 5px; background: #161b22; padding: 4px; border-radius: 6px; border: 1px solid #30363d;">
<button class="issue-filter-btn active" data-status="open" onclick="filterByStatus('open')">
🟢 Open ${issueCounts.open || 0}
</button>
<button class="issue-filter-btn" data-status="closed" onclick="filterByStatus('closed')">
🟣 Closed ${issueCounts.closed || 0}
</button>
<button class="issue-filter-btn" data-status="all" onclick="filterByStatus('all')">
All ${issueCounts.total || 0}
</button>
</div>
<!-- Search -->
<div style="flex: 1; min-width: 200px;">
<input type="text" id="issue-search" placeholder="🔍 Search issues..."
style="width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9;"
oninput="searchIssues(this.value)">
</div>
<!-- Sort -->
<select id="issue-sort" onchange="sortIssues(this.value)"
style="background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9;">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="recently-updated">Recently updated</option>
</select>
</div>
${labels.length > 0 ? `
<!-- Label filter -->
<div style="margin-bottom: 20px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px;">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<span style="color: #8b949e; font-size: 12px;">Labels:</span>
${labels.map(label => {
const styles = getLabelStyles(label.color)
return `
<button class="label-filter-btn" data-label="${escapeHtml(label.name)}"
onclick="toggleLabelFilter(${escapeHtml(JSON.stringify(label.name))})"
style="background: transparent; border: 1px solid ${escapeHtml(label.color)}; color: ${escapeHtml(label.color)}; padding: 2px 8px; border-radius: 12px; font-size: 11px; cursor: pointer;">
${escapeHtml(label.displayName || label.name)}
</button>
`
}).join('')}
<button onclick="clearLabelFilters()" style="background: transparent; border: none; color: #8b949e; font-size: 11px; cursor: pointer; padding: 2px 8px;">
Clear all
</button>
</div>
</div>
` : ''}
<div id="issues-container">
${issues.length === 0 ? `
<div class="empty-state">
<div class="empty-state-icon">🐛</div>
<div>No issues yet</div>
<div style="font-size: 12px; margin-top: 10px;">Click "New Issue" to create the first one</div>
</div>
` : `
<div class="issue-list">
${issues.map(issue => `
<div class="issue-item" data-status="${escapeHtml(issue.status)}" data-labels="${escapeHtml((issue.labels || []).join(','))}" onclick="viewIssue(${issue.number})">
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
<div class="issue-number">#${issue.number}</div>
<div style="flex: 1;">
<div class="issue-title">${escapeHtml(issue.title)}</div>
${(issue.labels && issue.labels.length > 0) ? `
<div style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
${issue.labels.map(labelName => {
const label = labelMap[labelName]
if (!label) return ''
const styles = getLabelStyles(label.color)
return `<span style="background: ${escapeHtml(styles.background)}; color: ${escapeHtml(styles.foreground)}; padding: 1px 6px; border-radius: 10px; font-size: 10px; font-weight: 500;">${escapeHtml(label.displayName || label.name)}</span>`
}).join('')}
</div>
` : ''}
</div>
</div>
<div class="issue-comments">💬 ${issue.comments?.length || 0}</div>
<div class="issue-status ${escapeHtml(issue.status)}">${escapeHtml(issue.status)}</div>
</div>
`).join('')}
</div>
`}
</div>
<style>
.issue-filter-btn {
background: transparent;
border: none;
color: #8b949e;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.issue-filter-btn:hover { background: #30363d; color: #c9d1d9; }
.issue-filter-btn.active { background: #238636; color: white; }
.label-filter-btn.active {
background: currentColor !important;
color: white !important;
}
</style>
<script>
// Issue filtering state
window.issueFilters = {
status: 'open',
labels: [],
search: ''
};
// Store original issues data for client-side filtering
window.issuesData = ${JSON.stringify(issues)};
window.labelsData = ${JSON.stringify(labels)};
function filterByStatus(status) {
window.issueFilters.status = status;
document.querySelectorAll('.issue-filter-btn[data-status]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.status === status);
});
applyFilters();
}
function toggleLabelFilter(labelName) {
const idx = window.issueFilters.labels.indexOf(labelName);
if (idx >= 0) {
window.issueFilters.labels.splice(idx, 1);
} else {
window.issueFilters.labels.push(labelName);
}
document.querySelectorAll('.label-filter-btn[data-label]').forEach(btn => {
btn.classList.toggle('active', window.issueFilters.labels.includes(btn.dataset.label));
});
applyFilters();
}
function clearLabelFilters() {
window.issueFilters.labels = [];
document.querySelectorAll('.label-filter-btn').forEach(btn => btn.classList.remove('active'));
applyFilters();
}
function searchIssues(query) {
window.issueFilters.search = query.toLowerCase();
applyFilters();
}
function sortIssues(sortBy) {
applyFilters();
}
function applyFilters() {
const items = document.querySelectorAll('.issue-item');
const sortBy = document.getElementById('issue-sort')?.value || 'newest';
items.forEach(item => {
const status = item.dataset.status;
const labels = (item.dataset.labels || '').split(',').filter(Boolean);
const title = item.querySelector('.issue-title')?.textContent?.toLowerCase() || '';
let visible = true;
// Status filter
if (window.issueFilters.status !== 'all' && status !== window.issueFilters.status) {
visible = false;
}
// Label filter (must have ALL selected labels)
if (visible && window.issueFilters.labels.length > 0) {
const hasAllLabels = window.issueFilters.labels.every(l => labels.includes(l));
if (!hasAllLabels) visible = false;
}
// Search filter
if (visible && window.issueFilters.search) {
if (!title.includes(window.issueFilters.search)) {
visible = false;
}
}
item.style.display = visible ? 'flex' : 'none';
});
}
// Apply initial filter (show open issues by default)
document.addEventListener('DOMContentLoaded', function() {
filterByStatus('open');
});
</script>
`
}
function renderPeersTab(writers, repoName) {
if (writers.length === 0) {
return `
<div class="empty-state">
<div class="empty-state-icon">🔗</div>
<div>No peers yet</div>
<div style="font-size: 12px; margin-top: 10px;">Create an invite to share this linked repo with collaborators</div>
</div>
<div class="invite-box">
<h3>Create Invite</h3>
<p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">Generate an invite code to share with collaborators. They can join with: <code style="background: #0d1117; padding: 2px 6px; border-radius: 3px;">pgh repo join <invite></code></p>
<button class="primary" onclick="createInvite('${escapeHtml(repoName)}')">+ Create Invite</button>
<div class="invite-result" id="invite-result"></div>
<button class="secondary" style="margin-top: 8px; display: none;" id="copy-invite-btn" onclick="copyInvite()">📋 Copy</button>
</div>
`
}
return `
<div class="peers-table">
<div class="peers-header">
<div>User</div>
<div>Role</div>
<div>Server Key</div>
</div>
${writers.map(w => {
const role = w.role || 'writer'
const userId = w.userId || 'unknown'
const serverKey = w.serverKey || ''
return `
<div class="peers-row">
<div class="peer-user">${escapeHtml(userId)}</div>
<div><span class="peer-role ${escapeHtml(role)}">${escapeHtml(role)}</span></div>
<div class="peer-key" title="${escapeHtml(serverKey)}">${serverKey ? serverKey.slice(0, 16) + '...' : '-'}</div>
</div>
`
}).join('')}
</div>
<div class="invite-box">
<h3>Create Invite</h3>
<p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">Generate an invite code to share with collaborators. They can join with: <code style="background: #0d1117; padding: 2px 6px; border-radius: 3px;">pgh repo join <invite></code></p>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="primary" onclick="createInvite('${escapeHtml(repoName)}')">+ Create Invite</button>
<button class="secondary" style="display: none;" id="copy-invite-btn" onclick="copyInvite()">📋 Copy</button>
</div>
<div class="invite-result" id="invite-result"></div>
</div>
`
}
function renderReleasesTab (repo, lastDeploy, pathPrefix) {
const prefix = pathPrefix || ''
const owner = repo.username || 'local'
const tarUrl = `${prefix}/${owner}/${repo.name}/archive/release.tar.gz`
const zipUrl = `${prefix}/${owner}/${repo.name}/archive/release.zip`
const deployInfo = lastDeploy
? `<p style="color: #8b949e; font-size: 13px;">Last deployed: ${new Date(lastDeploy.timestamp).toLocaleString()} ${lastDeploy.commitHash ? '(' + lastDeploy.commitHash.slice(0, 8) + ')' : ''}</p>`
: ''
return `
<div style="max-width: 700px;">
<div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 24px; margin-bottom: 20px;">
<h3 style="margin: 0 0 8px 0; color: #c9d1d9;">Latest Release</h3>
${deployInfo}
<div style="display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap;">
<a href="${escapeHtml(tarUrl)}" style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: #238636; color: #fff; border-radius: 6px; text-decoration: none; font-size: 14px; font-weight: 500;">
📦 Download .tar.gz
</a>
<a href="${escapeHtml(zipUrl)}" style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; text-decoration: none; font-size: 14px;">
📦 Download .zip
</a>
</div>
</div>
<div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px;">
<h4 style="margin: 0 0 12px 0; color: #c9d1d9;">Install</h4>
<pre style="background: #0d1117; border: 1px solid #30363d; border-radius: 4px; padding: 12px; color: #c9d1d9; overflow-x: auto; font-size: 13px;">pgh app install --app ${escapeHtml(repo.name)}</pre>
<p style="color: #8b949e; font-size: 12px; margin-top: 8px;">Re-running this command auto-updates if already installed.</p>
</div>
</div>
`
}
// Helper function to calculate label text color based on background
function getLabelStyles(color) {
if (!color) return { background: '#ccc', foreground: '#000' }
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16) || 0
const g = parseInt(hex.substr(2, 2), 16) || 0
const b = parseInt(hex.substr(4, 2), 16) || 0
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return {
background: color,
foreground: brightness > 125 ? '#000000' : '#ffffff'
}
}