Commit ff11ed9

mo khan <mo@mokhan.ca>
2025-12-11 22:12:56
feat: add initial index.html file tag: v0.1.0
1 parent 1d319de
Changed files (1)
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+
+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>