Git Explorer
Minimax m3 · 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 65,458 bytes · SHA-256 969d45091091
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Git & Context DAG Visualizer</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2128;
--border: #30363d;
--text: #c9d1d9;
--text-dim: #8b949e;
--accent: #58a6ff;
--accent-2: #f78166;
--green: #3fb950;
--purple: #bc8cff;
--orange: #ffa657;
--red: #f85149;
--yellow: #d29922;
--pink: #ff7b72;
--shadow: 0 4px 14px rgba(0,0,0,0.5);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh; overflow: hidden;
}
#app {
display: grid;
grid-template-columns: 320px 1fr 360px;
grid-template-rows: 50px 1fr;
grid-template-areas:
"header header header"
"left center right";
height: 100vh;
width: 100vw;
}
header {
grid-area: header;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
z-index: 10;
}
header h1 {
margin: 0; font-size: 16px; font-weight: 600;
display: flex; align-items: center; gap: 10px;
}
header h1 .logo {
width: 24px; height: 24px;
background: linear-gradient(135deg, var(--accent), var(--purple));
border-radius: 6px;
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 14px; font-weight: 700;
}
.header-actions { display: flex; gap: 8px; align-items: center; }
.btn {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
display: inline-flex; align-items: center; gap: 6px;
}
.btn:hover { background: #2d333b; border-color: #444c56; }
.btn-primary { background: var(--accent); color: #0d1117; border-color: var(--accent); }
.btn-primary:hover { background: #79b8ff; }
.btn-danger { color: var(--red); border-color: rgba(248,81,73,0.4); }
.btn-danger:hover { background: rgba(248,81,73,0.15); }
.btn-sm { padding: 3px 8px; font-size: 11px; }
#left {
grid-area: left;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
}
#center {
grid-area: center;
background: var(--bg);
position: relative;
overflow: hidden;
}
#right {
grid-area: right;
background: var(--panel);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
font-weight: 600;
letter-spacing: 0.5px;
margin: 12px 0 8px 0;
display: flex; align-items: center; justify-content: space-between;
}
.section-title:first-child { margin-top: 0; }
.branch-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.branch-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.branch-item:hover { border-color: var(--accent); }
.branch-item.active { border-color: var(--accent); background: rgba(88,166,255,0.1); }
.branch-color {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0;
}
.branch-name { flex: 1; font-weight: 500; }
.branch-sha { color: var(--text-dim); font-family: monospace; font-size: 10px; }
.event-list { display: flex; flex-direction: column; gap: 6px; }
.event-card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s;
font-size: 12px;
position: relative;
}
.event-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.event-card.selected { border-color: var(--accent); background: rgba(88,166,255,0.08); }
.event-card .event-title {
font-weight: 600;
margin-bottom: 4px;
display: flex; align-items: center; gap: 6px;
}
.event-card .event-title .pin {
color: var(--accent-2);
font-size: 10px;
}
.event-card .event-meta {
color: var(--text-dim);
font-size: 11px;
font-family: monospace;
}
.event-card .event-desc {
margin-top: 4px;
color: var(--text);
font-size: 11px;
line-height: 1.4;
}
#graph-container {
width: 100%; height: 100%;
position: relative;
}
#graph-svg {
width: 100%; height: 100%;
cursor: grab;
}
#graph-svg:active { cursor: grabbing; }
.tooltip {
position: absolute;
background: rgba(28, 33, 40, 0.98);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
pointer-events: none;
font-size: 12px;
max-width: 280px;
box-shadow: var(--shadow);
z-index: 100;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title {
font-weight: 600;
color: var(--accent);
margin-bottom: 4px;
}
.tooltip-meta {
font-size: 10px;
color: var(--text-dim);
font-family: monospace;
margin-bottom: 6px;
}
.tooltip-commit {
background: rgba(88,166,255,0.1);
border: 1px solid rgba(88,166,255,0.3);
border-radius: 4px;
padding: 4px 6px;
margin-top: 4px;
font-size: 10px;
font-family: monospace;
}
.form-row {
display: flex; flex-direction: column; gap: 4px;
margin-bottom: 8px;
}
.form-row label {
font-size: 11px; color: var(--text-dim); font-weight: 500;
}
.input, .select, .textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
width: 100%;
}
.input:focus, .select:focus, .textarea:focus {
outline: none;
border-color: var(--accent);
}
.textarea { resize: vertical; min-height: 60px; font-family: inherit; }
.form-actions {
display: flex; gap: 6px; margin-top: 10px;
}
.form-actions .btn { flex: 1; justify-content: center; }
.selected-commit-info {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
margin-top: 10px;
font-size: 12px;
}
.selected-commit-info .label {
font-size: 10px; color: var(--text-dim); text-transform: uppercase;
font-weight: 600; letter-spacing: 0.5px;
}
.selected-commit-info .value {
font-family: monospace; color: var(--accent);
font-size: 11px;
}
.selected-commit-info .msg {
font-size: 12px; margin-top: 6px;
line-height: 1.4;
}
.empty-state {
color: var(--text-dim);
font-size: 11px;
text-align: center;
padding: 12px;
font-style: italic;
}
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
display: none;
align-items: center; justify-content: center;
}
.modal-backdrop.visible { display: flex; }
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
width: 480px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal h2 {
margin: 0 0 16px 0;
font-size: 16px;
display: flex; align-items: center; justify-content: space-between;
}
.modal h2 .close-btn {
background: none; border: none; color: var(--text-dim);
cursor: pointer; font-size: 18px; padding: 0;
}
.modal h2 .close-btn:hover { color: var(--text); }
.commit-node circle {
cursor: pointer;
transition: r 0.15s, stroke-width 0.15s;
}
.commit-node:hover circle {
stroke-width: 3px;
}
.commit-node.highlighted circle {
stroke: var(--accent);
stroke-width: 4px;
filter: drop-shadow(0 0 8px var(--accent));
}
.anchor-badge {
cursor: pointer;
transition: transform 0.2s;
}
.anchor-badge:hover {
transform: scale(1.2);
}
.branch-label {
font-size: 10px;
font-weight: 600;
font-family: monospace;
}
.commit-id-label {
font-size: 9px;
fill: var(--text-dim);
font-family: monospace;
pointer-events: none;
}
.commit-msg-label {
font-size: 11px;
fill: var(--text);
pointer-events: none;
}
.link {
fill: none;
stroke-width: 2;
opacity: 0.7;
}
.zoom-controls {
position: absolute;
bottom: 16px; right: 16px;
display: flex; flex-direction: column;
gap: 4px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px;
z-index: 5;
}
.zoom-controls .btn {
padding: 4px 8px;
font-size: 14px;
min-width: 28px;
justify-content: center;
}
.legend {
position: absolute;
bottom: 16px; left: 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 11px;
z-index: 5;
box-shadow: var(--shadow);
}
.legend-item {
display: flex; align-items: center; gap: 6px;
margin: 2px 0;
}
.legend-dot {
width: 10px; height: 10px; border-radius: 50%;
}
.storage-status {
font-size: 11px;
color: var(--text-dim);
display: flex; align-items: center; gap: 6px;
}
.storage-status .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--green);
}
.badge {
background: var(--accent);
color: var(--bg);
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
.delete-x {
color: var(--text-dim);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
}
.delete-x:hover { color: var(--red); }
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.tab {
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
</style>
</head>
<body>
<div id="app">
<header>
<h1><span class="logo">G</span> Git & Context DAG Visualizer</h1>
<div class="header-actions">
<span class="storage-status" id="storage-status"><span class="dot"></span> Saved</span>
<button class="btn" id="btn-import">📥 Import</button>
<button class="btn" id="btn-export">📤 Export</button>
<button class="btn" id="btn-reset">🔄 Reset</button>
<button class="btn btn-primary" id="btn-new-commit">+ New Commit</button>
</div>
</header>
<aside id="left">
<div class="section-title">
<span>Branches</span>
<button class="btn btn-sm" id="btn-new-branch">+ Branch</button>
</div>
<div class="branch-list" id="branch-list"></div>
<div class="section-title">
<span>Tags</span>
<button class="btn btn-sm" id="btn-new-tag">+ Tag</button>
</div>
<div id="tag-list"></div>
</aside>
<main id="center">
<div id="graph-container">
<svg id="graph-svg"></svg>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="zoom-controls">
<button class="btn" id="zoom-in">+</button>
<button class="btn" id="zoom-out">−</button>
<button class="btn" id="zoom-reset">⌂</button>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#8b949e"></div>Commit</div>
<div class="legend-item"><div class="legend-dot" style="background:#f78166"></div>Context Event</div>
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div>Branch HEAD</div>
</div>
</main>
<aside id="right">
<div class="tabs">
<div class="tab active" data-tab="events">Events</div>
<div class="tab" data-tab="details">Selected</div>
<div class="tab" data-tab="add">+ Add Event</div>
</div>
<div id="tab-events" class="tab-panel">
<div class="section-title">
<span>Contextual Events</span>
<span class="badge" id="event-count">0</span>
</div>
<div class="event-list" id="event-list"></div>
</div>
<div id="tab-details" class="tab-panel" style="display:none">
<div id="selected-info"></div>
</div>
<div id="tab-add" class="tab-panel" style="display:none">
<div class="section-title">Add Contextual Event</div>
<div class="form-row">
<label>Title</label>
<input type="text" class="input" id="new-event-title" placeholder="e.g. Code Freeze, Release v1.0" />
</div>
<div class="form-row">
<label>Type</label>
<select class="select" id="new-event-type">
<option value="freeze">🧊 Code Freeze</option>
<option value="release">🚀 Release</option>
<option value="feature">✨ Feature Complete</option>
<option value="bugfix">🐛 Bug Fix</option>
<option value="review">👀 Code Review</option>
<option value="milestone">🎯 Milestone</option>
<option value="hotfix">🔥 Hotfix</option>
<option value="custom">📌 Custom</option>
</select>
</div>
<div class="form-row">
<label>Description</label>
<textarea class="textarea" id="new-event-desc" placeholder="Optional description..."></textarea>
</div>
<div class="form-row">
<label>Associate with Commit</label>
<select class="select" id="new-event-commit"></select>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="btn-create-event">Create Event</button>
</div>
</div>
</aside>
</div>
<!-- Modal -->
<div class="modal-backdrop" id="modal-backdrop">
<div class="modal" id="modal">
<h2><span id="modal-title">Title</span><button class="close-btn" id="modal-close">×</button></h2>
<div id="modal-content"></div>
</div>
</div>
<script>
// ============================================================
// STATE & DATA MODEL
// ============================================================
const STORAGE_KEY = 'git_dag_visualizer_v1';
const EVENT_TYPES = {
freeze: { color: '#58a6ff', icon: '🧊', label: 'Code Freeze' },
release: { color: '#3fb950', icon: '🚀', label: 'Release' },
feature: { color: '#bc8cff', icon: '✨', label: 'Feature Complete' },
bugfix: { color: '#f85149', icon: '🐛', label: 'Bug Fix' },
review: { color: '#ffa657', icon: '👀', label: 'Code Review' },
milestone:{ color: '#d29922', icon: '🎯', label: 'Milestone' },
hotfix: { color: '#ff7b72', icon: '🔥', label: 'Hotfix' },
custom: { color: '#f78166', icon: '📌', label: 'Custom' },
};
const BRANCH_COLORS = [
'#58a6ff', '#3fb950', '#bc8cff', '#ffa657', '#f78166',
'#ff7b72', '#d29922', '#a371f7', '#1f6feb', '#2ea043'
];
let state = {
commits: [], // { id, parents: [id], message, author, timestamp }
branches: [], // { name, head, color }
tags: [], // { name, commit }
events: [], // { id, title, type, description, commitId, timestamp }
activeBranch: null,
selectedCommit: null,
selectedEvent: null,
};
// ============================================================
// PERSISTENCE
// ============================================================
function saveState() {
try {
const persist = {
commits: state.commits,
branches: state.branches,
tags: state.tags,
events: state.events,
activeBranch: state.activeBranch,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(persist));
flashStorageStatus('Saved');
} catch (e) {
flashStorageStatus('Error', true);
console.error('Save failed', e);
}
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
state.commits = data.commits || [];
state.branches = data.branches || [];
state.tags = data.tags || [];
state.events = data.events || [];
state.activeBranch = data.activeBranch || (state.branches[0]?.name ?? null);
return true;
}
} catch (e) {
console.error('Load failed', e);
}
return false;
}
function flashStorageStatus(text, isError = false) {
const el = document.getElementById('storage-status');
el.innerHTML = `<span class="dot" style="background:${isError ? '#f85149' : '#3fb950'}"></span> ${text}`;
setTimeout(() => {
el.innerHTML = `<span class="dot" style="background:#3fb950"></span> Saved`;
}, 1500);
}
// ============================================================
// UTILITIES
// ============================================================
function uid(prefix = 'id') {
return `${prefix}_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-3)}`;
}
function shortHash(id) {
return id.slice(-7);
}
function getCommit(id) {
return state.commits.find(c => c.id === id);
}
function getCommitChildren(commitId) {
return state.commits.filter(c => c.parents.includes(commitId));
}
function getEventsForCommit(commitId) {
return state.events.filter(e => e.commitId === commitId);
}
function getBranchColor(branchName) {
const branch = state.branches.find(b => b.name === branchName);
return branch?.color || '#8b949e';
}
function findActiveBranchColor() {
if (state.activeBranch) {
return getBranchColor(state.activeBranch);
}
return '#8b949e';
}
// Assign columns (lanes) for commits via BFS-like algorithm
function assignLanes() {
const lanes = {}; // commitId -> laneIndex
const laneOwners = []; // laneIndex -> branch path (last commit id we tracked)
const sorted = [...state.commits].sort((a, b) => a.timestamp - b.timestamp);
// Find roots (commits with no parents in set)
const roots = sorted.filter(c => c.parents.length === 0 || c.parents.every(p => !getCommit(p)));
// Use a queue, processing commit by commit
const visited = new Set();
let nextLane = 0;
// Start with roots in order
const queue = [...roots];
while (queue.length > 0) {
const commit = queue.shift();
if (visited.has(commit.id)) continue;
visited.add(commit.id);
// Find a lane: try to continue a parent's lane
let assignedLane = -1;
for (const parentId of commit.parents) {
if (lanes[parentId] !== undefined) {
assignedLane = lanes[parentId];
break;
}
}
if (assignedLane === -1) {
assignedLane = nextLane++;
}
lanes[commit.id] = assignedLane;
laneOwners[assignedLane] = commit.id;
// Add children
for (const child of getCommitChildren(commit.id)) {
if (!visited.has(child.id)) queue.push(child);
}
}
// Process any unvisited commits (in case of orphans)
for (const commit of sorted) {
if (!visited.has(commit.id)) {
lanes[commit.id] = nextLane++;
}
}
return { lanes, laneCount: nextLane };
}
// Assign x positions based on lanes and y based on timestamp order
function layoutGraph() {
if (state.commits.length === 0) return { nodes: [], links: [], width: 800, height: 600 };
const { lanes, laneCount } = assignLanes();
const sorted = [...state.commits].sort((a, b) => a.timestamp - b.timestamp);
const laneSpacing = 80;
const rowSpacing = 80;
const padding = 80;
const nodes = sorted.map((commit, i) => ({
...commit,
x: padding + (lanes[commit.id] || 0) * laneSpacing,
y: padding + i * rowSpacing,
lane: lanes[commit.id] || 0,
}));
// Determine which color each node should be (based on which branch points here)
const commitBranch = {};
for (const branch of state.branches) {
let current = branch.head;
const visited = new Set();
while (current && !visited.has(current)) {
visited.add(current);
const commit = getCommit(current);
if (!commit) break;
if (!commitBranch[current]) commitBranch[current] = branch.color;
current = commit.parents[0];
}
}
nodes.forEach(n => {
n.branchColor = commitBranch[n.id] || '#8b949e';
});
const links = [];
for (const commit of state.commits) {
for (const parentId of commit.parents) {
const source = nodes.find(n => n.id === commit.id);
const target = nodes.find(n => n.id === parentId);
if (source && target) {
links.push({ source, target });
}
}
}
const width = padding * 2 + Math.max(laneCount, 1) * laneSpacing;
const height = padding * 2 + nodes.length * rowSpacing;
return { nodes, links, width, height };
}
// ============================================================
// RENDERING
// ============================================================
let svg, gRoot, gLinks, gNodes, gAnchors, gLabels, zoom;
let simulation;
function initGraph() {
svg = d3.select('#graph-svg');
svg.selectAll('*').remove();
// Add defs for arrowheads and gradients
const defs = svg.append('defs');
// Grid pattern
const pattern = defs.append('pattern')
.attr('id', 'grid')
.attr('width', 40)
.attr('height', 40)
.attr('patternUnits', 'userSpaceOnUse');
pattern.append('circle')
.attr('cx', 1).attr('cy', 1).attr('r', 1)
.attr('fill', '#21262d');
// Background grid
svg.append('rect')
.attr('width', '100%')
.attr('height', '100%')
.attr('fill', 'url(#grid)');
gRoot = svg.append('g').attr('class', 'root');
gLinks = gRoot.append('g').attr('class', 'links');
gNodes = gRoot.append('g').attr('class', 'nodes');
gAnchors = gRoot.append('g').attr('class', 'anchors');
gLabels = gRoot.append('g').attr('class', 'labels');
// Zoom
zoom = d3.zoom()
.scaleExtent([0.2, 3])
.on('zoom', (event) => {
gRoot.attr('transform', event.transform);
});
svg.call(zoom);
// Zoom controls
document.getElementById('zoom-in').onclick = () => svg.transition().duration(300).call(zoom.scaleBy, 1.3);
document.getElementById('zoom-out').onclick = () => svg.transition().duration(300).call(zoom.scaleBy, 1 / 1.3);
document.getElementById('zoom-reset').onclick = () => svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity);
}
function renderGraph() {
if (!svg) return;
const { nodes, links, width, height } = layoutGraph();
// Clear
gLinks.selectAll('*').remove();
gNodes.selectAll('*').remove();
gAnchors.selectAll('*').remove();
gLabels.selectAll('*').remove();
if (nodes.length === 0) {
const w = document.getElementById('graph-container').clientWidth;
const h = document.getElementById('graph-container').clientHeight;
svg.append('text')
.attr('x', w / 2).attr('y', h / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#8b949e')
.attr('font-size', 14)
.text('No commits yet. Click "New Commit" to start.');
return;
}
// Links - curved paths
const link = gLinks.selectAll('path')
.data(links)
.enter().append('path')
.attr('class', 'link')
.attr('d', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.max(30, Math.abs(dx) + Math.abs(dy) * 0.5);
return `M${d.source.x},${d.source.y} A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
})
.attr('stroke', d => d.source.branchColor || '#8b949e');
// Commit nodes
const node = gNodes.selectAll('g')
.data(nodes, d => d.id)
.enter().append('g')
.attr('class', 'commit-node')
.attr('transform', d => `translate(${d.x}, ${d.y})`)
.attr('data-id', d => d.id)
.style('cursor', 'pointer');
node.append('circle')
.attr('r', 12)
.attr('fill', d => d.branchColor || '#8b949e')
.attr('stroke', '#0d1117')
.attr('stroke-width', 2);
// Outer ring for selected
node.append('circle')
.attr('class', 'selection-ring')
.attr('r', 16)
.attr('fill', 'none')
.attr('stroke', '#58a6ff')
.attr('stroke-width', 0)
.attr('opacity', 0);
// Hover/click handlers
node
.on('mouseenter', (event, d) => {
const events = getEventsForCommit(d.id);
showTooltip(event, `
<div class="tooltip-title">${escapeHtml(d.message)}</div>
<div class="tooltip-meta">${shortHash(d.id)} · ${d.author || 'unknown'}</div>
${events.length > 0 ? `
<div style="margin-top:6px;font-size:10px;color:#8b949e;text-transform:uppercase;font-weight:600;">${events.length} event${events.length > 1 ? 's' : ''} anchored:</div>
${events.map(e => `
<div style="margin-top:3px;display:flex;align-items:center;gap:4px;">
<span style="color:${EVENT_TYPES[e.type]?.color || '#f78166'}">${EVENT_TYPES[e.type]?.icon || '📌'}</span>
<span>${escapeHtml(e.title)}</span>
</div>
`).join('')}
` : ''}
`);
})
.on('mousemove', moveTooltip)
.on('mouseleave', hideTooltip)
.on('click', (event, d) => {
event.stopPropagation();
selectCommit(d.id);
});
// Commit labels (message)
gLabels.selectAll('g.commit-label')
.data(nodes, d => d.id)
.enter().append('g')
.attr('class', 'commit-label')
.attr('transform', d => `translate(${d.x + 18}, ${d.y + 4})`)
.each(function(d) {
const g = d3.select(this);
g.append('text')
.attr('class', 'commit-msg-label')
.text(truncate(d.message, 32))
.attr('fill', d.id === state.selectedCommit ? '#58a6ff' : 'var(--text)');
g.append('text')
.attr('class', 'commit-id-label')
.attr('y', 14)
.text(shortHash(d.id));
});
// Branch labels at head
for (const branch of state.branches) {
const head = nodes.find(n => n.id === branch.head);
if (head) {
const labelG = gLabels.append('g')
.attr('transform', `translate(${head.x - 50}, ${head.y - 30})`)
.style('cursor', 'pointer');
labelG.append('rect')
.attr('x', 0).attr('y', 0)
.attr('width', 92).attr('height', 18)
.attr('rx', 9)
.attr('fill', branch.color)
.attr('opacity', 0.95);
labelG.append('text')
.attr('class', 'branch-label')
.attr('x', 46).attr('y', 12)
.attr('text-anchor', 'middle')
.attr('fill', '#0d1117')
.text(branch.name);
labelG.on('click', () => selectBranch(branch.name));
}
}
// Tag labels
for (const tag of state.tags) {
const commit = nodes.find(n => n.id === tag.commit);
if (commit) {
const tagG = gLabels.append('g')
.attr('transform', `translate(${commit.x + 22}, ${commit.y + 22})`)
.style('cursor', 'pointer');
tagG.append('rect')
.attr('x', 0).attr('y', 0)
.attr('width', 60).attr('height', 14)
.attr('rx', 3)
.attr('fill', '#d29922');
tagG.append('text')
.attr('class', 'tag-label')
.attr('x', 30).attr('y', 10)
.attr('text-anchor', 'middle')
.attr('fill', '#0d1117')
.attr('font-size', 9)
.attr('font-weight', 600)
.text('🏷 ' + tag.name);
}
}
// Anchored events - positioned around commit nodes
for (const commit of nodes) {
const events = getEventsForCommit(commit.id);
events.forEach((event, idx) => {
const angle = (idx / Math.max(events.length, 1)) * Math.PI * 2 - Math.PI / 2;
const radius = 32;
const ex = commit.x + Math.cos(angle) * radius;
const ey = commit.y + Math.sin(angle) * radius;
const etype = EVENT_TYPES[event.type] || EVENT_TYPES.custom;
const anchorG = gAnchors.append('g')
.attr('class', 'anchor-badge')
.attr('transform', `translate(${ex}, ${ey})`)
.style('cursor', 'pointer')
.attr('data-event-id', event.id);
anchorG.append('circle')
.attr('r', 11)
.attr('fill', etype.color)
.attr('stroke', '#0d1117')
.attr('stroke-width', 2)
.attr('class', 'anchor-circle');
anchorG.append('text')
.attr('text-anchor', 'middle')
.attr('y', 4)
.attr('font-size', 11)
.attr('pointer-events', 'none')
.text(etype.icon);
// Connection line from commit to anchor (subtle)
gLinks.append('line')
.attr('x1', commit.x).attr('y1', commit.y)
.attr('x2', ex).attr('y2', ey)
.attr('stroke', etype.color)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '2,2')
.attr('opacity', 0.5);
anchorG
.on('mouseenter', (e) => {
showTooltip(e, `
<div class="tooltip-title">${etype.icon} ${escapeHtml(event.title)}</div>
<div class="tooltip-meta">${etype.label}</div>
${event.description ? `<div style="margin-bottom:6px;font-size:11px;">${escapeHtml(event.description)}</div>` : ''}
<div class="tooltip-commit">📌 Anchored to: ${shortHash(event.commitId)} "${escapeHtml(commit.message)}"</div>
`);
})
.on('mousemove', moveTooltip)
.on('mouseleave', hideTooltip)
.on('click', (e) => {
e.stopPropagation();
selectEvent(event.id);
});
});
}
// Highlight selected
applySelection();
applyEventHighlight();
}
function truncate(str, n) {
if (!str) return '';
return str.length > n ? str.slice(0, n - 1) + '…' : str;
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
// ============================================================
// TOOLTIPS
// ============================================================
function showTooltip(event, html) {
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = html;
tooltip.classList.add('visible');
moveTooltip(event);
}
function moveTooltip(event) {
const tooltip = document.getElementById('tooltip');
const offset = 14;
let x = event.clientX + offset;
let y = event.clientY + offset;
const rect = tooltip.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = event.clientX - rect.width - offset;
if (y + rect.height > window.innerHeight) y = event.clientY - rect.height - offset;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('visible');
}
// ============================================================
// SIDEBAR RENDERING
// ============================================================
function renderBranches() {
const list = document.getElementById('branch-list');
list.innerHTML = '';
if (state.branches.length === 0) {
list.innerHTML = '<div class="empty-state">No branches yet</div>';
return;
}
for (const branch of state.branches) {
const item = document.createElement('div');
item.className = 'branch-item' + (branch.name === state.activeBranch ? ' active' : '');
item.innerHTML = `
<div class="branch-color" style="background:${branch.color}"></div>
<div style="flex:1;min-width:0;">
<div class="branch-name">${escapeHtml(branch.name)}${branch.name === state.activeBranch ? ' <span style="font-size:9px;color:#8b949e;">●</span>' : ''}</div>
<div class="branch-sha">${shortHash(branch.head)}</div>
</div>
<span class="delete-x" data-branch="${escapeHtml(branch.name)}" title="Delete branch">×</span>
`;
item.onclick = (e) => {
if (e.target.classList.contains('delete-x')) {
deleteBranch(branch.name);
} else {
checkoutBranch(branch.name);
}
};
list.appendChild(item);
}
}
function renderTags() {
const list = document.getElementById('tag-list');
list.innerHTML = '';
if (state.tags.length === 0) {
list.innerHTML = '<div class="empty-state">No tags yet</div>';
return;
}
for (const tag of state.tags) {
const item = document.createElement('div');
item.className = 'branch-item';
item.innerHTML = `
<div class="branch-color" style="background:#d29922"></div>
<div style="flex:1;min-width:0;">
<div class="branch-name">🏷 ${escapeHtml(tag.name)}</div>
<div class="branch-sha">${shortHash(tag.commit)}</div>
</div>
<span class="delete-x" data-tag="${escapeHtml(tag.name)}">×</span>
`;
item.onclick = (e) => {
if (e.target.classList.contains('delete-x')) {
deleteTag(tag.name);
} else {
selectCommit(tag.commit);
}
};
list.appendChild(item);
}
}
function renderEvents() {
const list = document.getElementById('event-list');
list.innerHTML = '';
const sorted = [...state.events].sort((a, b) => b.timestamp - a.timestamp);
document.getElementById('event-count').textContent = sorted.length;
if (sorted.length === 0) {
list.innerHTML = '<div class="empty-state">No events yet.<br>Add one from the "+ Add Event" tab.</div>';
return;
}
for (const event of sorted) {
const etype = EVENT_TYPES[event.type] || EVENT_TYPES.custom;
const commit = getCommit(event.commitId);
const card = document.createElement('div');
card.className = 'event-card' + (state.selectedEvent === event.id ? ' selected' : '');
card.innerHTML = `
<div class="event-title">
<span class="pin">📍</span>
<span>${etype.icon} ${escapeHtml(event.title)}</span>
</div>
<div class="event-meta">
${etype.label} → ${commit ? shortHash(event.commitId) : '???'} ${commit ? `· "${truncate(commit.message, 20)}"` : ''}
</div>
${event.description ? `<div class="event-desc">${escapeHtml(event.description)}</div>` : ''}
`;
card.onclick = () => selectEvent(event.id);
list.appendChild(card);
}
}
function renderSelectedInfo() {
const container = document.getElementById('selected-info');
container.innerHTML = '';
if (state.selectedEvent) {
const event = state.events.find(e => e.id === state.selectedEvent);
if (event) {
const etype = EVENT_TYPES[event.type] || EVENT_TYPES.custom;
const commit = getCommit(event.commitId);
container.innerHTML = `
<div class="section-title">Selected Event</div>
<div class="selected-commit-info">
<div class="label">Title</div>
<div style="font-weight:600;margin:4px 0;">${etype.icon} ${escapeHtml(event.title)}</div>
<div class="label" style="margin-top:8px;">Type</div>
<div class="value" style="color:${etype.color}">${etype.label}</div>
${event.description ? `
<div class="label" style="margin-top:8px;">Description</div>
<div class="msg">${escapeHtml(event.description)}</div>
` : ''}
<div class="label" style="margin-top:8px;">Anchored Commit</div>
<div class="value">${shortHash(event.commitId)}</div>
<div class="msg">"${escapeHtml(commit?.message || 'NOT FOUND')}"</div>
<div class="label" style="margin-top:8px;">Created</div>
<div class="msg">${new Date(event.timestamp).toLocaleString()}</div>
</div>
<div style="display:flex;gap:6px;margin-top:10px;">
<button class="btn btn-sm" id="edit-event-btn">Edit</button>
<button class="btn btn-sm btn-danger" id="delete-event-btn">Delete</button>
</div>
`;
document.getElementById('edit-event-btn').onclick = () => openEventEdit(event.id);
document.getElementById('delete-event-btn').onclick = () => deleteEvent(event.id);
return;
}
}
if (state.selectedCommit) {
const commit = getCommit(state.selectedCommit);
if (commit) {
const events = getEventsForCommit(commit.id);
const branches = state.branches.filter(b => b.head === commit.id);
const tags = state.tags.filter(t => t.commit === commit.id);
container.innerHTML = `
<div class="section-title">Selected Commit</div>
<div class="selected-commit-info">
<div class="label">Hash</div>
<div class="value">${commit.id}</div>
<div class="label" style="margin-top:8px;">Message</div>
<div class="msg">${escapeHtml(commit.message)}</div>
<div class="label" style="margin-top:8px;">Author</div>
<div class="msg">${escapeHtml(commit.author || 'unknown')}</div>
<div class="label" style="margin-top:8px;">Parents</div>
<div class="msg">${commit.parents.length === 0 ? '<em>root</em>' : commit.parents.map(p => shortHash(p)).join(', ')}</div>
${branches.length > 0 ? `
<div class="label" style="margin-top:8px;">Branches</div>
<div class="msg">${branches.map(b => `<span style="color:${b.color}">●</span> ${escapeHtml(b.name)}`).join(' ')}</div>
` : ''}
${tags.length > 0 ? `
<div class="label" style="margin-top:8px;">Tags</div>
<div class="msg">${tags.map(t => `🏷 ${escapeHtml(t.name)}`).join(', ')}</div>
` : ''}
<div class="label" style="margin-top:8px;">Anchored Events (${events.length})</div>
${events.length > 0 ? `
<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px;">
${events.map(e => {
const et = EVENT_TYPES[e.type] || EVENT_TYPES.custom;
return `<div style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg);border-radius:4px;font-size:11px;">
<span>${et.icon}</span><span>${escapeHtml(e.title)}</span>
</div>`;
}).join('')}
</div>
<button class="btn btn-sm" id="add-event-for-commit" style="margin-top:8px;">+ Add Event Here</button>
` : `
<button class="btn btn-sm" id="add-event-for-commit" style="margin-top:8px;">+ Anchor Event Here</button>
`}
</div>
<div style="display:flex;gap:6px;margin-top:10px;">
<button class="btn btn-sm" id="edit-commit-btn">Edit</button>
<button class="btn btn-sm" id="new-branch-on-btn">+ Branch Here</button>
<button class="btn btn-sm" id="new-tag-on-btn">+ Tag</button>
<button class="btn btn-sm btn-danger" id="delete-commit-btn">Delete</button>
</div>
`;
document.getElementById('edit-commit-btn').onclick = () => openCommitEdit(commit.id);
document.getElementById('delete-commit-btn').onclick = () => deleteCommit(commit.id);
document.getElementById('new-branch-on-btn').onclick = () => createBranchOnCommit(commit.id);
document.getElementById('new-tag-on-btn').onclick = () => createTagOnCommit(commit.id);
const addEvBtn = document.getElementById('add-event-for-commit');
if (addEvBtn) addEvBtn.onclick = () => { switchTab('add'); document.getElementById('new-event-commit').value = commit.id; };
return;
}
}
container.innerHTML = '<div class="empty-state">Select a commit or event to see details</div>';
}
// ============================================================
// SELECTION
// ============================================================
function selectCommit(id) {
state.selectedCommit = id;
state.selectedEvent = null;
applySelection();
applyEventHighlight();
renderEvents();
renderSelectedInfo();
updateDetailsTab();
}
function selectEvent(id) {
const event = state.events.find(e => e.id === id);
if (!event) return;
state.selectedEvent = id;
state.selectedCommit = event.commitId;
applySelection();
applyEventHighlight();
renderEvents();
renderSelectedInfo();
updateDetailsTab();
// Pan to the event
const node = gNodes.selectAll('.commit-node').filter(d => d.id === event.commitId);
if (!node.empty()) {
const d = node.datum();
const svgEl = document.getElementById('graph-svg');
const rect = svgEl.getBoundingClientRect();
const transform = d3.zoomIdentity
.translate(rect.width / 2 - d.x, rect.height / 2 - d.y)
.scale(1);
svg.transition().duration(500).call(zoom.transform, transform);
}
}
function selectBranch(name) {
state.activeBranch = name;
const branch = state.branches.find(b => b.name === name);
if (branch) {
selectCommit(branch.head);
}
saveState();
renderBranches();
}
function applySelection() {
gNodes.selectAll('.commit-node').each(function(d) {
const node = d3.select(this);
if (d.id === state.selectedCommit) {
node.classed('highlighted', true);
} else {
node.classed('highlighted', false);
}
});
}
function applyEventHighlight() {
// Highlight an anchor if its event is selected
gAnchors.selectAll('.anchor-badge').each(function() {
const evId = d3.select(this).attr('data-event-id');
const circle = d3.select(this).select('.anchor-circle');
if (evId === state.selectedEvent) {
circle.attr('stroke', '#fff').attr('stroke-width', 3).attr('r', 13);
} else {
circle.attr('stroke', '#0d1117').attr('stroke-width', 2).attr('r', 11);
}
});
}
// ============================================================
// CRUD OPERATIONS
// ============================================================
function createCommit({ message, author, parents }) {
const commit = {
id: uid('c'),
parents: parents || [],
message: message || 'Untitled commit',
author: author || 'user',
timestamp: Date.now(),
};
state.commits.push(commit);
// Move active branch head
if (state.activeBranch) {
const branch = state.branches.find(b => b.name === state.activeBranch);
if (branch) branch.head = commit.id;
}
saveState();
renderAll();
return commit;
}
function updateCommit(id, updates) {
const commit = getCommit(id);
if (!commit) return;
Object.assign(commit, updates);
saveState();
renderAll();
}
function deleteCommit(id) {
if (!confirm('Delete this commit? Children will be re-parented or orphaned.')) return;
const commit = state.commits.find(c => c.id === id);
if (!commit) return;
// Re-parent children to commit's parent
const children = state.commits.filter(c => c.parents.includes(id));
const newParent = commit.parents[0] || null;
for (const child of children) {
child.parents = child.parents.filter(p => p !== id);
if (newParent && !child.parents.includes(newParent)) {
child.parents.unshift(newParent);
}
}
// Remove from branches
for (const branch of state.branches) {
if (branch.head === id) {
branch.head = newParent;
}
}
// Remove events
state.events = state.events.filter(e => e.commitId !== id);
// Remove commit
state.commits = state.commits.filter(c => c.id !== id);
if (state.selectedCommit === id) state.selectedCommit = null;
saveState();
renderAll();
}
function createBranch(name, head) {
if (state.branches.find(b => b.name === name)) {
alert('Branch name already exists');
return;
}
const color = BRANCH_COLORS[state.branches.length % BRANCH_COLORS.length];
state.branches.push({ name, head, color });
state.activeBranch = name;
saveState();
renderAll();
}
function deleteBranch(name) {
if (state.branches.length <= 1) {
alert('Cannot delete the only branch');
return;
}
if (!confirm(`Delete branch "${name}"?`)) return;
state.branches = state.branches.filter(b => b.name !== name);
if (state.activeBranch === name) {
state.activeBranch = state.branches[0]?.name;
}
saveState();
renderAll();
}
function checkoutBranch(name) {
state.activeBranch = name;
saveState();
renderBranches();
}
function createTag(name, commitId) {
if (state.tags.find(t => t.name === name)) {
alert('Tag name already exists');
return;
}
state.tags.push({ name, commit: commitId });
saveState();
renderAll();
}
function deleteTag(name) {
state.tags = state.tags.filter(t => t.name !== name);
saveState();
renderAll();
}
function createEvent({ title, type, description, commitId }) {
const event = {
id: uid('e'),
title, type, description,
commitId,
timestamp: Date.now(),
};
state.events.push(event);
saveState();
renderAll();
return event;
}
function updateEvent(id, updates) {
const event = state.events.find(e => e.id === id);
if (!event) return;
Object.assign(event, updates);
saveState();
renderAll();
}
function deleteEvent(id) {
state.events = state.events.filter(e => e.id !== id);
if (state.selectedEvent === id) state.selectedEvent = null;
saveState();
renderAll();
}
// ============================================================
// MODALS
// ============================================================
function openModal(title, contentHtml) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-content').innerHTML = contentHtml;
document.getElementById('modal-backdrop').classList.add('visible');
}
function closeModal() {
document.getElementById('modal-backdrop').classList.remove('visible');
}
function openCommitEdit(id) {
const commit = getCommit(id);
if (!commit) return;
const branches = state.branches.map(b => `<option value="${b.id || b.name}">${escapeHtml(b.name)} @ ${shortHash(b.head)}</option>`).join('');
openModal('Edit Commit', `
<div class="form-row">
<label>Message</label>
<input type="text" class="input" id="edit-commit-msg" value="${escapeHtml(commit.message)}" />
</div>
<div class="form-row">
<label>Author</label>
<input type="text" class="input" id="edit-commit-author" value="${escapeHtml(commit.author)}" />
</div>
<div class="form-row">
<label>Parent Commit (single)</label>
<select class="select" id="edit-commit-parent">
<option value="">(root)</option>
${state.commits.filter(c => c.id !== id).map(c => `<option value="${c.id}" ${commit.parents[0] === c.id ? 'selected' : ''}>${shortHash(c.id)} - ${escapeHtml(truncate(c.message, 40))}</option>`).join('')}
</select>
</div>
<div class="form-actions">
<button class="btn" id="cancel-edit-commit">Cancel</button>
<button class="btn btn-primary" id="save-edit-commit">Save</button>
</div>
`);
document.getElementById('cancel-edit-commit').onclick = closeModal;
document.getElementById('save-edit-commit').onclick = () => {
const message = document.getElementById('edit-commit-msg').value;
const author = document.getElementById('edit-commit-author').value;
const parent = document.getElementById('edit-commit-parent').value || null;
updateCommit(id, {
message,
author,
parents: parent ? [parent] : [],
});
closeModal();
};
}
function openEventEdit(id) {
const event = state.events.find(e => e.id === id);
if (!event) return;
openModal('Edit Event', `
<div class="form-row">
<label>Title</label>
<input type="text" class="input" id="edit-event-title" value="${escapeHtml(event.title)}" />
</div>
<div class="form-row">
<label>Type</label>
<select class="select" id="edit-event-type">
${Object.entries(EVENT_TYPES).map(([k, v]) => `<option value="${k}" ${event.type === k ? 'selected' : ''}>${v.icon} ${v.label}</option>`).join('')}
</select>
</div>
<div class="form-row">
<label>Description</label>
<textarea class="textarea" id="edit-event-desc">${escapeHtml(event.description || '')}</textarea>
</div>
<div class="form-row">
<label>Anchor to Commit</label>
<select class="select" id="edit-event-commit">
${state.commits.map(c => `<option value="${c.id}" ${event.commitId === c.id ? 'selected' : ''}>${shortHash(c.id)} - ${escapeHtml(truncate(c.message, 40))}</option>`).join('')}
</select>
</div>
<div class="form-actions">
<button class="btn" id="cancel-edit-event">Cancel</button>
<button class="btn btn-primary" id="save-edit-event">Save</button>
</div>
`);
document.getElementById('cancel-edit-event').onclick = closeModal;
document.getElementById('save-edit-event').onclick = () => {
updateEvent(id, {
title: document.getElementById('edit-event-title').value,
type: document.getElementById('edit-event-type').value,
description: document.getElementById('edit-event-desc').value,
commitId: document.getElementById('edit-event-commit').value,
});
closeModal();
};
}
function openNewCommit() {
const parentOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} - ${escapeHtml(truncate(c.message, 40))}</option>`).join('');
const branchOptions = state.branches.map(b => `<option value="${b.name}" ${b.name === state.activeBranch ? 'selected' : ''}>${escapeHtml(b.name)} @ ${shortHash(b.head)}</option>`).join('');
openModal('New Commit', `
<div class="form-row">
<label>Message</label>
<input type="text" class="input" id="new-commit-msg" placeholder="e.g. Add login feature" />
</div>
<div class="form-row">
<label>Author</label>
<input type="text" class="input" id="new-commit-author" value="user" />
</div>
<div class="form-row">
<label>Branch</label>
<select class="select" id="new-commit-branch">${branchOptions}</select>
</div>
<div class="form-row">
<label>Parent Commit (from selected branch)</label>
<select class="select" id="new-commit-parent">
<option value="">(root)</option>
${parentOptions}
</select>
</div>
<div class="form-actions">
<button class="btn" id="cancel-new-commit">Cancel</button>
<button class="btn btn-primary" id="save-new-commit">Create</button>
</div>
`);
document.getElementById('cancel-new-commit').onclick = closeModal;
document.getElementById('save-new-commit').onclick = () => {
const message = document.getElementById('new-commit-msg').value || 'Untitled commit';
const author = document.getElementById('new-commit-author').value || 'user';
const branchName = document.getElementById('new-commit-branch').value;
const parent = document.getElementById('new-commit-parent').value || null;
if (branchName) state.activeBranch = branchName;
const commit = createCommit({ message, author, parents: parent ? [parent] : [] });
if (branchName) {
const branch = state.branches.find(b => b.name === branchName);
if (branch) branch.head = commit.id;
}
closeModal();
};
}
function openNewBranch() {
const commitOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} - ${escapeHtml(truncate(c.message, 40))}</option>`).join('');
openModal('New Branch', `
<div class="form-row">
<label>Branch Name</label>
<input type="text" class="input" id="new-branch-name" placeholder="e.g. feature/auth" />
</div>
<div class="form-row">
<label>Branch from Commit</label>
<select class="select" id="new-branch-commit">${commitOptions}</select>
</div>
<div class="form-actions">
<button class="btn" id="cancel-new-branch">Cancel</button>
<button class="btn btn-primary" id="save-new-branch">Create</button>
</div>
`);
document.getElementById('cancel-new-branch').onclick = closeModal;
document.getElementById('save-new-branch').onclick = () => {
const name = document.getElementById('new-branch-name').value.trim();
const head = document.getElementById('new-branch-commit').value;
if (!name) { alert('Please enter a branch name'); return; }
createBranch(name, head);
closeModal();
};
}
function createBranchOnCommit(commitId) {
const name = prompt('Branch name:');
if (!name) return;
createBranch(name, commitId);
}
function createTagOnCommit(commitId) {
const name = prompt('Tag name:');
if (!name) return;
createTag(name, commitId);
}
function openNewTag() {
const commitOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} - ${escapeHtml(truncate(c.message, 40))}</option>`).join('');
openModal('New Tag', `
<div class="form-row">
<label>Tag Name</label>
<input type="text" class="input" id="new-tag-name" placeholder="e.g. v1.0.0" />
</div>
<div class="form-row">
<label>Tag at Commit</label>
<select class="select" id="new-tag-commit">${commitOptions}</select>
</div>
<div class="form-actions">
<button class="btn" id="cancel-new-tag">Cancel</button>
<button class="btn btn-primary" id="save-new-tag">Create</button>
</div>
`);
document.getElementById('cancel-new-tag').onclick = closeModal;
document.getElementById('save-new-tag').onclick = () => {
const name = document.getElementById('new-tag-name').value.trim();
const commit = document.getElementById('new-tag-commit').value;
if (!name) { alert('Please enter a tag name'); return; }
createTag(name, commit);
closeModal();
};
}
function openImport() {
openModal('Import Data', `
<p style="font-size:12px;color:var(--text-dim);margin:0 0 10px 0;">Paste JSON data with commits, branches, tags, and events. Existing data will be replaced.</p>
<div class="form-row">
<label>JSON Data</label>
<textarea class="textarea" id="import-data" style="min-height:200px;font-family:monospace;font-size:11px;" placeholder='{"commits":[...], "branches":[...], "tags":[...], "events":[...]}'></textarea>
</div>
<div class="form-actions">
<button class="btn" id="load-sample">Load Sample</button>
<button class="btn" id="cancel-import">Cancel</button>
<button class="btn btn-primary" id="save-import">Import</button>
</div>
`);
document.getElementById('cancel-import').onclick = closeModal;
document.getElementById('load-sample').onclick = () => {
document.getElementById('import-data').value = JSON.stringify(getSampleData(), null, 2);
};
document.getElementById('save-import').onclick = () => {
try {
const data = JSON.parse(document.getElementById('import-data').value);
if (data.commits) state.commits = data.commits;
if (data.branches) state.branches = data.branches;
if (data.tags) state.tags = data.tags;
if (data.events) state.events = data.events;
state.activeBranch = data.activeBranch || (state.branches[0]?.name ?? null);
state.selectedCommit = null;
state.selectedEvent = null;
saveState();
renderAll();
closeModal();
} catch (e) {
alert('Invalid JSON: ' + e.message);
}
};
}
function openExport() {
const data = {
commits: state.commits,
branches: state.branches,
tags: state.tags,
events: state.events,
activeBranch: state.activeBranch,
exportedAt: new Date().toISOString(),
};
const json = JSON.stringify(data, null, 2);
openModal('Export Data', `
<p style="font-size:12px;color:var(--text-dim);margin:0 0 10px 0;">Copy this JSON or download as a file.</p>
<div class="form-row">
<textarea class="textarea" id="export-data" style="min-height:300px;font-family:monospace;font-size:11px;" readonly>${escapeHtml(json)}</textarea>
</div>
<div class="form-actions">
<button class="btn" id="copy-export">📋 Copy</button>
<button class="btn btn-primary" id="download-export">📥 Download</button>
<button class="btn" id="close-export">Close</button>
</div>
`);
document.getElementById('close-export').onclick = closeModal;
document.getElementById('copy-export').onclick = () => {
navigator.clipboard.writeText(json).then(() => flashStorageStatus('Copied!'));
};
document.getElementById('download-export').onclick = () => {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `git-dag-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
}
function resetData() {
if (!confirm('Reset all data and reload sample? This cannot be undone.')) return;
localStorage.removeItem(STORAGE_KEY);
state.commits = [];
state.branches = [];
state.tags = [];
state.events = [];
state.activeBranch = null;
state.selectedCommit = null;
state.selectedEvent = null;
const sample = getSampleData();
state.commits = sample.commits;
state.branches = sample.branches;
state.tags = sample.tags;
state.events = sample.events;
state.activeBranch = sample.activeBranch;
saveState();
renderAll();
}
// ============================================================
// SAMPLE DATA
// ============================================================
function getSampleData() {
// Build a small realistic-ish branch topology
const c0 = { id: 'c_init01', parents: [], message: 'Initial commit', author: 'alice', timestamp: Date.now() - 1000000 };
const c1 = { id: 'c_setup02', parents: ['c_init01'], message: 'Set up project structure', author: 'alice', timestamp: Date.now() - 900000 };
const c2 = { id: 'c_readme03', parents: ['c_setup02'], message: 'Add README and LICENSE', author: 'alice', timestamp: Date.now() - 850000 };
const c3 = { id: 'c_auth_a01', parents: ['c_readme03'], message: 'Auth: scaffold login form', author: 'bob', timestamp: Date.now() - 800000 };
const c4 = { id: 'c_auth_a02', parents: ['c_auth_a01'], message: 'Auth: add JWT validation', author: 'bob', timestamp: Date.now() - 750000 };
const c5 = { id: 'c_api_b01', parents: ['c_readme03'], message: 'API: add user endpoints', author: 'carol', timestamp: Date.now() - 730000 };
const c6 = { id: 'c_api_b02', parents: ['c_api_b01'], message: 'API: pagination support', author: 'carol', timestamp: Date.now() - 700000 };
const c7 = { id: 'c_ui_c01', parents: ['c_readme03'], message: 'UI: redesign with new theme', author: 'dave', timestamp: Date.now() - 680000 };
const c8 = { id: 'c_auth_a03', parents: ['c_auth_a02'], message: 'Auth: add password reset', author: 'bob', timestamp: Date.now() - 600000 };
const c9 = { id: 'c_merge01', parents: ['c_auth_a03', 'c_api_b02'], message: 'Merge auth + api', author: 'alice', timestamp: Date.now() - 550000 };
const c10 = { id: 'c_hotfix01', parents: ['c_merge01'], message: 'Hotfix: critical XSS vulnerability', author: 'alice', timestamp: Date.now() - 500000 };
const c11 = { id: 'c_merge02', parents: ['c_hotfix01', 'c_ui_c01'], message: 'Merge UI into main', author: 'alice', timestamp: Date.now() - 450000 };
const c12 = { id: 'c_release01', parents: ['c_merge02'], message: 'Bump version to 1.0.0', author: 'alice', timestamp: Date.now() - 400000 };
const c13 = { id: 'c_doc01', parents: ['c_release01'], message: 'Update changelog', author: 'alice', timestamp: Date.now() - 350000 };
const c14 = { id: 'c_perf01', parents: ['c_release01'], message: 'Perf: optimize query layer', author: 'carol', timestamp: Date.now() - 300000 };
const c15 = { id: 'c_v11001', parents: ['c_doc01', 'c_perf01'], message: 'Release v1.1.0 prep', author: 'alice', timestamp: Date.now() - 200000 };
const commits = [c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14, c15];
const branches = [
{ name: 'main', head: c15.id, color: '#3fb950' },
{ name: 'feature/auth', head: c8.id, color: '#58a6ff' },
{ name: 'feature/api', head: c6.id, color: '#bc8cff' },
{ name: 'feature/ui', head: c7.id, color: '#ffa657' },
{ name: 'hotfix/xss', head: c10.id, color: '#f85149' },
];
const tags = [
{ name: 'v1.0.0', commit: c12.id },
{ name: 'v1.1.0-rc', commit: c15.id },
];
const events = [
{ id: 'e_001', title: 'Project Kickoff', type: 'milestone', description: 'Initial repo + team agreement on architecture.', commitId: c0.id, timestamp: Date.now() - 990000 },
{ id: 'e_002', title: 'Design Review', type: 'review', description: 'Reviewed login UX mockups with product team.', commitId: c3.id, timestamp: Date.now() - 790000 },
{ id: 'e_003', title: 'API Freeze', type: 'freeze', description: 'Public API contract frozen for the v1.0 release.', commitId: c5.id, timestamp: Date.now() - 720000 },
{ id: 'e_004', title: 'Auth Feature Complete', type: 'feature', description: 'Login + JWT + password reset fully working.', commitId: c8.id, timestamp: Date.now() - 590000 },
{ id: 'e_005', title: 'Critical XSS Discovered', type: 'hotfix', description: 'Security team reported XSS in comment field. Patch shipped same day.', commitId: c10.id, timestamp: Date.now() - 490000 },
{ id: 'e_006', title: 'Release v1.0.0', type: 'release', description: 'Tagged and deployed to production.', commitId: c12.id, timestamp: Date.now() - 390000 },
{ id: 'e_007', title: 'Performance Sprint', type: 'milestone', description: 'Began query optimization initiative.', commitId: c14.id, timestamp: Date.now() - 290000 },
{ id: 'e_008', title: 'Release v1.1.0', type: 'release', description: 'Performance improvements and changelog delivered.', commitId: c15.id, timestamp: Date.now() - 190000 },
];
return { commits, branches, tags, events, activeBranch: 'main' };
}
// ============================================================
// RENDER ALL
// ============================================================
function renderAll() {
renderBranches();
renderTags();
renderEvents();
renderSelectedInfo();
renderGraph();
populateCommitSelect();
}
function populateCommitSelect() {
const select = document.getElementById('new-event-commit');
if (!select) return;
select.innerHTML = '';
for (const commit of state.commits) {
const opt = document.createElement('option');
opt.value = commit.id;
opt.textContent = `${shortHash(commit.id)} - ${truncate(commit.message, 40)}`;
select.appendChild(opt);
}
// Set to currently selected commit if any
if (state.selectedCommit) select.value = state.selectedCommit;
}
// ============================================================
// TABS
// ============================================================
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => {
p.style.display = p.id === `tab-${name}` ? 'block' : 'none';
});
if (name === 'details') renderSelectedInfo();
}
function updateDetailsTab() {
switchTab('details');
}
// ============================================================
// EVENT WIRING
// ============================================================
function wireEvents() {
document.getElementById('btn-new-commit').onclick = openNewCommit;
document.getElementById('btn-new-branch').onclick = openNewBranch;
document.getElementById('btn-new-tag').onclick = openNewTag;
document.getElementById('btn-import').onclick = openImport;
document.getElementById('btn-export').onclick = openExport;
document.getElementById('btn-reset').onclick = resetData;
document.getElementById('modal-close').onclick = closeModal;
document.getElementById('modal-backdrop').onclick = (e) => {
if (e.target.id === 'modal-backdrop') closeModal();
};
document.getElementById('btn-create-event').onclick = () => {
const title = document.getElementById('new-event-title').value.trim();
const type = document.getElementById('new-event-type').value;
const description = document.getElementById('new-event-desc').value.trim();
const commitId = document.getElementById('new-event-commit').value;
if (!title) { alert('Please enter an event title'); return; }
if (!commitId) { alert('Please select a commit'); return; }
createEvent({ title, type, description, commitId });
document.getElementById('new-event-title').value = '';
document.getElementById('new-event-desc').value = '';
switchTab('events');
};
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => switchTab(tab.dataset.tab);
});
// Click on background to deselect
svg.on('click', () => {
state.selectedCommit = null;
state.selectedEvent = null;
applySelection();
applyEventHighlight();
renderEvents();
renderSelectedInfo();
});
// Resize handler
window.addEventListener('resize', () => renderGraph());
}
// ============================================================
// INIT
// ============================================================
function init() {
initGraph();
wireEvents();
const loaded = loadState();
if (!loaded || state.commits.length === 0) {
const sample = getSampleData();
state.commits = sample.commits;
state.branches = sample.branches;
state.tags = sample.tags;
state.events = sample.events;
state.activeBranch = sample.activeBranch;
saveState();
}
renderAll();
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"full","sessionId":"8f0a9ba3-4a56-4742-b141-c8624f6911ab","tokensIn":258759,"tokensOut":19292,"tokensTotal":278051,"turns":11,"toolCalls":10,"failedToolCalls":0,"timestamp":"2026-07-24T18:07:09.544Z"} -->