Git Explorer
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 374bf80389
# Interactive Git & Structure Visualizer
**Goal:** Create a highly interactive single-page HTML application that visualizes the Git branching topology (the DAG) by integrating external structural events directly into the commit history. The application must demonstrate how specific contextual markers relate precisely to specific points in the code development process.
## 1. Technology Stack
* **Frontend:** HTML5, CSS3, JavaScript (ES6+).
* **Visualization Library:** Use **D3.js** or a similar powerful library for rendering interconnected data structures and spatial relationships simultaneously.
* **Persistence:** Use `localStorage` to ensure all application state is saved across refreshes.
## 2. Data Model Requirements
The application must manage two fundamental, interconnected data sets: Git history (the structure) and external contextual events (the associations).
### A. Git History (The Structure)
1. **Commits:** The primary nodes of the graph. Each commit must store:
* A unique ID.
* A list of parent commit IDs (defining the DAG structure).
* A specific anchor point for spatial placement.
2. **Branches:** Track current branch names and their pointers to the latest commit.
### B. External Contextual Events (The Associations)
1. **Events:** External structural markers that provide context. Each event must store:
* A descriptive Title (e.g., "Code Freeze," "Feature Completion").
* An identifying reference for spatial placement.
* **Association ID:** A mandatory field linking the event to a specific Git commit ID where it occurred.
## 3. Functional Requirements
The application must provide tools to manage both data sets and render their synthesized view.
### A. Data Management (CRUD Operations)
* **Git History Management:** Provide full functionality to create, edit, delete commits, branches, and tags to build the branching structure.
* **Event Management:** Allow users to add new contextual events and explicitly associate them with existing Git commits or branches.
### B. Visualization Requirements (The Synthesis)
The visualization must fuse the structural history and the external context into a single view:
1. **Primary View (Git Graph):** Render the standard network graph showing all commits and their merging paths.
2. **Integrated Anchoring:** All External Contextual Events must be visually anchored directly onto their corresponding Git Commit nodes on the graph. This establishes a direct spatial link between the code structure and the contextual marker.
3. **Contextual Flow Display:** A secondary view should display all contextual events, allowing for overall structural context.
4. **Interactivity & Synchronization:**
* Selecting a commit on the Git graph must highlight its associated anchored contextual marker.
* Hovering over an anchor must reveal the full details of the external event and confirm the specific Git commit it is linked to.
## 4. Data Handling and Persistence
* **Import Feature:** Implement functionality to import initial Git commit data and associated Contextual Events.
* **Local Storage State:** The complete, unified state of both Git history and contextual events must be serialized and saved to `localStorage`, ensuring full persistence across sessions.Inspect original source 40,231 bytes · SHA-256 5ebddf661f39
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Git & Structure Visualizer</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#0d1117;color:#e6edf3;font:13px/1.45 -apple-system,"Segoe UI",Roboto,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
header{display:flex;align-items:center;gap:6px;padding:8px 12px;background:#161b22;border-bottom:1px solid #30363d;flex-wrap:wrap}
h1{font-size:15px;margin:0 10px 0 0;white-space:nowrap}
.sep{width:1px;height:22px;background:#30363d;margin:0 4px}
button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:5px 10px;cursor:pointer;font:inherit;white-space:nowrap}
button:hover{border-color:#8b949e}
button.primary{background:#238636;border-color:#2ea043}
button.danger{color:#ff7b72}
.mono{font-family:ui-monospace,Menlo,Consolas,monospace;color:#8b949e;font-size:12px}
.muted{color:#8b949e;font-size:12px}
.layout{flex:1;display:flex;min-height:0}
#graphWrap{flex:1;overflow:auto;position:relative;background:#0d1117}
#graph{display:block}
aside{width:390px;border-left:1px solid #30363d;display:flex;flex-direction:column;background:#161b22;min-height:0}
.tabs{display:flex;border-bottom:1px solid #30363d}
.tabs button{flex:1;border:0;border-radius:0;background:none;padding:10px 4px;color:#8b949e}
.tabs button.on{border-bottom:2px solid #58a6ff;color:#58a6ff}
#panel{flex:1;overflow:auto;padding:12px}
h3{margin:0 0 4px;font-size:15px;word-break:break-word}
h4{margin:16px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#8b949e}
.empty{color:#8b949e;padding:14px 0;line-height:1.6}
.kv{display:grid;grid-template-columns:90px 1fr;gap:6px 10px;margin:10px 0;font-size:12px}
.kv>span:nth-child(odd){color:#8b949e}
.kv a{color:#58a6ff;cursor:pointer;margin-right:6px;font-family:ui-monospace,monospace}
.card{border:1px solid #30363d;border-radius:8px;padding:9px 10px;margin:8px 0;background:#0d1117;cursor:pointer}
.card.hl,.ctx-item.hl{border-color:#58a6ff;box-shadow:0 0 0 1px #58a6ff inset}
.card.hot,.ctx-item.hot{border-color:#e6edf3}
.ctx-item{border:1px solid #30363d;border-left:4px solid #888;border-radius:8px;padding:8px 10px;margin:8px 0;background:#0d1117;cursor:pointer}
.ctx-item:hover{background:#161b22}
.chip{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;color:#0d1117;font-weight:700;margin-right:4px}
.row-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.row-btns button{padding:3px 8px;font-size:12px}
.ref-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.toolbar-row{display:flex;gap:6px;margin-bottom:6px;align-items:center}
.toolbar-row select{flex:1}
select,input,textarea{background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:7px;font:inherit;width:100%}
select{width:auto}
.toolbar-row select{width:auto;flex:1}
/* SVG */
.edge{fill:none;stroke-width:2.5;opacity:.9}
.guide{stroke:#30363d;stroke-dasharray:2 4}
.rowbg{fill:transparent;rx:6}
.row.sel .rowbg{fill:#1c2433}
.row{cursor:pointer}
.sha{fill:#8b949e;font:12px ui-monospace,Menlo,monospace}
.msg{fill:#e6edf3;font:13px -apple-system,Segoe UI,Roboto,sans-serif}
.row:hover .msg{fill:#79c0ff}
.row.sel .msg{fill:#fff;font-weight:600}
.badge{fill:#fff;font:600 11px ui-monospace,Menlo,monospace}
.node{cursor:pointer}
.node .hit{fill:transparent}
.node .ring{fill:none;stroke:#58a6ff;stroke-width:2;stroke-dasharray:4 3;animation:spin 1.2s linear infinite}
.node .dot{stroke:#0d1117;stroke-width:2}
.node.sel .dot{stroke:#fff;stroke-width:2.5}
@keyframes spin{to{stroke-dashoffset:-14}}
.anchor{cursor:pointer;transition:opacity .2s}
.anchor path{stroke:#0d1117;stroke-width:1.5}
.anchor .tether{stroke-width:1.5;stroke-dasharray:3 3;opacity:.75}
.anchor.dim{opacity:.22}
.anchor.hl path,.anchor.hot path,.anchor:hover path{stroke:#fff;stroke-width:2}
.anchor.sel path{stroke:#fff;stroke-width:2.5}
.anchor.sel .tether{opacity:1;stroke-dasharray:none}
#tip{position:fixed;display:none;pointer-events:none;background:#1c2128;border:1px solid #444c56;border-radius:8px;padding:10px 12px;max-width:360px;box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:50;font-size:12px}
#tip .tip-h{font-size:13px;margin-bottom:4px}
#tip .tip-d{color:#c9d1d9;margin-bottom:8px}
#tip .kv2{display:grid;grid-template-columns:96px 1fr;gap:3px 8px}
#tip .kv2>span:nth-child(odd){color:#8b949e}
#modalWrap{position:fixed;inset:0;background:rgba(1,4,9,.72);display:flex;align-items:center;justify-content:center;z-index:100}
#modalWrap.hidden{display:none}
.modal{width:480px;max-width:92vw;background:#161b22;border:1px solid #30363d;border-radius:10px;padding:18px 20px;box-shadow:0 12px 40px rgba(0,0,0,.6)}
.modal h2{font-size:16px;margin:0 0 4px}
.modal label{display:block;margin:12px 0 5px;color:#8b949e;font-size:12px}
.modal .err{color:#ff7b72;min-height:18px;margin-top:8px;font-size:12px}
.modal .actions{display:flex;justify-content:flex-end;gap:8px;margin-top:6px}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:#1f6feb;color:#fff;padding:8px 14px;border-radius:8px;opacity:0;transition:.25s;pointer-events:none;z-index:200}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<header>
<h1>⎇ Git · Context Visualizer</h1>
<button class="primary" data-act="new-commit">+ Commit</button>
<button data-act="new-branch">+ Branch</button>
<button data-act="new-tag">+ Tag</button>
<button data-act="new-event">+ Event</button>
<span class="sep"></span>
<button data-act="import">Import</button>
<button data-act="export">Export</button>
<button data-act="sample">Load sample</button>
<span class="sep"></span>
<button data-act="zoom-out">−</button><span id="zoomLbl" class="mono" style="min-width:40px;text-align:center"></span><button data-act="zoom-in">+</button>
<span id="stats" class="mono" style="margin-left:auto"></span>
</header>
<div class="layout">
<main id="graphWrap"><svg id="graph" xmlns="http://www.w3.org/2000/svg"></svg></main>
<aside>
<div class="tabs">
<button data-tab="inspector">Inspector</button>
<button data-tab="context">Context Flow</button>
<button data-tab="refs">Refs</button>
</div>
<div id="panel"></div>
</aside>
</div>
<div id="tip"></div>
<div id="modalWrap" class="hidden"></div>
<div id="toast"></div>
<script>
'use strict';
const KEY='gitctx.visualizer.v1';
const LANE_C=['#58a6ff','#3fb950','#f0883e','#a371f7','#ff7b72','#e3b341','#56d4dd','#f778ba'];
const TRACK_C=['#f0883e','#a371f7','#3fb950','#ff7b72','#79c0ff','#e3b341','#56d4dd','#f778ba'];
const ROW_H=54, LANE_W=46, PAD_X=40, PAD_Y=40, LABEL_W=640;
const SAMPLE={
head:'main',
commits:[
{id:'4f1a9c2',message:'Initial scaffold',parents:[],author:'ada',date:1700000000000},
{id:'9b3e7d1',message:'Add tokenizer and parser',parents:['4f1a9c2'],author:'ada',date:1700086400000},
{id:'2c8d5e0',message:'Add SVG renderer',parents:['9b3e7d1'],author:'lin',date:1700172800000},
{id:'6a9f2e8',message:'Fix render performance regression',parents:['2c8d5e0'],author:'lin',date:1700259200000},
{id:'b7a4f92',message:'Start auth module',parents:['2c8d5e0'],author:'sam',date:1700265600000},
{id:'e1d0c3b',message:'Add login form',parents:['b7a4f92'],author:'sam',date:1700352000000},
{id:'f8c2b9e',message:'Experiment: grid layout',parents:['6a9f2e8'],author:'ada',date:1700438400000},
{id:'0c5b7d4',message:'Merge feature/auth into main',parents:['6a9f2e8','e1d0c3b'],author:'ada',date:1700444800000},
{id:'d3e8a16',message:'Write release notes',parents:['0c5b7d4'],author:'lin',date:1700531200000}
],
branches:[{name:'main',head:'d3e8a16'},{name:'feature/auth',head:'e1d0c3b'},{name:'exp/grid',head:'f8c2b9e'}],
tags:[{name:'v1.0',commit:'0c5b7d4'}],
events:[
{id:'ev-freeze',title:'Code Freeze',track:'Release',description:'No merges to main until the v1.0 candidate is verified.',associationId:'0c5b7d4',viaBranch:'main',createdAt:1700444900000},
{id:'ev-feature',title:'Feature Completion',track:'Feature',description:'Auth module feature-complete: login, logout, session refresh.',associationId:'e1d0c3b',viaBranch:'feature/auth',createdAt:1700352100000},
{id:'ev-sec',title:'Security Review',track:'Compliance',description:'Pen-test findings on the login form triaged; no blockers.',associationId:'e1d0c3b',viaBranch:null,createdAt:1700355000000},
{id:'ev-perf',title:'Perf Milestone',track:'Performance',description:'Frame time under 16ms on 10k-node graphs.',associationId:'6a9f2e8',viaBranch:null,createdAt:1700259300000},
{id:'ev-rel',title:'Release Published',track:'Release',description:'v1.0 release notes published.',associationId:'d3e8a16',viaBranch:'main',createdAt:1700531300000}
]
};
/* ---------- helpers ---------- */
const $=s=>document.querySelector(s);
const $$=s=>document.querySelectorAll(s);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const short=id=>String(id).slice(0,7);
const trunc=(s,n)=>s.length>n?s.slice(0,n-1)+'…':s;
const clone=o=>JSON.parse(JSON.stringify(o));
const C=id=>S.commits.find(c=>c.id===id);
const B=name=>S.branches.find(b=>b.name===name);
const trackColor=t=>{let h=0; for(const ch of String(t)) h=(h*31+ch.charCodeAt(0))>>>0; return TRACK_C[h%TRACK_C.length];};
const rnd=n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
const newCommitId=()=>{let id; do{id=rnd(4).slice(0,7);}while(C(id)); return id;};
const newEventId=()=>'ev-'+rnd(5);
const headId=()=>B(S.head)?.head || S.commits[0]?.id || '';
const refsOf=id=>({br:S.branches.filter(b=>b.head===id), tg:S.tags.filter(t=>t.commit===id)});
let S;
function defaultUI(){return {selected:null,selEvent:null,zoom:1,tab:'inspector',track:''};}
function save(){try{localStorage.setItem(KEY,JSON.stringify(S));}catch(e){toast('Could not save: '+e.message);}}
function load(){
try{
const d=JSON.parse(localStorage.getItem(KEY)||'null');
if(d&&Array.isArray(d.commits)){
S=Object.assign({head:'main',branches:[],tags:[],events:[]},d);
S.ui=Object.assign(defaultUI(),d.ui||{});
return;
}
}catch(e){}
S=clone(SAMPLE); S.ui=defaultUI(); save();
}
function toast(msg){
const t=$('#toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2400);
}
function changed(){save(); render(); renderSide();}
/* ---------- layout (git-style lanes, top-down) ---------- */
function layout(){
const byId=new Map(S.commits.map(c=>[c.id,c]));
const gen=new Map(), vis=new Set();
const G=id=>{
if(gen.has(id)) return gen.get(id);
if(vis.has(id)) return 0;
vis.add(id);
const c=byId.get(id); let v=0;
for(const p of c.parents) if(byId.has(p)) v=Math.max(v,G(p)+1);
gen.set(id,v); return v;
};
S.commits.forEach(c=>G(c.id));
// newest first: higher generation on top, stable by insertion order
const ordered=S.commits.map((c,i)=>({c,i,g:gen.get(c.id)})).sort((a,b)=>b.g-a.g||a.i-b.i);
const rows=new Map(ordered.map((o,r)=>[o.c.id,r]));
const lanes=[], lane=new Map();
const freeLane=()=>{let f=lanes.indexOf(null); if(f<0){lanes.push(null); f=lanes.length-1;} return f;};
for(const {c} of ordered){
let L=lanes.indexOf(c.id);
if(L<0) L=freeLane();
lane.set(c.id,L); lanes[L]=null;
c.parents.forEach((p,k)=>{
if(!byId.has(p) || lanes.indexOf(p)>=0) return;
if(k===0) lanes[L]=p; else lanes[freeLane()]=p;
});
}
return {rows,lane,ordered,n:ordered.length,nLanes:Math.max(0,...lane.values())+1};
}
function commitOpts(){
return layout().ordered.map(o=>({value:o.c.id,label:short(o.c.id)+' '+o.c.message}));
}
/* ---------- graph rendering ---------- */
function render(){
const svg=$('#graph');
const L=layout(), z=S.ui.zoom;
const X=l=>PAD_X+l*LANE_W, Y=r=>PAD_Y+r*ROW_H+ROW_H/2;
const labelX=PAD_X+L.nLanes*LANE_W+40;
const W=labelX+LABEL_W, H=PAD_Y*2+Math.max(1,L.n)*ROW_H;
const sel=S.ui.selected, selEv=S.ui.selEvent;
const evBy={}; S.events.forEach(e=>{(evBy[e.associationId]??=[]).push(e);});
let edges='', guides='', anchors='', nodes='', labels='';
for(const c of S.commits){
const cl=L.lane.get(c.id), cr=L.rows.get(c.id);
c.parents.forEach((p,k)=>{
if(!L.rows.has(p)) return;
const pl=L.lane.get(p), pr=L.rows.get(p);
const col=LANE_C[(k===0?cl:pl)%LANE_C.length];
const x1=X(cl),y1=Y(cr),x2=X(pl),y2=Y(pr);
const d=x1===x2?`M${x1},${y1} L${x2},${y2}`
:`M${x1},${y1} C${x1},${y1+ROW_H*0.7} ${x2},${y2-ROW_H*0.7} ${x2},${y2}`;
edges+=`<path class="edge" d="${d}" stroke="${col}"/>`;
});
}
for(const c of S.commits){
const l=L.lane.get(c.id), r=L.rows.get(c.id);
const x=X(l), y=Y(r), col=LANE_C[l%LANE_C.length];
const isSel=sel===c.id;
const evs=evBy[c.id]||[];
const refs=refsOf(c.id);
// guide line from node to label column
guides+=`<line class="guide" x1="${x+12}" y1="${y}" x2="${labelX-12}" y2="${y}"/>`;
// anchored contextual markers (tethered to their commit node)
evs.forEach((e,i)=>{
const ax=x+20+i*15, ay=y-18, tc=trackColor(e.track);
const cls=['anchor', isSel?'hl':'', selEv===e.id?'sel':'', (sel&&!isSel)?'dim':''].join(' ');
anchors+=`<g class="${cls}" data-ev="${e.id}" data-commit="${c.id}">
<line class="tether" x1="${ax}" y1="${ay}" x2="${x}" y2="${y}" stroke="${tc}"/>
<path d="M${ax},${ay-7} L${ax+7},${ay} L${ax},${ay+7} L${ax-7},${ay}Z" fill="${tc}"/>
<circle cx="${ax}" cy="${ay}" r="10" fill="transparent"/></g>`;
});
nodes+=`<g class="node${isSel?' sel':''}" data-commit="${c.id}">
<title>${esc(short(c.id))} — ${esc(c.message)}</title>
${isSel?`<circle class="ring" cx="${x}" cy="${y}" r="15"/>`:''}
<circle class="hit" cx="${x}" cy="${y}" r="14"/>
<circle class="dot" cx="${x}" cy="${y}" r="${isSel?8:6.5}" fill="${col}"/></g>`;
// label column: sha, ref badges, message
let bx=labelX+70, badges='';
const badge=(text,fill)=>{
const w=text.length*6.9+14;
badges+=`<rect x="${bx}" y="${y-9}" width="${w}" height="18" rx="4" fill="${fill}"/><text class="badge" x="${bx+7}" y="${y+4}">${esc(text)}</text>`;
bx+=w+6;
};
refs.br.forEach(b=>badge((b.name===S.head?'HEAD → ':'')+b.name, b.name===S.head?'#238636':'#1f6feb'));
refs.tg.forEach(t=>badge('🏷 '+t.name,'#9e6a03'));
labels+=`<g class="row${isSel?' sel':''}" data-commit="${c.id}">
<rect class="rowbg" x="${labelX-10}" y="${y-ROW_H/2+4}" width="${LABEL_W-14}" height="${ROW_H-8}" rx="6"/>
<text class="sha" x="${labelX}" y="${y+4}">${esc(c.id)}</text>
${badges}
<text class="msg" x="${bx+4}" y="${y+5}">${esc(trunc(c.message,70))}</text></g>`;
}
svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
svg.setAttribute('width',Math.round(W*z));
svg.setAttribute('height',Math.round(H*z));
svg.innerHTML=`<g>${edges}${guides}${anchors}${nodes}${labels}</g>`
+ (S.commits.length?'':`<text x="40" y="60" fill="#8b949e">No commits. Use “+ Commit” or “Import”.</text>`);
$('#zoomLbl').textContent=Math.round(z*100)+'%';
$('#stats').textContent=`${S.commits.length} commits · ${S.branches.length} branches · ${S.tags.length} tags · ${S.events.length} events`;
}
/* ---------- selection & interaction ---------- */
function selectCommit(id, toggle){
if(toggle && S.ui.selected===id) id=null;
S.ui.selected=id;
if(S.ui.selEvent){
const e=S.events.find(x=>x.id===S.ui.selEvent);
if(!e||e.associationId!==id) S.ui.selEvent=null;
}
if(id) S.ui.tab='inspector';
changed();
}
function selectEvent(id, keepTab){
const e=S.events.find(x=>x.id===id); if(!e) return;
S.ui.selEvent=id; S.ui.selected=e.associationId;
if(!keepTab) S.ui.tab='inspector';
changed();
const n=$('#graph .node.sel'); if(n) n.scrollIntoView({block:'nearest',inline:'nearest'});
}
function setZoom(z){S.ui.zoom=Math.min(2,Math.max(.5,Math.round(z*100)/100)); changed();}
const svgEl=$('#graph');
svgEl.addEventListener('click',e=>{
const a=e.target.closest('.anchor');
if(a) return selectEvent(a.dataset.ev);
const n=e.target.closest('[data-commit]');
if(n) return selectCommit(n.dataset.commit,true);
if(S.ui.selected){S.ui.selected=null; S.ui.selEvent=null; changed();}
});
const tip=$('#tip');
function showTip(e,ev){
if(!ev) return hideTip();
const c=C(ev.associationId);
tip.innerHTML=`<div class="tip-h"><span class="chip" style="background:${trackColor(ev.track)}">${esc(ev.track)}</span><b>${esc(ev.title)}</b></div>
<div class="tip-d">${esc(ev.description||'—')}</div>
<div class="kv2">
<span>Event ID</span><span class="mono">${esc(ev.id)}</span>
<span>Anchored to</span><span class="mono">${c?esc(c.id):'<missing>'}</span>
<span>Commit</span><span>${c?esc(c.message):'—'}</span>
<span>Parents</span><span class="mono">${c&&c.parents.length?c.parents.map(short).join(', '):'(root)'}</span>
<span>Via branch</span><span>${ev.viaBranch?esc(ev.viaBranch):'—'}</span>
<span>Recorded</span><span>${new Date(ev.createdAt).toLocaleString()}</span>
</div>`;
tip.style.display='block'; moveTip(e);
$$('#panel .ctx-item.hot').forEach(x=>x.classList.remove('hot'));
const ci=$(`#panel .ctx-item[data-sel-ev="${ev.id}"]`); if(ci) ci.classList.add('hot');
}
function moveTip(e){
if(tip.style.display!=='block') return;
const w=tip.offsetWidth, h=tip.offsetHeight;
let x=e.clientX+16, y=e.clientY+16;
if(x+w>innerWidth-8) x=e.clientX-w-16;
if(y+h>innerHeight-8) y=innerHeight-h-8;
tip.style.left=Math.max(8,x)+'px'; tip.style.top=Math.max(8,y)+'px';
}
function hideTip(){
tip.style.display='none';
$$('svg .anchor.hot').forEach(a=>a.classList.remove('hot'));
$$('#panel .ctx-item.hot').forEach(x=>x.classList.remove('hot'));
}
svgEl.addEventListener('mouseover',e=>{
const a=e.target.closest('.anchor');
if(a){ a.classList.add('hot'); showTip(e,S.events.find(x=>x.id===a.dataset.ev)); }
else hideTip();
});
svgEl.addEventListener('mousemove',moveTip);
svgEl.addEventListener('mouseleave',hideTip);
/* ---------- sidebar ---------- */
function renderSide(){
$$('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===S.ui.tab));
const p=$('#panel');
if(S.ui.tab==='inspector') p.innerHTML=inspectorHTML();
else if(S.ui.tab==='context') p.innerHTML=contextHTML();
else p.innerHTML=refsHTML();
}
function trackChip(t){return `<span class="chip" style="background:${trackColor(t)}">${esc(t)}</span>`;}
function inspectorHTML(){
const c=S.ui.selected && C(S.ui.selected);
if(!c){
return `<div class="empty"><b style="color:#e6edf3">No commit selected</b><br>
Click a commit node or row on the graph. Its anchored contextual events are highlighted on the graph and listed here.
Hover any diamond anchor to see the full event and the commit it is linked to.</div>
<h4>Anchors per commit</h4>
${S.commits.map(k=>{const n=S.events.filter(e=>e.associationId===k.id).length; return n?`<div class="card" data-sel-c="${k.id}"><span class="mono">${short(k.id)}</span> ${esc(trunc(k.message,44))} <span class="muted">· ${n} event${n>1?'s':''}</span></div>`:'';}).join('') || '<div class="muted">No anchored events yet.</div>'}`;
}
const refs=refsOf(c.id);
const evs=S.events.filter(e=>e.associationId===c.id);
const kids=S.commits.filter(k=>k.parents.includes(c.id));
const others=S.branches.filter(b=>b.head!==c.id);
return `
<h3>${esc(c.message)}</h3>
<div class="mono">commit ${esc(c.id)} · ${esc(c.author||'')}${c.date?' · '+new Date(c.date).toLocaleDateString():''}</div>
<div class="ref-line" style="margin:8px 0">
${refs.br.map(b=>`<span class="chip" style="background:${b.name===S.head?'#238636':'#1f6feb'};color:#fff">${b.name===S.head?'HEAD → ':''}${esc(b.name)}</span>`).join('')}
${refs.tg.map(t=>`<span class="chip" style="background:#9e6a03;color:#fff">🏷 ${esc(t.name)}</span>`).join('')}
</div>
<div class="kv">
<span>Parents</span><span>${c.parents.length?c.parents.map(p=>`<a data-sel-c="${p}">${short(p)}</a>`).join(''):'(root commit)'}</span>
<span>Children</span><span>${kids.length?kids.map(k=>`<a data-sel-c="${k.id}">${short(k.id)}</a>`).join(''):'—'}</span>
</div>
<h4>Anchored events (${evs.length})</h4>
${evs.map(e=>`<div class="card ${S.ui.selEvent===e.id?'hl':''}" data-sel-ev="${e.id}">
<div>${trackChip(e.track)}<b>${esc(e.title)}</b></div>
<div class="muted" style="margin-top:4px">${esc(e.description)}</div>
<div class="row-btns"><button data-pact="edit-event" data-id="${e.id}">Edit</button><button class="danger" data-pact="del-event" data-id="${e.id}">Delete</button></div>
</div>`).join('') || '<div class="muted">No events anchored to this commit.</div>'}
<h4>Actions</h4>
<div class="row-btns">
<button data-pact="edit-commit" data-id="${c.id}">Edit message</button>
<button data-pact="new-event-at" data-id="${c.id}">+ Event here</button>
<button data-pact="new-branch-at" data-id="${c.id}">+ Branch here</button>
<button data-pact="new-tag-at" data-id="${c.id}">+ Tag here</button>
<button class="danger" data-pact="del-commit" data-id="${c.id}">Delete commit</button>
</div>
${others.length?`<h4>Move branch here</h4><div class="row-btns">${others.map(b=>`<button data-pact="move-branch" data-id="${c.id}" data-name="${esc(b.name)}">⎇ ${esc(b.name)} ← here</button>`).join('')}</div>`:''}
`;
}
function contextHTML(){
const L=layout();
const tracks=[...new Set(S.events.map(e=>e.track))];
const f=S.ui.track||'';
const list=S.events.filter(e=>!f||e.track===f)
.sort((a,b)=>(L.rows.get(b.associationId)??0)-(L.rows.get(a.associationId)??0) || a.createdAt-b.createdAt);
return `
<div class="toolbar-row">
<select id="trackFilter"><option value="">All tracks</option>${tracks.map(t=>`<option value="${esc(t)}"${t===f?' selected':''}>${esc(t)}</option>`).join('')}</select>
<button class="primary" data-pact="new-event">+ Event</button>
</div>
<div class="muted" style="margin-bottom:4px">Chronological flow (oldest → newest). Hover to highlight the anchor on the graph; click to focus.</div>
${list.map(e=>{const c=C(e.associationId); return `
<div class="ctx-item ${S.ui.selEvent===e.id?'hl':''}" data-sel-ev="${e.id}" style="border-left-color:${trackColor(e.track)}">
<div>${trackChip(e.track)}<b>${esc(e.title)}</b></div>
<div class="mono" style="margin-top:3px">⌥ ${c?short(c.id):'missing'} ${c?'· '+esc(trunc(c.message,50)):''}${e.viaBranch?' · ⎇ '+esc(e.viaBranch):''}</div>
<div class="muted" style="margin-top:3px">${esc(e.description)}</div>
</div>`;}).join('') || '<div class="empty">No events. Add one with “+ Event”.</div>'}`;
}
function refsHTML(){
return `
<div class="toolbar-row"><button class="primary" data-pact="new-branch">+ Branch</button><button data-pact="new-tag">+ Tag</button></div>
<h4>Branches</h4>
${S.branches.map(b=>{const c=C(b.head); return `<div class="card" data-sel-c="${b.head}">
<div>${b.name===S.head?'<span class="chip" style="background:#238636;color:#fff">HEAD</span>':''}<b>${esc(b.name)}</b></div>
<div class="mono">${short(b.head)} ${c?esc(trunc(c.message,46)):''}</div>
<div class="row-btns">
${b.name!==S.head?`<button data-pact="checkout" data-name="${esc(b.name)}">Checkout</button>`:''}
<button data-pact="edit-branch" data-name="${esc(b.name)}">Edit</button>
<button class="danger" data-pact="del-branch" data-name="${esc(b.name)}">Delete</button>
</div></div>`;}).join('') || '<div class="muted">No branches.</div>'}
<h4>Tags</h4>
${S.tags.map(t=>{const c=C(t.commit); return `<div class="card" data-sel-c="${t.commit}">
<div><span class="chip" style="background:#9e6a03;color:#fff">🏷</span><b>${esc(t.name)}</b></div>
<div class="mono">${short(t.commit)} ${c?esc(trunc(c.message,46)):''}</div>
<div class="row-btns">
<button data-pact="edit-tag" data-name="${esc(t.name)}">Edit</button>
<button class="danger" data-pact="del-tag" data-name="${esc(t.name)}">Delete</button>
</div></div>`;}).join('') || '<div class="muted">No tags.</div>'}
`;
}
$('.tabs').addEventListener('click',e=>{
const b=e.target.closest('[data-tab]'); if(!b) return;
S.ui.tab=b.dataset.tab; save(); renderSide();
});
const panel=$('#panel');
panel.addEventListener('click',e=>{
const b=e.target.closest('[data-pact]');
if(b){ const fn=PACT[b.dataset.pact]; if(fn) fn(b.dataset); return; }
const se=e.target.closest('[data-sel-ev]');
if(se) return selectEvent(se.dataset.selEv, S.ui.tab==='context');
const sc=e.target.closest('[data-sel-c]');
if(sc) return selectCommit(sc.dataset.selC,false);
});
panel.addEventListener('change',e=>{
if(e.target.id==='trackFilter'){S.ui.track=e.target.value; save(); renderSide();}
});
panel.addEventListener('mouseover',e=>{
const it=e.target.closest('.ctx-item[data-sel-ev]');
$$('svg .anchor.hot').forEach(a=>a.classList.remove('hot'));
$$('#panel .ctx-item.hot').forEach(a=>a.classList.remove('hot'));
if(it){ it.classList.add('hot'); const a=svgEl.querySelector(`.anchor[data-ev="${it.dataset.selEv}"]`); if(a) a.classList.add('hot'); }
});
panel.addEventListener('mouseleave',()=>{ $$('svg .anchor.hot').forEach(a=>a.classList.remove('hot')); $$('#panel .ctx-item.hot').forEach(a=>a.classList.remove('hot')); });
/* ---------- modal forms ---------- */
function openForm(cfg){
const wrap=$('#modalWrap');
const fieldH=f=>{
const lbl=`<label for="f_${f.name}">${esc(f.label)}</label>`;
if(f.type==='select'){
const groups=new Map();
(f.options||[]).forEach(o=>{const g=o.group||''; if(!groups.has(g)) groups.set(g,[]); groups.get(g).push(o);});
let h='';
for(const [g,os] of groups){
const inner=os.map(o=>`<option value="${esc(o.value)}"${o.value===f.value?' selected':''}>${esc(o.label)}</option>`).join('');
h+=g?`<optgroup label="${esc(g)}">${inner}</optgroup>`:inner;
}
return lbl+`<select id="f_${f.name}" name="${f.name}">${h}</select>`;
}
if(f.type==='textarea') return lbl+`<textarea id="f_${f.name}" name="${f.name}" rows="${f.rows||4}" style="font-family:ui-monospace,monospace;font-size:12px">${esc(f.value||'')}</textarea>`;
if(f.type==='file') return lbl+`<input id="f_${f.name}" type="file" name="${f.name}" accept=".json,application/json">`;
return lbl+`<input id="f_${f.name}" type="text" name="${f.name}" value="${esc(f.value||'')}" placeholder="${esc(f.placeholder||'')}"${f.required?' required':''}>`;
};
wrap.innerHTML=`<form class="modal" autocomplete="off" novalidate><h2>${esc(cfg.title)}</h2>
${cfg.fields.map(fieldH).join('')}
<div class="err" id="ferr"></div>
<div class="actions"><button type="button" data-cancel>Cancel</button><button class="primary" type="submit">${esc(cfg.submit||'Save')}</button></div></form>`;
wrap.classList.remove('hidden');
const form=wrap.querySelector('form');
const close=()=>{wrap.classList.add('hidden'); wrap.innerHTML=''; wrap.onclick=null;};
wrap.onclick=e=>{ if(e.target===wrap||e.target.hasAttribute('data-cancel')) close(); };
form.onsubmit=async e=>{
e.preventDefault();
const vals={};
for(const f of cfg.fields){
const el=form.elements[f.name];
vals[f.name]=f.type==='file'?(el.files[0]||null):el.value;
}
if(cfg.fields.some(f=>f.required && !String(vals[f.name]||'').trim())){
$('#ferr').textContent='Please fill in the required fields.'; return;
}
try{
const err=await cfg.onSubmit(vals);
if(err){ $('#ferr').textContent=err; return; }
close();
}catch(x){ $('#ferr').textContent=x.message; }
};
setTimeout(()=>{const first=form.querySelector('input[type=text],textarea,select'); first&&first.focus();},0);
}
document.addEventListener('keydown',e=>{ if(e.key==='Escape') { const w=$('#modalWrap'); if(!w.classList.contains('hidden')){w.classList.add('hidden'); w.innerHTML='';} } });
/* ---------- commit operations ---------- */
function commitDlg(){
const opts=commitOpts();
const hid=headId();
const pre=(S.ui.selected&&C(S.ui.selected))?S.ui.selected:hid;
openForm({title:'New commit',submit:'Create commit',fields:[
{name:'message',label:'Commit message',type:'text',required:true,placeholder:'What changed?'},
{name:'parent',label:'Parent commit',type:'select',options:opts,value:pre},
{name:'parent2',label:'Second parent (makes a merge commit)',type:'select',options:[{value:'',label:'— none —'},...opts],value:''},
{name:'branch',label:'Advance branch to new commit',type:'select',options:[{value:'',label:'— detached (no branch) —'},...S.branches.map(b=>({value:b.name,label:b.name}))],value:S.head}
],onSubmit:v=>{
if(!C(v.parent)) return 'Pick a parent commit.';
if(v.parent2 && v.parent2===v.parent) return 'Second parent must differ from the first.';
const parents=[v.parent]; if(v.parent2) parents.push(v.parent2);
const id=newCommitId();
S.commits.push({id,message:v.message.trim(),parents,author:'you',date:Date.now()});
if(v.branch){const b=B(v.branch); if(b) b.head=id;}
S.ui.selected=id; S.ui.selEvent=null; S.ui.tab='inspector';
changed(); toast(`Created ${short(id)}`); return null;
}});
}
function commitEditDlg(id){
const c=C(id); if(!c) return;
openForm({title:'Edit commit message',submit:'Save',fields:[
{name:'message',label:'Message',type:'text',required:true,value:c.message}
],onSubmit:v=>{c.message=v.message.trim(); changed(); return null;}});
}
function deleteCommit(id){
const c=C(id); if(!c) return;
if(!confirm(`Delete commit ${short(id)} "${c.message}"?\nChildren are re-parented to its parents; branches, tags and events move to its first parent.`)) return;
const p0=c.parents[0];
S.commits.forEach(k=>{
if(!k.parents.includes(id)) return;
const np=[];
k.parents.forEach(p=>{ if(p===id) c.parents.forEach(q=>np.push(q)); else np.push(p); });
k.parents=[...new Set(np)];
});
S.commits=S.commits.filter(k=>k.id!==id);
S.branches=S.branches.filter(b=>{ if(b.head!==id) return true; if(p0){b.head=p0; return true;} return false; });
S.tags=S.tags.filter(t=>{ if(t.commit!==id) return true; if(p0){t.commit=p0; return true;} return false; });
const fb=p0||(S.commits[0]&&S.commits[0].id);
S.events=S.events.filter(e=>{ if(e.associationId!==id) return true; if(fb){e.associationId=fb; e.viaBranch=null; return true;} return false; });
if(!B(S.head)) S.head=S.branches[0]?.name||'';
S.ui.selected=null; S.ui.selEvent=null;
changed(); toast(`Deleted ${short(id)}`);
}
/* ---------- branch / tag operations ---------- */
function branchDlg(orig, at){
const b=orig?B(orig):null;
openForm({title:b?'Edit branch':'New branch',submit:b?'Save':'Create branch',fields:[
{name:'name',label:'Branch name',type:'text',required:true,value:b?b.name:'',placeholder:'feature/xyz'},
{name:'at',label:'Points at commit',type:'select',options:commitOpts(),value:b?b.head:(at||S.ui.selected||headId())}
],onSubmit:v=>{
const name=v.name.trim();
if(!/^[\w./-]+$/.test(name)) return 'Invalid name (letters, digits, _ . / - only).';
if(S.branches.some(x=>x.name===name && x!==b)) return 'A branch with that name already exists.';
if(!C(v.at)) return 'Pick a commit.';
if(b){
const old=b.name; b.name=name; b.head=v.at;
if(S.head===old) S.head=name;
S.events.forEach(e=>{if(e.viaBranch===old) e.viaBranch=name;});
} else S.branches.push({name,head:v.at});
changed(); toast(`Branch ${name} → ${short(v.at)}`); return null;
}});
}
function tagDlg(orig, at){
const t=orig?S.tags.find(x=>x.name===orig):null;
openForm({title:t?'Edit tag':'New tag',submit:t?'Save':'Create tag',fields:[
{name:'name',label:'Tag name',type:'text',required:true,value:t?t.name:'',placeholder:'v1.0'},
{name:'at',label:'Tags commit',type:'select',options:commitOpts(),value:t?t.commit:(at||S.ui.selected||headId())}
],onSubmit:v=>{
const name=v.name.trim();
if(!/^[\w./-]+$/.test(name)) return 'Invalid tag name.';
if(S.tags.some(x=>x.name===name && x!==t)) return 'Tag already exists.';
if(!C(v.at)) return 'Pick a commit.';
if(t){t.name=name; t.commit=v.at;} else S.tags.push({name,commit:v.at});
changed(); toast(`Tag ${name} saved`); return null;
}});
}
/* ---------- event operations ---------- */
function eventDlg(id, at){
const e=id?S.events.find(x=>x.id===id):null;
const branchOpts=S.branches.map(b=>({value:'b:'+b.name,label:`⎇ ${b.name} → ${short(b.head)}`,group:'Branches (resolve to head commit)'}));
const commitOptsE=layout().ordered.map(o=>({value:'c:'+o.c.id,label:`${short(o.c.id)} ${trunc(o.c.message,50)}`,group:'Commits'}));
let def;
if(e) def=(e.viaBranch&&B(e.viaBranch)&&B(e.viaBranch).head===e.associationId)?'b:'+e.viaBranch:'c:'+e.associationId;
else if(at) def='c:'+at;
else if(S.ui.selected&&C(S.ui.selected)) def='c:'+S.ui.selected;
else def=B(S.head)?'b:'+S.head:'c:'+(S.commits[0]?.id||'');
openForm({title:e?'Edit contextual event':'New contextual event',submit:e?'Save':'Add event',fields:[
{name:'title',label:'Title',type:'text',required:true,value:e?.title||'',placeholder:'e.g. Code Freeze'},
{name:'track',label:'Track / spatial reference',type:'text',value:e?.track||'General',placeholder:'Release, Feature, Compliance…'},
{name:'assoc',label:'Associate with (anchors to a commit)',type:'select',options:[...branchOpts,...commitOptsE],value:def},
{name:'description',label:'Description',type:'textarea',rows:3,value:e?.description||''}
],onSubmit:v=>{
const i=v.assoc.indexOf(':'); const kind=v.assoc.slice(0,i), ref=v.assoc.slice(i+1);
let cid=ref, via=null;
if(kind==='b'){const b=B(ref); if(!b) return 'Unknown branch.'; cid=b.head; via=ref;}
if(!C(cid)) return 'Associated commit does not exist.';
const data={title:v.title.trim(),track:v.track.trim()||'General',description:v.description.trim(),associationId:cid,viaBranch:via};
if(e) Object.assign(e,data);
else { const ne={id:newEventId(),...data,createdAt:Date.now()}; S.events.push(ne); S.ui.selEvent=ne.id; }
S.ui.selected=cid; S.ui.tab='inspector';
changed(); toast('Event anchored to '+short(cid)); return null;
}});
}
/* ---------- import / export ---------- */
function importData(text){
let d; try{d=JSON.parse(text);}catch(e){return 'Invalid JSON: '+e.message;}
if(!d||!Array.isArray(d.commits)) return 'Expected an object with a "commits" array (plus optional branches, tags, events, head).';
const commits=[];
for(const c of d.commits){
if(!c||c.id==null) return 'Every commit needs an "id".';
commits.push({id:String(c.id),message:String(c.message??''),parents:Array.isArray(c.parents)?c.parents.map(String):[],author:c.author||'unknown',date:c.date||Date.now()});
}
const ids=new Set(commits.map(c=>c.id));
if(ids.size!==commits.length) return 'Commit IDs must be unique.';
for(const c of commits) for(const p of c.parents) if(!ids.has(p)) return `Commit ${c.id} has unknown parent ${p}.`;
// cycle check
const byId=new Map(commits.map(c=>[c.id,c])), st=new Map(); let cyc=false;
const visit=id=>{ if(st.get(id)===1){cyc=true;return;} if(st.get(id)===2) return; st.set(id,1); byId.get(id).parents.forEach(visit); st.set(id,2); };
commits.forEach(c=>visit(c.id));
if(cyc) return 'Commit graph contains a cycle.';
const branches=(d.branches||[]).map(b=>({name:String(b.name),head:String(b.head)}));
for(const b of branches) if(!ids.has(b.head)) return `Branch ${b.name} points at unknown commit ${b.head}.`;
const tags=(d.tags||[]).map(t=>({name:String(t.name),commit:String(t.commit||t.head)}));
for(const t of tags) if(!ids.has(t.commit)) return `Tag ${t.name} points at unknown commit.`;
const events=(d.events||[]).map(e=>({id:e.id||newEventId(),title:String(e.title||'Untitled'),track:String(e.track||'General'),description:String(e.description||''),associationId:String(e.associationId||e.commitId||''),viaBranch:e.viaBranch||null,createdAt:e.createdAt||Date.now()}));
for(const e of events) if(!ids.has(e.associationId)) return `Event "${e.title}" has an Association ID that matches no commit.`;
const head=(d.head&&branches.some(b=>b.name===d.head))?d.head:(branches[0]?.name||'main');
S={head,commits,branches,tags,events,ui:defaultUI()};
changed(); toast(`Imported ${commits.length} commits, ${events.length} events`);
return null;
}
function importDlg(){
openForm({title:'Import commits & events',submit:'Import (replaces data)',fields:[
{name:'json',label:'Paste JSON: {head, commits:[{id,message,parents}], branches:[{name,head}], tags:[{name,commit}], events:[{title,track,description,associationId}]}',type:'textarea',rows:9,value:JSON.stringify(SAMPLE,null,2)},
{name:'file',label:'…or choose a .json file',type:'file'}
],onSubmit:async v=>{
let text=v.json.trim();
if(v.file) text=await v.file.text();
if(!text) return 'Provide JSON or choose a file.';
if(!confirm('Replace all current commits, branches, tags and events with the imported data?')) return 'Import cancelled.';
return importData(text);
}});
}
function exportJSON(){
const data={head:S.head,commits:S.commits,branches:S.branches,tags:S.tags,events:S.events};
const url=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));
const a=document.createElement('a'); a.href=url; a.download='git-context.json'; a.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
/* ---------- action wiring ---------- */
const PACT={
'edit-commit':d=>commitEditDlg(d.id),
'del-commit':d=>deleteCommit(d.id),
'new-event':()=>eventDlg(),
'new-event-at':d=>eventDlg(null,d.id),
'new-branch':()=>branchDlg(),
'new-branch-at':d=>branchDlg(null,d.id),
'new-tag':()=>tagDlg(),
'new-tag-at':d=>tagDlg(null,d.id),
'move-branch':d=>{const b=B(d.name); if(b){b.head=d.id; toast(`${d.name} → ${short(d.id)}`); changed();}},
'checkout':d=>{S.head=d.name; toast('HEAD → '+d.name); changed();},
'edit-branch':d=>branchDlg(d.name),
'del-branch':d=>{
if(d.name===S.head) return toast('Cannot delete the checked-out branch.');
if(!confirm(`Delete branch ${d.name}? (commits are kept)`)) return;
S.branches=S.branches.filter(b=>b.name!==d.name); changed();
},
'edit-tag':d=>tagDlg(d.name),
'del-tag':d=>{ if(!confirm(`Delete tag ${d.name}?`)) return; S.tags=S.tags.filter(t=>t.name!==d.name); changed(); },
'edit-event':d=>eventDlg(d.id),
'del-event':d=>{
if(!confirm('Delete this contextual event?')) return;
S.events=S.events.filter(e=>e.id!==d.id); if(S.ui.selEvent===d.id) S.ui.selEvent=null; changed();
}
};
document.addEventListener('click',e=>{
const b=e.target.closest('[data-act]'); if(!b) return;
const a=b.dataset.act;
const acts={
'new-commit':()=>commitDlg(),
'new-branch':()=>branchDlg(),
'new-tag':()=>tagDlg(),
'new-event':()=>eventDlg(),
'import':()=>importDlg(),
'export':()=>exportJSON(),
'sample':()=>{ if(confirm('Replace current data with the sample dataset?')){ S=clone(SAMPLE); S.ui=defaultUI(); changed(); toast('Sample loaded'); } },
'zoom-in':()=>setZoom(S.ui.zoom+0.15),
'zoom-out':()=>setZoom(S.ui.zoom-0.15)
};
if(acts[a]) acts[a]();
});
/* ---------- boot ---------- */
load();
render();
renderSide();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"b3155057-e300-4f9c-8918-4336b7d5c7cf","tokensIn":93386,"tokensOut":64795,"tokensTotal":158181,"cost":0.036806705,"turns":4,"toolCalls":4,"failedToolCalls":0,"timestamp":"2026-10-08T05:04:16.858Z"} -->