Git Explorer
GLM 5.2 · full

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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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"} -->