📃 Commit da6d7ad
tree sidebar, language detector, repo nav, ZIP download, home cards
da6d7ad387c9633cfcdbc582cfef80497eb59c06
📝 Diff
lib/git.js
| @@ -113,6 +113,13 @@ function blobStream(gitDir, ref, filePath) { | |
return spawn(bin(), ['--git-dir=' + gitDir, 'cat-file', 'blob', `${ref}:${filePath}`]); | |
} | |
| |
| + | function archiveStream(gitDir, ref, prefix) { |
| + | const args = ['--git-dir=' + gitDir, 'archive', '--format=zip']; |
| + | if (prefix) args.push('--prefix=' + prefix + '/'); |
| + | args.push(ref); |
| + | return spawn(bin(), args); |
| + | } |
| + | |
function blobContent(gitDir, ref, filePath) { | |
return new Promise((resolve, reject) => { | |
const proc = spawn(bin(), ['--git-dir=' + gitDir, 'cat-file', 'blob', `${ref}:${filePath}`]); | |
| @@ -159,9 +166,56 @@ async function fileCount(gitDir, ref) { | |
} catch { return 0; } | |
} | |
| |
| + | const LANG_EXT = { |
| + | js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', |
| + | ts: 'TypeScript', tsx: 'TypeScript', |
| + | py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', |
| + | c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', hh: 'C++', |
| + | cs: 'C#', php: 'PHP', sh: 'Shell', bash: 'Shell', zsh: 'Shell', |
| + | ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch', |
| + | html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'Sass', less: 'Less', |
| + | md: 'Markdown', markdown: 'Markdown', |
| + | json: 'JSON', yml: 'YAML', yaml: 'YAML', |
| + | xml: 'XML', toml: 'TOML', ini: 'INI', conf: 'Config', |
| + | sql: 'SQL', |
| + | vue: 'Vue', svelte: 'Svelte', |
| + | swift: 'Swift', kt: 'Kotlin', dart: 'Dart', |
| + | ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', |
| + | scala: 'Scala', clj: 'Clojure', elm: 'Elm', |
| + | lua: 'Lua', pl: 'Perl', r: 'R', |
| + | txt: 'Text', tex: 'TeX' |
| + | }; |
| + | |
| + | async function langStats(gitDir, ref) { |
| + | try { |
| + | const out = await run(['ls-tree', '-r', '-l', ref], gitDir); |
| + | const totals = {}; |
| + | let total = 0; |
| + | for (const line of out.split('\n')) { |
| + | const m = line.match(/^\d+ blob [0-9a-f]+\s+(\d+)\t(.+)$/); |
| + | if (!m) continue; |
| + | const size = parseInt(m[1], 10) || 0; |
| + | if (size === 0) continue; |
| + | const name = m[2]; |
| + | let ext; |
| + | if (/^Dockerfile/i.test(name.split('/').pop())) { ext = 'dockerfile'; } |
| + | else if (name.includes('.')) { ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); } |
| + | else continue; |
| + | const lang = LANG_EXT[ext]; |
| + | if (!lang) continue; |
| + | totals[lang] = (totals[lang] || 0) + size; |
| + | total += size; |
| + | } |
| + | if (total === 0) return []; |
| + | return Object.entries(totals) |
| + | .map(([lang, size]) => ({ lang, size, pct: (size / total) * 100 })) |
| + | .sort((a, b) => b.size - a.size); |
| + | } catch { return []; } |
| + | } |
| + | |
module.exports = { | |
reposRoot, repoPath, exists, | |
init, refs, commitCount, log, tree, | |
| - | blobStream, blobContent, objectSize, |
| - | commitInfo, diff, fileCount |
| + | blobStream, blobContent, objectSize, archiveStream, |
| + | commitInfo, diff, fileCount, langStats |
}; | |
lib/render.js
| @@ -134,7 +134,39 @@ function fileTreeSidebar(entries, owner, repo, ref, currentFile) { | |
return `<div class="panel"><div class="head">📁 Files</div><div class="body tree-sidebar-body">${rootLink}<ul class="tree-sidebar">${items}</ul></div></div>`; | |
} | |
| |
| + | const LANG_COLOR = { |
| + | JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', |
| + | Ruby: '#701516', Go: '#00ADD8', Rust: '#dea584', Java: '#b07219', |
| + | C: '#555555', 'C++': '#f34b7d', 'C#': '#178600', PHP: '#4F5D95', |
| + | Shell: '#89e051', PowerShell: '#012456', Batch: '#C1F12E', |
| + | HTML: '#e34c26', CSS: '#563d7c', SCSS: '#c6538c', Sass: '#a53b70', Less: '#1d365d', |
| + | Markdown: '#083fa1', JSON: '#292929', YAML: '#cb171e', |
| + | XML: '#0060ac', TOML: '#9c4221', INI: '#d1dbe0', Config: '#6d8086', |
| + | SQL: '#e38c00', |
| + | Vue: '#41b883', Svelte: '#ff3e00', |
| + | Swift: '#F05138', Kotlin: '#A97BFF', Dart: '#00B4AB', |
| + | Elixir: '#6e4a7e', Erlang: '#B83998', |
| + | Scala: '#c22d40', Clojure: '#db5855', Elm: '#60B5CC', |
| + | Lua: '#000080', Perl: '#0298c3', R: '#198CE7', |
| + | Text: '#cccccc', TeX: '#3D6117', Other: '#888888' |
| + | }; |
| + | |
| + | function langColor(name) { return LANG_COLOR[name] || LANG_COLOR.Other; } |
| + | |
| + | function langBar(stats) { |
| + | if (!stats || stats.length === 0) return ''; |
| + | const top = stats.slice(0, 6); |
| + | const segs = top.map(s => |
| + | `<span class="lang-segment" style="background:${langColor(s.lang)};width:${s.pct.toFixed(2)}%" title="${escape(s.lang)} ${s.pct.toFixed(1)}%"></span>` |
| + | ).join(''); |
| + | const labels = top.map(s => |
| + | `<span class="lang-label"><span class="lang-dot" style="background:${langColor(s.lang)}"></span> ${escape(s.lang)} <span class="lang-pct">${s.pct.toFixed(1)}%</span></span>` |
| + | ).join(''); |
| + | return `<div class="lang-section"><div class="lang-bar">${segs}</div><div class="lang-labels">${labels}</div></div>`; |
| + | } |
| + | |
module.exports = { | |
escape, fill, page, loadView, | |
| - | sidebar, breadcrumb, treeRows, commitRows, blobLines, fileTreeSidebar |
| + | sidebar, breadcrumb, treeRows, commitRows, blobLines, fileTreeSidebar, |
| + | langBar, langColor |
}; | |
public/pages.css
| @@ -51,6 +51,37 @@ | |
.tree-sidebar li.active { background: #ffffcc; padding: 2px 4px; } | |
.tree-sidebar li.active a { color: var(--navy); font-weight: bold; } | |
| |
| + | /* Language bar */ |
| + | .lang-section { |
| + | background: #fff; |
| + | border: 1px solid #808080; |
| + | margin-bottom: 10px; |
| + | padding: 8px 10px; |
| + | } |
| + | .lang-bar { |
| + | display: flex; |
| + | height: 10px; |
| + | border: 1px solid #808080; |
| + | border-right-color: #fff; |
| + | border-bottom-color: #fff; |
| + | background: #eee; |
| + | margin-bottom: 6px; |
| + | overflow: hidden; |
| + | } |
| + | .lang-segment { display: block; height: 100%; } |
| + | .lang-labels { display: flex; flex-wrap: wrap; gap: 10px; font-size: 11px; font-family: Tahoma, sans-serif; } |
| + | .lang-label { display: inline-flex; align-items: center; gap: 4px; } |
| + | .lang-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1px solid rgba(0,0,0,.2); } |
| + | .lang-pct { color: #666; } |
| + | |
| + | /* Repo nav strip */ |
| + | .repo-nav { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; } |
| + | .repo-nav .btn { padding: 4px 10px; font-size: 11px; } |
| + | |
| + | /* Visibility badges */ |
| + | .vis-badge.public { border-color: #060; background: #e8ffe8; color: #060; } |
| + | |
| + | |
/* Commit list */ | |
.commit-table td.commit-sha { width: 70px; font-family: "Courier New", monospace; font-size: 11px; } | |
.commit-table td.commit-msg { max-width: 400px; } | |
public/style.css
| @@ -27,7 +27,7 @@ body { | |
} | |
| |
a { color: var(--link); } | |
| - | a:visited { color: var(--visited); } |
| + | a:visited { color: #0004ff; } |
| |
/* Browser chrome */ | |
.browser { | |
| @@ -254,6 +254,7 @@ table.retro td { | |
font-weight: normal; | |
margin-left: 4px; | |
vertical-align: middle; | |
| + | text-transform: uppercase; |
} | |
.vis-badge.private { border-color: #c00; background: #fff0f0; color: #c00; } | |
| |
routes/pages.js
| @@ -12,12 +12,24 @@ function home(req, res) { | |
.sort((a, b) => b.createdAt - a.createdAt) | |
.slice(0, 20); | |
| |
| - | const repoCards = repos.map(r => |
| - | `<div class="repo-card"> |
| - | <div class="repo-card-name"><a href="/${render.escape(r.owner)}/${render.escape(r.name)}">${render.escape(r.owner)}/${render.escape(r.name)}</a></div> |
| - | <div class="repo-card-desc">${render.escape(r.description || '')}</div> |
| - | </div>` |
| - | ).join('\n'); |
| + | const repoCards = repos.map(r => { |
| + | const date = r.createdAt ? new Date(r.createdAt).toISOString().slice(0, 10) : ''; |
| + | const tag = r.visibility === 'private' |
| + | ? '<span class="vis-badge private">private</span>' |
| + | : '<span class="vis-badge public">public</span>'; |
| + | return `<div class="repo-card"> |
| + | <div class="repo-card-name"> |
| + | <a href="/${render.escape(r.owner)}/${render.escape(r.name)}">${render.escape(r.owner)}/${render.escape(r.name)}</a> |
| + | ${tag} |
| + | </div> |
| + | <div class="repo-card-desc">${render.escape(r.description || 'No description')}</div> |
| + | <div class="repo-card-meta"> |
| + | 👤 <a href="/${render.escape(r.owner)}">${render.escape(r.owner)}</a> |
| + | · 📅 ${render.escape(date)} |
| + | · <a href="/${render.escape(r.owner)}/${render.escape(r.name)}/archive/main.zip">⬇ ZIP</a> |
| + | </div> |
| + | </div>`; |
| + | }).join('\n'); |
| |
const content = render.fill(render.loadView('home.html'), { | |
SITE_NAME: render.escape(config.get('site.name')), | |
routes/repo.js
| @@ -26,13 +26,14 @@ async function repoHome(req, res, params) { | |
const gitDir = git.repoPath(params.owner, params.repo); | |
if (!git.exists(gitDir)) return respond.notFound(res); | |
| |
| - | let refsData, count, entries, readmeHtml, fileCount = 0; |
| + | let refsData, count, entries, readmeHtml, fileCount = 0, langs = []; |
try { | |
refsData = await git.refs(gitDir); | |
const ref = refsData.default; | |
count = await git.commitCount(gitDir, ref); | |
entries = await git.tree(gitDir, ref, ''); | |
fileCount = await git.fileCount(gitDir, ref); | |
| + | langs = await git.langStats(gitDir, ref); |
const readmeEntry = entries.find(e => e.type === 'blob' && /^readme(\.|$)/i.test(e.name)); | |
if (readmeEntry) { | |
const buf = await git.blobContent(gitDir, ref, readmeEntry.name); | |
| @@ -40,7 +41,7 @@ async function repoHome(req, res, params) { | |
} | |
} catch { | |
refsData = { default: config.get('repos.defaultBranch'), branches: [] }; | |
| - | count = 0; entries = []; fileCount = 0; |
| + | count = 0; entries = []; fileCount = 0; langs = []; |
} | |
| |
const ref = refsData.default; | |
| @@ -64,6 +65,7 @@ async function repoHome(req, res, params) { | |
CREATED_DATE: render.escape(createdDate), | |
BRANCH_OPTIONS: branchOpts, | |
TREE_ROWS: render.treeRows(entries, params.owner, params.repo, ref, ''), | |
| + | LANG_BAR: render.langBar(langs), |
README_HTML: readmeHtml ? `<div class="readme-body">${readmeHtml}</div>` : '', | |
SETTINGS_LINK: isOwner ? `<a class="btn" href="/${render.escape(params.owner)}/${render.escape(params.repo)}/settings">Settings</a>` : '' | |
}); | |
| @@ -84,6 +86,8 @@ async function repoTree(req, res, params) { | |
try { entries = await git.tree(gitDir, ref, treePath); } | |
catch { return respond.notFound(res); } | |
| |
| + | const sidebarHtml = render.fileTreeSidebar(entries, params.owner, params.repo, ref, ''); |
| + | |
const content = render.fill(render.loadView('repo-tree.html'), { | |
OWNER: render.escape(params.owner), | |
REPO: render.escape(params.repo), | |
| @@ -92,7 +96,36 @@ async function repoTree(req, res, params) { | |
TREE_ROWS: render.treeRows(entries, params.owner, params.repo, ref, treePath) | |
}); | |
| |
| - | res.end(render.page({ title: `${params.owner}/${params.repo}`, content, session, addressUrl: req.url })); |
| + | res.end(render.page({ title: `${params.owner}/${params.repo}`, content, session, addressUrl: req.url, extraSidebar: sidebarHtml })); |
| + | } |
| + | |
| + | async function repoArchive(req, res, params) { |
| + | const session = auth.getSession(req); |
| + | const repo = checkAccess(params.owner, params.repo, session); |
| + | if (!repo) return respond.notFound(res); |
| + | |
| + | const gitDir = git.repoPath(params.owner, params.repo); |
| + | const proc = git.archiveStream(gitDir, params.ref, `${params.repo}-${params.ref}`); |
| + | const filename = `${params.repo}-${params.ref}.zip`; |
| + | |
| + | let started = false; |
| + | proc.stdout.on('data', chunk => { |
| + | if (!started) { |
| + | started = true; |
| + | res.writeHead(200, { |
| + | 'Content-Type': 'application/zip', |
| + | 'Content-Disposition': `attachment; filename="${filename}"`, |
| + | ...respond.securityHeaders() |
| + | }); |
| + | } |
| + | res.write(chunk); |
| + | }); |
| + | proc.on('close', code => { |
| + | if (started) res.end(); |
| + | else if (!res.headersSent) respond.notFound(res); |
| + | else res.end(); |
| + | }); |
| + | proc.on('error', () => { if (!res.headersSent) respond.serverError(res); }); |
} | |
| |
async function repoBlob(req, res, params) { | |
| @@ -228,6 +261,7 @@ module.exports = function register(router) { | |
router.get('/:owner/:repo/tree/:ref/*', (q, s, p) => repoTree(q, s, p).catch(e => respond.serverError(s, e.message))); | |
router.get('/:owner/:repo/blob/:ref/*', (q, s, p) => repoBlob(q, s, p).catch(e => respond.serverError(s, e.message))); | |
router.get('/:owner/:repo/raw/:ref/*', (q, s, p) => rawBlob(q, s, p).catch(e => respond.serverError(s, e.message))); | |
| + | router.get('/:owner/:repo/archive/:ref.zip', (q, s, p) => repoArchive(q, s, p).catch(e => respond.serverError(s, e.message))); |
router.get('/:owner/:repo/history', (q, s, p) => repoHistory(q, s, p).catch(e => respond.serverError(s, e.message))); | |
router.get('/:owner/:repo/history/:ref', (q, s, p) => repoHistory(q, s, p).catch(e => respond.serverError(s, e.message))); | |
router.get('/:owner/:repo/commit/:sha', (q, s, p) => repoCommit(q, s, p).catch(e => respond.serverError(s, e.message))); | |
views/repo-home.html
| @@ -12,12 +12,15 @@ | |
· | |
<span>📅 created {{CREATED_DATE}}</span> | |
</div> | |
| + | <div class="repo-nav"> |
| + | <a class="btn" href="/{{OWNER}}/{{REPO}}">📝 Code</a> |
| + | <a class="btn" href="/{{OWNER}}/{{REPO}}/history">📃 History</a> |
| + | <a class="btn" href="/{{OWNER}}/{{REPO}}/archive/{{REF}}.zip">⬇ Download ZIP</a> |
| + | {{SETTINGS_LINK}} |
| + | </div> |
<div class="repo-meta"> | |
<label for="branchSel">Branch:</label> | |
<select id="branchSel" onchange="switchBranch(this.value)">{{BRANCH_OPTIONS}}</select> | |
| - | |
| - | <a class="btn" href="/{{OWNER}}/{{REPO}}/history">History</a> |
| - | {{SETTINGS_LINK}} |
</div> | |
<div class="clone-box"> | |
<label>Clone:</label> | |
| @@ -26,6 +29,8 @@ | |
</div> | |
</div> | |
| |
| + | {{LANG_BAR}} |
| + | |
<div class="panel"> | |
<div class="head">📁 Files</div> | |
<table class="retro file-tree"> | |
| @@ -43,8 +48,7 @@ | |
<script> | |
(function() { | |
var owner = '{{OWNER}}', repo = '{{REPO}}'; | |
| - | var base = window.location.origin; |
| - | document.getElementById('cloneUrl').value = base + '/' + owner + '/' + repo + '.git'; |
| + | document.getElementById('cloneUrl').value = window.location.origin + '/' + owner + '/' + repo + '.git'; |
})(); | |
function switchBranch(ref) { | |
var owner = '{{OWNER}}', repo = '{{REPO}}'; | |
| |