← Git Explorer results

Git Explorer

GLM 5.2 · full

Initial view of Git Explorer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 53,026 bytes · SHA-256 4f5fd9391679
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Git & Timeline Visualizer</title>
<style>
  :root{
    --bg:#0d1117; --panel:#161b22; --panel2:#1c2230; --border:#30363d;
    --text:#e6edf3; --muted:#8b949e; --accent:#2f81f7; --accent2:#a371f7;
    --good:#56d364; --warn:#d29922; --danger:#f85149;
    --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
    --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden}
  body{font-family:var(--sans);background:var(--bg);color:var(--text);display:flex;flex-direction:column;font-size:14px}
  button{font-family:inherit;font-size:13px;cursor:pointer;border-radius:6px;border:1px solid var(--border);background:var(--panel2);color:var(--text);padding:6px 10px;transition:.15s}
  button:hover{border-color:var(--accent);background:#22272e}
  button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
  button.primary:hover{background:#1f6feb}
  button.danger{color:var(--danger)}
  button.danger:hover{border-color:var(--danger)}
  button.subtle{background:transparent;border-color:transparent;color:var(--muted)}
  button.subtle:hover{color:var(--text);background:var(--panel2)}
  input,select,textarea{font-family:var(--mono);font-size:13px;background:#0d1117;color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 8px;width:100%}
  input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
  label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:3px}
  .row{display:flex;gap:6px;align-items:center}
  .row>*{flex:1}
  .grow{flex:1}

  /* header */
  header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--border);flex:0 0 auto}
  header h1{font-size:15px;margin:0;font-weight:600;display:flex;align-items:center;gap:8px}
  header h1 .logo{width:22px;height:22px}
  .hdr-tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
  .head-ref{font-family:var(--mono);font-size:12px;color:var(--muted);padding:3px 8px;border:1px solid var(--border);border-radius:12px}
  .head-ref b{color:var(--good)}

  /* layout */
  main{flex:1;display:flex;min-height:0}
  #sidebar{width:340px;flex:0 0 auto;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0}
  .tabs{display:flex;border-bottom:1px solid var(--border);flex:0 0 auto}
  .tabs button{flex:1;border-radius:0;border:none;border-right:1px solid var(--border);background:transparent;padding:10px;color:var(--muted);font-size:12px;font-weight:600;letter-spacing:.4px}
  .tabs button.active{color:var(--text);box-shadow:inset 0 -2px 0 var(--accent)}
  .tab-body{overflow-y:auto;padding:14px;flex:1;min-height:0}
  .section{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:12px}
  .section h3{margin:0 0 10px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);display:flex;justify-content:space-between;align-items:center}
  .field{margin-bottom:9px}
  .btn-full{width:100%}
  .list-item{display:flex;align-items:center;gap:8px;padding:7px 9px;border:1px solid var(--border);border-radius:6px;margin-bottom:6px;background:#0d1117}
  .list-item .dot{width:10px;height:10px;border-radius:50%;flex:0 0 auto}
  .list-item .li-title{font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .list-item .li-sub{font-size:11px;color:var(--muted);font-family:var(--mono)}
  .list-item.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
  .chip{font-size:10px;font-family:var(--mono);padding:1px 6px;border-radius:10px;background:#0d1117;border:1px solid var(--border);color:var(--muted)}

  #viz{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0}
  #graph-panel{flex:1;min-height:0;position:relative;border-bottom:1px solid var(--border);overflow:hidden}
  #timeline-panel{flex:0 0 210px;min-height:0;position:relative;background:#0a0e14}
  .panel-label{position:absolute;top:8px;left:12px;font-size:11px;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);z-index:5;pointer-events:none;font-weight:600}
  .panel-label .pill{margin-left:6px;color:var(--text);background:var(--panel);padding:1px 7px;border-radius:10px;border:1px solid var(--border)}
  svg{display:block;width:100%;height:100%}
  .empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px;pointer-events:none}

  /* graph svg styles */
  .edge{fill:none;stroke-width:2.4;opacity:.55;transition:opacity .2s}
  .edge.dim{opacity:.12}
  .edge.hl{opacity:1;stroke-width:3.4}
  .commit-node{cursor:pointer;transition:transform .15s}
  .commit-node circle{transition:.15s}
  .commit-node:hover circle{stroke-width:3}
  .commit-ring{fill:none;stroke-width:2}
  .commit-dot{stroke:#0d1117;stroke-width:2}
  .commit-label{font-size:11px;fill:var(--text);font-family:var(--mono);pointer-events:none}
  .branch-tag{font-size:11px;font-family:var(--mono);font-weight:600}
  .branch-tag rect{stroke-width:1}
  .tag-badge{font-size:10px;font-family:var(--mono);font-weight:700}
  .commit-node.selected circle.commit-ring{stroke:#fff}
  .commit-node.dim{opacity:.25}
  .ghost{opacity:.25}

  /* timeline svg */
  .tl-axis line,.tl-axis path{stroke:var(--border)}
  .tl-axis text{fill:var(--muted);font-size:10px;font-family:var(--mono)}
  .tl-event{cursor:pointer}
  .tl-event-line{stroke-width:1.5;opacity:.6}
  .tl-event-line.hl{stroke-width:2.5;opacity:1}
  .tl-event-pill{rx:4;ry:4}
  .tl-event text{font-size:11px;fill:var(--text);font-weight:600;pointer-events:none}
  .tl-commit-dot{cursor:pointer;stroke:#0a0e14;stroke-width:1.5}
  .tl-commit-dot.hl{stroke-width:3;stroke:#fff}
  .tl-link{stroke-dasharray:3 3;opacity:.5;fill:none}
  .tl-now{stroke:var(--danger);stroke-dasharray:2 3;opacity:.6}

  /* tooltip */
  #tooltip{position:fixed;pointer-events:none;background:rgba(13,17,23,.97);border:1px solid var(--accent);border-radius:8px;padding:10px 12px;font-size:12px;max-width:280px;z-index:100;display:none;box-shadow:0 8px 30px rgba(0,0,0,.5)}
  #tooltip .tt-title{font-weight:700;margin-bottom:4px;color:#fff}
  #tooltip .tt-desc{color:var(--muted);margin-bottom:6px}
  #tooltip .tt-meta{font-family:var(--mono);font-size:11px;color:var(--accent2)}

  /* modal */
  .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:50}
  .modal-bg.open{display:flex}
  .modal{background:var(--panel);border:1px solid var(--border);border-radius:10px;width:560px;max-width:92vw;max-height:86vh;display:flex;flex-direction:column}
  .modal h2{margin:0;padding:14px 16px;border-bottom:1px solid var(--border);font-size:14px}
  .modal-body{padding:16px;overflow:auto}
  .modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
  textarea.code{width:100%;min-height:220px;font-family:var(--mono);font-size:12px;resize:vertical}

  .toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--accent);color:var(--text);padding:8px 16px;border-radius:20px;font-size:13px;z-index:200;opacity:0;transition:.3s;pointer-events:none}
  .toast.show{opacity:1}
  .hint{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4}
  .divider{height:1px;background:var(--border);margin:10px 0}
  ::-webkit-scrollbar{width:10px;height:10px}
  ::-webkit-scrollbar-thumb{background:#30363d;border-radius:5px}
  ::-webkit-scrollbar-track{background:transparent}
  .mini-btn{padding:2px 7px;font-size:11px}
</style>
</head>
<body>
<header>
  <h1><svg class="logo" viewBox="0 0 24 24" fill="none"><path d="M6 3v12" stroke="#f9826c" stroke-width="2.2" stroke-linecap="round"/><circle cx="6" cy="6" r="2.5" fill="#f9826c"/><circle cx="6" cy="15" r="2.5" fill="#f9826c"/><circle cx="17" cy="15" r="2.5" fill="#a371f7"/><path d="M6 15h11" stroke="#a371f7" stroke-width="2.2" stroke-linecap="round"/></svg> Git &amp; Timeline Visualizer</h1>
  <div class="head-ref">HEAD → <b id="headDisplay">main</b></div>
  <div class="grow"></div>
  <div class="hdr-tools">
    <button onclick="actAddCommit()" class="primary">+ Commit</button>
    <button onclick="actMerge()">Merge</button>
    <button onclick="actAddBranch()">Branch</button>
    <button onclick="actAddEvent()">Event</button>
    <button onclick="actTag()">Tag</button>
    <span style="width:1px;height:22px;background:var(--border);margin:0 4px"></span>
    <button onclick="openImport()">Import</button>
    <button onclick="openExport()">Export</button>
    <button class="danger" onclick="actReset()">Reset</button>
  </div>
</header>

<main>
  <aside id="sidebar">
    <div class="tabs">
      <button id="tab-git" class="active" onclick="switchTab('git')">GIT HISTORY</button>
      <button id="tab-tl" onclick="switchTab('tl')">TIMELINE</button>
      <button id="tab-data" onclick="switchTab('data')">DATA</button>
    </div>
    <div class="tab-body" id="tabBody"></div>
  </aside>

  <div id="viz">
    <div id="graph-panel">
      <div class="panel-label">Git Graph <span class="pill" id="graphCount">0 commits</span></div>
      <svg id="graph"></svg>
      <div class="empty" id="graphEmpty">No commits yet — add one to begin building the DAG.</div>
    </div>
    <div id="timeline-panel">
      <div class="panel-label">Real-World Timeline <span class="pill" id="tlCount">0 events</span></div>
      <svg id="timeline"></svg>
      <div class="empty" id="tlEmpty">No timeline events — add a milestone to anchor real-world context.</div>
    </div>
  </div>
</main>

<div id="tooltip"></div>

<div class="modal-bg" id="modalBg" onclick="if(event.target===this)closeModal()">
  <div class="modal">
    <h2 id="modalTitle">Title</h2>
    <div class="modal-body" id="modalBody"></div>
    <div class="modal-foot" id="modalFoot"></div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
/* ============================================================
   Git & Timeline Visualizer — single-file app
   Custom SVG renderer (D3-style scales + lane layout), vanilla JS
   ============================================================ */

const LS_KEY = 'gitTimelineViz_state_v1';
const COLORS = ['#f9826c','#a371f7','#56d364','#79c0ff','#ffa657','#d2a8ff','#7ee787','#f0883e','#ff7b72','#79c0ff'];

let state = null;
let layout = null;        // computed graph layout
let tlScale = null;       // timeline scale
let activeTab = 'git';
let selected = { commit:null, event:null };
let hoverLink = null;     // commit id hovered/linked on timeline

/* ---------- utilities ---------- */
const $ = s => document.querySelector(s);
function uid(prefix='c'){ return prefix + Math.random().toString(16).slice(2,8); }
function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }
function esc(s){ return String(s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtDate(d){ const x=new Date(d); return x.toISOString().slice(0,10); }
function fmtDateTime(d){ const x=new Date(d); return x.toISOString().slice(0,16).replace('T',' '); }
function nowISO(){ return new Date().toISOString(); }
function daysBetween(a,b){ return (new Date(b)-new Date(a))/86400000; }
function nextColor(n){ return COLORS[n % COLORS.length]; }
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),2200); }

/* ---------- persistence ---------- */
function save(){
  try{ localStorage.setItem(LS_KEY, JSON.stringify(state)); }catch(e){ console.warn('save failed',e); }
}
function load(){
  try{
    const raw = localStorage.getItem(LS_KEY);
    if(raw){ state = JSON.parse(raw); migrateState(); return true; }
  }catch(e){ console.warn('load failed',e); }
  return false;
}
function migrateState(){
  state.commits = state.commits || {};
  state.branches = state.branches || [];
  state.tags = state.tags || [];
  state.events = state.events || [];
  state.head = state.head || (state.branches[0] && state.branches[0].name) || 'main';
  state.seq = state.seq || 0;
}

/* ---------- seed demo data ---------- */
function seedDemo(){
  const t0 = Date.now() - 30*86400000;
  function at(day){ return new Date(t0 + day*86400000).toISOString(); }
  state = { commits:{}, branches:[], tags:[], events:[], head:'main', seq:0 };
  // build a small project history
  const c1 = mkCommit([], 'Initial commit', 'main', at(0));
  const c2 = mkCommit([c1], 'Add README & setup', 'main', at(2));
  const c3 = mkCommit([c2], 'Implement core API', 'main', at(4));
  addBranch('feature-auth', c3, 1);
  state.head='feature-auth';
  const f1 = mkCommit([c3], 'Add login screen', 'feature-auth', at(6));
  const f2 = mkCommit([f1], 'Wire OAuth flow', 'feature-auth', at(8));
  state.head='main';
  const m1 = mkCommit([c3], 'Add dashboard', 'main', at(7));
  const m2 = mkCommit([m1], 'Performance tuning', 'main', at(10));
  // merge feature-auth into main
  const mg = mkCommit([m2, f2], 'Merge feature-auth into main', 'main', at(12));
  state.head='main';
  addBranch('release', mg, 2);
  const r1 = mkCommit([mg], 'Bump version 1.0', 'release', at(13));
  addTag('v1.0', r1);
  addTag('code-freeze', m2);
  // timeline events
  state.events = [
    { id:uid('e'), title:'Project Kickoff', desc:'Repository created and team aligned.', date:at(0), commit:c1, branch:'main' },
    { id:uid('e'), title:'Code Freeze', desc:'No new features merged after this point.', date:at(10), commit:m2, branch:'main' },
    { id:uid('e'), title:'Release Candidate', desc:'Feature-auth merged; QA cycle begins.', date:at(12), commit:mg, branch:'main' },
    { id:uid('e'), title:'v1.0 Launch', desc:'Official production release.', date:at(13), commit:r1, branch:'release' },
  ];
}

/* ---------- core data ops (Layer A) ---------- */
function mkCommit(parents, message, branch, ts){
  const id = uid('c');
  state.commits[id] = { id, parents:[...parents], message, branch, ts: ts||nowISO(), seq: state.seq++ };
  return id;
}
function branchColor(name){ const b=state.branches.find(x=>x.name===name); return b?b.color:'#8b949e'; }
function addBranch(name, target, colorIdx){
  if(state.branches.find(b=>b.name===name)) return false;
  state.branches.push({ name, target, color: nextColor(colorIdx ?? state.branches.length) });
  return true;
}
function addTag(name, commit){
  state.tags = state.tags.filter(t=>t.name!==name && t.commit!==commit || (t.name!==name && t.commit!==commit));
  state.tags = state.tags.filter(t=>!(t.name===name));
  state.tags.push({ name, commit });
}
function deleteTag(name){ state.tags = state.tags.filter(t=>t.name!==name); }

function actAddCommit(){
  if(state.branches.length===0){ addBranch('main', null, 0); state.head='main'; }
  const html = `
    <div class="field"><label>Branch</label>
      <select id="fBranch">${branchOptions(state.head)}</select></div>
    <div class="field"><label>Commit message</label>
      <input id="fMsg" placeholder="e.g. Add new feature" autofocus></div>
    <div class="field"><label>Timestamp (optional)</label>
      <input id="fTs" type="datetime-local" value="${fmtDateTime(nowISO()).slice(0,16)}"></div>
    <div class="hint">Parent: ${currentTargetLabel(state.head)}</div>`;
  openModal('Add Commit', html, ()=>{
    const b = $('#fBranch').value;
    const msg = $('#fMsg').value.trim() || 'Untitled commit';
    let ts = $('#fTs').value;
    if(ts) ts = new Date(ts).toISOString(); else ts = nowISO();
    const br = state.branches.find(x=>x.name===b);
    const parent = br && br.target ? [br.target] : [];
    const id = mkCommit(parent, msg, b, ts);
    br.target = id;
    state.head = b;
    save(); render(); closeModal(); toast('Commit added');
  });
}
function actAddBranch(){
  const html = `
    <div class="field"><label>Branch name</label><input id="fName" placeholder="feature-x" autofocus></div>
    <div class="field"><label>Start from</label>
      <select id="fFrom"><option value="head">Current HEAD (${state.head})</option>${commitOptions()}</select></div>`;
  openModal('New Branch', html, ()=>{
    const name = $('#fName').value.trim();
    if(!name){ toast('Name required'); return; }
    if(state.branches.find(b=>b.name===name)){ toast('Branch exists'); return; }
    const from = $('#fFrom').value;
    const target = from==='head' ? (state.branches.find(b=>b.name===state.head)||{}).target : from;
    addBranch(name, target, state.branches.length);
    save(); render(); closeModal(); toast('Branch "'+name+'" created');
  });
}
function actMerge(){
  if(state.branches.length<2){ toast('Need 2+ branches'); return; }
  const html = `
    <div class="field"><label>Merge INTO (current branch)</label>
      <select id="fInto">${branchOptions(state.head)}</select></div>
    <div class="field"><label>Merge FROM</label>
      <select id="fFrom">${state.branches.filter(b=>b.name!==state.head).map(b=>`<option value="${b.name}">${b.name}</option>`).join('')}</select></div>
    <div class="field"><label>Merge commit message</label><input id="fMsg" value="Merge branch '...' "></div>`;
  openModal('Merge Branch', html, ()=>{
    const into = $('#fInto').value;
    const from = $('#fFrom').value;
    const bi = state.branches.find(b=>b.name===into);
    const bf = state.branches.find(b=>b.name===from);
    if(!bi||!bf){ toast('Invalid branches'); return; }
    const parents = [];
    if(bi.target) parents.push(bi.target);
    if(bf.target && !parents.includes(bf.target)) parents.push(bf.target);
    if(parents.length===0){ toast('Nothing to merge'); return; }
    const msg = $('#fMsg').value.trim() || `Merge ${from} into ${into}`;
    const id = mkCommit(parents, msg, into, nowISO());
    bi.target = id;
    state.head = into;
    save(); render(); closeModal(); toast('Merged '+from+' → '+into);
  });
}
function actTag(){
  const html = `
    <div class="field"><label>Tag name</label><input id="fName" placeholder="v1.0" autofocus></div>
    <div class="field"><label>Attach to commit</label>
      <select id="fCommit">${commitOptions(currentTarget(state.head))}</select></div>`;
  openModal('Add Tag', html, ()=>{
    const name = $('#fName').value.trim();
    const commit = $('#fCommit').value;
    if(!name){ toast('Name required'); return; }
    addTag(name, commit);
    save(); render(); closeModal(); toast('Tag "'+name+'" added');
  });
}
function deleteCommit(id){
  // re-point branches/tags/events
  state.branches.forEach(b=>{ if(b.target===id) b.target=null; });
  state.tags = state.tags.filter(t=>t.commit!==id);
  state.events.forEach(e=>{ if(e.commit===id) e.commit=null; });
  // remove from parents lists
  Object.values(state.commits).forEach(c=>{ c.parents = c.parents.filter(p=>p!==id); });
  delete state.commits[id];
  save(); render();
}
function deleteBranch(name){
  state.branches = state.branches.filter(b=>b.name!==name);
  if(state.head===name) state.head = state.branches[0]?state.branches[0].name:'main';
  save(); render();
}
function checkout(name){ state.head=name; save(); render(); }

/* ---------- core data ops (Layer B) ---------- */
function actAddEvent(prefill){
  const html = `
    <div class="field"><label>Title</label><input id="fTitle" placeholder="Code Freeze" autofocus value="${esc(prefill&&prefill.title||'')}"></div>
    <div class="field"><label>Description</label><textarea id="fDesc" rows="2" placeholder="Context about this milestone...">${esc(prefill&&prefill.desc||'')}</textarea></div>
    <div class="field"><label>Date / Time</label><input id="fDate" type="datetime-local" value="${fmtDateTime(prefill&&prefill.date||nowISO()).slice(0,16)}"></div>
    <div class="row">
      <div style="flex:1"><label>Link commit (optional)</label><select id="fCommit"><option value="">— none —</option>${commitOptions(prefill&&prefill.commit)}</select></div>
      <div style="flex:1"><label>Link branch (optional)</label><select id="fBranch"><option value="">— none —</option>${branchOptions(prefill&&prefill.branch)}</select></div>
    </div>`;
  openModal(prefill?'Edit Timeline Event':'Add Timeline Event', html, ()=>{
    const title = $('#fTitle').value.trim();
    if(!title){ toast('Title required'); return; }
    const ev = {
      id: prefill?prefill.id:uid('e'),
      title,
      desc: $('#fDesc').value.trim(),
      date: new Date($('#fDate').value).toISOString(),
      commit: $('#fCommit').value || null,
      branch: $('#fBranch').value || null,
    };
    if(prefill){ const i=state.events.findIndex(e=>e.id===prefill.id); state.events[i]=ev; }
    else state.events.push(ev);
    save(); render(); closeModal(); toast('Event saved');
  }, prefill?{del:()=>{ state.events=state.events.filter(e=>e.id!==prefill.id); save(); render(); closeModal(); toast('Event removed'); }}:null);
}

/* ---------- import / export ---------- */
function openExport(){
  const data = JSON.stringify(state, null, 2);
  const html = `<div class="hint">Full application state (Layer A + Layer B). Copy or download.</div>
    <textarea class="code" id="expArea" readonly>${esc(data)}</textarea>`;
  openModal('Export State', html, ()=>{
    const blob = new Blob([data], {type:'application/json'});
    const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='git-timeline-state.json'; a.click();
    closeModal(); toast('Downloaded');
  });
}
function openImport(){
  const html = `<div class="field"><label>Load from file</label><input type="file" id="impFile" accept=".json,application/json"></div>
    <div class="hint">— or paste JSON below —</div>
    <textarea class="code" id="impArea" placeholder='{ "commits": {...}, "branches":[...], "events":[...] }'></textarea>`;
  openModal('Import State', html, ()=>{
    const area = $('#impArea').value.trim();
    const file = $('#impFile').files[0];
    const done = txt=>{
      try{
        const obj = JSON.parse(txt);
        if(!obj.commits && !obj.events) throw new Error('No commits/events found');
        state = obj; migrateState();
        save(); render(); closeModal(); toast('Imported '+Object.keys(state.commits||{}).length+' commits, '+(state.events||[]).length+' events');
      }catch(e){ toast('Import failed: '+e.message); }
    };
    if(file){ const r=new FileReader(); r.onload=()=>done(r.result); r.readAsText(file); }
    else if(area){ done(area); }
    else { toast('Nothing to import'); }
  });
}
function actReset(){
  openModal('Reset to Demo Data', '<div class="hint">This clears all current data and loads the sample repository & timeline. This cannot be undone.</div>', ()=>{
    seedDemo(); save(); render(); closeModal(); toast('Reset to demo data');
  });
}

/* ---------- helpers for option lists ---------- */
function branchOptions(sel){
  return state.branches.map(b=>`<option value="${b.name}" ${b.name===sel?'selected':''}>${b.name}</option>`).join('');
}
function commitOptions(sel){
  const arr = Object.values(state.commits).sort((a,b)=>b.ts.localeCompare(a.ts));
  return arr.map(c=>`<option value="${c.id}" ${c.id===sel?'selected':''}>${c.id} · ${esc(c.message.slice(0,40))}</option>`).join('');
}
function currentTarget(branch){ const b=state.branches.find(x=>x.name===branch); return b?b.target:null; }
function currentTargetLabel(branch){ const t=currentTarget(branch); if(!t) return '(root — will be first commit)'; const c=state.commits[t]; return c?`${t.slice(0,7)} · ${esc(c.message.slice(0,30))}`:'—'; }

/* ============================================================
   GRAPH LAYOUT (lane assignment)
   ============================================================ */
function computeLayout(){
  const commits = Object.values(state.commits);
  if(commits.length===0){ layout={commits:[],width:0,height:0,lanes:0}; return; }
  // reachable set from branch tips + tags
  const reachable = new Set();
  function visit(id){ if(!id||reachable.has(id)||!state.commits[id]) return; reachable.add(id); state.commits[id].parents.forEach(visit); }
  state.branches.forEach(b=>visit(b.target));
  state.tags.forEach(t=>visit(t.commit));
  state.events.forEach(e=>visit(e.commit));
  // include orphans too
  Object.keys(state.commits).forEach(id=>reachable.add(id));

  const list = [...reachable].map(id=>state.commits[id]);
  // sort newest first for lane assignment
  list.sort((a,b)=> b.ts.localeCompare(a.ts) || (b.seq-a.seq));

  // seed lanes from branch tips (main-like branches first)
  const lanes = []; // lanes[i] = next expected commit id or null
  const branchOrder = [...state.branches].sort((a,b)=> (a.name===state.head?-1:0)-(b.name===state.head?-1:0));
  branchOrder.forEach(b=>{ if(b.target && !lanes.includes(b.target)){ lanes.push(b.target); } });

  const laneOf = {};
  for(const c of list){
    let li = lanes.indexOf(c.id);
    if(li===-1){
      li = lanes.indexOf(null);
      if(li===-1){ li = lanes.length; lanes.push(null); }
    }
    laneOf[c.id] = li;
    lanes[li] = null;
    if(c.parents.length>0){
      // first parent continues in this lane
      lanes[li] = c.parents[0];
      // extra parents → own lanes
      for(let i=1;i<c.parents.length;i++){
        let fl = lanes.indexOf(null);
        if(fl===-1){ fl = lanes.length; lanes.push(null); }
        lanes[fl] = c.parents[i];
      }
    }
  }
  const laneCount = Math.max(1, ...Object.values(laneOf), lanes.length);

  // sequence order for Y (oldest → newest top? we'll do oldest top)
  const byTime = [...reachable].map(id=>state.commits[id]).sort((a,b)=> a.ts.localeCompare(b.ts) || (a.seq-b.seq));
  byTime.forEach((c,i)=> c._seq = i);

  const LANE_W = 70, ROW_H = 56, PAD_X = 60, PAD_Y = 50;
  layout = {
    commits: byTime.map(c=>({ id:c.id, x: PAD_X + laneOf[c.id]*LANE_W, y: PAD_Y + c._seq*ROW_H, lane: laneOf[c.id], data:c })),
    width: Math.max(PAD_X*2 + laneCount*LANE_W, 320),
    height: PAD_Y*2 + byTime.length*ROW_H,
    lanes: laneCount, ROW_H, LANE_W, PAD_X, PAD_Y,
    edges: buildEdges(byTime, laneOf)
  };
}
function buildEdges(byTime, laneOf){
  const edges = [];
  byTime.forEach(c=>{
    c.parents.forEach((p,i)=>{
      if(!state.commits[p]) return;
      edges.push({ id:p+'->'+c.id, from:p, to:c.id, merge:i>0, laneFrom:laneOf[p], laneTo:laneOf[c.id], branch:c.branch });
    });
  });
  return edges;
}

/* ============================================================
   RENDERING
   ============================================================ */
function render(){
  computeLayout();
  renderGraph();
  renderTimeline();
  renderSidebar();
  $('#headDisplay').textContent = state.head;
  $('#graphCount').textContent = Object.keys(state.commits).length+' commits · '+state.branches.length+' branches';
  $('#tlCount').textContent = state.events.length+' events';
  $('#graphEmpty').style.display = Object.keys(state.commits).length?'none':'flex';
  $('#tlEmpty').style.display = state.events.length?'none':'flex';
}

function renderGraph(){
  const svg = $('#graph'); svg.innerHTML='';
  if(!layout || layout.commits.length===0){ svg.setAttribute('viewBox','0 0 1 1'); return; }
  const W = layout.width, H = layout.height;
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
  const ns='http://www.w3.org/2000/svg';

  // defs for arrowheads not needed; edges as curves
  // lane background bands
  const laneG = document.createElementNS(ns,'g');
  for(let i=0;i<layout.lanes;i++){
    if(i%2===0){
      const r=document.createElementNS(ns,'rect');
      r.setAttribute('x', layout.PAD_X + i*layout.LANE_W - layout.LANE_W/2);
      r.setAttribute('y', 0); r.setAttribute('width', layout.LANE_W); r.setAttribute('height', H);
      r.setAttribute('fill','rgba(255,255,255,0.015)'); laneG.appendChild(r);
    }
  }
  svg.appendChild(laneG);

  // edges
  const edgeG = document.createElementNS(ns,'g');
  layout.edges.forEach(e=>{
    const fc = layout.commits.find(c=>c.id===e.from);
    const tc = layout.commits.find(c=>c.id===e.to);
    if(!fc||!tc) return;
    const col = branchColor(e.branch);
    const x1=fc.x, y1=fc.y, x2=tc.x, y2=tc.y;
    const dy = y2-y1;
    let d;
    if(e.laneFrom===e.laneTo){
      d = `M${x1},${y1} L${x2},${y2}`;
    }else{
      const cy = (y1+y2)/2;
      d = `M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}`;
    }
    const p=document.createElementNS(ns,'path');
    p.setAttribute('d',d); p.setAttribute('class','edge'); p.setAttribute('stroke',col);
    p.setAttribute('data-from',e.from); p.setAttribute('data-to',e.to);
    edgeG.appendChild(p);
  });
  svg.appendChild(edgeG);

  // branch tip labels
  const tipG = document.createElementNS(ns,'g');
  state.branches.forEach(b=>{
    if(!b.target) return;
    const c = layout.commits.find(x=>x.id===b.target);
    if(!c) return;
    const col = b.color;
    const g=document.createElementNS(ns,'g'); g.setAttribute('class','branch-tag');
    const txt=document.createTextNode(b.name);
    const tmp=document.createElementNS(ns,'text'); tmp.textContent=b.name; tmp.style.font='11px monospace';
    const w = b.name.length*7+16;
    const rect=document.createElementNS(ns,'rect');
    rect.setAttribute('x', c.x+14); rect.setAttribute('y', c.y-10); rect.setAttribute('width',w); rect.setAttribute('height',20); rect.setAttribute('rx',10);
    rect.setAttribute('fill',col); rect.setAttribute('opacity', state.head===b.name?1:.5);
    const t=document.createElementNS(ns,'text'); t.setAttribute('x', c.x+14+w/2); t.setAttribute('y', c.y+4); t.setAttribute('text-anchor','middle'); t.setAttribute('fill','#0d1117'); t.textContent=b.name;
    g.appendChild(rect); g.appendChild(t);
    g.style.cursor='pointer';
    g.onclick = ()=>checkout(b.name);
    g.title = 'Click to checkout '+b.name;
    tipG.appendChild(g);
  });
  svg.appendChild(tipG);

  // tags
  const tagG = document.createElementNS(ns,'g');
  state.tags.forEach(tg=>{
    const c = layout.commits.find(x=>x.id===tg.commit);
    if(!c) return;
    const w = tg.name.length*6.5+12;
    const rect=document.createElementNS(ns,'rect');
    rect.setAttribute('x', c.x-w-10); rect.setAttribute('y', c.y-22); rect.setAttribute('width',w); rect.setAttribute('height',16); rect.setAttribute('rx',3);
    rect.setAttribute('fill','#0d1117'); rect.setAttribute('stroke','#d29922'); rect.setAttribute('class','tag-badge');
    const t=document.createElementNS(ns,'text'); t.setAttribute('x', c.x-w/2-10); t.setAttribute('y', c.y-10); t.setAttribute('text-anchor','middle'); t.setAttribute('fill','#d29922'); t.setAttribute('class','tag-badge'); t.textContent='🏷 '+tg.name;
    tagG.appendChild(rect); tagG.appendChild(t);
  });
  svg.appendChild(tagG);

  // commit nodes
  const nodeG = document.createElementNS(ns,'g');
  layout.commits.forEach(c=>{
    const data=c.data;
    const isMerge = data.parents.length>1;
    const col = branchColor(data.branch);
    const g=document.createElementNS(ns,'g'); g.setAttribute('class','commit-node'); g.setAttribute('data-id',c.id);
    g.setAttribute('transform',`translate(${c.x},${c.y})`);
    if(selected.commit===c.id) g.classList.add('selected');
    const ring=document.createElementNS(ns,'circle'); ring.setAttribute('class','commit-ring'); ring.setAttribute('r',isMerge?12:9); ring.setAttribute('stroke',col);
    const dot=document.createElementNS(ns,'circle'); dot.setAttribute('class','commit-dot'); dot.setAttribute('r',isMerge?7:5.5); dot.setAttribute('fill',col);
    const hit=document.createElementNS(ns,'circle'); hit.setAttribute('r',16); hit.setAttribute('fill','transparent'); hit.setAttribute('stroke','none'); hit.style.cursor='pointer';
    g.appendChild(hit); g.appendChild(ring); g.appendChild(dot);
    // message label
    const lbl=document.createElementNS(ns,'text'); lbl.setAttribute('class','commit-label'); lbl.setAttribute('x',14); lbl.setAttribute('y',4);
    lbl.textContent = data.message.slice(0,34) + (data.message.length>34?'…':'');
    g.appendChild(lbl);
    g.onclick = ()=>selectCommit(c.id);
    g.onmouseenter = ()=>showCommitTooltip(c.id, c.x, c.y);
    g.onmouseleave = hideTooltip;
    nodeG.appendChild(g);
  });
  svg.appendChild(nodeG);

  applyGraphSelectionStyle();
}

function applyGraphSelectionStyle(){
  if(!selected.commit){ document.querySelectorAll('#graph .edge').forEach(e=>{e.classList.remove('dim','hl');}); document.querySelectorAll('#graph .commit-node').forEach(n=>n.classList.remove('dim')); return; }
  const anc = ancestors(selected.commit);
  document.querySelectorAll('#graph .edge').forEach(e=>{
    const on = anc.has(e.dataset.from) && (e.dataset.to===selected.commit || anc.has(e.dataset.to));
    const direct = e.dataset.to===selected.commit || e.dataset.from===selected.commit;
    e.classList.toggle('dim', !direct && !on);
    e.classList.toggle('hl', direct || on);
  });
  document.querySelectorAll('#graph .commit-node').forEach(n=>{
    n.classList.toggle('dim', !anc.has(n.dataset.id) && n.dataset.id!==selected.commit);
  });
}
function ancestors(id){
  const set=new Set(); const st=[id];
  while(st.length){ const x=st.pop(); if(set.has(x)||!state.commits[x]) continue; set.add(x); (state.commits[x].parents||[]).forEach(p=>st.push(p)); }
  return set;
}

/* ---------- timeline ---------- */
function timeDomain(){
  const pts = [];
  Object.values(state.commits).forEach(c=>pts.push(new Date(c.ts).getTime()));
  state.events.forEach(e=>pts.push(new Date(e.date).getTime()));
  if(pts.length===0){ const n=Date.now(); return [n-30*86400000, n+2*86400000]; }
  let mn=Math.min(...pts), mx=Math.max(...pts);
  if(mx-mn < 5*86400000){ const pad=3*86400000; mn-=pad; mx+=pad; }
  else { const pad=(mx-mn)*0.06; mn-=pad; mx+=pad; }
  return [mn,mx];
}
function renderTimeline(){
  const svg=$('#timeline'); svg.innerHTML='';
  const W = svg.clientWidth || 800, H = svg.clientHeight || 210;
  if(W<10) return;
  svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
  const ns='http://www.w3.org/2000/svg';
  const [t0,t1] = timeDomain();
  const padL=54, padR=24, padT=34, padB=30;
  const axisY = H - padB - 16;
  const xOf = t => padL + (t-t0)/(t1-t0)*(W-padL-padR);
  tlScale = { xOf, t0, t1, W, H, axisY };

  // axis
  const axisG=document.createElementNS(ns,'g'); axisG.setAttribute('class','tl-axis');
  const ax=document.createElementNS(ns,'line'); ax.setAttribute('x1',padL); ax.setAttribute('x2',W-padR); ax.setAttribute('y1',axisY); ax.setAttribute('y2',axisY); ax.setAttribute('stroke','var(--border)');
  axisG.appendChild(ax);
  // ticks ~ every ~day or auto
  const span = (t1-t0)/86400000;
  const stepDays = span<14?1: span<40?3: span<120?7: span<400?30:90;
  const start = Math.ceil(t0/86400000)*86400000;
  for(let t=start; t<=t1; t+=stepDays*86400000){
    const x=xOf(t);
    const tk=document.createElementNS(ns,'line'); tk.setAttribute('x1',x); tk.setAttribute('x2',x); tk.setAttribute('y1',axisY-4); tk.setAttribute('y2',axisY+4); tk.setAttribute('stroke','var(--border)');
    axisG.appendChild(tk);
    const lab=document.createElementNS(ns,'text'); lab.setAttribute('x',x); lab.setAttribute('y',axisY+16); lab.setAttribute('text-anchor','middle'); lab.textContent=fmtDate(t);
    axisG.appendChild(lab);
  }
  svg.appendChild(axisG);

  // "now" marker
  const nowX = xOf(Date.now());
  if(nowX>padL && nowX<W-padR){
    const nw=document.createElementNS(ns,'line'); nw.setAttribute('class','tl-now'); nw.setAttribute('x1',nowX); nw.setAttribute('x2',nowX); nw.setAttribute('y1',padT); nw.setAttribute('y2',axisY); svg.appendChild(nw);
    const nl=document.createElementNS(ns,'text'); nl.setAttribute('x',nowX); nl.setAttribute('y',padT-4); nl.setAttribute('text-anchor','middle'); nl.setAttribute('fill','var(--danger)'); nl.setAttribute('font-size','9'); nl.textContent='NOW'; svg.appendChild(nl);
  }

  // commit dots on timeline
  const commitDots=document.createElementNS(ns,'g');
  Object.values(state.commits).forEach(c=>{
    const x=xOf(new Date(c.ts).getTime());
    if(x<padL||x>W-padR) return;
    const col=branchColor(c.branch);
    const d=document.createElementNS(ns,'circle'); d.setAttribute('class','tl-commit-dot'); d.setAttribute('cx',x); d.setAttribute('cy',axisY); d.setAttribute('r',4); d.setAttribute('fill',col);
    d.setAttribute('data-id',c.id);
    d.onmouseenter=()=>showCommitTooltip(c.id, x, axisY);
    d.onmouseleave=hideTooltip;
    d.onclick=()=>selectCommit(c.id);
    commitDots.appendChild(d);
  });
  svg.appendChild(commitDots);

  // link lines from events to commits
  const linkG=document.createElementNS(ns,'g');
  state.events.forEach(e=>{
    if(e.commit && state.commits[e.commit]){
      const x=xOf(new Date(e.date).getTime());
      const cx=xOf(new Date(state.commits[e.commit].ts).getTime());
      const ln=document.createElementNS(ns,'line'); ln.setAttribute('class','tl-link'); ln.setAttribute('x1',x); ln.setAttribute('y1',axisY-4); ln.setAttribute('x2',cx); ln.setAttribute('y2',axisY-4); ln.setAttribute('stroke',branchColor(state.commits[e.commit].branch));
      linkG.appendChild(ln);
    }
  });
  svg.appendChild(linkG);

  // event markers (pills alternating above/below)
  // sort and stack to avoid overlap
  const sorted=[...state.events].sort((a,b)=>new Date(a.date)-new Date(b.date));
  const aboveStack=[], belowStack=[];
  const minGap=84;
  sorted.forEach((e,i)=>{
    const x=xOf(new Date(e.date).getTime());
    if(x<padL-10||x>W-padR+10) return;
    const above = i%2===0;
    const stack = above?aboveStack:belowStack;
    let level=0;
    while(stack[level] && Math.abs(stack[level].x - x)<minGap) level++;
    stack[level]={x};
    e._x=x; e._above=above; e._level=level;
  });
  const maxAbove=Math.max(0,...aboveStack.map((_,i)=>0)), maxLevelAbove=Math.max(0,...aboveStack.map((_,i)=>0));
  // compute max level
  let maxLvA=0,maxLvB=0;
  aboveStack.forEach((s,i)=>{ if(s) maxLvA=Math.max(maxLvA,Object.keys(aboveStack).filter(k=>aboveStack[k]).length); });
  // simpler: recompute levels
  sorted.forEach(e=>{ if(e._above) maxLvA=Math.max(maxLvA,e._level); else maxLvB=Math.max(maxLvB,e._level); });

  sorted.forEach(e=>{
    if(e._x===undefined) return;
    const above=e._above, lvl=e._level;
    const lineTop = above? axisY - 8 - lvl*42 : axisY + 8 + lvl*42;
    const pillY = above? lineTop - 14 : lineTop + 14;
    const col = e.branch?branchColor(e.branch):'#8b949e';
    const isSel = selected.event===e.id;
    const isHl = selected.commit && (e.commit===selected.commit);
    // stem
    const stem=document.createElementNS(ns,'line'); stem.setAttribute('class','tl-event-line'+(isSel||isHl?' hl':'')); stem.setAttribute('stroke',col);
    stem.setAttribute('x1',e._x); stem.setAttribute('x2',e._x); stem.setAttribute('y1',axisY); stem.setAttribute('y2',lineTop);
    svg.appendChild(stem);
    // pill
    const txtWidth = e.title.length*6.4+22;
    const pillH=20;
    const py = above? lineTop-pillH : lineTop;
    const g=document.createElementNS(ns,'g'); g.setAttribute('class','tl-event'); g.setAttribute('data-id',e.id);
    const rect=document.createElementNS(ns,'rect'); rect.setAttribute('class','tl-event-pill'); rect.setAttribute('x',e._x - txtWidth/2); rect.setAttribute('y',py); rect.setAttribute('width',txtWidth); rect.setAttribute('height',pillH); rect.setAttribute('fill', isSel?col:'#161b22'); rect.setAttribute('stroke',col); rect.setAttribute('stroke-width',isSel?2:1);
    g.appendChild(rect);
    const t=document.createElementNS(ns,'text'); t.setAttribute('x',e._x); t.setAttribute('y',py+14); t.setAttribute('text-anchor','middle'); t.setAttribute('fill', isSel?'#0d1117':'var(--text)'); t.textContent=e.title;
    g.appendChild(t);
    g.onmouseenter=()=>showEventTooltip(e, e._x, py);
    g.onmouseleave=hideTooltip;
    g.onclick=()=>selectEvent(e.id);
    svg.appendChild(g);
  });

  applyTimelineSelection();
}
function applyTimelineSelection(){
  document.querySelectorAll('#timeline .tl-commit-dot').forEach(d=>{
    const hl = selected.commit===d.dataset.id || (hoverLink && hoverLink===d.dataset.id);
    d.classList.toggle('hl', hl);
  });
  document.querySelectorAll('#timeline .tl-event').forEach(g=>{
    const e = state.events.find(x=>x.id===g.dataset.id);
    g.classList.toggle('ghost', selected.commit && !(e && (e.commit===selected.commit)));
  });
}

/* ---------- selection + sync ---------- */
function selectCommit(id){
  selected.commit = selected.commit===id?null:id;
  if(selected.commit) selected.event=null;
  save(); renderGraph(); renderTimeline(); renderSidebar();
}
function selectEvent(id){
  selected.event = selected.event===id?null:id;
  if(selected.event){
    const e=state.events.find(x=>x.id===id);
    if(e && e.commit) selected.commit=e.commit;
  }
  renderGraph(); renderTimeline(); renderSidebar();
}

/* ---------- tooltips ---------- */
function showCommitTooltip(id, x, y){
  const c=state.commits[id]; if(!c) return;
  const tt=$('#tooltip');
  const tags = state.tags.filter(t=>t.commit===id).map(t=>t.name);
  tt.innerHTML = `<div class="tt-title">${esc(c.message)}</div>
    <div class="tt-meta">${id.slice(0,7)} · ${fmtDateTime(c.ts)}<br>branch: ${c.branch}${tags.length?' · 🏷 '+tags.join(', '):''}<br>parents: ${c.parents.length?c.parents.map(p=>p.slice(0,7)).join(', '):'(root)'}</div>`;
  positionTooltip(x,y,'graph');
}
function showEventTooltip(e, x, y){
  const tt=$('#tooltip');
  let ctx='';
  if(e.commit && state.commits[e.commit]) ctx=`<div class="tt-meta">linked: ${state.commits[e.commit].id.slice(0,7)} · ${esc(state.commits[e.commit].message)}</div>`;
  if(e.branch) ctx+=`<div class="tt-meta">branch context: ${e.branch}</div>`;
  tt.innerHTML = `<div class="tt-title">${esc(e.title)}</div>
    <div class="tt-desc">${esc(e.desc||'—')}</div>
    <div class="tt-meta">${fmtDateTime(e.date)}</div>${ctx}`;
  positionTooltip(x,y,'tl');
}
function positionTooltip(x,y,which){
  const tt=$('#tooltip'); tt.style.display='block';
  const panel = which==='graph'?$('#graph-panel'):$('#timeline-panel');
  const pr = panel.getBoundingClientRect();
  // x,y are SVG coords; map via viewBox scale
  const svg = which==='graph'?$('#graph'):$('#timeline');
  const vb = svg.viewBox.baseVal;
  const sx = pr.width/vb.width, sy = pr.height/vb.height;
  const px = pr.left + x*sx, py = pr.top + y*sy;
  let lx = px+14, ly = py+14;
  const tw=tt.offsetWidth, th=tt.offsetHeight;
  if(lx+tw>window.innerWidth-8) lx=px-tw-14;
  if(ly+th>window.innerHeight-8) ly=py-th-14;
  tt.style.left=lx+'px'; tt.style.top=ly+'px';
}
function hideTooltip(){ $('#tooltip').style.display='none'; }

/* ============================================================
   SIDEBAR
   ============================================================ */
function switchTab(t){ activeTab=t; ['git','tl','data'].forEach(x=>$('#tab-'+x).classList.toggle('active',x===t)); renderSidebar(); }

function renderSidebar(){
  const body=$('#tabBody');
  if(activeTab==='git') body.innerHTML = gitSidebar();
  else if(activeTab==='tl') body.innerHTML = tlSidebar();
  else body.innerHTML = dataSidebar();
}

function gitSidebar(){
  // selection editor first if a commit is selected
  let sel='';
  if(selected.commit && state.commits[selected.commit]){
    const c=state.commits[selected.commit];
    const tags = state.tags.filter(t=>t.commit===c.id).map(t=>t.name);
    sel = `<div class="section"><h3>Selected Commit <span class="chip">${c.id.slice(0,7)}</span></h3>
      <div class="field"><label>Message</label><input id="selMsg" value="${esc(c.message)}"></div>
      <div class="row">
        <div><label>Timestamp</label><input id="selTs" type="datetime-local" value="${fmtDateTime(c.ts).slice(0,16)}"></div>
        <div><label>Branch</label><select id="selBranch">${branchOptions(c.branch)}</select></div>
      </div>
      <div class="field"><label>Parents</label><input id="selParents" value="${c.parents.join(', ')}" placeholder="comma-separated ids"></div>
      <div class="hint">Tags: ${tags.length?tags.join(', '):'none'}</div>
      <div class="row" style="margin-top:8px">
        <button class="primary mini-btn grow" onclick="saveSelectedCommit()">Save</button>
        <button class="mini-btn" onclick="actTagFor('${c.id}')">+ Tag</button>
        <button class="danger mini-btn" onclick="confirmDeleteCommit('${c.id}')">Delete</button>
      </div></div>`;
  }
  // branches list
  const branches = state.branches.map(b=>{
    const tip = b.target && state.commits[b.target];
    return `<div class="list-item ${state.head===b.name?'selected':''}">
      <span class="dot" style="background:${b.color}"></span>
      <span class="li-title">${esc(b.name)}</span>
      <span class="li-sub">${tip?tip.id.slice(0,7):'∅'}</span>
      <button class="subtle mini-btn" onclick="checkout('${b.name}')" title="checkout">↳</button>
      <button class="danger mini-btn" onclick="confirmDeleteBranch('${b.name}')">✕</button>
    </div>`;
  }).join('');
  // tags list
  const tags = state.tags.map(t=>{
    const c=state.commits[t.commit];
    return `<div class="list-item">
      <span class="dot" style="background:#d29922"></span>
      <span class="li-title">🏷 ${esc(t.name)}</span>
      <span class="li-sub">${c?c.id.slice(0,7):'∅'}</span>
      <button class="danger mini-btn" onclick="deleteTag('${t.name}');render()">✕</button>
    </div>`;
  }).join('');
  // recent commits
  const recent = Object.values(state.commits).sort((a,b)=>b.ts.localeCompare(a.ts)).slice(0,8).map(c=>{
    return `<div class="list-item ${selected.commit===c.id?'selected':''}" style="cursor:pointer" onclick="selectCommit('${c.id}')">
      <span class="dot" style="background:${branchColor(c.branch)}"></span>
      <span class="li-title">${esc(c.message.slice(0,30))}</span>
      <span class="li-sub">${c.id.slice(0,7)}</span>
    </div>`;
  }).join('');
  return `${sel}
    <div class="section"><h3>Branches <span class="chip">${state.branches.length}</span></h3>
      ${branches||'<div class="hint">No branches. Adding a commit creates "main".</div>'}</div>
    <div class="section"><h3>Tags <span class="chip">${state.tags.length}</span></h3>
      ${tags||'<div class="hint">No tags yet.</div>'}</div>
    <div class="section"><h3>Recent Commits</h3>${recent||'<div class="hint">No commits.</div>'}</div>`;
}
function saveSelectedCommit(){
  const c=state.commits[selected.commit]; if(!c) return;
  c.message=$('#selMsg').value.trim()||'Untitled';
  const ts=$('#selTs').value; if(ts) c.ts=new Date(ts).toISOString();
  c.branch=$('#selBranch').value;
  c.parents=$('#selParents').value.split(',').map(s=>s.trim()).filter(Boolean);
  save(); render(); toast('Commit updated');
}
function actTagFor(commit){
  const name=prompt('Tag name:','v'+ (state.tags.length+1)+'.0');
  if(name){ addTag(name,commit); save(); render(); toast('Tag added'); }
}
function confirmDeleteCommit(id){
  if(confirm('Delete this commit? Branches/tags/events pointing here will be cleared.')) deleteCommit(id);
}
function confirmDeleteBranch(name){
  if(confirm('Delete branch "'+name+'"? (commits remain in history)')) deleteBranch(name);
}

function tlSidebar(){
  const sel = selected.event && state.events.find(e=>e.id===selected.event) ? selected.event : null;
  let selHtml='';
  if(sel){
    selHtml=`<div class="section"><h3>Selected Event <span class="chip">${esc(sel.title)}</span></h3>
      <button class="primary mini-btn btn-full" onclick="actAddEvent(state.events.find(e=>e.id===selected.event))">Edit Event</button>
      <button class="danger mini-btn btn-full" style="margin-top:6px" onclick="state.events=state.events.filter(e=>e.id!==selected.event);selected.event=null;save();render()">Remove Event</button>
      <div class="hint" style="margin-top:6px">${esc(sel.desc||'—')}<br>${fmtDateTime(sel.date)}<br>${sel.commit?'→ '+sel.commit.slice(0,7):''}${sel.branch?' · '+sel.branch:''}</div></div>`;
  }
  const list = [...state.events].sort((a,b)=>new Date(a.date)-new Date(b.date)).map(e=>{
    return `<div class="list-item ${selected.event===e.id?'selected':''}" style="cursor:pointer" onclick="selectEvent('${e.id}')">
      <span class="dot" style="background:${e.branch?branchColor(e.branch):'#8b949e'}"></span>
      <span class="li-title">${esc(e.title)}</span>
      <span class="li-sub">${fmtDate(e.date)}</span>
      <button class="subtle mini-btn" onclick="actAddEvent(state.events.find(x=>x.id==='${e.id}'))">✎</button>
    </div>`;
  }).join('');
  return `${selHtml}
    <div class="section"><h3>Timeline Events <span class="chip">${state.events.length}</span></h3>
      ${list||'<div class="hint">No events. Use the "Event" button to add a milestone.</div>'}</div>
    <div class="section"><h3>Association</h3><div class="hint">When editing an event you can link it to a commit and/or a branch. Linked events draw a dashed line to the commit's position on the timeline and highlight the matching dot/commit.</div></div>`;
}
function dataSidebar(){
  return `<div class="section"><h3>Persistence</h3>
    <div class="hint">All state (Layer A: commits/branches/tags + Layer B: timeline events) is auto-saved to <code>localStorage</code> on every change.</div>
    <button class="btn-full" onclick="openExport()" style="margin-top:8px">Export to JSON</button>
    <button class="btn-full" style="margin-top:6px" onclick="openImport()">Import from JSON</button></div>
    <div class="section"><h3>State Stats</h3>
      <div class="list-item"><span class="li-title">Commits</span><span class="li-sub">${Object.keys(state.commits).length}</span></div>
      <div class="list-item"><span class="li-title">Branches</span><span class="li-sub">${state.branches.length}</span></div>
      <div class="list-item"><span class="li-title">Tags</span><span class="li-sub">${state.tags.length}</span></div>
      <div class="list-item"><span class="li-title">Timeline Events</span><span class="li-sub">${state.events.length}</span></div></div>
    <div class="section"><h3>Danger Zone</h3>
      <button class="danger btn-full" onclick="actReset()">Reset to Demo Data</button>
      <button class="danger btn-full" style="margin-top:6px" onclick="if(confirm('Erase EVERYTHING?')){state={commits:{},branches:[],tags:[],events:[],head:'main',seq:0};save();render();toast('Cleared')}">Clear All Data</button></div>
    <div class="section"><h3>Legend</h3>
      <div class="list-item"><span class="dot" style="background:${COLORS[0]}"></span><span class="li-title">Branch lanes</span><span class="li-sub">color-coded</span></div>
      <div class="list-item"><span class="dot" style="background:#d29922"></span><span class="li-title">Tags</span><span class="li-sub">🏷 amber</span></div>
      <div class="hint">● circles = commits (double-ring = merge). Click a commit to trace its ancestry and sync the timeline.</div></div>`;
}

/* ============================================================
   MODAL
   ============================================================ */
let modalSaveFn=null, modalExtra=null;
function openModal(title, bodyHtml, onSave, extra){
  $('#modalTitle').textContent=title;
  $('#modalBody').innerHTML=bodyHtml;
  $('#modalFoot').innerHTML = (extra&&extra.del?`<button class="danger" id="modalDel">Delete</button><div class="grow"></div>`:'') + `<button id="modalCancel">Cancel</button><button class="primary" id="modalOk">OK</button>`;
  modalSaveFn=onSave; modalExtra=extra;
  $('#modalOk').onclick=onSave;
  $('#modalCancel').onclick=closeModal;
  if(extra&&extra.del) $('#modalDel').onclick=extra.del;
  $('#modalBg').classList.add('open');
  setTimeout(()=>{ const f=$('#modalBody input,#modalBody textarea'); if(f) f.focus(); },50);
}
function closeModal(){ $('#modalBg').classList.remove('open'); modalSaveFn=null; modalExtra=null; }

/* ============================================================
   INIT
   ============================================================ */
function init(){
  if(!load()) seedDemo();
  migrateState();
  // ensure a main branch exists if there are commits
  if(state.branches.length===0 && Object.keys(state.commits).length>0){
    const newest=Object.values(state.commits).sort((a,b)=>b.ts.localeCompare(a.ts))[0];
    addBranch('main', newest.id, 0);
  }
  render();
  // re-render timeline on resize (clientWidth)
  let rt; window.addEventListener('resize', ()=>{ clearTimeout(rt); rt=setTimeout(()=>{ renderTimeline(); }, 120); });
  // keyboard: Esc closes modal
  document.addEventListener('keydown', e=>{ if(e.key==='Escape') closeModal(); });
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.2","provider":"openrouter","persona":"full","sessionId":"e6c37200-0a4a-4ed5-a558-2ddc2c3aa7d4","tokensIn":242021,"tokensOut":21789,"tokensTotal":263810,"turns":11,"toolCalls":10,"failedToolCalls":0,"timestamp":"2026-07-24T17:45:25.147Z"} -->