Commit ff11ed9
Changed files (1)
lib
gitem
lib/gitem/index.html.erb
@@ -0,0 +1,429 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Git Browser</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
+ <style>
+ :root{--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-canvas-inset:#010409;--color-border-default:#30363d;--color-border-muted:#21262d;--color-fg-default:#e6edf3;--color-fg-muted:#8b949e;--color-fg-subtle:#6e7681;--color-accent-fg:#2f81f7;--color-accent-emphasis:#1f6feb;--color-success-fg:#3fb950;--color-danger-fg:#f85149;--color-attention-fg:#d29922;--color-btn-bg:#21262d;--color-btn-border:#363b42;--color-btn-hover-bg:#30363d}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:var(--color-fg-default);background:var(--color-canvas-default)}
+ a{color:var(--color-accent-fg);text-decoration:none}a:hover{text-decoration:underline}
+ .container{max-width:1280px;margin:0 auto;padding:0 32px}
+ .AppHeader{background:var(--color-canvas-subtle);border-bottom:1px solid var(--color-border-default);padding:16px 0}
+ .AppHeader-logo{display:flex;align-items:center;gap:8px}
+ .AppHeader-logo svg{fill:var(--color-fg-default)}
+ .AppHeader-context{display:flex;align-items:center;gap:8px;font-size:16px}
+ .UnderlineNav{border-bottom:1px solid var(--color-border-default);margin-bottom:16px}
+ .UnderlineNav-body{display:flex;gap:8px;margin-bottom:-1px}
+ .UnderlineNav-item{padding:8px 16px;color:var(--color-fg-default);border-bottom:2px solid transparent;display:flex;align-items:center;gap:8px}
+ .UnderlineNav-item:hover{text-decoration:none;border-bottom-color:var(--color-border-muted)}
+ .UnderlineNav-item.selected{font-weight:600;border-bottom-color:var(--color-attention-fg)}
+ .Counter{background:var(--color-btn-bg);border-radius:10px;padding:0 6px;font-size:12px;font-weight:500;min-width:20px;text-align:center}
+ .Box{border:1px solid var(--color-border-default);border-radius:6px;background:var(--color-canvas-subtle)}
+ .Box-header{padding:16px;border-bottom:1px solid var(--color-border-default);display:flex;align-items:center;justify-content:space-between;background:var(--color-canvas-subtle)}
+ .Box-row{padding:8px 16px;border-bottom:1px solid var(--color-border-default);display:flex;align-items:center}
+ .Box-row:last-child{border-bottom:none}
+ .Box-row:hover{background:var(--color-canvas-inset)}
+ .file-wrap{margin-top:16px}
+ .branch-select{display:flex;align-items:center;gap:16px;margin-bottom:16px;flex-wrap:wrap}
+ .btn{background:var(--color-btn-bg);border:1px solid var(--color-btn-border);border-radius:6px;padding:5px 16px;color:var(--color-fg-default);font-size:14px;cursor:pointer;display:inline-flex;align-items:center;gap:8px}
+ .btn:hover{background:var(--color-btn-hover-bg)}
+ .btn-primary{background:var(--color-accent-emphasis);border-color:var(--color-accent-emphasis)}
+ .btn-primary:hover{background:#388bfd}
+ .dropdown{position:relative;display:inline-block}
+ .dropdown-menu{position:absolute;top:100%;left:0;z-index:100;min-width:300px;max-height:400px;overflow-y:auto;background:var(--color-canvas-subtle);border:1px solid var(--color-border-default);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;margin-top:4px}
+ .dropdown-menu.show{display:block}
+ .dropdown-header{padding:8px 16px;font-weight:600;border-bottom:1px solid var(--color-border-default)}
+ .dropdown-item{padding:8px 16px;cursor:pointer;display:flex;align-items:center;gap:8px}
+ .dropdown-item:hover{background:var(--color-accent-emphasis)}
+ .dropdown-item .check{opacity:0;width:16px}
+ .dropdown-item.selected .check{opacity:1}
+ .octicon{display:inline-block;vertical-align:text-bottom;fill:currentColor}
+ .icon-directory{color:var(--color-accent-fg)}
+ .icon-file{color:var(--color-fg-muted)}
+ .file-name{flex:1;margin-left:8px}
+ .file-commit{color:var(--color-fg-muted);flex:2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
+ .file-time{color:var(--color-fg-muted);min-width:100px;text-align:right}
+ .readme-box{margin-top:16px}
+ .readme-header{padding:10px 16px;font-weight:600;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-default)}
+ .markdown-body{padding:32px;background:var(--color-canvas-default)}
+ .markdown-body h1,.markdown-body h2{padding-bottom:.3em;border-bottom:1px solid var(--color-border-default);margin-top:24px;margin-bottom:16px}
+ .markdown-body h1{font-size:2em}.markdown-body h2{font-size:1.5em}.markdown-body h3{font-size:1.25em}
+ .markdown-body p{margin-bottom:16px}
+ .markdown-body code{background:var(--color-btn-bg);padding:.2em .4em;border-radius:6px;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;font-size:85%}
+ .markdown-body pre{background:var(--color-canvas-subtle);padding:16px;border-radius:6px;overflow-x:auto;margin-bottom:16px}
+ .markdown-body pre code{background:none;padding:0}
+ .markdown-body ul,.markdown-body ol{padding-left:2em;margin-bottom:16px}
+ .markdown-body li{margin-bottom:4px}
+ .markdown-body a{color:var(--color-accent-fg)}
+ .markdown-body blockquote{padding:0 1em;color:var(--color-fg-muted);border-left:4px solid var(--color-border-default);margin-bottom:16px}
+ .markdown-body img{max-width:100%}
+ .markdown-body table{border-collapse:collapse;margin-bottom:16px;width:100%}
+ .markdown-body th,.markdown-body td{border:1px solid var(--color-border-default);padding:6px 13px}
+ .markdown-body th{background:var(--color-canvas-subtle)}
+ .blob-code{padding:0;overflow-x:auto}
+ .blob-code table{width:100%;border-collapse:collapse;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;font-size:12px}
+ .blob-code td{padding:0 10px;line-height:20px;vertical-align:top}
+ .blob-num{width:1%;min-width:50px;text-align:right;color:var(--color-fg-subtle);user-select:none;padding-right:10px;background:var(--color-canvas-subtle);border-right:1px solid var(--color-border-default)}
+ .blob-content-cell{white-space:pre;padding-left:10px!important}
+ .commit-list .Box-row{padding:16px}
+ .commit-msg{font-weight:600;margin-bottom:4px}
+ .commit-meta{color:var(--color-fg-muted);font-size:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+ .commit-sha{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:var(--color-accent-fg);background:var(--color-btn-bg);padding:4px 8px;border-radius:6px;border:1px solid var(--color-btn-border)}
+ .commit-sha:hover{background:var(--color-btn-hover-bg);text-decoration:none}
+ .avatar{width:20px;height:20px;border-radius:50%;background:var(--color-btn-bg)}
+ .diff-stat{display:flex;align-items:center;gap:4px;font-size:12px}
+ .diff-stat .add{color:var(--color-success-fg)}.diff-stat .del{color:var(--color-danger-fg)}
+ .tag{display:inline-flex;align-items:center;gap:4px;background:var(--color-accent-emphasis);color:#fff;padding:2px 8px;border-radius:16px;font-size:12px;font-weight:500}
+ .breadcrumb{display:flex;align-items:center;gap:4px;margin-bottom:16px}
+ .breadcrumb-item{color:var(--color-accent-fg)}
+ .breadcrumb-sep{color:var(--color-fg-muted)}
+ .empty-state{padding:64px;text-align:center;color:var(--color-fg-muted)}
+ .loading{padding:64px;text-align:center;color:var(--color-fg-muted)}
+ .error-banner{background:rgba(248,81,73,.15);border:1px solid var(--color-danger-fg);color:var(--color-danger-fg);padding:16px;border-radius:6px;margin-bottom:16px;display:none}
+ .compare-selector{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+ .compare-arrow{color:var(--color-fg-muted);font-size:20px}
+ .compare-diff{margin-top:16px}
+ .file-diff{margin-bottom:16px}
+ .file-diff-header{padding:8px 16px;background:var(--color-canvas-subtle);border-bottom:1px solid var(--color-border-default);display:flex;justify-content:space-between;align-items:center}
+ .diff-line{font-family:ui-monospace,monospace;font-size:12px;line-height:20px;white-space:pre}
+ .diff-add{background:rgba(63,185,80,.15);color:var(--color-success-fg)}
+ .diff-del{background:rgba(248,81,73,.15);color:var(--color-danger-fg)}
+ .diff-hunk{background:rgba(56,139,253,.15);color:var(--color-accent-fg)}
+ @media(max-width:768px){.container{padding:0 16px}.file-commit,.file-time{display:none}.Box-row{padding:8px 12px}}
+ </style>
+</head>
+<body>
+<header class="AppHeader">
+ <div class="container">
+ <div class="AppHeader-context">
+ <svg height="32" viewBox="0 0 16 16" width="32" class="octicon"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path></svg>
+ <span id="repo-name" style="font-weight:600;font-size:20px">Repository</span>
+ </div>
+ </div>
+</header>
+<div class="container" style="padding-top:24px">
+ <nav class="UnderlineNav">
+ <div class="UnderlineNav-body" id="nav-tabs"></div>
+ </nav>
+ <div id="error" class="error-banner"></div>
+ <div id="app"><div class="loading">Loading repository...</div></div>
+</div>
+<script>
+const S={repo:null,view:'code',ref:'main',path:'',sha:'',base:'',compare:''};
+const $=id=>document.getElementById(id);
+const h=(t,a={},c=[])=>{const e=document.createElement(t);Object.entries(a).forEach(([k,v])=>k==='text'?e.textContent=v:k==='html'?e.innerHTML=v:k.startsWith('on')?e.addEventListener(k.slice(2).toLowerCase(),v):e.setAttribute(k,v));c.forEach(x=>e.appendChild(typeof x==='string'?document.createTextNode(x):x));return e};
+const svg=(d,w=16)=>`<svg viewBox="0 0 16 16" width="${w}" height="${w}" class="octicon" style="fill:currentColor">${d}</svg>`;
+const icons={
+ file:'<path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/>',
+ dir:'<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/>',
+ branch:'<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>',
+ tag:'<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/>',
+ commit:'<path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>',
+ code:'<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>',
+ compare:'<path d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1 0 1.06l-1 1a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l.47-.47H2.5v10.5h1.69l-.47-.47a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l1 1a.75.75 0 0 1 0 1.06l-1 1a.751.751 0 0 1-1.042.018.751.751 0 0 1-.018-1.042l.47-.47H2.25a.75.75 0 0 1-.75-.75V2.75A.75.75 0 0 1 2.25 2h2.44l-.47-.47a.75.75 0 0 1 0-1.06ZM13.5 2.5v10.5h-1.69l.47.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1-1a.75.75 0 0 1 0-1.06l1-1a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-.47.47H13.5V2.5h-1.69l.47.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1-1a.75.75 0 0 1 0-1.06l1-1a.751.751 0 0 1 1.042-.018.751.751 0 0 1 .018 1.042l-.47.47h2.44a.75.75 0 0 1 .75.75Z"/>',
+ readme:'<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>',
+ check:'<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>',
+ down:'<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>'
+};
+const timeAgo=d=>{const s=Math.floor((Date.now()-new Date(d))/1000);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+' min ago';if(s<86400)return Math.floor(s/3600)+' hours ago';if(s<604800)return Math.floor(s/86400)+' days ago';return new Date(d).toLocaleDateString()};
+const esc=s=>s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
+
+async function load(p){const r=await fetch(p);if(!r.ok)throw new Error(`Failed: ${p}`);return r.json()}
+function showError(m){const e=$('error');e.textContent=m;e.style.display='block'}
+function hideError(){$('error').style.display='none'}
+
+function renderNav(){
+ const nav=$('nav-tabs');nav.innerHTML='';
+ const tabs=[['code','Code',icons.code],['commits','Commits',icons.commit],['branches','Branches',icons.branch],['tags','Tags',icons.tag],['compare','Compare',icons.compare]];
+ tabs.forEach(([v,t,i])=>{
+ const a=h('a',{class:'UnderlineNav-item'+(S.view===v?' selected':''),href:'#'});
+ a.innerHTML=svg(i)+' '+t;
+ if(v==='branches')a.innerHTML+=` <span class="Counter">${S.repo?.branches_count||0}</span>`;
+ if(v==='tags')a.innerHTML+=` <span class="Counter">${S.repo?.tags_count||0}</span>`;
+ a.onclick=e=>{e.preventDefault();S.view=v;S.path='';S.sha='';render()};
+ nav.appendChild(a);
+ });
+}
+
+function branchDropdown(id,selected,onSelect){
+ const wrap=h('div',{class:'dropdown'});
+ const btn=h('button',{class:'btn',id});
+ btn.innerHTML=svg(icons.branch)+` <span>${esc(selected)}</span> `+svg(icons.down);
+ const menu=h('div',{class:'dropdown-menu',id:id+'-menu'});
+ btn.onclick=()=>menu.classList.toggle('show');
+ document.addEventListener('click',e=>{if(!wrap.contains(e.target))menu.classList.remove('show')});
+ wrap.append(btn,menu);
+ load('branches.json').then(branches=>{
+ menu.appendChild(h('div',{class:'dropdown-header',text:'Switch branches'}));
+ branches.forEach(b=>{
+ const item=h('div',{class:'dropdown-item'+(b.name===selected?' selected':'')});
+ item.innerHTML=svg(icons.check,12)+' '+esc(b.name);
+ item.querySelector('svg').classList.add('check');
+ item.onclick=()=>{onSelect(b.name);menu.classList.remove('show')};
+ menu.appendChild(item);
+ });
+ });
+ return wrap;
+}
+
+async function renderCode(){
+ const wrap=h('div');
+ const branches=await load('branches.json');
+ const branch=branches.find(b=>b.name===S.ref)||branches.find(b=>b.is_head)||branches[0];
+ if(!branch)return h('div',{class:'empty-state',text:'No branches found'});
+ const commit=await load(`commits/${branch.sha}.json`);
+
+ const top=h('div',{class:'branch-select'});
+ top.appendChild(branchDropdown('branch-sel',S.ref,name=>{S.ref=name;S.path='';render()}));
+
+ const goTo=h('button',{class:'btn',html:svg(icons.commit)+' <span>Go to commit</span>'});
+ goTo.onclick=()=>{S.view='commits';render()};
+ top.appendChild(goTo);
+ wrap.appendChild(top);
+
+ if(S.path){
+ const bc=h('div',{class:'breadcrumb'});
+ const root=h('a',{class:'breadcrumb-item',href:'#',text:S.repo.name});
+ root.onclick=e=>{e.preventDefault();S.path='';render()};
+ bc.appendChild(root);
+ const parts=S.path.split('/');
+ parts.forEach((p,i)=>{
+ bc.appendChild(h('span',{class:'breadcrumb-sep',text:'/'}));
+ if(i<parts.length-1){
+ const link=h('a',{class:'breadcrumb-item',href:'#',text:p});
+ link.onclick=e=>{e.preventDefault();S.path=parts.slice(0,i+1).join('/');render()};
+ bc.appendChild(link);
+ }else bc.appendChild(h('span',{text:p}));
+ });
+ wrap.appendChild(bc);
+ }
+
+ let tree,isBlob=false,blobEntry=null;
+ if(S.path){
+ const root=await load(`trees/${commit.tree_sha}.json`);
+ const parts=S.path.split('/');
+ let current=root;
+ for(let i=0;i<parts.length;i++){
+ const entry=current.entries.find(e=>e.name===parts[i]);
+ if(!entry)return h('div',{class:'empty-state',text:'Path not found'});
+ if(entry.type==='blob'){isBlob=true;blobEntry=entry;break}
+ current=await load(`trees/${entry.sha}.json`);
+ }
+ tree=current;
+ }else tree=await load(`trees/${commit.tree_sha}.json`);
+
+ if(isBlob){
+ const blob=await load(`blobs/${blobEntry.sha}.json`);
+ const box=h('div',{class:'Box'});
+ const header=h('div',{class:'Box-header'});
+ header.innerHTML=`<span>${esc(blobEntry.name)}</span><span style="color:var(--color-fg-muted)">${blob.size} bytes</span>`;
+ box.appendChild(header);
+ if(blob.binary){
+ box.appendChild(h('div',{class:'empty-state',text:'Binary file not shown'}));
+ }else{
+ const code=h('div',{class:'blob-code'});
+ const tbl=h('table');
+ (blob.content||'').split('\n').forEach((line,i)=>{
+ const tr=h('tr');
+ tr.innerHTML=`<td class="blob-num">${i+1}</td><td class="blob-content-cell">${esc(line)}</td>`;
+ tbl.appendChild(tr);
+ });
+ code.appendChild(tbl);
+ box.appendChild(code);
+ }
+ wrap.appendChild(box);
+ return wrap;
+ }
+
+ const box=h('div',{class:'Box'});
+ const header=h('div',{class:'Box-header'});
+ header.innerHTML=`<span><strong>${esc(commit.author.name)}</strong> ${esc(commit.message_headline)}</span><a href="#" class="commit-sha">${commit.short_sha}</a>`;
+ header.querySelector('.commit-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=commit.sha;render()};
+ box.appendChild(header);
+
+ const sorted=[...tree.entries].sort((a,b)=>a.type===b.type?a.name.localeCompare(b.name):a.type==='tree'?-1:1);
+ if(S.path){
+ const up=h('div',{class:'Box-row',style:'cursor:pointer'});
+ up.innerHTML=`<span class="icon-directory">${svg(icons.dir)}</span><span class="file-name">..</span>`;
+ up.onclick=()=>{S.path=S.path.split('/').slice(0,-1).join('/');render()};
+ box.appendChild(up);
+ }
+ sorted.forEach(e=>{
+ const row=h('div',{class:'Box-row',style:'cursor:pointer'});
+ const icon=e.type==='tree'?`<span class="icon-directory">${svg(icons.dir)}</span>`:`<span class="icon-file">${svg(icons.file)}</span>`;
+ row.innerHTML=`${icon}<span class="file-name">${esc(e.name)}</span><span class="file-commit"></span><span class="file-time"></span>`;
+ row.onclick=()=>{S.path=e.path;render()};
+ box.appendChild(row);
+ });
+ wrap.appendChild(box);
+
+ if(!S.path&&S.repo.readme){
+ const readme=h('div',{class:'Box readme-box'});
+ const rh=h('div',{class:'readme-header'});
+ rh.innerHTML=svg(icons.readme)+' '+esc(S.repo.readme_name||'README.md');
+ readme.appendChild(rh);
+ const md=h('div',{class:'markdown-body'});
+ md.innerHTML=marked.parse(S.repo.readme);
+ readme.appendChild(md);
+ wrap.appendChild(readme);
+ }
+ return wrap;
+}
+
+async function renderCommits(){
+ const commits=await load('commits.json');
+ const wrap=h('div');
+ wrap.appendChild(branchDropdown('commits-branch',S.ref,name=>{S.ref=name;render()}));
+ const box=h('div',{class:'Box commit-list',style:'margin-top:16px'});
+ commits.forEach(c=>{
+ const row=h('div',{class:'Box-row',style:'display:block'});
+ const msg=h('a',{class:'commit-msg',href:'#',text:c.message_headline});
+ msg.onclick=e=>{e.preventDefault();S.view='commit';S.sha=c.sha;render()};
+ const meta=h('div',{class:'commit-meta'});
+ meta.innerHTML=`<span class="avatar"></span><strong>${esc(c.author.name)}</strong> committed ${timeAgo(c.committed_at)}`;
+ const sha=h('a',{class:'commit-sha',href:'#',text:c.short_sha,style:'float:right;margin-top:-24px'});
+ sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=c.sha;render()};
+ row.append(msg,meta,sha);
+ box.appendChild(row);
+ });
+ wrap.appendChild(box);
+ return wrap;
+}
+
+async function renderCommit(){
+ const c=await load(`commits/${S.sha}.json`);
+ const wrap=h('div');
+ const box=h('div',{class:'Box'});
+ const header=h('div',{class:'Box-header',style:'display:block'});
+ header.innerHTML=`<div class="commit-msg" style="font-size:18px;margin-bottom:8px">${esc(c.message_headline)}</div>
+ <div class="commit-meta"><span class="avatar"></span><strong>${esc(c.author.name)}</strong> committed ${timeAgo(c.committed_at)}</div>`;
+ box.appendChild(header);
+ const body=h('div',{style:'padding:16px'});
+ if(c.message.split('\n').length>1)body.innerHTML=`<pre style="background:var(--color-canvas-inset);padding:16px;border-radius:6px;white-space:pre-wrap;font-size:13px">${esc(c.message)}</pre>`;
+ body.innerHTML+=`<div class="diff-stat" style="margin-top:16px"><span class="add">+${c.stats.additions}</span><span class="del">-${c.stats.deletions}</span><span>${c.stats.changed} files changed</span></div>`;
+ box.appendChild(body);
+ wrap.appendChild(box);
+ if(c.files?.length){
+ const files=h('div',{class:'Box',style:'margin-top:16px'});
+ files.appendChild(h('div',{class:'Box-header',text:'Files changed'}));
+ c.files.forEach(f=>{
+ const row=h('div',{class:'Box-row'});
+ const status=f.status==='added'?'color:var(--color-success-fg)':f.status==='deleted'?'color:var(--color-danger-fg)':'color:var(--color-attention-fg)';
+ row.innerHTML=`<span style="width:20px;${status}">${f.status[0].toUpperCase()}</span><span style="flex:1">${esc(f.path)}</span><span class="diff-stat"><span class="add">+${f.additions}</span><span class="del">-${f.deletions}</span></span>`;
+ files.appendChild(row);
+ });
+ wrap.appendChild(files);
+ }
+ return wrap;
+}
+
+async function renderBranches(){
+ const branches=await load('branches.json');
+ const box=h('div',{class:'Box'});
+ box.appendChild(h('div',{class:'Box-header',text:`${branches.length} branches`}));
+ branches.forEach(b=>{
+ const row=h('div',{class:'Box-row'});
+ row.innerHTML=`${b.is_head?'<span style="color:gold;margin-right:8px">★</span>':''}<span style="flex:1"><span class="tag">${svg(icons.branch,12)} ${esc(b.name)}</span></span>
+ <span style="color:var(--color-fg-muted);margin-right:16px">${timeAgo(b.committed_at)}</span>`;
+ const sha=h('a',{class:'commit-sha',href:'#',text:b.sha.slice(0,7)});
+ sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=b.sha;render()};
+ row.appendChild(sha);
+ box.appendChild(row);
+ });
+ return box;
+}
+
+async function renderTags(){
+ const tags=await load('tags.json');
+ const box=h('div',{class:'Box'});
+ box.appendChild(h('div',{class:'Box-header',text:`${tags.length} tags`}));
+ if(!tags.length){box.appendChild(h('div',{class:'empty-state',text:'No tags'}));return box}
+ tags.forEach(t=>{
+ const row=h('div',{class:'Box-row'});
+ row.innerHTML=`<span style="flex:1"><span class="tag">${svg(icons.tag,12)} ${esc(t.name)}</span></span>
+ <span style="color:var(--color-fg-muted);margin-right:16px">${timeAgo(t.committed_at)}</span>`;
+ const sha=h('a',{class:'commit-sha',href:'#',text:t.sha.slice(0,7)});
+ sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=t.sha;render()};
+ row.appendChild(sha);
+ box.appendChild(row);
+ });
+ return box;
+}
+
+async function renderCompare(){
+ const wrap=h('div');
+ const branches=await load('branches.json');
+ if(!S.base)S.base=S.repo.default_branch;
+ if(!S.compare)S.compare=branches.find(b=>b.name!==S.base)?.name||S.base;
+
+ const sel=h('div',{class:'compare-selector'});
+ sel.appendChild(branchDropdown('base-branch',S.base,name=>{S.base=name;render()}));
+ sel.appendChild(h('span',{class:'compare-arrow',text:'←'}));
+ sel.appendChild(branchDropdown('compare-branch',S.compare,name=>{S.compare=name;render()}));
+ wrap.appendChild(sel);
+
+ const baseBranch=branches.find(b=>b.name===S.base);
+ const compareBranch=branches.find(b=>b.name===S.compare);
+ if(!baseBranch||!compareBranch)return wrap;
+
+ const baseCommit=await load(`commits/${baseBranch.sha}.json`);
+ const compareCommit=await load(`commits/${compareBranch.sha}.json`);
+
+ const info=h('div',{class:'Box',style:'margin-top:16px'});
+ info.innerHTML=`<div class="Box-header">Comparing <strong>${esc(S.base)}</strong> with <strong>${esc(S.compare)}</strong></div>
+ <div style="padding:16px">
+ <p><strong>Base:</strong> ${esc(baseCommit.message_headline)} <a href="#" class="commit-sha base-sha">${baseCommit.short_sha}</a></p>
+ <p style="margin-top:8px"><strong>Compare:</strong> ${esc(compareCommit.message_headline)} <a href="#" class="commit-sha compare-sha">${compareCommit.short_sha}</a></p>
+ </div>`;
+ info.querySelector('.base-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=baseBranch.sha;render()};
+ info.querySelector('.compare-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=compareBranch.sha;render()};
+ wrap.appendChild(info);
+
+ if(compareCommit.files?.length){
+ const diff=h('div',{class:'Box compare-diff'});
+ diff.appendChild(h('div',{class:'Box-header',html:`<span>Files changed in compare branch</span><span class="diff-stat"><span class="add">+${compareCommit.stats.additions}</span><span class="del">-${compareCommit.stats.deletions}</span></span>`}));
+ compareCommit.files.forEach(f=>{
+ const row=h('div',{class:'Box-row'});
+ const st=f.status==='added'?'var(--color-success-fg)':f.status==='deleted'?'var(--color-danger-fg)':'var(--color-attention-fg)';
+ row.innerHTML=`<span style="width:20px;color:${st}">${f.status[0].toUpperCase()}</span><span style="flex:1">${esc(f.path)}</span><span class="diff-stat"><span class="add">+${f.additions}</span><span class="del">-${f.deletions}</span></span>`;
+ diff.appendChild(row);
+ });
+ wrap.appendChild(diff);
+ }
+ return wrap;
+}
+
+async function render(){
+ hideError();renderNav();
+ const app=$('app');
+ app.innerHTML='<div class="loading">Loading...</div>';
+ try{
+ let view;
+ switch(S.view){
+ case'code':view=await renderCode();break;
+ case'commits':view=await renderCommits();break;
+ case'commit':view=await renderCommit();break;
+ case'branches':view=await renderBranches();break;
+ case'tags':view=await renderTags();break;
+ case'compare':view=await renderCompare();break;
+ }
+ app.innerHTML='';app.appendChild(view);
+ }catch(e){showError(e.message);app.innerHTML=''}
+}
+
+async function init(){
+ try{
+ S.repo=await load('repo.json');
+ S.ref=S.repo.default_branch;
+ $('repo-name').textContent=S.repo.name;
+ document.title=S.repo.name+' - Git Browser';
+ render();
+ }catch(e){showError('Failed to load. Run the Ruby script first and serve with: python3 -m http.server')}
+}
+init();
+</script>
+</body>
+</html>