Git Explorer
muse spark 1.2 contributor · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 56,515 bytes · SHA-256 e4a1f2ebb76e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Git & Structure Visualizer — DAG + Context</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0e14;
--panel:#111827;
--panel2:#1a2234;
--panel3:#1e2a44;
--border:#243044;
--border2:#2d3d56;
--text:#e2e8f0;
--muted:#94a3b8;
--dim:#64748b;
--accent:#38bdf8;
--accent2:#818cf8;
--green:#22c55e;
--yellow:#eab308;
--orange:#f97316;
--pink:#ec4899;
--red:#ef4444;
--violet:#8b5cf6;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);overflow:hidden;height:100vh;display:flex;flex-direction:column}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-thumb{background:#2d3d56;border-radius:99px}
::-webkit-scrollbar-track{background:transparent}
/* Header */
header{
height:56px;background:rgba(17,24,39,0.9);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;padding:0 16px;gap:12px;flex-shrink:0;z-index:10
}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#38bdf8,#818cf8);display:grid;place-items:center;font-size:18px}
.brand h1{font-size:15px;font-weight:800;letter-spacing:-0.02em;line-height:1}
.brand p{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace}
.header-actions{display:flex;align-items:center;gap:8px}
.btn{font-family:'Inter',sans-serif;font-size:12.5px;font-weight:600;padding:7px 12px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--text);cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:.15s}
.btn:hover{background:var(--panel3);border-color:var(--border2);transform:translateY(-1px)}
.btn.primary{background:linear-gradient(135deg,#0ea5e9,#6366f1);border-color:transparent;color:white}
.btn.primary:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent}
.btn.small{padding:5px 9px;font-size:11.5px}
.btn.icon{width:32px;height:32px;padding:0;justify-content:center}
.badge{font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;background:var(--panel3);border:1px solid var(--border);color:var(--muted);font-family:'JetBrains Mono',monospace}
/* Layout */
.app{flex:1;display:grid;grid-template-columns:300px 1fr 320px;overflow:hidden}
@media(max-width:1100px){.app{grid-template-columns:280px 1fr} .right{display:none}}
@media(max-width:720px){.app{grid-template-columns:1fr} .left{display:none} header{height:auto;padding:10px;flex-wrap:wrap}}
/* Panels */
.left,.right{background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.right{border-right:none;border-left:1px solid var(--border)}
.panel-head{padding:14px 14px 10px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0}
.panel-head h2{font-size:11px;font-weight:800;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted)}
.panel-body{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:14px}
/* Cards */
.card{background:var(--panel2);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.card-head{padding:10px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);background:rgba(255,255,255,0.02)}
.card-head h3{font-size:12px;font-weight:700}
.card-body{padding:12px}
/* Branch list */
.branch-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;border:1px solid transparent;cursor:pointer;transition:.15s}
.branch-item:hover{background:var(--panel3);border-color:var(--border)}
.branch-item.active{background:rgba(56,189,248,0.08);border-color:rgba(56,189,248,0.3)}
.branch-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 3px rgba(255,255,255,0.06)}
.branch-meta{flex:1;min-width:0}
.branch-name{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.branch-head{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace}
.branch-actions{display:flex;gap:4px}
/* Forms */
.form-grid{display:grid;gap:8px}
label{font-size:11px;font-weight:600;color:var(--muted);letter-spacing:0.04em;text-transform:uppercase}
input,select,textarea{width:100%;background:#0f172a;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 10px;font-size:13px;font-family:'Inter',sans-serif;outline:none;transition:.15s}
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,0.15)}
textarea{resize:vertical;min-height:60px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.hint{font-size:11px;color:var(--dim);line-height:1.4}
/* Center */
.center{display:flex;flex-direction:column;overflow:hidden;background:radial-gradient(1200px 600px at 50% -10%, rgba(56,189,248,0.06), transparent 60%), radial-gradient(800px 400px at 90% 90%, rgba(129,140,248,0.07), transparent 60%), var(--bg)}
.graph-toolbar{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border);background:rgba(17,24,39,0.6);backdrop-filter:blur(8px);flex-shrink:0;gap:8px}
.toolbar-left,.toolbar-right{display:flex;align-items:center;gap:8px}
.legend{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted)}
.legend i{width:10px;height:10px;border-radius:50%;display:inline-block}
.graph-wrap{flex:1;position:relative;overflow:auto;background:var(--bg);scrollbar-width:thin;min-height:0;padding:12px 12px 120px 4px;box-sizing:border-box}
#gitGraph{display:block;overflow:visible}
.graph-overlay{position:absolute;top:10px;left:10px;display:flex;gap:6px;z-index:2;pointer-events:none}
.mini-stat{background:rgba(15,23,42,0.85);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--muted)}
.mini-stat b{color:var(--text)}
/* Tooltip */
.tooltip{position:fixed;pointer-events:none;z-index:50;max-width:320px;background:#0f172a;border:1px solid var(--border2);border-radius:12px;padding:12px;box-shadow:0 20px 40px rgba(0,0,0,0.5);opacity:0;transform:translateY(4px);transition:.15s}
.tooltip.show{opacity:1;transform:translateY(0)}
.tooltip h4{font-size:13px;font-weight:700;margin-bottom:4px}
.tooltip p{font-size:12px;color:var(--muted);line-height:1.5}
.tooltip .meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--dim);margin-top:6px}
/* Contextual Flow */
.flow{height:170px;border-top:1px solid var(--border);background:rgba(17,24,39,0.7);backdrop-filter:blur(8px);flex-shrink:0;display:flex;flex-direction:column}
.flow-head{height:36px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border);flex-shrink:0}
.flow-head h3{font-size:11px;font-weight:800;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted)}
.flow-track{flex:1;overflow:auto;padding:10px 12px;display:flex;gap:10px;align-items:stretch}
.event-chip{flex-shrink:0;width:220px;background:var(--panel2);border:1px solid var(--border);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:.15s;position:relative}
.event-chip:hover{border-color:var(--border2);transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,0.3)}
.event-chip.active{border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,0.15), 0 8px 20px rgba(0,0,0,0.3)}
.event-chip.anchored::before{content:'';position:absolute;top:-1px;left:12px;right:12px;height:3px;border-radius:0 0 6px 6px}
.event-top{display:flex;align-items:center;gap:8px}
.event-icon{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-size:14px;flex-shrink:0}
.event-title{font-size:12.5px;font-weight:700;line-height:1.2}
.event-commit{font-family:'JetBrains Mono',monospace;font-size:10.5px;color:var(--muted);display:flex;align-items:center;gap:4px}
.event-desc{font-size:11.5px;color:var(--muted);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.event-tag{font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;border:1px solid var(--border);align-self:flex-start}
/* Right details */
.detail-empty{flex:1;display:grid;place-items:center;padding:20px;text-align:center;color:var(--muted)}
.detail-empty div{max-width:240px}
.commit-detail{padding:14px;display:flex;flex-direction:column;gap:12px}
.commit-hash{font-family:'JetBrains Mono',monospace;font-size:12px;background:#0f172a;border:1px solid var(--border);padding:6px 8px;border-radius:8px;display:flex;align-items:center;justify-content:space-between}
.commit-msg{font-size:14px;font-weight:700;line-height:1.4}
.commit-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.meta-box{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:8px}
.meta-box span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;font-weight:700}
.meta-box p{font-size:12px;font-weight:600;margin-top:2px;font-family:'JetBrains Mono',monospace}
.anchored-list{display:flex;flex-direction:column;gap:8px}
.anchored-item{background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:10px;display:flex;gap:10px;align-items:flex-start}
.anchored-item .icon{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;flex-shrink:0}
/* D3 styles */
.link{fill:none;stroke-width:2.5;opacity:0.9}
.link.merge{stroke-dasharray:0}
.commit-node{cursor:pointer;transition:.15s}
.commit-node:hover{filter:brightness(1.2)}
.commit-node.selected circle{stroke:white;stroke-width:2.5;filter:drop-shadow(0 0 8px rgba(255,255,255,0.5))}
.commit-label{font-family:'JetBrains Mono',monospace;font-size:10px;fill:var(--muted);pointer-events:none}
.commit-label-bg{fill:rgba(10,14,20,0.85);stroke:var(--border);stroke-width:0.5;rx:4}
.event-anchor{cursor:pointer}
.event-anchor:hover{filter:brightness(1.15)}
.event-connector{stroke-width:1.5;stroke-dasharray:4 4;opacity:0.6}
/* Modal */
.modal-backdrop{position:fixed;inset:0;background:rgba(2,6,23,0.7);backdrop-filter:blur(6px);display:none;place-items:center;z-index:40;padding:16px}
.modal-backdrop.open{display:grid}
.modal{background:var(--panel);border:1px solid var(--border2);border-radius:16px;width:min(520px,100%);max-height:90vh;overflow:auto;box-shadow:0 24px 60px rgba(0,0,0,0.6)}
.modal-head{padding:16px 16px 0;display:flex;align-items:center;justify-content:space-between}
.modal-head h3{font-size:15px;font-weight:800}
.modal-body{padding:16px;display:grid;gap:12px}
.modal-foot{padding:12px 16px 16px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);margin-top:4px}
/* Empty */
.empty{padding:16px;text-align:center;color:var(--muted);font-size:12px}
/* Tag pill */
.tag-pill{font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;background:rgba(234,179,8,0.15);border:1px solid rgba(234,179,8,0.3);color:#facc15;font-family:'JetBrains Mono',monospace}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">◈</div>
<div>
<h1>Git Structure Visualizer</h1>
<p>DAG + Contextual Anchors • D3.js</p>
</div>
<span class="badge" id="statsBadge">0 commits • 0 events</span>
</div>
<div class="header-actions">
<button class="btn ghost small" onclick="importData()">⤒ Import</button>
<button class="btn ghost small" onclick="exportData()">⤓ Export</button>
<button class="btn ghost small" onclick="resetData()">↺ Reset</button>
<button class="btn primary small" onclick="openModal('commit')">+ New Commit</button>
<input type="file" id="fileInput" accept=".json" style="display:none">
</div>
</header>
<div class="app">
<!-- LEFT -->
<aside class="left">
<div class="panel-head">
<h2>Workspace</h2>
<button class="btn small ghost" onclick="openModal('branch')" title="New branch">+ Branch</button>
</div>
<div class="panel-body">
<!-- Branches -->
<div class="card">
<div class="card-head">
<h3>⎇ Branches</h3>
<span class="badge" id="branchCount">0</span>
</div>
<div class="card-body" style="padding:8px">
<div id="branchList"></div>
</div>
</div>
<!-- Quick commit -->
<div class="card">
<div class="card-head"><h3>◉ Create Commit</h3></div>
<div class="card-body">
<div class="form-grid">
<label>Message</label>
<input id="quickMsg" placeholder="feat: add authentication">
<div class="row">
<div><label>Branch</label><select id="quickBranch"></select></div>
<div><label>Parent(s)</label><select id="quickParent"></select></div>
</div>
<button class="btn primary" onclick="quickCommit()">Commit to branch</button>
<p class="hint">Creates a new commit on the selected branch. For merges, use “New Commit” and pick two parents.</p>
</div>
</div>
</div>
<!-- Events -->
<div class="card">
<div class="card-head">
<h3>◆ Contextual Events</h3>
<button class="btn small" onclick="openModal('event')">+ Add</button>
</div>
<div class="card-body" style="padding:0">
<div id="eventList" style="max-height:260px;overflow:auto"></div>
</div>
</div>
<!-- Tags -->
<div class="card">
<div class="card-head"><h3>🏷 Tags</h3><button class="btn small ghost" onclick="openModal('tag')">+ Tag</button></div>
<div class="card-body" id="tagList" style="padding:8px"></div>
</div>
</div>
</aside>
<!-- CENTER -->
<main class="center">
<div class="graph-toolbar">
<div class="toolbar-left">
<div class="legend">
<span><i style="background:#38bdf8"></i> main</span>
<span><i style="background:#22c55e"></i> feature</span>
<span><i style="background:#f97316"></i> hotfix</span>
<span><i style="background:#a78bfa"></i> release</span>
<span style="margin-left:8px;color:var(--dim)">— parent ⇢ merge</span>
</div>
</div>
<div class="toolbar-right">
<button class="btn small ghost" onclick="zoomFit()">⊡ Fit</button>
<button class="btn small ghost" onclick="toggleLabels()" id="labelBtn">Labels: On</button>
<button class="btn small" onclick="openModal('event')">◆ Anchor Event</button>
</div>
</div>
<div class="graph-wrap" id="graphWrap">
<div class="graph-overlay">
<div class="mini-stat"><b id="commitCount">0</b> commits</div>
<div class="mini-stat"><b id="eventCount">0</b> anchored</div>
<div class="mini-stat" id="selectedInfo" style="display:none"></div>
</div>
<svg id="gitGraph"></svg>
</div>
<div class="flow">
<div class="flow-head">
<h3>Contextual Flow — Events anchored to commits</h3>
<span class="badge" id="flowBadge">drag to scroll • click to focus</span>
</div>
<div class="flow-track" id="flowTrack"></div>
</div>
</main>
<!-- RIGHT -->
<aside class="right">
<div class="panel-head">
<h2>Inspector</h2>
<span class="badge" id="inspectorBadge">select a node</span>
</div>
<div id="inspector" style="flex:1;overflow:auto;display:flex;flex-direction:column">
<div class="detail-empty" id="emptyState">
<div>
<div style="font-size:32px;margin-bottom:8px">◈</div>
<p style="font-weight:700;color:var(--text)">No selection</p>
<p style="font-size:12px;margin-top:6px;line-height:1.5">Click any commit node on the graph. Its anchored contextual markers will highlight in the flow below and details appear here.</p>
<div style="margin-top:12px;display:flex;gap:6px;justify-content:center;flex-wrap:wrap">
<span class="badge">hover anchor → tooltip</span>
<span class="badge">click chip → focus</span>
</div>
</div>
</div>
<div id="detailView" style="display:none"></div>
</div>
</aside>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip"></div>
<!-- Modals -->
<div class="modal-backdrop" id="modalCommit">
<div class="modal">
<div class="modal-head"><h3>New Commit</h3><button class="btn icon ghost" onclick="closeModals()">✕</button></div>
<div class="modal-body">
<label>Commit message</label><input id="m_msg" placeholder="feat(auth): add OAuth flow">
<div class="row">
<div><label>Author</label><input id="m_author" placeholder="Alex Chen"></div>
<div><label>Branch</label><select id="m_branch"></select></div>
</div>
<div class="row">
<div><label>Parent 1 (required)</label><select id="m_parent1"></select></div>
<div><label>Parent 2 (merge, optional)</label><select id="m_parent2"><option value="">— none —</option></select></div>
</div>
<label>Anchor event (optional, auto-creates)</label>
<div class="row">
<div><input id="m_eventTitle" placeholder="e.g., Code Freeze"></div>
<div><select id="m_eventType"><option value="milestone">Milestone</option><option value="review">Review</option><option value="release">Release</option><option value="incident">Incident</option><option value="decision">Decision</option></select></div>
</div>
<p class="hint">Tip: select two parents to create a merge commit. The graph will draw a merging path.</p>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModals()">Cancel</button><button class="btn primary" onclick="createCommitModal()">Create Commit</button></div>
</div>
</div>
<div class="modal-backdrop" id="modalBranch">
<div class="modal">
<div class="modal-head"><h3>New Branch</h3><button class="btn icon ghost" onclick="closeModals()">✕</button></div>
<div class="modal-body">
<label>Branch name</label><input id="b_name" placeholder="feature/payments">
<label>Start from commit</label><select id="b_from"></select>
<label>Color</label><select id="b_color"><option value="#38bdf8">Ocean (main)</option><option value="#22c55e">Green (feature)</option><option value="#f97316">Orange (hotfix)</option><option value="#a78bfa">Violet (release)</option><option value="#ec4899">Pink</option><option value="#eab308">Amber</option></select>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModals()">Cancel</button><button class="btn primary" onclick="createBranch()">Create Branch</button></div>
</div>
</div>
<div class="modal-backdrop" id="modalEvent">
<div class="modal">
<div class="modal-head"><h3>Anchor Contextual Event</h3><button class="btn icon ghost" onclick="closeModals()">✕</button></div>
<div class="modal-body">
<label>Title *</label><input id="e_title" placeholder="Code Freeze — v2.0">
<div class="row">
<div><label>Type</label><select id="e_type"><option value="milestone">Milestone</option><option value="review">Review</option><option value="release">Release</option><option value="incident">Incident</option><option value="decision">Decision</option><option value="deploy">Deploy</option></select></div>
<div><label>Association — Commit *</label><select id="e_commit"></select></div>
</div>
<label>Description</label><textarea id="e_desc" placeholder="Context about this marker and why it matters at this point in history..."></textarea>
<label>Spatial reference (optional)</label><input id="e_ref" placeholder="e.g., Sprint 12, Q3, Gate 3">
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModals()">Cancel</button><button class="btn primary" onclick="createEvent()">Anchor Event</button></div>
</div>
</div>
<div class="modal-backdrop" id="modalTag">
<div class="modal">
<div class="modal-head"><h3>New Tag</h3><button class="btn icon ghost" onclick="closeModals()">✕</button></div>
<div class="modal-body">
<label>Tag name</label><input id="t_name" placeholder="v1.2.0">
<label>Commit</label><select id="t_commit"></select>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModals()">Cancel</button><button class="btn primary" onclick="createTag()">Create Tag</button></div>
</div>
</div>
<div class="modal-backdrop" id="modalEditCommit">
<div class="modal">
<div class="modal-head"><h3>Edit Commit</h3><button class="btn icon ghost" onclick="closeModals()">✕</button></div>
<div class="modal-body">
<label>Message</label><input id="ec_msg">
<div class="row">
<div><label>Author</label><input id="ec_author"></div>
<div><label>Branch</label><select id="ec_branch"></select></div>
</div>
<input type="hidden" id="ec_id">
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModals()">Cancel</button><button class="btn primary" onclick="saveEditCommit()">Save</button></div>
</div>
</div>
<script>
const STORAGE_KEY='gitVisualizer_v2';
const BRANCH_COLORS={main:'#38bdf8','feature/login':'#22c55e','feature/api':'#22c55e','feature/payments':'#22c55e',hotfix:'#f97316',release:'#a78bfa'};
let state={
commits:[],
branches:[],
events:[],
tags:[]
};
let selectedId=null;
let showLabels=true;
const EVENT_STYLES={
milestone:{icon:'◆',bg:'#0ea5e9',color:'#e0f2fe',label:'Milestone'},
review:{icon:'◎',bg:'#8b5cf6',color:'#ede9fe',label:'Review'},
release:{icon:'⬢',bg:'#22c55e',color:'#dcfce7',label:'Release'},
incident:{icon:'⚠',bg:'#ef4444',color:'#fee2e2',label:'Incident'},
decision:{icon:'⬡',bg:'#eab308',color:'#fef9c3',label:'Decision'},
deploy:{icon:'🚀',bg:'#06b6d4',color:'#cffafe',label:'Deploy'}
};
function uid(prefix='c'){return prefix+Math.random().toString(36).slice(2,7)+Date.now().toString(36).slice(-3)}
function short(id){return id.slice(0,7)}
// Initial data
function seed(){
const commits=[
{id:'c_a1b2c3d',message:'Initial commit — project scaffold',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*14,parents:[],branch:'main'},
{id:'c_b2c3d4e',message:'feat: add routing & layout',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*13,parents:['c_a1b2c3d'],branch:'main'},
{id:'c_c3d4e5f',message:'feat: user model & DB schema',author:'Sam Rivera',timestamp:Date.now()-1000*60*60*24*12,parents:['c_b2c3d4e'],branch:'main'},
{id:'c_d4e5f6g',message:'feat(login): OAuth form UI',author:'Sam Rivera',timestamp:Date.now()-1000*60*60*24*11,parents:['c_c3d4e5f'],branch:'feature/login'},
{id:'c_e5f6g7h',message:'feat(login): JWT middleware',author:'Sam Rivera',timestamp:Date.now()-1000*60*60*24*10,parents:['c_d4e5f6g'],branch:'feature/login'},
{id:'c_f6g7h8i',message:'feat(api): REST endpoints',author:'Jordan Kim',timestamp:Date.now()-1000*60*60*24*11,parents:['c_c3d4e5f'],branch:'feature/api'},
{id:'c_g7h8i9j',message:'feat(api): pagination & caching',author:'Jordan Kim',timestamp:Date.now()-1000*60*60*24*9,parents:['c_f6g7h8i'],branch:'feature/api'},
{id:'c_h8i9j0k',message:'fix: sanitize inputs',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*9,parents:['c_c3d4e5f'],branch:'hotfix'},
{id:'c_i9j0k1l',message:'Merge feature/login → main',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*8,parents:['c_b2c3d4e','c_e5f6g7h'],branch:'main'},
{id:'c_j0k1l2m',message:'Merge feature/api → main',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*7,parents:['c_i9j0k1l','c_g7h8i9j'],branch:'main'},
{id:'c_k1l2m3n',message:'Merge hotfix → main',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*6,parents:['c_j0k1l2m','c_h8i9j0k'],branch:'main'},
{id:'c_l2m3n4o',message:'chore: bump to v1.2.0-rc',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*5,parents:['c_k1l2m3n'],branch:'release'},
{id:'c_m3n4o5p',message:'docs: release notes',author:'Taylor Wu',timestamp:Date.now()-1000*60*60*24*4,parents:['c_l2m3n4o'],branch:'release'},
{id:'c_n4o5p6q',message:'Merge release → main (v1.2.0)',author:'Alex Chen',timestamp:Date.now()-1000*60*60*24*2,parents:['c_k1l2m3n','c_m3n4o5p'],branch:'main'},
];
const branches=[
{name:'main',head:'c_n4o5p6q',color:'#38bdf8'},
{name:'feature/login',head:'c_e5f6g7h',color:'#22c55e'},
{name:'feature/api',head:'c_g7h8i9j',color:'#22c55e'},
{name:'hotfix',head:'c_h8i9j0k',color:'#f97316'},
{name:'release',head:'c_m3n4o5p',color:'#a78bfa'},
];
const events=[
{id:'e1',title:'Sprint Kickoff — Auth & API',type:'milestone',commitId:'c_c3d4e5f',ref:'Sprint 11',description:'Team splits into two streams: login UI and REST API. Branching strategy agreed.'},
{id:'e2',title:'Security Review',type:'review',commitId:'c_h8i9j0k',ref:'Gate 2',description:'Hotfix for input sanitization flagged in pen-test. Must merge before RC.'},
{id:'e3',title:'Feature Completion — Login',type:'milestone',commitId:'c_e5f6g7h',ref:'Sprint 11',description:'OAuth + JWT complete, ready for integration.'},
{id:'e4',title:'API Freeze',type:'decision',commitId:'c_g7h8i9j',ref:'Sprint 12',description:'Pagination & caching finalized. No further API changes until release.'},
{id:'e5',title:'Code Freeze — v1.2.0',type:'milestone',commitId:'c_k1l2m3n',ref:'Q3 Release',description:'All features merged to main. Main is now frozen for RC.'},
{id:'e6',title:'Release Candidate',type:'release',commitId:'c_l2m3n4o',ref:'v1.2.0-rc',description:'RC cut on release branch. QA sign-off pending.'},
{id:'e7',title:'Production Deploy',type:'deploy',commitId:'c_n4o5p6q',ref:'Prod',description:'v1.2.0 merged and deployed. Tag created, release notes published.'},
{id:'e8',title:'Post-mortem Incident #42',type:'incident',commitId:'c_h8i9j0k',ref:'INC-42',description:'XSS vector discovered in legacy form. Hotfix branch created same day.'},
];
const tags=[
{name:'v1.1.0',commitId:'c_b2c3d4e'},
{name:'v1.2.0-rc',commitId:'c_l2m3n4o'},
{name:'v1.2.0',commitId:'c_n4o5p6q'},
];
return {commits,branches,events,tags};
}
function load(){
try{
const raw=localStorage.getItem(STORAGE_KEY);
if(raw){ state=JSON.parse(raw); }
else { state=seed(); save(); }
}catch(e){ state=seed(); }
}
function save(){ localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); updateBadges(); }
function updateBadges(){
document.getElementById('statsBadge').textContent=`${state.commits.length} commits • ${state.events.length} events`;
document.getElementById('commitCount').textContent=state.commits.length;
document.getElementById('eventCount').textContent=state.events.length;
document.getElementById('branchCount').textContent=state.branches.length;
}
// Layout computation
function computeLayout(){
// order commits topologically by timestamp
const sorted=[...state.commits].sort((a,b)=>a.timestamp-b.timestamp);
const idToIndex=new Map(sorted.map((c,i)=>[c.id,i]));
// lanes: assign each branch a lane index
const branchNames=[...new Set(state.branches.map(b=>b.name))];
// ensure all commit branches are represented
state.commits.forEach(c=>{ if(!branchNames.includes(c.branch)) branchNames.push(c.branch); });
const laneMap=new Map(branchNames.map((n,i)=>[n,i]));
const laneCount=Math.max(branchNames.length,1);
const xFor=(commit)=>{
const lane=laneMap.get(commit.branch) ?? 0;
return 70 + lane * 130;
};
const yFor=(commit)=>{
const idx=idToIndex.get(commit.id);
return 52 + idx * 46;
};
// also need to handle commits whose parents are on different lanes -> x is already lane-based
return {sorted, xFor, yFor, laneMap, laneCount, idToIndex};
}
// Color helper
function branchColor(name){
const b=state.branches.find(x=>x.name===name);
if(b) return b.color;
return BRANCH_COLORS[name] || '#64748b';
}
// Rendering
let svg, g, zoomBehavior;
function initGraph(){
svg=d3.select('#gitGraph');
const wrap=document.getElementById('graphWrap');
g=svg.append('g').attr('class','graph-g');
zoomBehavior=d3.zoom().scaleExtent([0.4,2]).on('zoom',(e)=>{ g.attr('transform',e.transform); });
svg.call(zoomBehavior);
window.addEventListener('resize',()=> renderGraph());
}
function renderAll(){
renderBranches();
renderEventList();
renderTagList();
renderQuickSelects();
renderGraph();
renderFlow();
updateBadges();
}
function renderBranches(){
const el=document.getElementById('branchList');
el.innerHTML=state.branches.map(b=>{
const isActive=b.name==='main';
return `<div class="branch-item ${selectedId && state.commits.find(c=>c.id===selectedId)?.branch===b.name?'active':''}" onclick="focusBranch('${b.name}')">
<span class="branch-dot" style="background:${b.color}"></span>
<div class="branch-meta"><div class="branch-name">${b.name}</div><div class="branch-head">→ ${short(b.head)}</div></div>
<div class="branch-actions">
<button class="btn small ghost" onclick="event.stopPropagation(); deleteBranch('${b.name}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || `<div class="empty">No branches</div>`;
}
function renderEventList(){
const el=document.getElementById('eventList');
if(!state.events.length){ el.innerHTML=`<div class="empty">No events anchored yet</div>`; return; }
// sort by commit order
const order=new Map(state.commits.map((c,i)=>[c.id,i]));
const sorted=[...state.events].sort((a,b)=>(order.get(a.commitId)??999)-(order.get(b.commitId)??999));
el.innerHTML=sorted.map(ev=>{
const s=EVENT_STYLES[ev.type]||EVENT_STYLES.milestone;
const commit=state.commits.find(c=>c.id===ev.commitId);
const isActive=selectedId===ev.commitId;
return `<div class="branch-item" style="align-items:flex-start;cursor:pointer;${isActive?'background:rgba(56,189,248,0.08);border-color:rgba(56,189,248,0.3)':''}" onclick="selectCommit('${ev.commitId}')">
<span class="event-icon" style="background:${s.bg};color:white;font-size:11px">${s.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:700;line-height:1.2">${ev.title}</div>
<div style="font-size:11px;color:var(--muted)">${s.label} • <span style="font-family:'JetBrains Mono',monospace">${commit?short(commit.id):'—'}</span> ${ev.ref?`• ${ev.ref}`:''}</div>
<div style="font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${ev.description||''}</div>
</div>
<button class="btn small ghost" onclick="event.stopPropagation(); deleteEvent('${ev.id}')">✕</button>
</div>`;
}).join('');
}
function renderTagList(){
const el=document.getElementById('tagList');
if(!state.tags.length){ el.innerHTML=`<div class="empty" style="padding:8px">No tags</div>`; return; }
el.innerHTML=state.tags.map(t=>{
const c=state.commits.find(x=>x.id===t.commitId);
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--panel);border:1px solid var(--border);border-radius:8px;margin-bottom:6px">
<span class="tag-pill">${t.name}</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">${c?short(c.id):'—'} ${c?c.message.slice(0,24):''}</span>
<button class="btn small ghost" style="margin-left:auto" onclick="deleteTag('${t.name}')">✕</button>
</div>`;
}).join('');
}
function renderQuickSelects(){
const branches=state.branches;
const commits=state.commits;
const optsB=branches.map(b=>`<option value="${b.name}">${b.name}</option>`).join('');
const optsC=commits.map(c=>`<option value="${c.id}">${short(c.id)} — ${c.message.slice(0,30)}</option>`).join('');
const qB=document.getElementById('quickBranch'); if(qB) qB.innerHTML=optsB;
const qP=document.getElementById('quickParent'); if(qP) qP.innerHTML=optsC;
const mB=document.getElementById('m_branch'); if(mB) mB.innerHTML=optsB;
const mP1=document.getElementById('m_parent1'); if(mP1) mP1.innerHTML=optsC;
const mP2=document.getElementById('m_parent2'); if(mP2) mP2.innerHTML='<option value="">— none —</option>'+optsC;
const bFrom=document.getElementById('b_from'); if(bFrom) bFrom.innerHTML=optsC;
const eCommit=document.getElementById('e_commit'); if(eCommit) eCommit.innerHTML=optsC;
const tCommit=document.getElementById('t_commit'); if(tCommit) tCommit.innerHTML=optsC;
const ecBranch=document.getElementById('ec_branch'); if(ecBranch) ecBranch.innerHTML=optsB;
}
function renderFlow(){
const track=document.getElementById('flowTrack');
if(!state.events.length){ track.innerHTML=`<div class="empty" style="align-self:center">No contextual events — anchor one to a commit</div>`; return; }
const order=new Map(state.commits.map((c,i)=>[c.id,i]));
const sorted=[...state.events].sort((a,b)=>(order.get(a.commitId)??999)-(order.get(b.commitId)??999));
track.innerHTML=sorted.map(ev=>{
const s=EVENT_STYLES[ev.type]||EVENT_STYLES.milestone;
const isActive=selectedId===ev.commitId;
return `<div class="event-chip ${isActive?'active':''} anchored" style="border-top-color:${s.bg}" onclick="selectCommit('${ev.commitId}')" onmouseenter="hoverEvent('${ev.id}',true)" onmouseleave="hoverEvent('${ev.id}',false)">
<div class="event-top">
<span class="event-icon" style="background:${s.bg};color:white">${s.icon}</span>
<div style="flex:1;min-width:0">
<div class="event-title">${ev.title}</div>
<div class="event-commit"><span style="width:8px;height:8px;border-radius:50%;background:${branchColor(state.commits.find(c=>c.id===ev.commitId)?.branch)};display:inline-block"></span> ${short(ev.commitId)} ${ev.ref?`• ${ev.ref}`:''}</div>
</div>
</div>
<div class="event-desc">${ev.description||'No description'}</div>
<span class="event-tag" style="background:${s.bg}18;border-color:${s.bg}40;color:${s.bg}">${s.label}</span>
</div>`;
}).join('');
document.getElementById('flowBadge').textContent=`${sorted.length} events • ${selectedId?'linked to '+short(selectedId):'click to focus'}`;
}
function renderGraph(){
if(!svg) return;
const {sorted, xFor, yFor, laneMap} = computeLayout();
const neededW = laneMap.size*130+580;
const height=Math.max(1080, sorted.length*46+520);
svg.attr('viewBox',null).attr('width', neededW).attr('height', height);
g.selectAll('*').remove();
// defs for gradients
const defs=g.append('defs');
// draw links
const links=[];
sorted.forEach(c=>{
c.parents.forEach(pid=>{
const parent=state.commits.find(x=>x.id===pid);
if(parent) links.push({source:parent,target:c});
});
});
// links group
const linkG=g.append('g').attr('class','links');
links.forEach(l=>{
const x1=xFor(l.source), y1=yFor(l.source);
const x2=xFor(l.target), y2=yFor(l.target);
const isMerge=l.target.parents.length>1;
// bezier: vertical then horizontal
const midY=(y1+y2)/2;
let path;
if(x1===x2){
path=`M ${x1} ${y1+14} L ${x2} ${y2-14}`;
} else {
// curved merge
path=`M ${x1} ${y1+14} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2-14}`;
}
linkG.append('path')
.attr('d',path)
.attr('class','link'+(isMerge?' merge':''))
.attr('stroke', isMerge ? '#a78bfa' : branchColor(l.target.branch))
.attr('stroke-opacity', isMerge?0.9:0.55)
.attr('stroke-width', isMerge?2.5:2);
// arrow for merge?
if(isMerge){
linkG.append('circle').attr('cx',x2).attr('cy',y2-14).attr('r',2).attr('fill','#a78bfa');
}
});
// branch lane labels
const laneG=g.append('g');
[...laneMap.entries()].forEach(([name,idx])=>{
const x=70+idx*130;
laneG.append('rect').attr('x',x-36).attr('y',8).attr('width',72).attr('height',16).attr('rx',8).attr('fill',branchColor(name)).attr('opacity',0.14).attr('stroke',branchColor(name)).attr('stroke-opacity',0.25);
laneG.append('text').attr('x',x).attr('y',19).attr('text-anchor','middle').attr('font-size','9px').attr('font-family','JetBrains Mono,monospace').attr('fill',branchColor(name)).attr('font-weight','800').text(name);
laneG.append('line').attr('x1',x).attr('x2',x).attr('y1',28).attr('y2',height-10).attr('stroke',branchColor(name)).attr('stroke-opacity',0.1).attr('stroke-width',1.5).attr('stroke-dasharray','8 8');
});
// event connectors & anchors
const eventByCommit=new Map();
state.events.forEach(ev=>{
if(!eventByCommit.has(ev.commitId)) eventByCommit.set(ev.commitId,[]);
eventByCommit.get(ev.commitId).push(ev);
});
// nodes group
const nodeG=g.append('g').attr('class','nodes');
sorted.forEach(c=>{
const x=xFor(c), y=yFor(c);
const isSelected=selectedId===c.id;
const hasEvents=eventByCommit.has(c.id);
const color=branchColor(c.branch);
const events=eventByCommit.get(c.id)||[];
const ng=nodeG.append('g')
.attr('class','commit-node'+(isSelected?' selected':''))
.attr('transform',`translate(${x},${y})`)
.on('click',()=>selectCommit(c.id))
.on('mouseenter',(e)=>showCommitTooltip(e,c))
.on('mouseleave',hideTooltip)
.on('mousemove',moveTooltip);
// halo for selected
if(isSelected){
ng.append('circle').attr('r',18).attr('fill',color).attr('opacity',0.15);
}
// commit circle
ng.append('circle').attr('r',hasEvents?11:9).attr('fill',color).attr('stroke','#0a0e14').attr('stroke-width',2);
// inner dot for merge
if(c.parents.length>1){
ng.append('circle').attr('r',3).attr('fill','white').attr('opacity',0.95);
} else {
ng.append('circle').attr('r',2.5).attr('fill','white').attr('opacity',0.9);
}
// tag indicator
const tag=state.tags.find(t=>t.commitId===c.id);
if(tag){
ng.append('g').attr('transform','translate(14,-14)').call(g=>{
g.append('rect').attr('x',-2).attr('y',-8).attr('width', tag.name.length*6+10).attr('height',14).attr('rx',6).attr('fill','#eab308').attr('stroke','#0a0e14').attr('stroke-width',1);
g.append('text').attr('x',3).attr('y',2).attr('font-size','8px').attr('font-family','JetBrains Mono,monospace').attr('font-weight','700').attr('fill','#0a0e14').text(tag.name);
});
}
// labels — placed below-right to avoid anchor collision
if(showLabels){
const maxLen = 30;
const label=c.message.length>maxLen?c.message.slice(0,maxLen-1)+'…':c.message;
const tw=label.length*5.6+20;
const hashW=52;
const bg=ng.append('g').attr('transform','translate(16,14)');
bg.append('rect').attr('x',0).attr('y',-9).attr('width',tw+hashW).attr('height',18).attr('rx',6).attr('fill', isSelected?'#1e293b':'#0f172a').attr('stroke', isSelected?'#334155':'#1e293b').attr('stroke-width',1).attr('opacity',1);
bg.append('text').attr('x',8).attr('y',3).text(label).attr('fill', isSelected?'#f1f5f9':'#e2e8f0').attr('font-size','11px').attr('font-family','Inter,sans-serif').attr('font-weight', isSelected?'700':'500');
bg.append('rect').attr('x',tw+4).attr('y',-6).attr('width',hashW-6).attr('height',12).attr('rx',4).attr('fill','#1e293b').attr('stroke','#334155').attr('stroke-width',0.5);
bg.append('text').attr('x',tw+8).attr('y',3).attr('font-size','8px').attr('font-family','JetBrains Mono,monospace').attr('fill','#94a3b8').attr('font-weight','600').text(short(c.id));
}
// event anchors — above the node to avoid label overlap
events.forEach((ev,idx)=>{
const s=EVENT_STYLES[ev.type]||EVENT_STYLES.milestone;
const ax= 28 + idx*34;
const ay= -24;
// connector — short diagonal from node to anchor
g.append('line')
.attr('class','event-connector')
.attr('x1',x+10).attr('y1',y-10)
.attr('x2',x+ax).attr('y2',y+ay+10)
.attr('stroke',s.bg)
.attr('opacity', isSelected?0.85:0.45);
const ag=g.append('g')
.attr('class','event-anchor')
.attr('transform',`translate(${x+ax},${y+ay})`)
.on('click',(e)=>{e.stopPropagation(); selectCommit(c.id);})
.on('mouseenter',(e)=>showEventTooltip(e,ev,c))
.on('mouseleave',hideTooltip)
.on('mousemove',moveTooltip);
// diamond/pill
ag.append('rect').attr('x',-14).attr('y',-10).attr('width',28).attr('height',20).attr('rx',6).attr('fill',s.bg).attr('stroke','white').attr('stroke-width', isSelected?1.5:0.8).attr('opacity', isSelected?1:0.95);
ag.append('text').attr('text-anchor','middle').attr('y',4).attr('font-size','11px').attr('fill','white').text(s.icon);
// small label below
if(events.length===1){
ag.append('text').attr('text-anchor','middle').attr('y',22).attr('font-size','8px').attr('font-family','JetBrains Mono,monospace').attr('fill',s.bg).attr('font-weight','700').text(ev.type.toUpperCase());
}
});
});
// update inspector highlight
document.getElementById('selectedInfo').style.display=selectedId?'block':'none';
if(selectedId){
const c=state.commits.find(x=>x.id===selectedId);
document.getElementById('selectedInfo').innerHTML=`● <b style="color:${branchColor(c.branch)}">${c.branch}</b> ${short(c.id)} • ${c.message.slice(0,24)}`;
}
}
// Tooltips
const tooltip=document.getElementById('tooltip');
function showCommitTooltip(e,c){
const tag=state.tags.find(t=>t.commitId===c.id);
const evs=state.events.filter(ev=>ev.commitId===c.id);
tooltip.innerHTML=`
<h4 style="font-family:'JetBrains Mono',monospace">${short(c.id)} <span style="color:${branchColor(c.branch)}">⎇ ${c.branch}</span></h4>
<p style="color:var(--text);font-weight:600">${c.message}</p>
<p>by ${c.author} • ${new Date(c.timestamp).toLocaleDateString()} • parents: ${c.parents.length?c.parents.map(short).join(', '):'— root'}</p>
${tag?`<div class="meta"><span class="tag-pill">${tag.name}</span> tagged</div>`:''}
${evs.length?`<div class="meta" style="color:#38bdf8">◆ ${evs.length} anchored event(s): ${evs.map(x=>x.title).join(', ')}</div>`:''}
<div class="meta">Click to inspect • Hover anchor for event details</div>
`;
tooltip.classList.add('show');
moveTooltip(e);
}
function showEventTooltip(e,ev,c){
const s=EVENT_STYLES[ev.type]||EVENT_STYLES.milestone;
tooltip.innerHTML=`
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="width:28px;height:28px;border-radius:8px;background:${s.bg};display:grid;place-items:center;color:white">${s.icon}</span><h4>${ev.title}</h4></div>
<p>${ev.description||'No description'}</p>
<div class="meta">Type: ${s.label} • Ref: ${ev.ref||'—'}</div>
<div class="meta" style="color:${branchColor(c.branch)}">Anchored to commit <b>${short(c.id)}</b> on <b>${c.branch}</b> — "${c.message}"</div>
<div class="meta">Spatial link confirmed ✓</div>
`;
tooltip.classList.add('show');
moveTooltip(e);
}
function moveTooltip(e){
const x=e.clientX+14, y=e.clientY+14;
const r=tooltip.getBoundingClientRect();
let nx=x, ny=y;
if(nx+r.width>window.innerWidth-10) nx=e.clientX - r.width -14;
if(ny+r.height>window.innerHeight-10) ny=e.clientY - r.height -14;
tooltip.style.left=nx+'px';
tooltip.style.top=ny+'px';
}
function hideTooltip(){ tooltip.classList.remove('show'); }
function hoverEvent(id,enter){
if(!enter) return hideTooltip();
const ev=state.events.find(x=>x.id===id);
if(!ev) return;
const c=state.commits.find(x=>x.id===ev.commitId);
// fake event for tooltip
const fake={clientX: window.innerWidth/2, clientY: 200};
showEventTooltip(fake,ev,c);
}
// Selection
function selectCommit(id){
selectedId=id;
renderGraph();
renderFlow();
renderBranches();
renderEventList();
showInspector(id);
save();
}
function focusBranch(name){
const head=state.branches.find(b=>b.name===name)?.head;
if(head) selectCommit(head);
}
function showInspector(id){
const c=state.commits.find(x=>x.id===id);
if(!c){ document.getElementById('emptyState').style.display='grid'; document.getElementById('detailView').style.display='none'; return; }
document.getElementById('emptyState').style.display='none';
const dv=document.getElementById('detailView');
dv.style.display='block';
const evs=state.events.filter(e=>e.commitId===id);
const tag=state.tags.find(t=>t.commitId===id);
const children=state.commits.filter(x=>x.parents.includes(id));
dv.innerHTML=`
<div class="commit-detail">
<div class="commit-hash"><span>${short(c.id)} • ${c.id}</span><button class="btn small ghost" onclick="copyHash('${c.id}')">copy</button></div>
<div class="commit-msg">${c.message}</div>
${tag?`<div><span class="tag-pill">${tag.name}</span></div>`:''}
<div class="commit-meta">
<div class="meta-box"><span>Branch</span><p style="color:${branchColor(c.branch)}">${c.branch}</p></div>
<div class="meta-box"><span>Author</span><p>${c.author}</p></div>
<div class="meta-box"><span>Date</span><p style="font-size:11px">${new Date(c.timestamp).toLocaleString()}</p></div>
<div class="meta-box"><span>Parents</span><p>${c.parents.length?c.parents.map(short).join(', '):'— root'}</p></div>
</div>
${children.length?`<div class="meta-box"><span>Children</span><p>${children.map(x=>short(x.id)).join(', ')}</p></div>`:''}
<div style="display:flex;gap:6px">
<button class="btn small" onclick="openEditCommit('${c.id}')">✎ Edit</button>
<button class="btn small ghost" onclick="deleteCommit('${c.id}')" style="color:var(--red);border-color:rgba(239,68,68,0.3)">Delete</button>
<button class="btn small ghost" onclick="openModal('event','${c.id}')">◆ Anchor Event</button>
</div>
<div>
<h4 style="font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px">Anchored Contextual Events (${evs.length})</h4>
<div class="anchored-list">
${evs.length? evs.map(ev=>{
const s=EVENT_STYLES[ev.type];
return `<div class="anchored-item" style="border-color:${s.bg}40">
<span class="icon" style="background:${s.bg};color:white">${s.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:700">${ev.title}</div>
<div style="font-size:11px;color:var(--muted)">${s.label} • ${ev.ref||'no ref'}</div>
<div style="font-size:11px;color:var(--dim);margin-top:4px">${ev.description||''}</div>
</div>
<button class="btn small ghost" onclick="deleteEvent('${ev.id}')">✕</button>
</div>`;
}).join('') : `<div class="empty" style="border:1px dashed var(--border);border-radius:8px">No events anchored here. <a href="#" onclick="openModal('event','${c.id}');return false" style="color:var(--accent)">Anchor one →</a></div>`}
</div>
</div>
</div>
`;
document.getElementById('inspectorBadge').textContent=short(c.id);
}
// CRUD
function quickCommit(){
const msg=document.getElementById('quickMsg').value.trim();
const branch=document.getElementById('quickBranch').value;
const parent=document.getElementById('quickParent').value;
if(!msg) return alert('Message required');
if(!branch) return alert('Branch required');
const id=uid('c_');
const commit={id, message:msg, author:'You', timestamp:Date.now(), parents: parent?[parent]:[], branch};
// update branch head
const b=state.branches.find(x=>x.name===branch);
if(b) b.head=id; else state.branches.push({name:branch,head:id,color:'#22c55e'});
state.commits.push(commit);
document.getElementById('quickMsg').value='';
save(); renderAll(); selectCommit(id);
}
function createCommitModal(){
const msg=document.getElementById('m_msg').value.trim();
const author=document.getElementById('m_author').value.trim()||'You';
const branch=document.getElementById('m_branch').value;
const p1=document.getElementById('m_parent1').value;
const p2=document.getElementById('m_parent2').value;
const evTitle=document.getElementById('m_eventTitle').value.trim();
const evType=document.getElementById('m_eventType').value;
if(!msg) return alert('Message required');
if(!p1) return alert('Parent 1 required');
const id=uid('c_');
const parents=[p1]; if(p2) parents.push(p2);
const commit={id,message:msg,author,timestamp:Date.now(),parents,branch};
state.commits.push(commit);
const b=state.branches.find(x=>x.name===branch);
if(b) b.head=id; else state.branches.push({name:branch,head:id,color:branchColor(branch)});
if(evTitle){
state.events.push({id:uid('e'),title:evTitle,type:evType,commitId:id,ref:'',description:`Auto-anchored at commit ${short(id)}`});
}
closeModals(); save(); renderAll(); selectCommit(id);
document.getElementById('m_msg').value=''; document.getElementById('m_eventTitle').value='';
}
function createBranch(){
const name=document.getElementById('b_name').value.trim();
const from=document.getElementById('b_from').value;
const color=document.getElementById('b_color').value;
if(!name) return alert('Name required');
if(state.branches.find(b=>b.name===name)) return alert('Branch exists');
if(!from) return alert('Pick a commit');
state.branches.push({name,head:from,color});
closeModals(); save(); renderAll();
document.getElementById('b_name').value='';
}
function createEvent(){
const title=document.getElementById('e_title').value.trim();
const type=document.getElementById('e_type').value;
const commitId=document.getElementById('e_commit').value;
const desc=document.getElementById('e_desc').value.trim();
const ref=document.getElementById('e_ref').value.trim();
if(!title) return alert('Title required');
if(!commitId) return alert('Commit association required');
state.events.push({id:uid('e'),title,type,commitId,description:desc,ref});
closeModals(); save(); renderAll(); selectCommit(commitId);
document.getElementById('e_title').value=''; document.getElementById('e_desc').value=''; document.getElementById('e_ref').value='';
}
function createTag(){
const name=document.getElementById('t_name').value.trim();
const commitId=document.getElementById('t_commit').value;
if(!name) return alert('Name required');
state.tags.push({name,commitId});
closeModals(); save(); renderAll();
document.getElementById('t_name').value='';
}
function deleteBranch(name){
if(name==='main') return alert('Cannot delete main');
if(!confirm(`Delete branch "${name}"? Commits remain.`)) return;
state.branches=state.branches.filter(b=>b.name!==name);
save(); renderAll();
}
function deleteEvent(id){
state.events=state.events.filter(e=>e.id!==id);
save(); renderAll(); if(selectedId) showInspector(selectedId);
}
function deleteTag(name){
state.tags=state.tags.filter(t=>t.name!==name);
save(); renderAll();
}
function deleteCommit(id){
if(!confirm('Delete this commit? Children that reference it will become roots. Anchored events will be removed.')) return;
state.commits=state.commits.filter(c=>c.id!==id);
// remove parent refs
state.commits.forEach(c=>{ c.parents=c.parents.filter(p=>p!==id); });
state.events=state.events.filter(e=>e.commitId!==id);
state.tags=state.tags.filter(t=>t.commitId!==id);
// fix branch heads
state.branches.forEach(b=>{ if(b.head===id){ const remaining=state.commits.filter(c=>c.branch===b.name); b.head=remaining.length?remaining[remaining.length-1].id: (state.commits[0]?.id||'') }});
selectedId=null;
document.getElementById('emptyState').style.display='grid';
document.getElementById('detailView').style.display='none';
save(); renderAll();
}
function openEditCommit(id){
const c=state.commits.find(x=>x.id===id);
if(!c) return;
document.getElementById('ec_id').value=c.id;
document.getElementById('ec_msg').value=c.message;
document.getElementById('ec_author').value=c.author;
document.getElementById('ec_branch').value=c.branch;
openModal('editCommit');
}
function saveEditCommit(){
const id=document.getElementById('ec_id').value;
const c=state.commits.find(x=>x.id===id);
if(!c) return;
c.message=document.getElementById('ec_msg').value.trim()||c.message;
c.author=document.getElementById('ec_author').value.trim()||c.author;
const newBranch=document.getElementById('ec_branch').value;
if(newBranch!==c.branch){
c.branch=newBranch;
const b=state.branches.find(x=>x.name===newBranch);
if(!b) state.branches.push({name:newBranch,head:id,color:'#22c55e'});
}
closeModals(); save(); renderAll(); selectCommit(id);
}
function copyHash(id){ navigator.clipboard.writeText(id); }
// Modals
function openModal(kind, presetCommit){
closeModals();
if(kind==='commit') document.getElementById('modalCommit').classList.add('open');
if(kind==='branch') document.getElementById('modalBranch').classList.add('open');
if(kind==='event'){ document.getElementById('modalEvent').classList.add('open'); if(presetCommit) document.getElementById('e_commit').value=presetCommit; }
if(kind==='tag') document.getElementById('modalTag').classList.add('open');
if(kind==='editCommit') document.getElementById('modalEditCommit').classList.add('open');
}
function closeModals(){ document.querySelectorAll('.modal-backdrop').forEach(m=>m.classList.remove('open')); }
document.querySelectorAll('.modal-backdrop').forEach(m=> m.addEventListener('click',e=>{ if(e.target===m) closeModals(); }));
// Import / Export / Reset
function exportData(){
const blob=new Blob([JSON.stringify(state,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a'); a.href=url; a.download='git-visualizer-state.json'; a.click(); URL.revokeObjectURL(url);
}
function importData(){ document.getElementById('fileInput').click(); }
document.getElementById('fileInput').addEventListener('change',e=>{
const file=e.target.files[0]; if(!file) return;
const reader=new FileReader();
reader.onload=()=>{
try{
const data=JSON.parse(reader.result);
if(data.commits && data.branches){ state=data; if(!state.tags) state.tags=[]; if(!state.events) state.events=[]; save(); renderAll(); alert('Imported successfully'); }
else alert('Invalid file: missing commits/branches');
}catch(err){ alert('Invalid JSON'); }
};
reader.readAsText(file);
e.target.value='';
});
function resetData(){
if(!confirm('Reset to seeded demo data? LocalStorage will be overwritten.')) return;
state=seed(); save(); selectedId=null; renderAll();
document.getElementById('emptyState').style.display='grid'; document.getElementById('detailView').style.display='none';
}
// Helpers
function zoomFit(){
svg.transition().duration(350).call(zoomBehavior.transform, d3.zoomIdentity.translate(10,10).scale(1));
// also scroll container to top-left
document.getElementById('graphWrap').scrollTo({top:0, left:0, behavior:'smooth'});
}
function toggleLabels(){ showLabels=!showLabels; document.getElementById('labelBtn').textContent='Labels: '+(showLabels?'On':'Off'); renderGraph(); }
// Init
load();
initGraph();
renderAll();
// auto-select a commit with events to demonstrate anchoring
setTimeout(()=>selectCommit('c_k1l2m3n'), 300);
// drag scroll for flow
const track=document.getElementById('flowTrack');
let isDown=false, startX, scrollLeft;
track.addEventListener('mousedown',e=>{isDown=true; track.style.cursor='grabbing'; startX=e.pageX-track.offsetLeft; scrollLeft=track.scrollLeft});
track.addEventListener('mouseleave',()=>{isDown=false; track.style.cursor='grab'});
track.addEventListener('mouseup',()=>{isDown=false; track.style.cursor='grab'});
track.addEventListener('mousemove',e=>{if(!isDown) return; e.preventDefault(); const x=e.pageX-track.offsetLeft; const walk=(x-startX)*1.2; track.scrollLeft=scrollLeft-walk});
track.style.cursor='grab';
</script>
</body>
</html>