Git Explorer
Qwen 3.6 27b MTP · 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 64,642 bytes · SHA-256 0bb39385107c
<!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</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-purple: #bc8cff;
--accent-orange: #d29922;
--accent-red: #f85149;
--accent-pink: #f778ba;
--commit-color: #58a6ff;
--merge-color: #bc8cff;
--event-color: #d29922;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
min-height: 48px;
}
.header h1 {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.header h1 svg { width: 20px; height: 20px; }
.header-actions {
display: flex;
gap: 8px;
}
/* Buttons */
.btn {
padding: 5px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.btn:hover { background: var(--border-color); border-color: #8b949e; }
.btn-primary { background: #238636; border-color: #2ea043; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; border-color: #f85149; }
.btn-danger:hover { background: #f85149; }
.btn-sm { padding: 3px 8px; font-size: 11px; }
/* Main Layout */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 320px;
min-width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.sidebar-tab {
flex: 1;
padding: 8px;
text-align: center;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
transition: all 0.15s;
}
.sidebar-tab:hover { color: var(--text-primary); }
.sidebar-tab.active {
color: var(--accent-blue);
border-bottom-color: var(--accent-blue);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.sidebar-content::-webkit-scrollbar { width: 6px; }
.sidebar-content::-webkit-scrollbar-track { background: transparent; }
.sidebar-content::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
/* Panels */
.panel-section { margin-bottom: 16px; }
.panel-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Form Elements */
.form-group { margin-bottom: 10px; }
.form-label {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 6px 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--accent-blue);
}
.form-textarea { resize: vertical; min-height: 40px; font-family: inherit; }
/* List Items */
.list-item {
padding: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.15s;
}
.list-item:hover { border-color: #8b949e; }
.list-item.selected { border-color: var(--accent-blue); background: rgba(88,166,255,0.08); }
.list-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.list-item-title {
font-size: 12px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.list-item-meta {
font-size: 10px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Badge */
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
}
.badge-blue { background: rgba(88,166,255,0.15); color: var(--accent-blue); }
.badge-green { background: rgba(63,185,80,0.15); color: var(--accent-green); }
.badge-purple { background: rgba(188,140,255,0.15); color: var(--accent-purple); }
.badge-orange { background: rgba(210,153,34,0.15); color: var(--accent-orange); }
.badge-red { background: rgba(248,81,73,0.15); color: var(--accent-red); }
/* Graph Area */
.graph-container {
flex: 1;
position: relative;
overflow: hidden;
}
#graph-svg { width: 100%; height: 100%; display: block; }
/* View Toggle */
.view-toggle {
position: absolute;
top: 12px;
right: 12px;
z-index: 50;
display: flex;
gap: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 3px;
}
.view-btn {
padding: 5px 10px;
font-size: 11px;
border-radius: 5px;
cursor: pointer;
color: var(--text-secondary);
background: transparent;
border: none;
transition: all 0.15s;
}
.view-btn:hover { color: var(--text-primary); }
.view-btn.active { background: var(--bg-tertiary); color: var(--accent-blue); }
/* Zoom Controls */
.zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 4px;
}
.zoom-btn {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.zoom-btn:hover { background: var(--bg-tertiary); border-color: #8b949e; }
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
max-width: 280px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title { font-weight: 600; margin-bottom: 4px; }
.tooltip-body { color: var(--text-secondary); line-height: 1.4; }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.visible { opacity: 1; pointer-events: auto; }
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.modal-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 { font-size: 15px; font-weight: 600; }
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.modal-body { padding: 16px; }
.modal-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Event Timeline */
.timeline-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
max-height: 200px;
overflow-y: auto;
z-index: 40;
transition: transform 0.3s;
}
.timeline-container.collapsed { transform: translateY(calc(100% - 36px)); }
.timeline-header {
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
min-height: 36px;
}
.timeline-header h3 { font-size: 12px; font-weight: 600; color: var(--text-secondary); }
.timeline-body { padding: 8px 12px; }
.timeline-item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
}
.timeline-item:last-child { border-bottom: none; }
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.timeline-event-info { flex: 1; min-width: 0; }
.timeline-event-title { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.timeline-event-commit { color: var(--text-secondary); font-size: 11px; }
/* Legend */
.legend {
position: absolute;
top: 12px;
left: 12px;
z-index: 50;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
font-size: 11px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.legend-item:last-child { margin-bottom: 0; }
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 12px;
}
.empty-state svg { width: 40px; height: 40px; margin-bottom: 8px; opacity: 0.5; }
/* Color picker row */
.color-options { display: flex; gap: 6px; flex-wrap: wrap; }
.color-option {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s;
}
.color-option:hover { transform: scale(1.2); }
.color-option.selected { border-color: white; box-shadow: 0 0 0 2px var(--accent-blue); }
/* Responsive */
@media (max-width: 768px) {
.sidebar { width: 260px; min-width: 260px; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<h1>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
Git & Structure Visualizer
</h1>
<div class="header-actions">
<button class="btn" onclick="App.importData()">📥 Import</button>
<button class="btn" onclick="App.exportData()">📤 Export</button>
<button class="btn btn-danger btn-sm" onclick="App.resetAll()">↺ Reset</button>
</div>
</div>
<!-- Main Layout -->
<div class="main-layout">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-tabs">
<div class="sidebar-tab active" data-tab="commits" onclick="App.switchTab('commits')">Commits</div>
<div class="sidebar-tab" data-tab="branches" onclick="App.switchTab('branches')">Branches</div>
<div class="sidebar-tab" data-tab="events" onclick="App.switchTab('events')">Events</div>
</div>
<div class="sidebar-content" id="sidebar-content"></div>
</div>
<!-- Graph -->
<div class="graph-container">
<svg id="graph-svg"></svg>
<!-- Legend -->
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--commit-color)"></div> Commit</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--merge-color)"></div> Merge</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--event-color)"></div> Event Anchor</div>
</div>
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" data-view="graph" onclick="App.switchView('graph')">Graph</button>
<button class="view-btn" data-view="timeline" onclick="App.switchView('timeline')">Timeline</button>
</div>
<!-- Zoom Controls -->
<div class="zoom-controls">
<button class="zoom-btn" onclick="App.zoomIn()">+</button>
<button class="zoom-btn" onclick="App.zoomOut()">−</button>
<button class="zoom-btn" onclick="App.resetZoom()" style="font-size:12px">⊡</button>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip"></div>
<!-- Event Timeline Panel -->
<div class="timeline-container collapsed" id="timeline-panel">
<div class="timeline-header" onclick="App.toggleTimeline()">
<h3>📋 Contextual Events Timeline</h3>
<span style="font-size:10px;color:var(--text-secondary)">▲ Toggle</span>
</div>
<div class="timeline-body" id="timeline-body"></div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 id="modal-title">Modal</h2>
<button class="modal-close" onclick="App.closeModal()">✕</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer" id="modal-footer"></div>
</div>
</div>
<!-- Hidden file input -->
<input type="file" id="file-input" accept=".json" style="display:none">
<script>
const App = (() => {
// ─── State ──────────────────────────────────────────────
let state = {
commits: [],
branches: [],
tags: [],
events: [],
selectedCommitId: null,
hoveredCommitId: null,
currentTab: 'commits',
currentView: 'graph'
};
const STORAGE_KEY = 'git-visualizer-state';
let svg, g, zoom;
let simulation;
let width, height;
// ─── Persistence ────────────────────────────────────────
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch(e) {}
}
function loadState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
state = { ...state, ...parsed };
}
} catch(e) {}
}
// ─── ID Generation ──────────────────────────────────────
function genId() {
return Math.random().toString(16).substr(2, 8);
}
function shortHash(id) {
return id ? id.substr(0, 7) : '';
}
// ─── CRUD: Commits ──────────────────────────────────────
function addCommit(data) {
const commit = {
id: genId(),
message: data.message || 'New commit',
parents: data.parents || [],
author: data.author || 'Developer',
date: data.date || new Date().toISOString(),
x: null, y: null
};
state.commits.push(commit);
saveState();
return commit;
}
function updateCommit(id, updates) {
const idx = state.commits.findIndex(c => c.id === id);
if (idx >= 0) {
Object.assign(state.commits[idx], updates);
// Update events that reference this commit
saveState();
}
}
function deleteCommit(id) {
const commit = state.commits.find(c => c.id === id);
if (!commit) return;
// Remove references from other commits' parents
state.commits.forEach(c => {
c.parents = c.parents.filter(p => p !== id);
});
// Update branches pointing to this commit
state.branches.forEach(b => {
if (b.head === id) b.head = null;
});
// Remove associated events
state.events = state.events.filter(e => e.commitId !== id);
// Remove tags
state.tags = state.tags.filter(t => t.commitId !== id);
state.commits = state.commits.filter(c => c.id !== id);
if (state.selectedCommitId === id) state.selectedCommitId = null;
saveState();
}
// ─── CRUD: Branches ─────────────────────────────────────
function addBranch(name, headCommitId) {
if (state.branches.find(b => b.name === name)) return null;
const branch = { id: genId(), name, head: headCommitId || null };
state.branches.push(branch);
saveState();
return branch;
}
function updateBranch(id, updates) {
const idx = state.branches.findIndex(b => b.id === id);
if (idx >= 0) {
Object.assign(state.branches[idx], updates);
saveState();
}
}
function deleteBranch(id) {
state.branches = state.branches.filter(b => b.id !== id);
saveState();
}
// ─── CRUD: Tags ─────────────────────────────────────────
function addTag(name, commitId) {
if (state.tags.find(t => t.name === name)) return null;
const tag = { id: genId(), name, commitId };
state.tags.push(tag);
saveState();
return tag;
}
function deleteTag(id) {
state.tags = state.tags.filter(t => t.id !== id);
saveState();
}
// ─── CRUD: Events ───────────────────────────────────────
const eventColors = ['#d29922','#f85149','#3fb950','#bc8cff','#f778ba','#58a6ff'];
function addEvent(data) {
const event = {
id: genId(),
title: data.title || 'New Event',
description: data.description || '',
commitId: data.commitId,
color: data.color || eventColors[Math.floor(Math.random() * eventColors.length)],
date: data.date || new Date().toISOString()
};
state.events.push(event);
saveState();
return event;
}
function updateEvent(id, updates) {
const idx = state.events.findIndex(e => e.id === id);
if (idx >= 0) {
Object.assign(state.events[idx], updates);
saveState();
}
}
function deleteEvent(id) {
state.events = state.events.filter(e => e.id !== id);
saveState();
}
// ─── Import / Export ────────────────────────────────────
function exportData() {
const data = JSON.stringify(state, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'git-visualizer-data.json';
a.click();
URL.revokeObjectURL(url);
}
function importData() {
const input = document.getElementById('file-input');
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
state = { ...state, ...imported };
saveState();
renderAll();
} catch(err) {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
};
input.click();
}
function resetAll() {
if (!confirm('Reset all data? This cannot be undone.')) return;
state = { commits: [], branches: [], tags: [], events: [], selectedCommitId: null, hoveredCommitId: null, currentTab: 'commits', currentView: 'graph' };
saveState();
renderAll();
}
// ─── Load Sample Data ───────────────────────────────────
function loadSampleData() {
const c1 = addCommit({ message: 'Initial commit', author: 'Alice', date: '2024-01-01T10:00:00Z' });
const c2 = addCommit({ message: 'Add README', author: 'Bob', parents: [c1.id], date: '2024-01-02T11:00:00Z' });
const c3 = addCommit({ message: 'Setup project structure', author: 'Alice', parents: [c2.id], date: '2024-01-03T09:00:00Z' });
const c4 = addCommit({ message: 'Implement auth module', author: 'Charlie', parents: [c3.id], date: '2024-01-05T14:00:00Z' });
const c5 = addCommit({ message: 'Add database layer', author: 'Bob', parents: [c3.id], date: '2024-01-06T10:00:00Z' });
const c6 = addCommit({ message: 'API endpoints v1', author: 'Alice', parents: [c4.id, c5.id], date: '2024-01-08T16:00:00Z' });
const c7 = addCommit({ message: 'Fix auth bug #42', author: 'Charlie', parents: [c6.id], date: '2024-01-09T11:00:00Z' });
const c8 = addCommit({ message: 'Add CI/CD pipeline', author: 'Bob', parents: [c7.id], date: '2024-01-10T09:00:00Z' });
const c9 = addCommit({ message: 'Performance optimization', author: 'Alice', parents: [c8.id], date: '2024-01-12T15:00:00Z' });
const c10 = addCommit({ message: 'Add monitoring dashboard', author: 'Charlie', parents: [c9.id], date: '2024-01-14T13:00:00Z' });
// Branches
addBranch('main', c10.id);
addBranch('develop', c8.id);
addBranch('feature/auth', c4.id);
addBranch('feature/db', c5.id);
// Tags
addTag('v1.0.0', c6.id);
addTag('v1.1.0', c9.id);
// Events
addEvent({ title: 'Project Kickoff', description: 'Initial project setup and team formation', commitId: c1.id, color: '#3fb950' });
addEvent({ title: 'Code Review Sprint', description: 'First major code review cycle completed', commitId: c4.id, color: '#bc8cff' });
addEvent({ title: 'Merge Feature Branches', description: 'Auth and DB branches merged into main', commitId: c6.id, color: '#d29922' });
addEvent({ title: 'Security Audit', description: 'External security audit passed with minor findings', commitId: c7.id, color: '#f85149' });
addEvent({ title: 'CI/CD Integration', description: 'Automated deployment pipeline established', commitId: c8.id, color: '#58a6ff' });
addEvent({ title: 'Code Freeze', description: 'Pre-release code freeze for v1.2.0', commitId: c9.id, color: '#f778ba' });
addEvent({ title: 'Feature Completion', description: 'All planned features implemented and tested', commitId: c10.id, color: '#3fb950' });
saveState();
}
// ─── D3 Visualization ───────────────────────────────────
function initGraph() {
const container = document.querySelector('.graph-container');
width = container.clientWidth;
height = container.clientHeight;
svg = d3.select('#graph-svg')
.attr('width', width)
.attr('height', height);
// Clear previous
svg.selectAll('*').remove();
zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
g = svg.append('g');
// Defs for markers and filters
const defs = svg.append('defs');
// Arrow marker
defs.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 28)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L8,0L0,4')
.attr('fill', '#30363d');
// Glow filter
const glow = defs.append('filter').attr('id', 'glow');
glow.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'coloredBlur');
const feMerge = glow.append('feMerge');
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
// Event glow filter
const eventGlow = defs.append('filter').attr('id', 'event-glow');
eventGlow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'coloredBlur');
const feMerge2 = eventGlow.append('feMerge');
feMerge2.append('feMergeNode').attr('in', 'coloredBlur');
feMerge2.append('feMergeNode').attr('in', 'SourceGraphic');
renderGraph();
}
function renderGraph() {
if (!g) return;
g.selectAll('*').remove();
const commits = state.commits;
if (commits.length === 0) {
// Empty state
const text = g.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#8b949e')
.attr('font-size', '14px');
text.append('tspan').text('No commits yet. Add your first commit to get started.');
return;
}
// Build nodes and links
const nodeMap = {};
commits.forEach(c => {
nodeMap[c.id] = c;
});
const nodes = commits.map(c => ({ ...c, type: 'commit' }));
const links = [];
commits.forEach(c => {
c.parents.forEach(pid => {
if (nodeMap[pid]) {
links.push({ source: pid, target: c.id });
}
});
});
// Compute layout positions using a topological sort + layered approach
computeLayout(nodes, links);
// ─── Edges ──────────────────────────────────────────
const linkGroup = g.append('g').attr('class', 'links');
const link = linkGroup.selectAll('.link')
.data(links)
.join('line')
.attr('class', 'link')
.attr('stroke', '#30363d')
.attr('stroke-width', 1.5)
.attr('marker-end', 'url(#arrow)');
// ─── Nodes ──────────────────────────────────────────
const nodeGroup = g.append('g').attr('class', 'nodes');
const node = nodeGroup.selectAll('.node')
.data(nodes, d => d.id)
.join('g')
.attr('class', 'node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.style('cursor', 'pointer');
// Node circle
node.append('circle')
.attr('r', 14)
.attr('fill', d => {
if (d.parents.length >= 2) return '#bc8cff';
return '#58a6ff';
})
.attr('stroke', d => state.selectedCommitId === d.id ? '#fff' : 'transparent')
.attr('stroke-width', 2)
.attr('filter', d => state.hoveredCommitId === d.id || state.selectedCommitId === d.id ? 'url(#glow)' : null);
// Node hash label
node.append('text')
.attr('dy', -20)
.attr('text-anchor', 'middle')
.attr('fill', '#8b949e')
.attr('font-size', '10px')
.attr('font-family', 'monospace')
.text(d => shortHash(d.id));
// Node message label
node.append('text')
.attr('dy', 28)
.attr('text-anchor', 'middle')
.attr('fill', '#e6edf3')
.attr('font-size', '10px')
.each(function(d) {
const msg = d.message.length > 25 ? d.message.substr(0, 24) + '…' : d.message;
d3.select(this).text(msg);
});
// Branch labels on nodes
node.each(function(d) {
const branchNames = state.branches.filter(b => b.head === d.id).map(b => b.name);
if (branchNames.length > 0) {
d3.select(this).append('text')
.attr('dy', -32)
.attr('text-anchor', 'middle')
.attr('fill', '#3fb950')
.attr('font-size', '9px')
.attr('font-weight', '600')
.text(branchNames.join(', '));
}
});
// Tag labels on nodes
node.each(function(d) {
const tagNames = state.tags.filter(t => t.commitId === d.id).map(t => t.name);
if (tagNames.length > 0) {
d3.select(this).append('text')
.attr('dy', 42)
.attr('text-anchor', 'middle')
.attr('fill', '#f778ba')
.attr('font-size', '9px')
.attr('font-weight', '600')
.text(tagNames.join(', '));
}
});
// ─── Event Anchors ──────────────────────────────────
const eventGroup = g.append('g').attr('class', 'events');
state.events.forEach(ev => {
const commit = nodeMap[ev.commitId];
if (!commit) return;
const angle = (state.events.indexOf(ev) / Math.max(state.events.length, 1)) * Math.PI * 2;
const radius = 30;
const ex = commit.x + Math.cos(angle) * radius;
const ey = commit.y + Math.sin(angle) * radius;
// Connector line
eventGroup.append('line')
.attr('x1', commit.x)
.attr('y1', commit.y)
.attr('x2', ex)
.attr('y2', ey)
.attr('stroke', ev.color)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '3,3')
.attr('opacity', 0.6);
// Event dot
const evt = eventGroup.append('g')
.attr('class', `event-anchor event-${ev.id}`)
.attr('transform', `translate(${ex},${ey})`)
.style('cursor', 'pointer');
evt.append('circle')
.attr('r', 6)
.attr('fill', ev.color)
.attr('stroke', '#0d1117')
.attr('stroke-width', 2)
.attr('filter', 'url(#event-glow)');
evt.append('text')
.attr('dy', -10)
.attr('text-anchor', 'middle')
.attr('fill', ev.color)
.attr('font-size', '9px')
.attr('font-weight', '600')
.text(ev.title.length > 15 ? ev.title.substr(0, 14) + '…' : ev.title);
// Event interactions
evt.on('mouseover', function(event) {
d3.select(this).select('circle').attr('r', 8);
showTooltip(event, `<div class="tooltip-title" style="color:${ev.color}">● ${ev.title}</div>
<div class="tooltip-body">${ev.description || 'No description'}</div>
<div class="tooltip-body" style="margin-top:4px">Linked to commit: <code>${shortHash(ev.commitId)}</code></div>`);
})
.on('mousemove', function(event) { moveTooltip(event); })
.on('mouseout', function() {
d3.select(this).select('circle').attr('r', 6);
hideTooltip();
});
});
// ─── Node Interactions ──────────────────────────────
node.on('click', function(event, d) {
state.selectedCommitId = state.selectedCommitId === d.id ? null : d.id;
saveState();
renderGraph();
renderSidebar();
})
.on('mouseover', function(event, d) {
state.hoveredCommitId = d.id;
const commitEvents = state.events.filter(e => e.commitId === d.id);
let html = `<div class="tooltip-title">🔵 ${shortHash(d.id)}</div>
<div class="tooltip-body"><strong>${d.message}</strong></div>
<div class="tooltip-body">Author: ${d.author}<br>Date: ${new Date(d.date).toLocaleDateString()}</div>`;
if (commitEvents.length > 0) {
html += `<div class="tooltip-body" style="margin-top:4px;border-top:1px solid #30363d;padding-top:4px">
Events (${commitEvents.length}):<br>` + commitEvents.map(e => `• <span style="color:${e.color}">${e.title}</span>`).join('<br>') + '</div>';
}
showTooltip(event, html);
})
.on('mousemove', function(event) { moveTooltip(event); })
.on('mouseout', function() {
state.hoveredCommitId = null;
hideTooltip();
});
// Drag behavior
const drag = d3.drag()
.on('start', function(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', function(event, d) {
d.fx = event.x;
d.fy = event.y;
updatePositions();
})
.on('end', function(event, d) {
if (!event.active) simulation.alphaTarget(0);
// Keep position fixed after drag
});
node.call(drag);
// ─── Force Simulation (lightweight for stability) ──
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80).strength(0.5))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.05))
.force('collision', d3.forceCollide().radius(40))
.stop();
// Run a few ticks to settle
for (let i = 0; i < 60; i++) simulation.tick();
function updatePositions() {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
// Update event positions
state.events.forEach((ev, i) => {
const commit = nodeMap[ev.commitId];
if (!commit) return;
const angle = (i / Math.max(state.events.length, 1)) * Math.PI * 2;
const radius = 30;
const ex = commit.x + Math.cos(angle) * radius;
const ey = commit.y + Math.sin(angle) * radius;
eventGroup.selectAll(`.event-${ev.id}`)
.attr('transform', `translate(${ex},${ey})`);
// Update connector lines (they're in order of events)
const lineIdx = state.events.indexOf(ev);
eventGroup.selectAll('line').filter((d, i) => i === lineIdx)
.attr('x1', commit.x).attr('y1', commit.y)
.attr('x2', ex).attr('y2', ey);
});
}
updatePositions();
// Store for later access
g._nodes = nodes;
g._links = links;
g._updatePositions = updatePositions;
}
function computeLayout(nodes, links) {
if (nodes.length === 0) return;
// Topological sort to determine layers
const inDegree = {};
const adjList = {};
nodes.forEach(n => { inDegree[n.id] = 0; adjList[n.id] = []; });
links.forEach(l => {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
inDegree[tgtId] = (inDegree[tgtId] || 0) + 1;
adjList[srcId].push(tgtId);
});
// Kahn's algorithm for topological layers
const layers = [];
const remaining = { ...inDegree };
let currentLayer = nodes.filter(n => inDegree[n.id] === 0).map(n => n.id);
while (currentLayer.length > 0) {
layers.push(currentLayer);
const nextLayer = [];
currentLayer.forEach(id => {
delete remaining[id];
adjList[id].forEach(childId => {
if (remaining[childId] !== undefined) {
// Only add to next layer when all parents are processed
let allParentsDone = true;
links.forEach(l => {
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
if (tgtId === childId) {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
if (!layers.flat().includes(srcId)) allParentsDone = false;
}
});
if (allParentsDone && !nextLayer.includes(childId)) nextLayer.push(childId);
}
});
});
// Add any remaining nodes that aren't in current or next layer to avoid infinite loops
const processed = layers.flat();
const unprocessed = Object.keys(remaining).filter(id => !currentLayer.includes(id) && !nextLayer.includes(id));
if (unprocessed.length > 0 && nextLayer.length === 0) {
// Break cycle: add all remaining
currentLayer = unprocessed;
continue;
}
currentLayer = nextLayer;
}
// Assign positions based on layers
const layerHeight = 120;
const nodeSpacing = 140;
const startX = width / 2;
const startY = 60;
layers.forEach((layer, li) => {
const y = startY + li * layerHeight;
const totalWidth = (layer.length - 1) * nodeSpacing;
const offsetX = startX - totalWidth / 2;
layer.forEach((id, ni) => {
const node = nodes.find(n => n.id === id);
if (node) {
// Only set position if not already fixed by drag
if (!node.fx && !node.fy) {
node.x = offsetX + ni * nodeSpacing;
node.y = y;
}
}
});
});
// Handle nodes that weren't placed (cycles etc.)
const placedIds = layers.flat();
nodes.forEach(n => {
if (!placedIds.includes(n.id)) {
n.x = startX + Math.random() * 200 - 100;
n.y = startY + layers.length * layerHeight + Math.random() * 100;
}
});
}
// ─── Timeline View ──────────────────────────────────────
function renderTimelineView() {
if (!g) return;
g.selectAll('*').remove();
const events = [...state.events].sort((a, b) => new Date(a.date) - new Date(b.date));
if (events.length === 0) {
g.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#8b949e')
.attr('font-size', '14px')
.text('No events yet. Add contextual events to see them here.');
return;
}
const padding = { top: 60, right: 100, bottom: 40, left: 100 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
// Time scale
const dates = events.map(e => new Date(e.date));
const timeScale = d3.scaleTime()
.domain(d3.extent(dates))
.range([padding.left, width - padding.right]);
// Y scale for positioning
const yScale = d3.scaleBand()
.domain(events.map((_, i) => i))
.range([padding.top + 20, height - padding.bottom])
.padding(0.5);
// Axis
g.append('g')
.attr('transform', `translate(0,${height - padding.bottom + 10})`)
.call(d3.axisBottom(timeScale).ticks(6).tickFormat(d => d.toLocaleDateString()))
.selectAll('text').attr('fill', '#8b949e').attr('font-size', '10px');
g.selectAll('.domain, .tick line').attr('stroke', '#30363d');
// Events as horizontal bars with commit info
events.forEach((ev, i) => {
const y = yScale(i);
const x = timeScale(new Date(ev.date));
const commit = state.commits.find(c => c.id === ev.commitId);
// Background bar
g.append('rect')
.attr('x', padding.left)
.attr('y', y - 12)
.attr('width', chartW)
.attr('height', 24)
.attr('fill', 'rgba(33,38,45,0.5)')
.attr('rx', 4);
// Event dot
g.append('circle')
.attr('cx', x)
.attr('cy', y)
.attr('r', 7)
.attr('fill', ev.color)
.attr('stroke', '#0d1117')
.attr('stroke-width', 2);
// Event title (left side)
g.append('text')
.attr('x', padding.left + 8)
.attr('y', y + 4)
.attr('fill', '#e6edf3')
.attr('font-size', '12px')
.attr('font-weight', '500')
.text(ev.title);
// Commit info (right side)
if (commit) {
g.append('text')
.attr('x', width - padding.right - 8)
.attr('y', y + 4)
.attr('text-anchor', 'end')
.attr('fill', '#8b949e')
.attr('font-size', '10px')
.attr('font-family', 'monospace')
.text(`${shortHash(commit.id)} — ${commit.message.length > 20 ? commit.message.substr(0, 19) + '…' : commit.message}`);
}
// Vertical line from dot to axis
g.append('line')
.attr('x1', x)
.attr('y1', y + 7)
.attr('x2', x)
.attr('y2', height - padding.bottom)
.attr('stroke', ev.color)
.attr('stroke-width', 1)
.attr('opacity', 0.3);
// Click interaction
g.append('rect')
.attr('x', padding.left)
.attr('y', y - 12)
.attr('width', chartW)
.attr('height', 24)
.attr('fill', 'transparent')
.style('cursor', 'pointer')
.on('click', () => {
state.selectedCommitId = ev.commitId;
saveState();
App.switchView('graph');
})
.on('mouseover', (event) => {
showTooltip(event, `<div class="tooltip-title" style="color:${ev.color}">● ${ev.title}</div>
<div class="tooltip-body">${ev.description || 'No description'}</div>
<div class="tooltip-body" style="margin-top:4px">Commit: <code>${shortHash(ev.commitId)}</code></div>`);
})
.on('mousemove', (event) => moveTooltip(event))
.on('mouseout', hideTooltip);
});
// Title
g.append('text')
.attr('x', width / 2)
.attr('y', 30)
.attr('text-anchor', 'middle')
.attr('fill', '#e6edf3')
.attr('font-size', '14px')
.attr('font-weight', '600')
.text('Contextual Events Timeline');
}
// ─── Tooltip ────────────────────────────────────────────
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 container = document.querySelector('.graph-container');
const rect = container.getBoundingClientRect();
let x = event.clientX - rect.left + 15;
let y = event.clientY - rect.top - 10;
// Keep within bounds
if (x + 280 > rect.width) x = x - 300;
if (y + tooltip.offsetHeight > rect.height) y = y - tooltip.offsetHeight - 10;
if (y < 0) y = 10;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('visible');
}
// ─── Sidebar Rendering ──────────────────────────────────
function renderSidebar() {
const content = document.getElementById('sidebar-content');
switch (state.currentTab) {
case 'commits': renderCommitsPanel(content); break;
case 'branches': renderBranchesPanel(content); break;
case 'events': renderEventsPanel(content); break;
}
}
function renderCommitsPanel(el) {
let html = `
<div class="panel-section">
<div class="panel-title">
Commits (${state.commits.length})
<button class="btn btn-primary btn-sm" onclick="App.showAddCommitModal()">+ Add</button>
</div>`;
if (state.commits.length === 0) {
html += `<div class="empty-state">No commits yet.<br>Add your first commit or import data.</div>`;
} else {
const sorted = [...state.commits].sort((a, b) => new Date(b.date) - new Date(a.date));
sorted.forEach(c => {
const isSelected = state.selectedCommitId === c.id;
const eventCount = state.events.filter(e => e.commitId === c.id).length;
html += `
<div class="list-item ${isSelected ? 'selected' : ''}" onclick="App.selectCommit('${c.id}')">
<div class="list-item-header">
<span class="badge badge-blue">${shortHash(c.id)}</span>
<span style="font-size:10px;color:#8b949e">${new Date(c.date).toLocaleDateString()}</span>
</div>
<div class="list-item-title" style="margin-top:4px">${c.message}</div>
<div class="list-item-meta">by ${c.author}${eventCount > 0 ? ` · ${eventCount} event${eventCount > 1 ? 's' : ''}` : ''}${c.parents.length >= 2 ? ' · merge' : ''}</div>
</div>`;
});
}
html += `</div>`;
// Selected commit detail
if (state.selectedCommitId) {
const c = state.commits.find(x => x.id === state.selectedCommitId);
if (c) {
const branchNames = state.branches.filter(b => b.head === c.id).map(b => `<span class="badge badge-green">${b.name}</span>`).join(' ');
const tagNames = state.tags.filter(t => t.commitId === c.id).map(t => `<span class="badge badge-purple">${t.name}</span>`).join(' ');
const commitEvents = state.events.filter(e => e.commitId === c.id);
html += `
<div class="panel-section" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border-color)">
<div class="panel-title">Selected Commit</div>
<div style="font-size:12px;margin-bottom:8px">
<strong>${c.message}</strong><br>
<span style="color:#8b949e;font-family:monospace">${shortHash(c.id)}</span><br>
Author: ${c.author}<br>
Date: ${new Date(c.date).toLocaleString()}<br>
Parents: ${c.parents.length > 0 ? c.parents.map(p => shortHash(p)).join(', ') : 'none (root)'}
</div>
${branchNames ? `<div style="margin-bottom:4px">${branchNames}</div>` : ''}
${tagNames ? `<div style="margin-bottom:4px">${tagNames}</div>` : ''}
${commitEvents.length > 0 ? `
<div style="margin-top:8px">
<strong style="font-size:11px;color:#d29922">Linked Events:</strong>
${commitEvents.map(e => `<div style="display:flex;align-items:center;gap:4px;margin-top:4px;font-size:11px"><span style="color:${e.color}">●</span>${e.title}</div>`).join('')}
</div>` : ''}
<div style="margin-top:8px;display:flex;gap:4px">
<button class="btn btn-sm" onclick="App.showEditCommitModal('${c.id}')">✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick="App.confirmDeleteCommit('${c.id}')">🗑 Delete</button>
</div>
</div>`;
}
}
el.innerHTML = html;
}
function renderBranchesPanel(el) {
let html = `
<div class="panel-section">
<div class="panel-title">
Branches (${state.branches.length})
<button class="btn btn-primary btn-sm" onclick="App.showAddBranchModal()">+ Add</button>
</div>`;
if (state.branches.length === 0) {
html += `<div class="empty-state">No branches yet.</div>`;
} else {
state.branches.forEach(b => {
const headCommit = state.commits.find(c => c.id === b.head);
html += `
<div class="list-item" onclick="App.selectBranch('${b.id}')">
<div class="list-item-header">
<span class="badge badge-green">${b.name}</span>
</div>
<div class="list-item-meta" style="margin-top:4px">
HEAD → ${headCommit ? shortHash(b.head) + ' (' + headCommit.message.substr(0, 20) + ')' : 'detached'}
</div>
</div>`;
});
}
html += `</div>`;
// Tags section
html += `
<div class="panel-section">
<div class="panel-title">
Tags (${state.tags.length})
<button class="btn btn-primary btn-sm" onclick="App.showAddTagModal()">+ Add</button>
</div>`;
if (state.tags.length === 0) {
html += `<div class="empty-state">No tags yet.</div>`;
} else {
state.tags.forEach(t => {
const commit = state.commits.find(c => c.id === t.commitId);
html += `
<div class="list-item">
<div class="list-item-header">
<span class="badge badge-purple">${t.name}</span>
<button class="btn btn-danger btn-sm" style="padding:1px 4px;font-size:9px" onclick="App.deleteTag('${t.id}')">×</button>
</div>
<div class="list-item-meta" style="margin-top:4px">
→ ${commit ? shortHash(t.commitId) + ' (' + commit.message.substr(0, 20) + ')' : 'unknown'}
</div>
</div>`;
});
}
html += `</div>`;
el.innerHTML = html;
}
function renderEventsPanel(el) {
let html = `
<div class="panel-section">
<div class="panel-title">
Contextual Events (${state.events.length})
<button class="btn btn-primary btn-sm" onclick="App.showAddEventModal()">+ Add</button>
</div>`;
if (state.events.length === 0) {
html += `<div class="empty-state">No events yet.<br>Add contextual markers to link with commits.</div>`;
} else {
const sorted = [...state.events].sort((a, b) => new Date(b.date) - new Date(a.date));
sorted.forEach(ev => {
const commit = state.commits.find(c => c.id === ev.commitId);
html += `
<div class="list-item" onclick="App.selectEvent('${ev.id}')">
<div class="list-item-header">
<span style="color:${ev.color};font-size:14px">●</span>
<span class="list-item-title">${ev.title}</span>
<button class="btn btn-danger btn-sm" style="padding:1px 4px;font-size:9px" onclick="event.stopPropagation();App.deleteEvent('${ev.id}')">×</button>
</div>
${commit ? `<div class="list-item-meta" style="margin-top:4px">Linked to: <code>${shortHash(ev.commitId)}</code> — ${commit.message.substr(0, 25)}</div>` : '<div class="list-item-meta" style="margin-top:4px;color:#f85149">⚠ Orphaned (commit not found)</div>'}
<div class="list-item-meta">${new Date(ev.date).toLocaleDateString()}</div>
</div>`;
});
}
html += `</div>`;
el.innerHTML = html;
}
// ─── Modal Helpers ──────────────────────────────────────
function showModal(title, bodyHtml, footerHtml) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = bodyHtml;
document.getElementById('modal-footer').innerHTML = footerHtml || '';
document.getElementById('modal-overlay').classList.add('visible');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('visible');
}
// ─── Add Commit Modal ───────────────────────────────────
function showAddCommitModal() {
const parentOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} — ${c.message.substr(0, 25)}</option>`).join('');
showModal('Add Commit', `
<div class="form-group">
<label class="form-label">Message</label>
<input class="form-input" id="commit-msg" placeholder="Commit message..." value="New commit">
</div>
<div class="form-group">
<label class="form-label">Author</label>
<input class="form-input" id="commit-author" placeholder="Author name" value="Developer">
</div>
<div class="form-group">
<label class="form-label">Parent Commit(s) — select one or more (hold Ctrl/Cmd)</label>
<select class="form-select" id="commit-parents" multiple size="4">${parentOptions || '<option value="">(none — root commit)</option>'}</select>
</div>
`, `
<button class="btn" onclick="App.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="App.confirmAddCommit()">Add Commit</button>
`);
}
function confirmAddCommit() {
const msg = document.getElementById('commit-msg').value.trim();
const author = document.getElementById('commit-author').value.trim();
const parentSelect = document.getElementById('commit-parents');
const parents = Array.from(parentSelect.selectedOptions).map(o => o.value);
if (!msg) { alert('Please enter a commit message.'); return; }
addCommit({ message: msg, author: author || 'Developer', parents });
closeModal();
renderAll();
}
function showEditCommitModal(id) {
const c = state.commits.find(x => x.id === id);
if (!c) return;
const parentOptions = state.commits.filter(x => x.id !== id).map(x => `<option value="${x.id}" ${c.parents.includes(x.id) ? 'selected' : ''}>${shortHash(x.id)} — ${x.message.substr(0, 25)}</option>`).join('');
showModal('Edit Commit', `
<div class="form-group">
<label class="form-label">Message</label>
<input class="form-input" id="edit-commit-msg" value="${c.message}">
</div>
<div class="form-group">
<label class="form-label">Author</label>
<input class="form-input" id="edit-commit-author" value="${c.author}">
</div>
<div class="form-group">
<label class="form-label">Parent Commit(s)</label>
<select class="form-select" id="edit-commit-parents" multiple size="4">${parentOptions}</select>
</div>
`, `
<button class="btn" onclick="App.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="App.confirmEditCommit('${id}')">Save</button>
`);
}
function confirmEditCommit(id) {
const msg = document.getElementById('edit-commit-msg').value.trim();
const author = document.getElementById('edit-commit-author').value.trim();
const parentSelect = document.getElementById('edit-commit-parents');
const parents = Array.from(parentSelect.selectedOptions).map(o => o.value);
if (!msg) { alert('Please enter a commit message.'); return; }
updateCommit(id, { message: msg, author: author || 'Developer', parents });
closeModal();
renderAll();
}
function confirmDeleteCommit(id) {
const c = state.commits.find(x => x.id === id);
if (!c) return;
if (!confirm(`Delete commit "${c.message}"? This will also remove associated events and update branch pointers.`)) return;
deleteCommit(id);
renderAll();
}
// ─── Add Branch Modal ───────────────────────────────────
function showAddBranchModal() {
const commitOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} — ${c.message.substr(0, 25)}</option>`).join('');
showModal('Add Branch', `
<div class="form-group">
<label class="form-label">Branch Name</label>
<input class="form-input" id="branch-name" placeholder="e.g., feature/new-ui" value="">
</div>
<div class="form-group">
<label class="form-label">HEAD Commit</label>
<select class="form-select" id="branch-head">${commitOptions || '<option value="">(detached)</option>'}</select>
</div>
`, `
<button class="btn" onclick="App.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="App.confirmAddBranch()">Add Branch</button>
`);
}
function confirmAddBranch() {
const name = document.getElementById('branch-name').value.trim();
const head = document.getElementById('branch-head').value || null;
if (!name) { alert('Please enter a branch name.'); return; }
addBranch(name, head);
closeModal();
renderAll();
}
function selectBranch(id) {
const b = state.branches.find(x => x.id === id);
if (b && b.head) {
state.selectedCommitId = b.head;
saveState();
renderAll();
}
}
// ─── Add Tag Modal ──────────────────────────────────────
function showAddTagModal() {
const commitOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} — ${c.message.substr(0, 25)}</option>`).join('');
showModal('Add Tag', `
<div class="form-group">
<label class="form-label">Tag Name</label>
<input class="form-input" id="tag-name" placeholder="e.g., v1.0.0" value="">
</div>
<div class="form-group">
<label class="form-label">Target Commit</label>
<select class="form-select" id="tag-commit">${commitOptions}</select>
</div>
`, `
<button class="btn" onclick="App.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="App.confirmAddTag()">Add Tag</button>
`);
}
function confirmAddTag() {
const name = document.getElementById('tag-name').value.trim();
const commitId = document.getElementById('tag-commit').value;
if (!name) { alert('Please enter a tag name.'); return; }
addTag(name, commitId);
closeModal();
renderAll();
}
function deleteTag(id) {
deleteTag(id);
renderAll();
}
// ─── Add Event Modal ────────────────────────────────────
function showAddEventModal() {
const commitOptions = state.commits.map(c => `<option value="${c.id}">${shortHash(c.id)} — ${c.message.substr(0, 25)}</option>`).join('');
showModal('Add Contextual Event', `
<div class="form-group">
<label class="form-label">Title</label>
<input class="form-input" id="event-title" placeholder="e.g., Code Freeze" value="">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="event-desc" placeholder="Describe this event..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Linked Commit (required)</label>
<select class="form-select" id="event-commit">${commitOptions || '<option value="">No commits available</option>'}</select>
</div>
<div class="form-group">
<label class="form-label">Color</label>
<div class="color-options" id="event-colors">
${eventColors.map((c, i) => `<div class="color-option ${i === 0 ? 'selected' : ''}" style="background:${c}" data-color="${c}" onclick="App.selectEventColor(this)"></div>`).join('')}
</div>
</div>
`, `
<button class="btn" onclick="App.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="App.confirmAddEvent()">Add Event</button>
`);
}
function selectEventColor(el) {
document.querySelectorAll('.color-option').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
}
function confirmAddEvent() {
const title = document.getElementById('event-title').value.trim();
const desc = document.getElementById('event-desc').value.trim();
const commitId = document.getElementById('event-commit').value;
const colorEl = document.querySelector('.color-option.selected');
const color = colorEl ? colorEl.dataset.color : eventColors[0];
if (!title) { alert('Please enter an event title.'); return; }
if (!commitId) { alert('Please select a commit to link this event to.'); return; }
addEvent({ title, description: desc, commitId, color });
closeModal();
renderAll();
}
function deleteEvent(id) {
deleteEvent(id);
renderAll();
}
function selectEvent(id) {
const ev = state.events.find(e => e.id === id);
if (ev && ev.commitId) {
state.selectedCommitId = ev.commitId;
saveState();
renderAll();
}
}
// ─── View Switching ─────────────────────────────────────
function switchView(view) {
state.currentView = view;
document.querySelectorAll('.view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
if (view === 'graph') {
renderGraph();
} else {
renderTimelineView();
}
}
function switchTab(tab) {
state.currentTab = tab;
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
renderSidebar();
}
// ─── Zoom Controls ──────────────────────────────────────
function zoomIn() {
if (!svg) return;
svg.transition().duration(300).call(zoom.scaleBy, 1.5);
}
function zoomOut() {
if (!svg) return;
svg.transition().duration(300).call(zoom.scaleBy, 0.67);
}
function resetZoom() {
if (!svg) return;
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
}
// ─── Timeline Panel Toggle ──────────────────────────────
function toggleTimeline() {
const panel = document.getElementById('timeline-panel');
panel.classList.toggle('collapsed');
renderTimelinePanel();
}
function renderTimelinePanel() {
const body = document.getElementById('timeline-body');
const events = [...state.events].sort((a, b) => new Date(b.date) - new Date(a.date));
if (events.length === 0) {
body.innerHTML = '<div class="empty-state">No events yet.</div>';
return;
}
body.innerHTML = events.map(ev => {
const commit = state.commits.find(c => c.id === ev.commitId);
return `
<div class="timeline-item" onclick="App.selectCommit('${ev.commitId}')" style="cursor:pointer">
<div class="timeline-dot" style="background:${ev.color}"></div>
<div class="timeline-event-info">
<div class="timeline-event-title">${ev.title}</div>
<div class="timeline-event-commit">${commit ? shortHash(ev.commitId) + ' — ' + commit.message.substr(0, 30) : 'orphaned'} · ${new Date(ev.date).toLocaleDateString()}</div>
</div>
</div>`;
}).join('');
}
// ─── Selection ──────────────────────────────────────────
function selectCommit(id) {
state.selectedCommitId = state.selectedCommitId === id ? null : id;
saveState();
renderAll();
}
// ─── Render All ─────────────────────────────────────────
function renderAll() {
renderSidebar();
if (state.currentView === 'graph') {
renderGraph();
} else {
renderTimelineView();
}
renderTimelinePanel();
}
// ─── Resize Handler ─────────────────────────────────────
function handleResize() {
const container = document.querySelector('.graph-container');
width = container.clientWidth;
height = container.clientHeight;
if (svg) {
svg.attr('width', width).attr('height', height);
}
renderAll();
}
// ─── Init ───────────────────────────────────────────────
function init() {
loadState();
// Load sample data if empty
if (state.commits.length === 0) {
loadSampleData();
}
initGraph();
renderSidebar();
renderTimelinePanel();
window.addEventListener('resize', handleResize);
// Close modal on overlay click
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// Keyboard shortcut: Escape to close modal / deselect
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.getElementById('modal-overlay').classList.contains('visible')) {
closeModal();
} else {
state.selectedCommitId = null;
saveState();
renderAll();
}
}
});
}
// ─── Public API ─────────────────────────────────────────
return {
init, switchTab, switchView, selectCommit, selectBranch, selectEvent,
showAddCommitModal, confirmAddCommit, showEditCommitModal, confirmEditCommit,
confirmDeleteCommit, showAddBranchModal, confirmAddBranch, showAddTagModal,
confirmAddTag, deleteTag, showAddEventModal, confirmAddEvent, deleteEvent,
selectEventColor, closeModal, exportData, importData, resetAll,
zoomIn, zoomOut, resetZoom, toggleTimeline
};
})();
// Start the app when DOM is ready
document.addEventListener('DOMContentLoaded', App.init);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"07492e5d-62e9-4dd0-8441-cff5dfdfafd8","tokensIn":138136,"tokensOut":20142,"tokensTotal":158278,"turns":6,"toolCalls":5,"failedToolCalls":0,"timestamp":"2026-07-29T15:11:01.721Z"} -->