Git Explorer
Deepseek v4.1 Flash · 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 73,031 bytes · SHA-256 82d4377ee09d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitContext · Branching Topology + Contextual Events</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
*{box-sizing:border-box}
:root{
--bg:#080c12; --panel:#0e141d; --panel2:#121b26; --line:#1d2836; --line2:#26374a;
--text:#d9e3ef; --dim:#7b8ea3; --accent:#58a6ff; --accent2:#7ee787;
--freeze:#f0883e; --milestone:#3fb950; --incident:#f85149; --audit:#bc8cff; --release:#58a6ff; --note:#8b949e;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
}
html,body{height:100%;margin:0}
body{
background:
radial-gradient(1100px 620px at 78% -8%, #14304d55, transparent 60%),
radial-gradient(900px 500px at 8% 105%, #1d3b2c55, transparent 60%),
var(--bg);
color:var(--text);
font:13px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
overflow:hidden;
}
button,input,select,textarea{font:inherit;color:inherit}
.hidden{display:none!important}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-thumb{background:#22303f;border-radius:8px}
::-webkit-scrollbar-thumb:hover{background:#2e4358}
::-webkit-scrollbar-track{background:transparent}
/* ---------- shell ---------- */
.app{height:100%;display:grid;grid-template-rows:50px 1fr}
.topbar{display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid var(--line);
background:linear-gradient(180deg,#0f1721,#0b1119)}
.brand{display:flex;align-items:baseline;gap:9px;font-weight:600;letter-spacing:.3px}
.brand b{font-size:15px}
.brand .glyph{color:var(--accent);font-size:17px}
.brand small{color:var(--dim);font-weight:400;font-size:11px;letter-spacing:.6px;text-transform:uppercase}
.spacer{flex:1}
.btn{background:#152131;border:1px solid var(--line2);color:#c7d5e4;padding:5px 11px;border-radius:7px;
cursor:pointer;transition:.14s;font-size:12px;white-space:nowrap}
.btn:hover{background:#1c2c40;border-color:#39536f;color:#fff;transform:translateY(-1px)}
.btn.primary{background:linear-gradient(180deg,#2f7fd6,#1e5fa8);border-color:#4a90d9;color:#fff;font-weight:600}
.btn.primary:hover{background:linear-gradient(180deg,#3a8ee8,#2469b8)}
.btn.ghost{background:transparent;border-color:#2a3a4d}
.btn.danger:hover{background:#3a1b1b;border-color:#7a3232;color:#ffb4b4}
.btn.tiny{padding:2px 7px;font-size:11px;border-radius:6px}
.sep{width:1px;height:22px;background:var(--line2)}
.body{display:grid;grid-template-columns:326px 1fr;min-height:0}
/* ---------- sidebar ---------- */
.sidebar{display:flex;flex-direction:column;min-height:0;border-right:1px solid var(--line);background:#0b1119d9}
.tabs{display:flex;gap:2px;padding:8px 8px 0;border-bottom:1px solid var(--line)}
.tab{flex:1;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--dim);
padding:7px 4px;cursor:pointer;font-size:11.5px;letter-spacing:.4px;text-transform:uppercase;font-weight:600}
.tab:hover{color:#b6c8da}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.panels{flex:1;overflow-y:auto;padding:12px}
.panel{display:none;flex-direction:column;gap:10px}
.panel.active{display:flex}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:11px;display:flex;flex-direction:column;gap:8px}
.card h3{margin:0;font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:var(--dim);font-weight:700}
.frow{display:flex;flex-direction:column;gap:4px}
.frow2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
label{font-size:10.5px;letter-spacing:.5px;text-transform:uppercase;color:var(--dim);font-weight:600}
input[type=text],input[type=date],select,textarea{
background:#0a121b;border:1px solid var(--line2);border-radius:7px;padding:6px 8px;width:100%;outline:none;transition:.14s}
textarea{resize:vertical;min-height:52px;font-size:12px}
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #58a6ff22}
.picklist{max-height:132px;overflow-y:auto;border:1px solid var(--line2);border-radius:8px;background:#0a121b;padding:5px}
.pick{display:flex;gap:7px;align-items:center;padding:4px 5px;border-radius:6px;cursor:pointer;font-size:11.5px}
.pick:hover{background:#16222f}
.pick input{accent-color:var(--accent)}
.pick .pid{font-family:var(--mono);color:var(--accent2);font-size:11px}
.pick .pmsg{color:#9fb1c4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rowbtns{display:flex;gap:6px}
.hint{color:var(--dim);font-size:11px;line-height:1.4}
.list{display:flex;flex-direction:column;gap:5px;max-height:290px;overflow-y:auto;padding-right:2px}
.item{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);
border-radius:8px;padding:6px 8px;cursor:pointer;transition:.13s}
.item:hover{border-color:#38506c;background:#16222f}
.item.sel{border-color:var(--accent);box-shadow:0 0 0 2px #58a6ff25}
.item .dot{width:9px;height:9px;border-radius:50%;flex:none}
.item .mid{flex:1;min-width:0}
.item .t{font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .s{font-size:10.5px;color:var(--dim);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{font-size:9.5px;padding:1px 6px;border-radius:20px;border:1px solid;letter-spacing:.4px;text-transform:uppercase;font-weight:700;flex:none}
.actions{display:flex;gap:3px;flex:none}
/* ---------- stage ---------- */
.stage{display:grid;grid-template-rows:minmax(0,1fr) 214px;min-width:0;min-height:0}
.pane{position:relative;min-height:0;display:flex;flex-direction:column;min-width:0}
.pane-head{display:flex;align-items:center;gap:10px;padding:7px 13px;border-bottom:1px solid var(--line);
background:#0c131c;font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:#9db0c4;font-weight:700}
.pane-head .hint{text-transform:none;letter-spacing:0;font-weight:400}
.graph-pane{flex:1;position:relative;min-height:0;overflow:hidden}
#graph{display:block;width:100%;height:100%;cursor:grab}
#graph.grabbing{cursor:grabbing}
.flow-pane{border-top:1px solid var(--line);background:#0a1017}
.flow-scroll{flex:1;overflow-x:auto;overflow-y:hidden;min-height:0}
.flow-inner{display:flex;gap:14px;padding:20px 16px 12px;position:relative;width:max-content;min-width:100%}
.flow-inner::before{content:'';position:absolute;left:0;right:0;top:31px;height:2px;
background:linear-gradient(90deg,transparent,#22303f 4%,#22303f 96%,transparent)}
.flow-empty{color:var(--dim);padding:26px 18px;font-size:12px}
.flow-item{position:relative;width:196px;padding-top:44px;cursor:pointer}
.flow-item .fdot{position:absolute;top:32px;left:50%;transform:translate(-50%,-50%);width:11px;height:11px;
border-radius:50%;background:var(--c);box-shadow:0 0 0 3px #0a1017,0 0 12px var(--c);transition:.15s}
.flow-item .fstem{position:absolute;top:37px;left:50%;width:2px;height:14px;background:var(--c);opacity:.45}
.flow-item .fcard{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--c);
border-radius:9px;padding:8px 10px;transition:.15s;height:100%}
.flow-item:hover .fcard{border-color:#3d5a7a;transform:translateY(-2px);box-shadow:0 8px 20px #0008}
.flow-item:hover .fdot{transform:translate(-50%,-50%) scale(1.35)}
.flow-item.sel .fcard{border-color:var(--accent);box-shadow:0 0 0 2px #58a6ff33,0 8px 22px #0009}
.flow-item.sel .fdot{box-shadow:0 0 0 4px #58a6ff55,0 0 16px var(--c)}
.fcard .ftop{display:flex;justify-content:space-between;gap:6px;align-items:center}
.fcard .ftype{font-size:9.5px;letter-spacing:.6px;text-transform:uppercase;font-weight:800;color:var(--c)}
.fcard .fdate{font-size:10px;color:var(--dim);font-family:var(--mono)}
.fcard .ftitle{font-weight:600;font-size:12.5px;margin:3px 0 6px;line-height:1.25}
.fcard .flink{font-family:var(--mono);font-size:10.5px;color:#8fa6bd;display:flex;gap:5px;align-items:center;
border-top:1px dashed #1f2b3a;padding-top:5px;overflow:hidden}
.fcard .flink b{color:var(--accent2);font-weight:600}
.gnode .msg,.gnode .pill,.gnode .tagtxt{paint-order:stroke;stroke:#0a0f16;stroke-width:3.4px;stroke-linejoin:round}
svg text{user-select:none}
/* ---------- modal ---------- */
.overlay{position:fixed;inset:0;background:#04070bcc;backdrop-filter:blur(3px);z-index:100;display:flex;
align-items:center;justify-content:center;animation:fade .14s ease}
@keyframes fade{from{opacity:0}to{opacity:1}}
.modal{width:min(660px,92vw);max-height:86vh;display:flex;flex-direction:column;background:#0d141d;
border:1px solid var(--line2);border-radius:13px;box-shadow:0 24px 70px #000c;overflow:hidden}
.modal h3{margin:0;padding:12px 15px;font-size:12px;letter-spacing:.9px;text-transform:uppercase;color:#a9bccf;
border-bottom:1px solid var(--line);background:#0b1119}
.modal .mbody{padding:14px 15px;overflow:auto;display:flex;flex-direction:column;gap:10px}
.modal .mact{display:flex;gap:8px;justify-content:flex-end;padding:11px 15px;border-top:1px solid var(--line);background:#0b1119}
.modal textarea.mtext{width:100%;min-height:230px;font-family:var(--mono);font-size:11.5px;line-height:1.5;
background:#080d14;border:1px solid var(--line2);border-radius:9px;padding:10px;resize:vertical}
.modal .mnote{color:var(--dim);font-size:11.5px;line-height:1.5}
.modal .mnote code{font-family:var(--mono);color:#7ee787;background:#12202c;padding:1px 5px;border-radius:5px}
input[type=file]{font-size:11.5px;color:var(--dim)}
/* ---------- responsive ---------- */
.menu-btn{display:none}
@media (max-width: 980px){
.body{grid-template-columns:1fr}
.menu-btn{display:inline-block}
.topbar{overflow-x:auto}
.topbar::-webkit-scrollbar{height:0}
.sidebar{position:fixed;top:51px;bottom:0;left:0;width:302px;z-index:60;transform:translateX(-104%);
transition:transform .22s ease;box-shadow:18px 0 44px #000b;background:#0b1119f7}
.sidebar.open{transform:none}
.stage{grid-template-rows:minmax(0,1fr) 178px}
.inspector{width:260px}
.legend{max-width:150px}
}
/* ---------- legend / inspector / tooltip ---------- */
.legend{position:absolute;top:12px;right:12px;background:#0b1119e6;border:1px solid var(--line);border-radius:9px;
padding:8px 10px;font-size:10.5px;display:flex;flex-direction:column;gap:4px;backdrop-filter:blur(6px);pointer-events:none;
max-width:190px}
.legend .lg-title{color:var(--dim);letter-spacing:.8px;font-weight:700;font-size:9.5px;text-transform:uppercase;margin-bottom:1px}
.legend .row{display:flex;align-items:center;gap:6px;color:#a9bccf}
.legend .sw{width:9px;height:9px;border-radius:50%}
.legend .sw.sq{border-radius:3px}
.inspector{position:absolute;left:14px;bottom:12px;width:322px;background:#0b1119f0;border:1px solid var(--line2);
border-radius:11px;padding:11px 12px;backdrop-filter:blur(8px);box-shadow:0 14px 40px #000a;display:none}
.inspector.on{display:block}
.inspector .ihead{display:flex;align-items:center;gap:8px}
.inspector .ihash{font-family:var(--mono);color:var(--accent2);font-size:12.5px;font-weight:700}
.inspector .iclose{margin-left:auto;background:none;border:0;color:var(--dim);cursor:pointer;font-size:15px;line-height:1;padding:0 3px}
.inspector .iclose:hover{color:#fff}
.inspector h4{margin:7px 0 3px;font-size:13px;font-weight:600}
.inspector .meta{font-size:11px;color:var(--dim);font-family:var(--mono)}
.inspector .chip{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:2px 8px;border-radius:20px;
border:1px solid var(--line2);margin:6px 5px 0 0;background:#121b26}
.inspector .evlist{margin-top:8px;border-top:1px dashed #22303f;padding-top:7px;display:flex;flex-direction:column;gap:5px}
.inspector .ev{display:flex;gap:7px;align-items:flex-start;font-size:11.5px;padding:5px 7px;border-radius:7px;
background:#121b26;border-left:3px solid var(--c)}
.inspector .ev .et{font-weight:600}
.inspector .ev .es{color:var(--dim);font-size:10.5px}
.tooltip{position:absolute;pointer-events:none;z-index:40;background:#0d151ff2;border:1px solid #35506e;
border-radius:9px;padding:9px 11px;max-width:300px;box-shadow:0 12px 34px #000b;opacity:0;transition:opacity .12s;
backdrop-filter:blur(8px)}
.tooltip.on{opacity:1}
.tooltip .tt-type{font-size:9.5px;letter-spacing:.7px;text-transform:uppercase;font-weight:800;margin-bottom:3px}
.tooltip .tt-title{font-weight:700;font-size:13px;margin-bottom:4px}
.tooltip .tt-desc{color:#a4b7ca;font-size:11.5px;margin-bottom:6px}
.tooltip .tt-sep{border-top:1px dashed #26374a;margin:6px 0;padding-top:6px}
.tooltip .tt-line{font-family:var(--mono);font-size:11px;color:#9db0c4;display:flex;gap:6px}
.tooltip .tt-line b{color:var(--accent2)}
.tooltip .k{color:var(--dim);font-size:10px;letter-spacing:.5px;text-transform:uppercase;font-weight:700}
/* ---------- svg bits ---------- */
.edge{fill:none;stroke-linecap:round;transition:stroke .18s,opacity .18s}
.gnode{cursor:pointer}
.gnode .halo{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-dasharray:4 4;opacity:0;transition:opacity .2s}
.gnode.sel .halo{opacity:1;animation:spin 9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.gnode .core{transition:.18s}
.gnode:hover .core{filter:url(#glow)}
.gnode .msg{font-size:12.5px;fill:#bcd0e4;pointer-events:none}
.gnode.sel .msg{fill:#fff}
.gnode .shas{font-family:var(--mono);font-size:10px;fill:#5f7488;pointer-events:none}
.gnode.dim{opacity:.42}
.evmark{cursor:pointer;transition:.16s}
.evmark .box{stroke-width:1;transition:.16s}
.evmark.sel{filter:url(#glow)}
.evmark.dim{opacity:.35}
.evmark:hover .box{stroke-width:1.6}
.pill{font-size:9.5px;font-weight:700;letter-spacing:.4px}
.pathlink{fill:none;stroke-dasharray:3 3}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<button class="btn menu-btn" id="btnMenu" title="Panels">☰</button>
<div class="brand"><span class="glyph">◈</span><b>GitContext</b><small>topology × events</small></div>
<div class="sep"></div>
<button class="btn" id="btnImport">⇩ Import</button>
<button class="btn" id="btnExport">⇧ Export</button>
<button class="btn" id="btnSample">⟲ Sample repo</button>
<button class="btn danger" id="btnClear">✕ Clear</button>
<div class="spacer"></div>
<div class="sep"></div>
<button class="btn" id="btnFit">⛶ Fit view</button>
<button class="btn" id="btnDagre">⇅ Re-flow</button>
</header>
<div class="body">
<aside class="sidebar">
<div class="tabs">
<button class="tab active" data-tab="commits">Commits</button>
<button class="tab" data-tab="branches">Branches</button>
<button class="tab" data-tab="tags">Tags</button>
<button class="tab" data-tab="events">Events</button>
</div>
<div class="panels">
<section class="panel active" data-panel="commits">
<div class="card">
<h3 id="cFormTitle">New commit</h3>
<div class="frow"><label>Message</label><input type="text" id="cMsg" placeholder="e.g. Add OAuth token refresh"></div>
<div class="frow2">
<div class="frow"><label>Author</label><input type="text" id="cAuthor" value="dev"></div>
<div class="frow"><label>Date</label><input type="date" id="cDate"></div>
</div>
<div class="frow"><label>Branch label</label><input type="text" id="cBranch" list="branchDL" placeholder="main"><datalist id="branchDL"></datalist></div>
<div class="frow"><label>Parent commits (DAG parents)</label><div class="picklist" id="cParents"></div></div>
<div class="rowbtns">
<button class="btn primary" id="cSave">Add commit</button>
<button class="btn ghost hidden" id="cCancel">Cancel</button>
</div>
<div class="hint">⌥ Tip: tick two parents to create a merge commit.</div>
</div>
<div class="card">
<h3>History (newest first)</h3>
<div class="list" id="commitList"></div>
</div>
</section>
<section class="panel" data-panel="branches">
<div class="card">
<h3 id="bFormTitle">New branch</h3>
<div class="frow"><label>Branch name</label><input type="text" id="bName" placeholder="feature/payments"></div>
<div class="frow"><label>Points at commit</label><select id="bTarget"></select></div>
<div class="rowbtns">
<button class="btn primary" id="bSave">Add branch</button>
<button class="btn ghost hidden" id="bCancel">Cancel</button>
</div>
</div>
<div class="card"><h3>Branches</h3><div class="list" id="branchList"></div></div>
</section>
<section class="panel" data-panel="tags">
<div class="card">
<h3 id="tFormTitle">New tag</h3>
<div class="frow"><label>Tag name</label><input type="text" id="tName" placeholder="v1.0.0"></div>
<div class="frow"><label>Tagged commit</label><select id="tTarget"></select></div>
<div class="rowbtns">
<button class="btn primary" id="tSave">Add tag</button>
<button class="btn ghost hidden" id="tCancel">Cancel</button>
</div>
</div>
<div class="card"><h3>Tags</h3><div class="list" id="tagList"></div></div>
</section>
<section class="panel" data-panel="events">
<div class="card">
<h3 id="eFormTitle">New contextual event</h3>
<div class="frow"><label>Title</label><input type="text" id="eTitle" placeholder="Code Freeze"></div>
<div class="frow2">
<div class="frow"><label>Type</label><select id="eType"></select></div>
<div class="frow"><label>Date</label><input type="date" id="eDate"></div>
</div>
<div class="frow"><label>Description</label><textarea id="eDesc" placeholder="What happened, and why it matters…"></textarea></div>
<div class="frow"><label>Anchor</label>
<select id="eTargetGroup"></select>
</div>
<div class="hint" id="eAnchorPreview"></div>
<div class="rowbtns">
<button class="btn primary" id="eSave">Add event</button>
<button class="btn ghost hidden" id="eCancel">Cancel</button>
</div>
</div>
<div class="card"><h3>Events (chronological)</h3><div class="list" id="eventList"></div></div>
</section>
</div>
</aside>
<main class="stage">
<div class="pane graph-pane">
<div class="pane-head">Git graph <span class="hint">· drag to pan · wheel to zoom · click a commit to reveal its anchored events</span></div>
<div style="position:relative;flex:1;min-height:0" id="graphHolder">
<svg id="graph"></svg>
<div class="legend" id="legend"></div>
<div class="inspector" id="inspector"></div>
<div class="tooltip" id="tooltip"></div>
</div>
</div>
<div class="pane flow-pane">
<div class="pane-head">Contextual flow <span class="hint">· every external event, ordered along the history it belongs to</span></div>
<div class="flow-scroll" id="flowScroll"><div class="flow-inner" id="flowInner"></div></div>
</div>
</main>
</div>
</div>
<script>
'use strict';
/* ============================================================
GitContext — Git branching topology × external contextual events
============================================================ */
const STORE_KEY = 'gitcontext.state.v1';
const PALETTE = ['#58a6ff','#3fb950','#f0883e','#bc8cff','#f778ba','#e3b341','#4dd0e1','#ff7b72','#7ee787','#d2a8ff'];
const TYPES = {
freeze: {name:'Code Freeze', hex:'#f0883e', glyph:'❄'},
milestone: {name:'Milestone', hex:'#3fb950', glyph:'◆'},
incident: {name:'Incident', hex:'#f85149', glyph:'▲'},
audit: {name:'Audit', hex:'#bc8cff', glyph:'⚖'},
release: {name:'Release', hex:'#58a6ff', glyph:'★'},
note: {name:'Note', hex:'#8b949e', glyph:'✎'}
};
let ROW_GAP = 62, LANE_GAP = 132, GUTTER_X = -190, MARK_W = 158, MARK_H = 28;
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
const trunc = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
function rnd(n){ let s=''; const h='0123456789abcdef'; for(let i=0;i<n;i++) s+=h[Math.floor(Math.random()*16)]; return s; }
function uid(p){ return p + rnd(6); }
const tOf = x => TYPES[x] ? TYPES[x] : TYPES.note;
const dstr = ts => { const d = new Date(ts||Date.now()); return d.toISOString().slice(0,10); };
const tstr = s => { const d = new Date((s||'') + 'T12:00:00Z'); return isNaN(d) ? Date.now() : d.getTime(); };
const fmtDate = s => { const d = new Date((s||'') + 'T12:00:00Z'); return isNaN(d) ? '—' : d.toISOString().slice(0,10); };
/* ---------------- state ---------------- */
let state = freshState();
let layout = null;
let selectedId = null; // selected commit id
let hoverEv = null;
let editing = { commit:null, branch:null, tag:null, event:null };
let lastCommitCount = -1;
function freshState(){ return { commits:[], branches:[], tags:[], events:[], head:'main', meta:{name:'repo'} }; }
function sampleState(){
const base = Date.UTC(2024, 2, 4, 9, 0, 0), D = 86400000;
const c = (id, message, parents, branch, day, author) =>
({ id, message, parents, branch, ts: base + day*D, author });
const commits = [
c('a1c0e77','Initial commit: project scaffolding',[],'main',0,'ana'),
c('b7d21f4','Add CI pipeline (lint + unit tests)',['a1c0e77'],'main',2,'ana'),
c('c93aa10','Introduce auth module skeleton',['b7d21f4'],'feature/auth',4,'ravi'),
c('d2f8b65','Implement JWT signing & rotation',['c93aa10'],'feature/auth',6,'ravi'),
c('e5b1043','Add refresh-token endpoint',['d2f8b65'],'feature/auth',8,'ravi'),
c('f4a9c02','Refresh README badges',['b7d21f4'],'main',5,'ana'),
c('07be318','Bump eslint to v9',['f4a9c02'],'main',7,'sam'),
c('1de6a90','Merge branch feature/auth',['e5b1043','07be318'],'main',10,'ana'),
c('2ab77c5','Rate-limit the token endpoint',['1de6a90'],'feature/hardening',12,'sam'),
c('3c0d5ef','Add structured audit logging',['2ab77c5'],'feature/hardening',13,'sam'),
c('4e91b23','Document deployment runbook',['1de6a90'],'main',12,'ana'),
c('5fa0c76','Merge branch feature/hardening',['3c0d5ef','4e91b23'],'main',15,'ana'),
c('6b2e144','Release v1.0.0 polish',['5fa0c76'],'main',18,'ana')
];
const ev = (title, type, commitId, date, description, branch) =>
({ id: uid('ev_'), title, type, commitId, date, description, branch: branch || null });
const events = [
ev('Project kickoff review','note','a1c0e77','2024-03-04','Scope agreed with stakeholders; the trunk is now under protected-branch rules.',null),
ev('Sprint 1 code freeze','freeze','07be318','2024-03-11','Only release-blocking fixes allowed into main until the v0.9 cut.',null),
ev('Auth feature complete','milestone','e5b1043','2024-03-12','Login, refresh and rotation paths all pass acceptance. Ready for review.',null),
ev('Third-party security audit','audit','d2f8b65','2024-03-10','External reviewers checked token storage and signing boundaries.',null),
ev('Sev-2: token revocation gap','incident','2ab77c5','2024-03-16','Revoked tokens stayed valid for 15 minutes. Mitigated with a deny-list.',null),
ev('Docs freeze for launch','freeze','4e91b23','2024-03-16','Runbook and API reference frozen ahead of the release candidate.',null),
ev('Hardening sign-off','audit','3c0d5ef','2024-03-17','Security team signed off on the hardening branch before it merged.',null),
ev('v1.0.0 released','release','6b2e144','2024-03-22','Trunk tagged and published; the milestone is now closed.',null)
];
return {
commits, events,
branches:[
{name:'main', head:'6b2e144'},
{name:'feature/auth', head:'e5b1043'},
{name:'feature/hardening', head:'3c0d5ef'}
],
tags:[{name:'v1.0.0', commit:'5fa0c76'},{name:'v0.9.0-beta', commit:'1de6a90'}],
head:'main', meta:{name:'demo-service'}
};
}
/* ---------------- persistence ---------------- */
function save(){
try{ localStorage.setItem(STORE_KEY, JSON.stringify(state)); }
catch(e){ console.warn('save failed', e); }
}
function load(){
try{
const raw = localStorage.getItem(STORE_KEY);
if(!raw) return null;
const s = JSON.parse(raw);
return normalize(s);
}catch(e){ console.warn('load failed', e); return null; }
}
function normalize(s){
const out = freshState();
if(!s || typeof s !== 'object') return out;
out.meta = s.meta || {name:'repo'};
out.commits = (Array.isArray(s.commits) ? s.commits : []).map(c => ({
id: String(c.id),
message: String(c.message == null ? '' : c.message),
parents: (Array.isArray(c.parents) ? c.parents : []).filter(p => p && p !== c.id).map(String),
branch: c.branch ? String(c.branch) : 'main',
ts: typeof c.ts === 'number' ? c.ts : tstr(c.date),
author: c.author ? String(c.author) : 'dev'
}));
const ids = new Set(out.commits.map(c => c.id));
out.commits.forEach(c => { c.parents = c.parents.filter(p => ids.has(p)); });
out.branches = (Array.isArray(s.branches) ? s.branches : []).map(b => ({
name: String(b.name), head: ids.has(b.head) ? b.head : (out.commits[0] ? out.commits[0].id : null)
})).filter(b => b.name);
out.tags = (Array.isArray(s.tags) ? s.tags : []).map(t => ({ name:String(t.name), commit:String(t.commit) }))
.filter(t => t.name && ids.has(t.commit));
out.events = (Array.isArray(s.events) ? s.events : []).map(e => ({
id: e.id ? String(e.id) : uid('ev_'),
title: String(e.title == null ? '' : e.title),
type: TYPES[e.type] ? e.type : 'note',
description: e.description ? String(e.description) : '',
commitId: String(e.commitId || e.commit || ''),
date: e.date ? String(e.date).slice(0,10) : dstr(e.ts),
branch: e.branch ? String(e.branch) : null
})).filter(e => ids.has(e.commitId));
out.head = s.head ? String(s.head) : (out.branches[0] ? out.branches[0].name : 'main');
if(!out.branches.some(b => b.name === out.head) && out.branches[0]) out.head = out.branches[0].name;
return out;
}
/* ---------------- colours & lookups ---------------- */
function branchColorMap(){
const m = new Map();
const names = state.branches.map(b => b.name);
state.commits.forEach(c => { if(c.branch && !names.includes(c.branch)) names.push(c.branch); });
names.forEach((n,i) => m.set(n, PALETTE[i % PALETTE.length]));
return m;
}
let COLOR = new Map();
const colorOf = name => COLOR.get(name) || '#5b6b7d';
const commitById = id => state.commits.find(c => c.id === id);
const branchByName = n => state.branches.find(b => b.name === n);
const headCommit = () => { const b = branchByName(state.head); return b ? b.head : null; };
function headOfBranch(cid){
const kids = childrenOf(cid);
if(!kids.length) return true;
return false;
}
function childrenOf(id){
return state.commits.filter(c => (c.parents||[]).includes(id));
}
function eventsFor(cid){ return state.events.filter(e => e.commitId === cid); }
function branchLabelOf(c){ return c.branch || 'main'; }
function branchTips(){
const m = new Map();
state.branches.forEach(b => { if(!b.head) return; if(!m.has(b.head)) m.set(b.head, []); m.get(b.head).push(b.name); });
return m;
}
function tagsByCommit(){
const m = new Map();
state.tags.forEach(t => { if(!m.has(t.commit)) m.set(t.commit, []); m.get(t.commit).push(t.name); });
return m;
}
function descendants(id){
const out = new Set(); const stack = [id];
while(stack.length){
const cur = stack.pop();
childrenOf(cur).forEach(k => { if(!out.has(k.id)){ out.add(k.id); stack.push(k.id); } });
}
return out;
}
/* ---------------- DAG layout ---------------- */
function computeLayout(){
const commits = state.commits, byId = new Map(commits.map(c => [c.id, c]));
const remaining = new Map(commits.map(c => [c.id, 0]));
for(const c of commits) for(const p of (c.parents||[])) if(byId.has(p)) remaining.set(p, remaining.get(p)+1);
const pool = commits.filter(c => remaining.get(c.id) === 0).slice();
const sortPool = () => pool.sort((a,b) => (b.ts||0)-(a.ts||0) || String(a.id).localeCompare(String(b.id)));
sortPool();
const order = [], placed = new Set();
while(pool.length){
const c = pool.shift();
if(placed.has(c.id)) continue;
placed.add(c.id); order.push(c);
for(const p of (c.parents||[])){
if(!byId.has(p)) continue;
remaining.set(p, remaining.get(p)-1);
if(remaining.get(p) <= 0 && !placed.has(p)){ pool.push(byId.get(p)); sortPool(); }
}
}
commits.forEach(c => { if(!placed.has(c.id)){ placed.add(c.id); order.push(c); } });
const lanes = [], pos = new Map();
order.forEach((c, row) => {
let lane = lanes.indexOf(c.id);
if(lane === -1){ lane = lanes.indexOf(null); if(lane === -1){ lanes.push(null); lane = lanes.length-1; } }
for(let k = 0; k < lanes.length; k++) if(k !== lane && lanes[k] === c.id) lanes[k] = null;
pos.set(c.id, { lane, row });
const ps = (c.parents||[]).filter(p => byId.has(p));
if(ps.length){
lanes[lane] = ps[0];
for(let i = 1; i < ps.length; i++){
let pl = lanes.indexOf(ps[i]);
if(pl === -1){ pl = lanes.indexOf(null); if(pl === -1){ lanes.push(null); pl = lanes.length-1; } lanes[pl] = ps[i]; }
}
} else lanes[lane] = null;
});
const edges = [];
for(const c of order) for(const p of (c.parents||[])) if(byId.has(p)) edges.push({ child:c, parent:byId.get(p) });
const laneCount = Math.max(1, lanes.length);
const rows = Math.max(1, order.length);
const p = id => { const q = pos.get(id) || {lane:0,row:0}; return { x:q.lane*LANE_GAP, y:q.row*ROW_GAP, lane:q.lane, row:q.row }; };
const bounds = {
minX: GUTTER_X - 10, minY: -56,
maxX: (laneCount-1)*LANE_GAP + 360, maxY: (rows-1)*ROW_GAP + 70
};
return { order, edges, pos, laneCount, rows, point:p, bounds };
}
/* ============================================================
Rendering
============================================================ */
let svg, view, zoom;
function initGraph(){
svg = d3.select('#graph');
const defs = svg.append('defs');
const f = defs.append('filter').attr('id','glow').attr('x','-90%').attr('y','-90%').attr('width','280%').attr('height','280%');
f.append('feGaussianBlur').attr('stdDeviation','3.2').attr('result','b');
const fm = f.append('feMerge');
fm.append('feMergeNode').attr('in','b');
fm.append('feMergeNode').attr('in','SourceGraphic');
view = svg.append('g').attr('class','view');
zoom = d3.zoom().scaleExtent([0.18, 2.8]).on('zoom', ev => view.attr('transform', ev.transform));
svg.call(zoom).on('dblclick.zoom', null);
svg.on('click', ev => { if(ev.target.closest('svg') === ev.target) selectCommit(null); });
svg.on('mousedown', () => svg.classed('grabbing', true));
window.addEventListener('mouseup', () => svg.classed('grabbing', false));
}
function neighborsOf(id){
const s = new Set([id]);
const c = commitById(id);
if(c) (c.parents||[]).forEach(p => s.add(p));
childrenOf(id).forEach(k => s.add(k.id));
state.events.filter(e => e.commitId === id).forEach(() => {});
return s;
}
/* ---- graph ---- */
function renderGraph(){
const prevCount = lastCommitCount;
COLOR = branchColorMap();
layout = computeLayout();
lastCommitCount = state.commits.length;
const { order, edges, point } = layout;
view.selectAll('*').remove();
const root = view.append('g');
const dPath = (a,b) => { const dy = Math.max(26, Math.abs(b.y-a.y)*0.44); return `M${a.x},${a.y} C${a.x},${a.y+dy} ${b.x},${b.y-dy} ${b.x},${b.y}`; };
const near = selectedId ? neighborsOf(selectedId) : null;
const eOp = e => !selectedId ? 0.8 : (e.child.id === selectedId || e.parent.id === selectedId) ? 1 : 0.22;
const eg = root.append('g').attr('class','edges');
const cg = root.append('g').attr('class','connectors');
eg.selectAll('path.u').data(edges).join('path')
.attr('d', e => dPath(point(e.child.id), point(e.parent.id)))
.attr('fill','none').attr('stroke','#05090e').attr('stroke-width',7.5).attr('stroke-linecap','round').attr('opacity',.85);
eg.selectAll('path.c').data(edges).join('path')
.attr('class','edge')
.attr('d', e => dPath(point(e.child.id), point(e.parent.id)))
.attr('stroke', e => colorOf(e.child.branch))
.attr('stroke-width', e => (selectedId && (e.child.id === selectedId || e.parent.id === selectedId)) ? 3.6 : 2.6)
.attr('opacity', eOp);
const tips = branchTips(), tagMap = tagsByCommit(), hc = headCommit();
/* labels layer (behind nodes) */
const lg = root.append('g').attr('class','labels');
order.forEach(c => {
const q = point(c.id);
const g = lg.append('g').attr('class','lab').attr('transform',`translate(${q.x},${q.y})`);
const dim = selectedId && selectedId !== c.id && !near.has(c.id);
if(dim) g.attr('opacity', .34);
const label = trunc(c.message, 30);
const tw = Math.min(268, label.length * 6.6 + 20);
g.append('rect').attr('x',13).attr('y',-10).attr('width',tw).attr('height',21).attr('rx',6)
.attr('fill', c.id === selectedId ? '#0d1826' : '#080d14')
.attr('fill-opacity', c.id === selectedId ? .92 : .74)
.attr('stroke', c.id === selectedId ? '#35597f' : 'none').attr('stroke-width',1);
g.append('text').attr('class','msg').attr('x',18).attr('y',1).attr('dominant-baseline','central').text(label);
if(c.id === selectedId) g.append('text').attr('class','shas').attr('x',18).attr('y',22).attr('dominant-baseline','central').text(c.id);
let px = 18;
(tips.get(c.id) || []).slice(0,3).forEach(n => {
const t = state.branches.find(b => b.name === n) || {};
const isHead = state.head === n;
px += svgPill(g, px, -19, (isHead ? '⌂ ' : '') + n, colorOf(n), { fill: isHead ? colorOf(n) : null, textFill: isHead ? '#08111b' : colorOf(n), fs:9.5 });
px += 5;
});
(tagMap.get(c.id) || []).slice(0,3).forEach(n => { px += svgPill(g, px, -19, '⌗ ' + n, '#e3b341', {fs:9.5, fill:'#3a2f10', textFill:'#f2cd66'}) + 5; });
});
/* nodes layer */
const ng = root.append('g').attr('class','nodes');
const gn = ng.selectAll('g').data(order, d => d.id).join('g')
.attr('class', d => 'gnode' + (d.id === selectedId ? ' sel' : ''))
.attr('transform', d => { const q = point(d.id); return `translate(${q.x},${q.y})`; })
.attr('data-id', d => d.id)
.attr('opacity', d => (!selectedId || near.has(d.id)) ? 1 : .4);
gn.append('circle').attr('class','halo').attr('r',19);
gn.filter(d => (d.parents||[]).length > 1).append('path')
.attr('class','core').attr('d','M0,-13 L13,0 L0,13 L-13,0 Z')
.attr('fill', d => colorOf(d.branch)).attr('stroke','#050a10').attr('stroke-width',2.5);
gn.filter(d => (d.parents||[]).length <= 1).append('circle')
.attr('class','core').attr('r',11)
.attr('fill', d => colorOf(d.branch)).attr('stroke','#050a10').attr('stroke-width',2.5);
gn.filter(d => d.id === hc).append('circle')
.attr('r',16.5).attr('fill','none').attr('stroke','#e6edf6').attr('stroke-width',2).attr('stroke-dasharray','3 4')
.attr('opacity',.95);
gn.filter(d => !(d.parents||[]).length).append('circle')
.attr('r',11).attr('fill','none').attr('stroke', d => colorOf(d.branch)).attr('stroke-width',5).attr('opacity',.28);
/* commit -> commit link lines are done; now the event anchors */
const evByCommit = new Map();
state.events.forEach(e => { if(!layout.pos.has(e.commitId)) return; if(!evByCommit.has(e.commitId)) evByCommit.set(e.commitId, []); evByCommit.get(e.commitId).push(e); });
const mg = root.append('g').attr('class','markers');
evByCommit.forEach((list, cid) => {
const q = point(cid);
list.forEach((e, i) => {
const y = q.y + (i - (list.length-1)/2) * 36;
const t = tOf(e.type);
const isSel = selectedId === cid;
const otherSel = selectedId && !isSel;
cg.append('path')
.attr('class','pathlink')
.attr('d', `M${GUTTER_X + MARK_W},${y} C${GUTTER_X + MARK_W + 26},${y} ${q.x - 40},${q.y} ${q.x - 12},${q.y}`)
.attr('stroke', t.hex).attr('stroke-opacity', otherSel ? .12 : .38).attr('stroke-width', 1);
const g = mg.append('g')
.attr('class','evmark' + (isSel ? ' sel' : ''))
.attr('transform',`translate(${GUTTER_X},${y})`)
.attr('opacity', otherSel ? .35 : 1)
.attr('data-ev', e.id).attr('data-commit', cid);
g.append('rect').attr('class','box').attr('x',0).attr('y',-MARK_H/2)
.attr('width',MARK_W).attr('height',MARK_H).attr('rx',8)
.attr('fill', t.hex).attr('fill-opacity', isSel ? .28 : .13)
.attr('stroke', t.hex).attr('stroke-opacity', .75);
g.append('rect').attr('x',0).attr('y',-MARK_H/2).attr('width',3.5).attr('height',MARK_H).attr('rx',1.8).attr('fill', t.hex);
g.append('text').attr('x',12).attr('y',0).attr('dominant-baseline','central')
.attr('font-size',11).attr('fill', t.hex).text(t.glyph);
g.append('text').attr('class','pill').attr('x',28).attr('y',0).attr('dominant-baseline','central')
.attr('font-size',11).attr('fill','#cfdcea').text(trunc(e.title, 20));
});
});
/* zone frame: contextual gutter ⟷ commit DAG */
const fg = root.append('g').attr('class','frame');
const bottom = ((layout.rows - 1) * ROW_GAP) + 46;
fg.append('line').attr('x1',-18).attr('x2',-18).attr('y1',-44).attr('y2',bottom)
.attr('stroke','#1e2a38').attr('stroke-width',1).attr('stroke-dasharray','5 7');
fg.append('text').attr('x',GUTTER_X + 2).attr('y',-34).attr('fill','#63768a')
.attr('font-size',10).attr('font-weight',700).attr('letter-spacing','1.6').text('CONTEXTUAL EVENTS');
fg.append('text').attr('x',4).attr('y',-34).attr('fill','#63768a')
.attr('font-size',10).attr('font-weight',700).attr('letter-spacing','1.6').text('COMMIT DAG · newest first');
/* branch / head legend inside graph: none, kept in the legend box */
eg.lower(); cg.raise(); lg.raise(); mg.raise(); ng.raise();
if(prevCount !== state.commits.length || prevCount === -1){
requestAnimationFrame(() => fitView(prevCount === -1 ? 0 : 320));
}
}
function svgPill(g, x, y, text, hex, opts){
opts = opts || {};
const fs = opts.fs || 9.5;
const w = Math.max(24, text.length * fs * 0.62 + 14), h = fs + 9;
gg(g, x, y, text, hex, opts, w, h);
return w;
}
function gg(g, x, y, text, hex, opts, w, h){
const fs = opts.fs || 9.5;
const grp = g.append('g');
grp.append('rect').attr('x',x).attr('y',y-h/2).attr('width',w).attr('height',h).attr('rx',h/2)
.attr('fill', opts.fill || hex).attr('fill-opacity', opts.fill ? 1 : .16)
.attr('stroke', hex).attr('stroke-opacity', opts.fill ? .9 : .8).attr('stroke-width',1);
grp.append('text').attr('class','pill').attr('x', x + w/2).attr('y', y).attr('text-anchor','middle')
.attr('dominant-baseline','central').attr('font-size', fs).attr('fill', opts.textFill || hex).text(text);
}
function fitView(dur){
const holder = $('#graphHolder');
const w = holder.clientWidth || 900, h = holder.clientHeight || 500;
if(!layout) return;
const b = layout.bounds;
const cw = Math.max(240, b.maxX - b.minX), ch = Math.max(160, b.maxY - b.minY);
const k = clamp(Math.min(w/cw, h/ch) * 0.96, 0.7, 1.15);
const scH = ch*k;
const tx = (w - cw*k)/2 - b.minX*k;
const ty = (scH > h - 8) ? (14 - b.minY*k) : ((h - scH)/2 - b.minY*k);
const target = d3.zoomIdentity.translate(tx, ty).scale(k);
if(dur) svg.transition().duration(dur).call(zoom.transform, target);
else svg.call(zoom.transform, target);
}
function ensureVisible(id, dur){
if(!layout || !id) return;
const holder = $('#graphHolder');
const w = holder.clientWidth, h = holder.clientHeight;
const t = d3.zoomTransform(svg.node());
const p = layout.point(id);
const sx = t.applyX(p.x), sy = t.applyY(p.y);
if(sx > 120 && sx < w - 60 && sy > 30 && sy < h - 30) return;
const tx = w * 0.46 - p.x * t.k, ty = h * 0.5 - p.y * t.k;
const target = d3.zoomIdentity.translate(tx, ty).scale(t.k);
svg.transition().duration(dur || 420).call(zoom.transform, target);
}
/* ---- contextual flow ---- */
function sortedEvents(){
const rowOf = e => { const p = layout && layout.pos.get(e.commitId); return p ? p.row : 9999; };
return state.events.slice().sort((a,b) => rowOf(a) - rowOf(b) || String(a.date).localeCompare(String(b.date)) || String(a.title).localeCompare(String(b.title)));
}
function renderFlow(){
const inner = $('#flowInner');
const evs = sortedEvents();
if(!evs.length){
inner.innerHTML = '<div class="flow-empty">No contextual events yet — create one in the <b>Events</b> tab and anchor it to a commit.</div>';
return;
}
inner.innerHTML = evs.map(e => {
const t = tOf(e.type), c = commitById(e.commitId);
const sel = selectedId && selectedId === e.commitId;
return `<div class="flow-item${sel ? ' sel' : ''}" data-ev="${esc(e.id)}" data-commit="${esc(e.commitId)}" style="--c:${t.hex}">
<div class="fstem"></div><div class="fdot"></div>
<div class="fcard">
<div class="ftop"><span class="ftype">${t.glyph} ${esc(t.name)}</span><span class="fdate">${esc(e.date || '')}</span></div>
<div class="ftitle">${esc(e.title)}</div>
<div class="flink">⛓ <b>${esc(e.commitId)}</b><span>${esc(c ? trunc(c.message, 20) : '')}</span></div>
</div>
</div>`;
}).join('');
}
/* ---- sidebar lists ---- */
function renderLists(){
const byTs = state.commits.slice().sort((a,b) => (b.ts||0)-(a.ts||0));
$('#commitList').innerHTML = byTs.length ? byTs.map(c => {
const evn = eventsFor(c.id).length;
return `<div class="item${c.id === selectedId ? ' sel' : ''}" data-commit="${esc(c.id)}">
<span class="dot" style="background:${colorOf(c.branch)}"></span>
<div class="mid">
<div class="t">${esc(c.message)}</div>
<div class="s">${esc(c.id)} · ${esc(c.branch)} · ${esc(dstr(c.ts))}${evn ? ' · ⚑' + evn : ''}</div>
</div>
<div class="actions">
<button class="btn tiny" data-act="edit-commit" data-id="${esc(c.id)}" title="Edit">✎</button>
<button class="btn tiny danger" data-act="del-commit" data-id="${esc(c.id)}" title="Delete">✕</button>
</div>
</div>`;
}).join('') : '<div class="hint">No commits yet. Add one above to start the history.</div>';
$('#branchList').innerHTML = state.branches.length ? state.branches.map(b => {
const c = commitById(b.head);
const isHead = b.name === state.head;
return `<div class="item" data-branch="${esc(b.name)}">
<span class="dot" style="background:${colorOf(b.name)}"></span>
<div class="mid">
<div class="t">${esc(b.name)} ${isHead ? '<span class="badge" style="color:#7ee787;border-color:#2c5a34">HEAD</span>' : ''}</div>
<div class="s">→ ${esc(b.head || '∅')} ${esc(c ? trunc(c.message, 18) : '')}</div>
</div>
<div class="actions">
${isHead ? '' : `<button class="btn tiny" data-act="head-branch" data-name="${esc(b.name)}" title="Checkout (set HEAD)">⌂</button>`}
<button class="btn tiny" data-act="edit-branch" data-name="${esc(b.name)}" title="Edit">✎</button>
<button class="btn tiny danger" data-act="del-branch" data-name="${esc(b.name)}" title="Delete">✕</button>
</div>
</div>`;
}).join('') : '<div class="hint">No branches.</div>';
$('#tagList').innerHTML = state.tags.length ? state.tags.map(t => {
const c = commitById(t.commit);
return `<div class="item" data-tag="${esc(t.name)}">
<span class="dot" style="background:#e3b341;border-radius:3px"></span>
<div class="mid"><div class="t">${esc(t.name)}</div><div class="s">@ ${esc(t.commit)} ${esc(c ? trunc(c.message, 18) : '')}</div></div>
<div class="actions">
<button class="btn tiny" data-act="edit-tag" data-name="${esc(t.name)}" title="Edit">✎</button>
<button class="btn tiny danger" data-act="del-tag" data-name="${esc(t.name)}" title="Delete">✕</button>
</div>
</div>`;
}).join('') : '<div class="hint">No tags.</div>';
$('#eventList').innerHTML = sortedEvents().length ? sortedEvents().map(e => {
const t = tOf(e.type), c = commitById(e.commitId);
return `<div class="item${selectedId === e.commitId ? ' sel' : ''}" data-ev="${esc(e.id)}" data-commit="${esc(e.commitId)}">
<span class="dot" style="background:${t.hex};border-radius:3px"></span>
<div class="mid">
<div class="t">${t.glyph} ${esc(e.title)}</div>
<div class="s">${esc(t.name)} · ${esc(e.date || '')} · ⛓ ${esc(e.commitId)} ${esc(c ? trunc(c.message, 16) : '')}</div>
</div>
<div class="actions">
<button class="btn tiny" data-act="edit-event" data-id="${esc(e.id)}" title="Edit">✎</button>
<button class="btn tiny danger" data-act="del-event" data-id="${esc(e.id)}" title="Delete">✕</button>
</div>
</div>`;
}).join('') : '<div class="hint">No events yet.</div>';
}
/* ---- legend / inspector / tooltip ---- */
function renderLegend(){
const swatch = Object.keys(TYPES).map(k =>
`<div class="row"><span class="sw sq" style="background:${TYPES[k].hex}"></span>${esc(TYPES[k].name)}</div>`).join('');
const brs = state.branches.slice(0,6).map(b =>
`<div class="row"><span class="sw" style="background:${colorOf(b.name)}"></span>${esc(b.name)}</div>`).join('');
$('#legend').innerHTML =
`<div class="lg-title">Branches</div>${brs || '<div class="row">—</div>'}
<div class="lg-title" style="margin-top:6px">Event types</div>${swatch}`;
}
function renderInspector(){
const box = $('#inspector');
const c = selectedId ? commitById(selectedId) : null;
if(!c){ box.classList.remove('on'); box.innerHTML = ''; return; }
const evs = eventsFor(c.id);
const tags = state.tags.filter(t => t.commit === c.id);
const brs = state.branches.filter(b => b.head === c.id);
box.classList.add('on');
box.innerHTML = `
<div class="ihead">
<span class="dot" style="width:10px;height:10px;border-radius:50%;background:${colorOf(c.branch)}"></span>
<span class="ihash">${esc(c.id)}</span>
<span class="badge" style="color:${colorOf(c.branch)};border-color:${colorOf(c.branch)}">${esc(c.branch)}</span>
<button class="iclose" data-act="close-inspector">✕</button>
</div>
<h4>${esc(c.message)}</h4>
<div class="meta">${esc(c.author || 'dev')} · ${esc(dstr(c.ts))} · parents: ${(c.parents||[]).length ? (c.parents||[]).map(esc).join(', ') : '∅ (root)'}</div>
<div>${brs.map(b => `<span class="chip">⌂ ${esc(b.name)}${b.name === state.head ? ' · HEAD' : ''}</span>`).join('')}
${tags.map(t => `<span class="chip" style="color:#f2cd66;border-color:#4a3c14">⌗ ${esc(t.name)}</span>`).join('')}
<span class="chip" style="color:${evs.length ? '#bc8cff' : '#5d6f80'}">⚑ ${evs.length} event${evs.length === 1 ? '' : 's'}</span></div>
${evs.length ? `<div class="evlist">${evs.map(e => { const t = tOf(e.type);
return `<div class="ev" style="--c:${t.hex}"><div><div class="et">${t.glyph} ${esc(e.title)}</div>
<div class="es">${esc(t.name)} · ${esc(e.date || '')}${e.description ? ' — ' + esc(trunc(e.description, 70)) : ''}</div></div></div>`;
}).join('')}</div>` : `<div class="hint" style="margin-top:8px">No contextual events anchored here.</div>`}
`;
}
const tip = () => $('#tooltip');
function showTip(html, cx, cy){
const el = tip(), holder = $('#graphHolder').getBoundingClientRect();
el.innerHTML = html;
el.classList.add('on');
const w = el.offsetWidth, h = el.offsetHeight;
let x = cx - holder.left + 16, y = cy - holder.top + 14;
x = clamp(x, 8, holder.width - w - 8);
y = clamp(y, 8, Math.max(8, holder.height - h - 8));
el.style.left = x + 'px'; el.style.top = y + 'px';
}
function hideTip(){ tip().classList.remove('on'); }
function commitTip(c){
return `<div class="k">Commit</div>
<div class="tt-title">${esc(c.message)}</div>
<div class="tt-line"><b>${esc(c.id)}</b><span>${esc(c.branch)}</span></div>
<div class="tt-line"><span class="k" style="margin-right:4px">by</span>${esc(c.author || 'dev')} · ${esc(dstr(c.ts))}</div>
<div class="tt-line"><span class="k" style="margin-right:4px">parents</span>${(c.parents||[]).length ? (c.parents||[]).map(esc).join(', ') : 'root'}</div>
${eventsFor(c.id).length ? `<div class="tt-sep"><div class="k">Anchored events</div>${eventsFor(c.id).map(e => { const t = tOf(e.type); return `<div class="tt-line" style="color:${t.hex}">${t.glyph} ${esc(e.title)}</div>`; }).join('')}</div>` : ''}
<div class="tt-sep"><div class="k">click to reveal its contextual markers</div></div>`;
}
function eventTip(e){
const t = tOf(e.type), c = commitById(e.commitId);
return `<div class="tt-type" style="color:${t.hex}">${t.glyph} ${esc(t.name)}${e.branch ? ' · branch ' + esc(e.branch) : ''}</div>
<div class="tt-title">${esc(e.title)}</div>
${e.description ? `<div class="tt-desc">${esc(e.description)}</div>` : ''}
<div class="tt-sep">
<div class="k">Anchored to commit</div>
<div class="tt-line"><b>${esc(e.commitId)}</b><span>${esc(c ? trunc(c.message, 26) : 'missing')}</span></div>
<div class="tt-line" style="margin-top:2px"><span class="k">branch</span><span>${esc(c ? c.branch : '—')}</span><span class="k">date</span><span>${esc(e.date || '—')}</span></div>
</div>`;
}
/* ---- master render ---- */
function renderAll(){
sortState();
renderGraph();
renderFlow();
renderLists();
renderLegend();
renderInspector();
refreshSelects();
renderAnchorPreview();
}
function sortState(){
state.branches = state.branches.filter(b => b && b.name);
state.tags = state.tags.filter(t => t && t.name && commitById(t.commit));
state.events = state.events.filter(e => commitById(e.commitId));
}
function selectCommit(id){
selectedId = (selectedId === id) ? null : id;
renderGraph(); renderFlow(); renderLists(); renderInspector();
}
/* ============================================================
Forms, CRUD & wiring
============================================================ */
function flash(el){ if(!el) return; el.focus(); el.style.borderColor = '#f85149'; el.style.boxShadow = '0 0 0 3px #f8514933';
setTimeout(() => { el.style.borderColor = ''; el.style.boxShadow = ''; }, 1100); }
function byTsDesc(a,b){ return (b.ts||0)-(a.ts||0); }
function fillSelect(el, items, keep){
const prev = (keep !== undefined && keep !== null) ? keep : el.value;
el.innerHTML = '';
items.forEach(it => el.add(new Option(it.label, it.v)));
if(prev && Array.from(el.options).some(o => o.value === prev)) el.value = prev;
}
function refreshSelects(){
const commits = state.commits.slice().sort(byTsDesc);
$('#branchDL').innerHTML = state.branches.map(b => `<option value="${esc(b.name)}">`).join('');
fillSelect($('#bTarget'), commits.map(c => ({v:c.id, label:`${c.id} · ${trunc(c.message,30)}`})),
editing.branch && branchByName(editing.branch) ? branchByName(editing.branch).head : undefined);
fillSelect($('#tTarget'), commits.map(c => ({v:c.id, label:`${c.id} · ${trunc(c.message,30)}`})),
editing.tag ? (state.tags.find(t => t.name === editing.tag) || {}).commit : undefined);
const gsel = $('#eTargetGroup'), prev = gsel.value;
gsel.innerHTML = '';
const og1 = document.createElement('optgroup'); og1.label = 'Anchored to commit';
commits.forEach(c => og1.appendChild(new Option(`${c.id} · ${trunc(c.message,34)}`, 'commit:' + c.id)));
gsel.appendChild(og1);
if(state.branches.length){
const og2 = document.createElement('optgroup'); og2.label = 'Anchored to branch (follows its tip)';
state.branches.forEach(b => og2.appendChild(new Option(`${b.name} → ${b.head || '∅'}`, 'branch:' + b.name)));
gsel.appendChild(og2);
}
const evNow = editing.event ? state.events.find(e => e.id === editing.event) : null;
const want = evNow ? 'commit:' + evNow.commitId : prev;
if(want && Array.from(gsel.options).some(o => o.value === want)) gsel.value = want;
const tsel = $('#eType');
if(!tsel.options.length){
Object.keys(TYPES).forEach(k => tsel.add(new Option(TYPES[k].glyph + ' ' + TYPES[k].name, k)));
tsel.value = 'milestone';
}
}
function renderParentPicker(){
const editingId = editing.commit;
const banned = editingId ? new Set([editingId, ...descendants(editingId)]) : new Set();
const cur = editingId ? (commitById(editingId) || {parents:[]}).parents : [];
const checked = new Set(cur);
const list = state.commits.slice().sort(byTsDesc).filter(c => !banned.has(c.id));
$('#cParents').innerHTML = list.length ? list.map(c => `
<label class="pick"><input type="checkbox" value="${esc(c.id)}" ${checked.has(c.id) ? 'checked' : ''}>
<span class="pid">${esc(c.id)}</span>
<span class="dot" style="width:8px;height:8px;border-radius:50%;background:${colorOf(c.branch)}"></span>
<span class="pmsg">${esc(trunc(c.message, 34))}</span></label>`).join('')
: '<div class="hint" style="padding:5px">No other commits available.</div>';
}
function renderAnchorPreview(){
const el = $('#eAnchorPreview'); if(!el) return;
const v = $('#eTargetGroup').value || '';
let c = null, via = '';
if(v.startsWith('commit:')) c = commitById(v.slice(7));
else if(v.startsWith('branch:')){ const b = branchByName(v.slice(7)); c = b ? commitById(b.head) : null; via = ' via branch ' + v.slice(7); }
el.innerHTML = c
? `🔗 Anchored to commit <b style="color:#7ee787">${esc(c.id)}</b>${esc(via)} — “${esc(trunc(c.message, 44))}” on <b style="color:${colorOf(c.branch)}">${esc(c.branch)}</b>`
: 'Select a commit or branch to anchor this event.';
}
/* ---- commit form ---- */
function resetCommitForm(){
editing.commit = null;
$('#cFormTitle').textContent = 'New commit';
$('#cSave').textContent = 'Add commit';
$('#cCancel').classList.add('hidden');
$('#cMsg').value = '';
$('#cDate').value = dstr(Date.now());
$('#cParents').querySelectorAll('input').forEach(i => i.checked = false);
renderParentPicker();
}
function startEditCommit(id){
const c = commitById(id); if(!c) return;
editing.commit = id;
$('#cFormTitle').textContent = 'Edit commit ' + id;
$('#cSave').textContent = 'Save changes';
$('#cCancel').classList.remove('hidden');
$('#cMsg').value = c.message;
$('#cAuthor').value = c.author || 'dev';
$('#cDate').value = dstr(c.ts);
$('#cBranch').value = c.branch || 'main';
renderParentPicker();
switchTab('commits');
$('#cMsg').focus();
}
function saveCommit(){
const msg = $('#cMsg').value.trim();
if(!msg){ flash($('#cMsg')); return; }
const parents = $$('#cParents input:checked').map(i => i.value);
const branch = $('#cBranch').value.trim() || 'main';
const author = $('#cAuthor').value.trim() || 'dev';
const ts = tstr($('#cDate').value) || Date.now();
if(editing.commit){
const c = commitById(editing.commit);
if(!c){ flash($('#cMsg')); return; }
Object.assign(c, { message:msg, parents, branch, author, ts });
const b = branchByName(branch);
if(!b) state.branches.push({ name:branch, head:c.id });
} else {
let id; do { id = rnd(7); } while(commitById(id));
state.commits.push({ id, message:msg, parents, branch, author, ts });
if(!state.branches.some(b => b.name === branch)) state.branches.push({ name:branch, head:id });
else { const b = branchByName(branch); if(b) b.head = id; }
selectedId = id;
}
resetCommitForm(); save(); renderAll();
}
function deleteCommit(id){
const c = commitById(id); if(!c) return;
const kids = childrenOf(id), evs = eventsFor(id).length;
const reparent = (c.parents || []).join(', ') || 'root';
if(!confirm(`Delete commit ${id}?\n\n• ${kids.length} child commit(s) re-parented onto [${reparent}]\n• ${evs} anchored event(s) removed\n• branch pointers and tags on this commit are relinked`)) return;
state.commits = state.commits.filter(x => x.id !== id);
state.commits.forEach(x => {
const i = x.parents.indexOf(id);
if(i >= 0){
x.parents.splice(i, 1);
(c.parents || []).forEach(p => { if(p !== x.id && !x.parents.includes(p)) x.parents.push(p); });
}
});
state.events = state.events.filter(e => e.commitId !== id);
state.tags = state.tags.filter(t => t.commit !== id);
const fallback = (c.parents || [])[0] || (state.commits[0] ? state.commits[0].id : null);
state.branches.forEach(b => { if(b.head === id) b.head = fallback; });
state.branches = state.branches.filter(b => b.head);
if(selectedId === id) selectedId = null;
save(); renderAll();
}
/* ---- branch form ---- */
function resetBranchForm(){
editing.branch = null;
$('#bFormTitle').textContent = 'New branch';
$('#bSave').textContent = 'Add branch';
$('#bCancel').classList.add('hidden');
$('#bName').value = '';
}
function startEditBranch(name){
const b = branchByName(name); if(!b) return;
editing.branch = name;
$('#bFormTitle').textContent = 'Edit branch ' + name;
$('#bSave').textContent = 'Save changes';
$('#bCancel').classList.remove('hidden');
$('#bName').value = name;
refreshSelects();
if(b.head) $('#bTarget').value = b.head;
switchTab('branches');
$('#bName').focus();
}
function saveBranch(){
const name = $('#bName').value.trim();
if(!name){ flash($('#bName')); return; }
const head = $('#bTarget').value || (state.commits[0] ? state.commits[0].id : null);
if(editing.branch){
const b = branchByName(editing.branch);
if(!b){ editing.branch = null; return; }
if(name !== editing.branch){
if(branchByName(name)){ alert('A branch named “' + name + '” already exists.'); return; }
const old = editing.branch;
b.name = name;
state.commits.forEach(c => { if(c.branch === old) c.branch = name; });
state.events.forEach(e => { if(e.branch === old) e.branch = name; });
if(state.head === old) state.head = name;
}
b.head = head;
} else {
if(branchByName(name)){ alert('A branch named “' + name + '” already exists.'); return; }
state.branches.push({ name, head });
if(head) selectedId = head;
}
resetBranchForm(); save(); renderAll();
}
function deleteBranch(name){
if(!confirm('Delete branch “' + name + '”?\nThe commits themselves are kept.')) return;
state.branches = state.branches.filter(b => b.name !== name);
state.events.forEach(e => { if(e.branch === name) e.branch = null; });
if(state.head === name) state.head = state.branches[0] ? state.branches[0].name : 'main';
save(); renderAll();
}
/* ---- tag form ---- */
function resetTagForm(){
editing.tag = null;
$('#tFormTitle').textContent = 'New tag';
$('#tSave').textContent = 'Add tag';
$('#tCancel').classList.add('hidden');
$('#tName').value = '';
}
function startEditTag(name){
const t = state.tags.find(x => x.name === name); if(!t) return;
editing.tag = name;
$('#tFormTitle').textContent = 'Edit tag ' + name;
$('#tSave').textContent = 'Save changes';
$('#tCancel').classList.remove('hidden');
$('#tName').value = name;
refreshSelects();
$('#tTarget').value = t.commit;
switchTab('tags');
$('#tName').focus();
}
function saveTag(){
const name = $('#tName').value.trim();
if(!name){ flash($('#tName')); return; }
const commit = $('#tTarget').value;
if(!commit){ alert('Add a commit first.'); return; }
if(editing.tag){
const t = state.tags.find(x => x.name === editing.tag);
if(t){ t.name = name; t.commit = commit; }
} else {
if(state.tags.some(t => t.name === name)){ alert('Tag “' + name + '” already exists.'); return; }
state.tags.push({ name, commit });
}
resetTagForm(); save(); renderAll();
}
function deleteTag(name){
if(!confirm('Delete tag “' + name + '”?')) return;
state.tags = state.tags.filter(t => t.name !== name);
save(); renderAll();
}
/* ---- event form ---- */
function resetEventForm(){
editing.event = null;
$('#eFormTitle').textContent = 'New contextual event';
$('#eSave').textContent = 'Add event';
$('#eCancel').classList.add('hidden');
$('#eTitle').value = '';
$('#eDesc').value = '';
$('#eDate').value = dstr(Date.now());
renderAnchorPreview();
}
function startEditEvent(id){
const e = state.events.find(x => x.id === id); if(!e) return;
editing.event = id;
$('#eFormTitle').textContent = 'Edit event';
$('#eSave').textContent = 'Save changes';
$('#eCancel').classList.remove('hidden');
$('#eTitle').value = e.title;
$('#eType').value = e.type;
$('#eDesc').value = e.description || '';
$('#eDate').value = e.date || dstr(Date.now());
refreshSelects();
$('#eTargetGroup').value = 'commit:' + e.commitId;
renderAnchorPreview();
switchTab('events');
$('#eTitle').focus();
}
function saveEvent(){
const title = $('#eTitle').value.trim();
if(!title){ flash($('#eTitle')); return; }
const v = $('#eTargetGroup').value || '';
let commitId = null, branchName = null;
if(v.startsWith('commit:')) commitId = v.slice(7);
else if(v.startsWith('branch:')){ branchName = v.slice(7); const b = branchByName(branchName); commitId = b ? b.head : null; }
if(!commitId || !commitById(commitId)){ alert('Choose the commit (or branch) this event is anchored to.'); return; }
const payload = {
title, type: $('#eType').value, date: $('#eDate').value || dstr(Date.now()),
description: $('#eDesc').value.trim(), commitId, branch: branchName
};
if(editing.event){
const e = state.events.find(x => x.id === editing.event);
if(e) Object.assign(e, payload);
} else {
state.events.push(Object.assign({ id: uid('ev_') }, payload));
}
selectedId = commitId;
resetEventForm(); save(); renderAll();
}
function deleteEvent(id){
const e = state.events.find(x => x.id === id); if(!e) return;
if(!confirm('Delete event “' + e.title + '”?')) return;
state.events = state.events.filter(x => x.id !== id);
save(); renderAll();
}
/* ---- tabs ---- */
function switchTab(name){
$$('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
$$('.panel').forEach(p => p.classList.toggle('active', p.dataset.panel === name));
}
/* ---- modal ---- */
function closeModal(){ const o = $('#overlay'); if(o) o.remove(); }
function modal(title, bodyHTML, buttons){
closeModal();
const ov = document.createElement('div');
ov.className = 'overlay'; ov.id = 'overlay';
ov.innerHTML = `<div class="modal"><h3>${title}</h3><div class="mbody">${bodyHTML}</div><div class="mact"></div></div>`;
document.body.appendChild(ov);
const act = ov.querySelector('.mact');
buttons.forEach(b => {
const el = document.createElement('button');
el.className = 'btn ' + (b.cls || '');
el.textContent = b.label;
el.onclick = () => b.onClick(ov);
act.appendChild(el);
});
ov.addEventListener('mousedown', e => { if(e.target === ov) closeModal(); });
return ov;
}
function download(name, text){
const blob = new Blob([text], { type:'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 400);
}
function importState(text){
let parsed;
try { parsed = JSON.parse(text); }
catch(err){ alert('That is not valid JSON.\n\n' + err.message); return false; }
if(Array.isArray(parsed)) parsed = { commits: parsed, events: parsed.events || [] };
const next = normalize(parsed);
if(!next.commits.length && !next.events.length){ alert('No commits or events found in that data.'); return false; }
if(!next.branches.length && next.commits.length){
const newest = next.commits.slice().sort(byTsDesc)[0];
next.branches.push({ name:'main', head:newest.id });
}
next.branches.forEach(b => { if(!b.head) b.head = next.commits[0].id; });
state = next;
selectedId = null;
lastCommitCount = -1;
save(); renderAll(); closeModal();
return true;
}
function exportModal(){
modal('Export state — JSON', `
<div class="mnote">The complete unified state (commits, DAG parents, branches, tags and anchored events). Paste it back through <b>Import</b> to restore this exact view.</div>
<textarea class="mtext" id="jsonOut" spellcheck="false"></textarea>`,
[
{ label:'Copy', onClick: () => { const t = $('#jsonOut'); t.select(); try{ document.execCommand('copy'); }catch(e){} } },
{ label:'Download .json', cls:'primary', onClick: () => download('gitcontext-' + new Date().toISOString().slice(0,10) + '.json', $('#jsonOut').value) },
{ label:'Close', cls:'ghost', onClick: closeModal }
]);
$('#jsonOut').value = JSON.stringify(state, null, 2);
}
function importModal(){
modal('Import commits & contextual events', `
<div class="mnote">Paste JSON exported from here (or any object shaped like
<code>{ commits:[{ id, message, parents[], branch, ts }], branches:[…], tags:[…], events:[{ id, title, type, commitId, description, date }] }</code>).</div>
<input type="file" id="jsonFile" accept=".json,application/json">
<textarea class="mtext" id="jsonIn" spellcheck="false" placeholder='{"commits":[ … ],"events":[ … ]}'></textarea>`,
[
{ label:'Cancel', cls:'ghost', onClick: closeModal },
{ label:'Load & replace state', cls:'primary', onClick: () => { const t = $('#jsonIn').value.trim(); if(!t){ alert('Paste some JSON first.'); return; } importState(t); } }
]);
$('#jsonFile').addEventListener('change', ev => {
const f = ev.target.files && ev.target.files[0]; if(!f) return;
const r = new FileReader();
r.onload = () => { $('#jsonIn').value = r.result; };
r.readAsText(f);
});
}
/* ---- interaction wiring ---- */
const DENSITY = [ {row:62, lane:132}, {row:84, lane:158}, {row:46, lane:112} ];
let densityIdx = 0;
function wireUI(){
/* tabs */
$$('.tab').forEach(t => t.addEventListener('click', () => {
switchTab(t.dataset.tab);
document.querySelector('.sidebar').classList.remove('open');
}));
$('#btnMenu').addEventListener('click', () => document.querySelector('.sidebar').classList.toggle('open'));
/* buttons: commit */
$('#cSave').addEventListener('click', saveCommit);
$('#cCancel').addEventListener('click', () => { resetCommitForm(); });
$('#cMsg').addEventListener('keydown', e => { if(e.key === 'Enter') saveCommit(); });
/* branch */
$('#bSave').addEventListener('click', saveBranch);
$('#bCancel').addEventListener('click', resetBranchForm);
$('#bName').addEventListener('keydown', e => { if(e.key === 'Enter') saveBranch(); });
/* tag */
$('#tSave').addEventListener('click', saveTag);
$('#tCancel').addEventListener('click', resetTagForm);
$('#tName').addEventListener('keydown', e => { if(e.key === 'Enter') saveTag(); });
/* event */
$('#eSave').addEventListener('click', saveEvent);
$('#eCancel').addEventListener('click', resetEventForm);
$('#eTargetGroup').addEventListener('change', renderAnchorPreview);
/* list delegation */
$('.panels').addEventListener('click', ev => {
const btn = ev.target.closest('button[data-act]');
if(btn){
ev.stopPropagation();
const a = btn.dataset.act;
if(a === 'edit-commit') startEditCommit(btn.dataset.id);
else if(a === 'del-commit') deleteCommit(btn.dataset.id);
else if(a === 'edit-branch') startEditBranch(btn.dataset.name);
else if(a === 'del-branch') deleteBranch(btn.dataset.name);
else if(a === 'head-branch'){ state.head = btn.dataset.name; save(); renderAll(); }
else if(a === 'edit-tag') startEditTag(btn.dataset.name);
else if(a === 'del-tag') deleteTag(btn.dataset.name);
else if(a === 'edit-event') startEditEvent(btn.dataset.id);
else if(a === 'del-event') deleteEvent(btn.dataset.id);
return;
}
const item = ev.target.closest('.item');
if(item){
if(item.dataset.commit) setSelection(item.dataset.commit);
else if(item.dataset.ev){ const e = state.events.find(x => x.id === item.dataset.ev); if(e) setSelection(e.commitId); }
else if(item.dataset.branch){ const b = branchByName(item.dataset.branch); if(b && b.head) setSelection(b.head); }
}
});
/* graph interaction */
let downAt = null;
$('#graph').addEventListener('mousedown', e => { downAt = { x:e.clientX, y:e.clientY }; });
$('#graph').addEventListener('click', e => {
if(downAt && (Math.abs(e.clientX - downAt.x) + Math.abs(e.clientY - downAt.y)) > 4) return;
const m = e.target.closest('.evmark');
if(m){
const ev = state.events.find(x => x.id === m.dataset.ev);
setSelection(m.dataset.commit);
if(ev) showTip(eventTip(ev), e.clientX, e.clientY);
return;
}
const n = e.target.closest('.gnode');
if(n){ selectCommit(n.dataset.id); return; }
setSelection(null);
});
$('#graph').addEventListener('mousemove', e => {
const m = e.target.closest('.evmark');
if(m){ const ev = state.events.find(x => x.id === m.dataset.ev); if(ev){ showTip(eventTip(ev), e.clientX, e.clientY); return; } }
const n = e.target.closest('.gnode');
if(n){ const c = commitById(n.dataset.id); if(c){ showTip(commitTip(c), e.clientX, e.clientY); return; } }
hideTip();
});
$('#graph').addEventListener('mouseleave', hideTip);
/* flow interaction */
$('#flowInner').addEventListener('click', e => {
const it = e.target.closest('.flow-item'); if(!it) return;
setSelection(it.dataset.commit);
});
$('#flowInner').addEventListener('mouseover', e => {
const it = e.target.closest('.flow-item'); if(!it) return;
const ev = state.events.find(x => x.id === it.dataset.ev);
if(ev){ const r = it.getBoundingClientRect(); showTip(eventTip(ev), r.left + r.width/2, r.top); }
});
$('#flowInner').addEventListener('mouseout', e => { if(e.target.closest('.flow-item')) hideTip(); });
/* inspector */
$('#inspector').addEventListener('click', e => {
if(e.target.closest('[data-act=close-inspector]')) setSelection(null);
});
/* toolbar */
$('#btnExport').addEventListener('click', exportModal);
$('#btnImport').addEventListener('click', importModal);
$('#btnFit').addEventListener('click', () => fitView(320));
$('#btnDagre').addEventListener('click', () => {
densityIdx = (densityIdx + 1) % DENSITY.length;
ROW_GAP = DENSITY[densityIdx].row; LANE_GAP = DENSITY[densityIdx].lane;
renderAll(); fitView(340);
});
$('#btnSample').addEventListener('click', () => {
if(state.commits.length && !confirm('Replace the current state with the sample repository?')) return;
state = sampleState(); selectedId = null; lastCommitCount = -1; save(); renderAll();
});
$('#btnClear').addEventListener('click', () => {
if(!confirm('Delete all commits, branches, tags and events?')) return;
state = freshState(); selectedId = null; lastCommitCount = -1; save(); renderAll();
});
document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ if($('#overlay')) closeModal(); else setSelection(null); }
});
}
function setSelection(id){
selectedId = id;
renderGraph(); renderFlow(); renderLists(); renderInspector(); renderLegend();
if(id) ensureVisible(id, 380);
}
/* ---- boot ---- */
(function init(){
initGraph();
const saved = load();
state = saved || sampleState();
if(!saved) save();
if(!state.branches.length && state.commits.length){
state.branches.push({ name:'main', head: state.commits.slice().sort(byTsDesc)[0].id });
}
if(!state.head || !branchByName(state.head)) state.head = state.branches[0] ? state.branches[0].name : 'main';
resetCommitForm(); resetBranchForm(); resetTagForm(); resetEventForm();
wireUI();
renderAll();
window.addEventListener('beforeunload', save);
window.GitContext = {
get state(){ return state; },
setSelection, selectCommit, renderAll, importState, fitView,
loadSample(){ state = sampleState(); selectedId = null; lastCommitCount = -1; save(); renderAll(); },
clearAll(){ state = freshState(); selectedId = null; lastCommitCount = -1; save(); renderAll(); },
layout: () => layout, save
};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"94434600-d56c-4a4b-a53d-3f1307a952fb","tokensIn":2754092,"tokensOut":62092,"tokensTotal":2816184,"cost":0.05899620000000001,"turns":32,"toolCalls":42,"failedToolCalls":0,"timestamp":"2026-09-10T23:40:43.429Z"} -->