← Git Explorer results

Git Explorer

Qwen 3.6 27b MTP · full

Initial view of Git Explorer

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"} -->