da6d7ad - git - Webernet Explorer _X
git 🏠 Home 👤 Sign up Self hosted git, retro shell
★ otgit, code hosting from the year 2000 ★
📃 Commit da6d7ad

tree sidebar, language detector, repo nav, ZIP download, home cards

dek · 2026-05-04 · History

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">&#128193; 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">
+ &#128100; <a href="/${render.escape(r.owner)}">${render.escape(r.owner)}</a>
+ &nbsp;&middot;&nbsp; &#128197; ${render.escape(date)}
+ &nbsp;&middot;&nbsp; <a href="/${render.escape(r.owner)}/${render.escape(r.name)}/archive/main.zip">&#11015; 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 @@
&nbsp;&middot;&nbsp;
<span>&#128197; created {{CREATED_DATE}}</span>
</div>
+ <div class="repo-nav">
+ <a class="btn" href="/{{OWNER}}/{{REPO}}">&#128221; Code</a>
+ <a class="btn" href="/{{OWNER}}/{{REPO}}/history">&#128195; History</a>
+ <a class="btn" href="/{{OWNER}}/{{REPO}}/archive/{{REF}}.zip">&#11015; 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>
- &nbsp;&nbsp;
- <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">&#128193; 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}}';
Done 🔒 Internet