// Git Graph visualization using D3.js with interactive base/head selection // Multi-branch layout with colored lanes and right-aligned branch labels const GitGraph = { // Branch color palette BRANCH_COLORS: [ '#3b82f6', // blue '#f97316', // orange '#10b981', // emerald '#8b5cf6', // violet '#ef4444', // red '#06b6d4', // cyan '#f59e0b', // amber '#ec4899', // pink '#14b8a6', // teal '#6366f1', // indigo ], init(containerId, repoId) { this.container = document.getElementById(containerId); this.repoId = repoId; this.commits = []; this.refs = []; this.edges = []; // Selection state this.selectedBase = null; this.selectedHead = null; this.onSelectionChange = null; if (!this.container) { console.error('Graph container not found:', containerId); return; } this.load(); }, async load() { try { const resp = await fetch(`/api/repos/${this.repoId}/graph`, { credentials: 'same-origin' }); if (!resp.ok) throw new Error('Failed to load graph'); const data = await resp.json(); this.commits = data.commits || []; this.refs = data.refs || []; this.edges = data.edges || []; this.render(); } catch (err) { this.container.innerHTML = `
Error loading graph: ${err.message}
`; } }, setSelection(baseHash, headHash) { this.selectedBase = baseHash; this.selectedHead = headHash; this._updateSelectionVisuals(); }, render() { if (this.commits.length === 0) { this.container.innerHTML = '
No commits found
'; return; } this.container.innerHTML = ''; const width = Math.max(this.container.clientWidth || 800, 600); const nodeHeight = 40; const laneWidth = 32; const height = Math.max(400, this.commits.length * nodeHeight + 60); const margin = { top: 20, right: 140, bottom: 20, left: 40 }; // Compute layout const lanes = this.assignLanes(); this._lanes = lanes; this._laneBranchMap = {}; this._mapBranchesToLanes(lanes, this._laneBranchMap); // Debug: log lane assignments const laneCounts = {}; Object.values(lanes).forEach(l => { laneCounts[l] = (laneCounts[l] || 0) + 1; }); console.log('=== Git Graph Debug ==='); console.log('Lane distribution:', laneCounts); console.log('Total unique lanes:', Object.keys(laneCounts).length); console.log('Branches:', this.refs.filter(r => !r.is_tag).map(r => r.name)); const merges = this.commits.filter(c => c.parent_ids && c.parent_ids.length > 1); console.log('Merge commits:', merges.length); merges.slice(0, 5).forEach(c => { console.log(` ${c.short_hash} lane=${lanes[c.hash]} parents=${c.parent_ids.length} msg="${c.message.substring(0,40)}"`); c.parent_ids.forEach((p, i) => { const inList = this.commits.some(cc => cc.hash === p); const pLane = lanes[p]; console.log(` parent[${i}]: ${p.substring(0,7)} inList=${inList} lane=${pLane}`); }); }); const maxLane = Math.max(0, ...Object.values(lanes)); const graphWidth = (maxLane + 1) * laneWidth + margin.left + margin.right; const svg = d3.select(this.container) .append('svg') .attr('width', Math.max(width, graphWidth)) .attr('height', height); const g = svg.append('g') .attr('transform', `translate(${margin.left},${margin.top})`); // Build node positions const nodeMap = {}; this.commits.forEach((commit, i) => { nodeMap[commit.hash] = { x: (lanes[commit.hash] || 0) * laneWidth, y: i * nodeHeight + 20, lane: lanes[commit.hash] || 0, commit }; }); // Draw colored lane lines (vertical lines through each branch's commits) this._drawLaneLines(g, nodeMap, this._laneBranchMap, laneWidth, height); // Draw edges (parent-child connections) this._drawEdges(g, nodeMap, lanes); // Draw commit nodes this._drawNodes(g, nodeMap, lanes); // Draw branch labels on the right side this._drawBranchLabels(g, nodeMap, this._laneBranchMap, laneWidth, maxLane); // Draw legend in top-right corner const svgWidth = Math.max(width, graphWidth); this._drawLegend(svg, this._laneBranchMap, svgWidth); // Store for updates this._nodeMap = nodeMap; // Apply any pre-existing selection this._updateSelectionVisuals(); }, // Map branch refs to their lanes _mapBranchesToLanes(lanes, laneBranchMap) { // First, map refs to lanes this.refs.forEach(ref => { if (ref.is_tag) return; const lane = lanes[ref.hash]; if (lane !== undefined && !laneBranchMap[lane]) { laneBranchMap[lane] = { name: ref.name, color: ref.is_head ? '#8b5cf6' : this._getBranchColor(ref.name), isHead: ref.is_head }; } }); // Ensure lane 0 always has a color if (!laneBranchMap[0]) { laneBranchMap[0] = { name: 'main', color: this.BRANCH_COLORS[0], isHead: false }; } // For any lane that has commits but no branch ref, create an anonymous label const maxLane = Math.max(0, ...Object.values(lanes)); for (let i = 0; i <= maxLane; i++) { if (!laneBranchMap[i]) { laneBranchMap[i] = { name: `branch-${i}`, color: this.BRANCH_COLORS[i % this.BRANCH_COLORS.length], isHead: false }; } } }, _getBranchColor(branchName) { // Deterministic color based on branch name let hash = 0; for (let i = 0; i < branchName.length; i++) { hash = ((hash << 5) - hash) + branchName.charCodeAt(i); hash |= 0; } return this.BRANCH_COLORS[Math.abs(hash) % this.BRANCH_COLORS.length]; }, // Draw vertical colored lines connecting consecutive commits in each lane _drawLaneLines(g, nodeMap, laneBranchMap, laneWidth, height) { Object.entries(laneBranchMap).forEach(([lane, info]) => { const laneNum = parseInt(lane); // Find all nodes in this lane, sorted by Y const nodesInLane = Object.values(nodeMap) .filter(n => n.lane === laneNum) .sort((a, b) => a.y - b.y); if (nodesInLane.length < 2) return; // Draw line segments between consecutive commits for (let i = 0; i < nodesInLane.length - 1; i++) { const a = nodesInLane[i]; const b = nodesInLane[i + 1]; g.append('line') .attr('x1', a.x) .attr('y1', a.y) .attr('x2', b.x) .attr('y2', b.y) .attr('stroke', info.color) .attr('stroke-width', 2) .attr('stroke-opacity', 0.4); } }); }, // Draw edges between commits _drawEdges(g, nodeMap, lanes) { const self = this; g.selectAll('.edge') .data(this.edges.filter(e => nodeMap[e.source] && nodeMap[e.target])) .enter() .append('path') .attr('class', 'edge') .attr('d', d => { const s = nodeMap[d.source]; const t = nodeMap[d.target]; if (!s || !t) return ''; // If same lane, draw straight vertical line if (s.lane === t.lane) { return `M ${s.x} ${s.y} L ${t.x} ${t.y}`; } // Different lanes: draw curved connection (merge/diverge) const midY = (s.y + t.y) / 2; return `M ${s.x} ${s.y} C ${s.x} ${midY}, ${t.x} ${midY}, ${t.x} ${t.y}`; }) .attr('fill', 'none') .attr('stroke', d => { // Color edge by source commit's lane const sourceLane = lanes[d.source]; const info = this._findLaneInfo(sourceLane); return info ? info.color : '#94a3b8'; }) .attr('stroke-width', 2) .attr('stroke-opacity', 0.6); }, _findLaneInfo(lane) { return this._laneBranchMap ? this._laneBranchMap[lane] : null; }, // Draw commit nodes _drawNodes(g, nodeMap, lanes) { const self = this; const nodes = g.selectAll('.node') .data(this.commits) .enter() .append('g') .attr('class', 'node') .attr('data-hash', d => d.hash) .attr('transform', d => { const pos = nodeMap[d.hash]; return `translate(${pos ? pos.x : 0},${pos ? pos.y : 0})`; }) .style('cursor', 'pointer'); // Node circles - colored by lane nodes.append('circle') .attr('r', 6) .attr('fill', d => { const lane = lanes[d.hash]; const info = this._findLaneInfo(lane); return info ? info.color : '#3b82f6'; }) .attr('stroke', d => { const lane = lanes[d.hash]; const info = this._findLaneInfo(lane); return info ? d3.color(info.color).darker(0.5) : '#1e40af'; }) .attr('stroke-width', 2); // Commit message nodes.append('text') .attr('x', 16) .attr('y', 4) .text(d => d.message.substring(0, 60) + (d.message.length > 60 ? '...' : '')) .attr('font-size', '12px') .attr('fill', '#374151'); // Short hash nodes.append('text') .attr('x', 16) .attr('y', -8) .text(d => d.short_hash) .attr('font-size', '10px') .attr('fill', '#6b7280') .attr('font-family', 'monospace'); // Hover effect nodes.on('mouseover', function(event, d) { d3.select(this).select('circle') .attr('r', 8); }).on('mouseout', function(event, d) { const node = d3.select(this); const hash = d.hash; if (hash === self.selectedBase) { node.select('circle').attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669'); } else if (hash === self.selectedHead) { node.select('circle').attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { const lane = lanes[hash]; const info = self._findLaneInfo(lane); node.select('circle') .attr('r', 6) .attr('fill', info ? info.color : '#3b82f6') .attr('stroke', info ? d3.color(info.color).darker(0.5) : '#1e40af'); } }); // Click handler nodes.on('click', function(event, d) { const hash = d.hash; if (!self.selectedBase) { self.selectedBase = hash; } else if (!self.selectedHead) { if (hash === self.selectedBase) { self.selectedBase = null; } else { self.selectedHead = hash; } } else { self.selectedBase = hash; self.selectedHead = null; } self._updateSelectionVisuals(); if (self.onSelectionChange) { self.onSelectionChange(self.selectedBase, self.selectedHead); } }); this._nodes = nodes; }, // Draw branch labels on the right side, aligned with lanes _drawBranchLabels(g, nodeMap, laneBranchMap, laneWidth, maxLane) { const self = this; // For each branch, find the first commit (topmost) in its lane // and place the label at that Y position on the right side Object.entries(laneBranchMap).forEach(([lane, info]) => { const laneNum = parseInt(lane); const nodesInLane = Object.values(nodeMap).filter(n => n.lane === laneNum); if (nodesInLane.length === 0) return; // Use the first commit's Y position for the label const firstNode = nodesInLane[0]; const labelX = (maxLane + 1) * laneWidth + 16; const labelY = firstNode.y; const group = g.append('g') .attr('class', 'branch-label') .attr('transform', `translate(${labelX},${labelY - 8})`); // Background pill const text = group.append('text') .attr('x', 8) .attr('y', 12) .text(info.name) .attr('font-size', '11px') .attr('fill', info.color) .attr('font-weight', '600') .attr('font-family', 'system-ui, -apple-system, sans-serif'); const textBBox = text.node().getBBox(); group.insert('rect', 'text') .attr('x', 0) .attr('y', 0) .attr('width', textBBox.width + 16) .attr('height', textBBox.height + 8) .attr('rx', 10) .attr('ry', 10) .attr('fill', info.color) .attr('opacity', 0.12); // If HEAD, add a dot indicator if (info.isHead) { group.append('circle') .attr('cx', -6) .attr('cy', 8) .attr('r', 3) .attr('fill', info.color); } }); // Also draw tags on the right side this.refs.filter(r => r.is_tag).forEach(ref => { const pos = nodeMap[ref.hash]; if (!pos) return; const labelX = (maxLane + 1) * laneWidth + 16; const labelY = pos.y; const group = g.append('g') .attr('class', 'tag-label') .attr('transform', `translate(${labelX},${labelY - 8})`); const text = group.append('text') .attr('x', 8) .attr('y', 12) .text(ref.name) .attr('font-size', '11px') .attr('fill', '#10b981') .attr('font-weight', '500'); const textBBox = text.node().getBBox(); group.insert('rect', 'text') .attr('x', 0) .attr('y', 0) .attr('width', textBBox.width + 16) .attr('height', textBBox.height + 8) .attr('rx', 10) .attr('ry', 10) .attr('fill', '#10b981') .attr('opacity', 0.12); }); }, // Draw legend in top-right corner of SVG _drawLegend(svg, laneBranchMap, svgWidth) { const legendPadding = 12; const itemHeight = 20; const circleRadius = 5; const fontSize = 11; // Collect unique branches const branches = []; const seen = new Set(); Object.values(laneBranchMap).forEach(info => { if (!seen.has(info.name)) { seen.add(info.name); branches.push(info); } }); if (branches.length === 0) return; // Create legend group const legend = svg.append('g') .attr('class', 'graph-legend'); // Calculate legend dimensions const tempText = svg.append('text') .attr('font-size', `${fontSize}px`) .attr('font-family', 'system-ui, -apple-system, sans-serif'); let maxTextWidth = 0; branches.forEach(b => { tempText.text(b.name); maxTextWidth = Math.max(maxTextWidth, tempText.node().getBBox().width); }); tempText.remove(); const legendWidth = circleRadius * 2 + 8 + maxTextWidth + legendPadding * 2; const legendHeight = branches.length * itemHeight + legendPadding * 2; // Position in top-right const legendX = svgWidth - legendWidth - 10; const legendY = 10; legend.attr('transform', `translate(${legendX},${legendY})`); // Background legend.append('rect') .attr('width', legendWidth) .attr('height', legendHeight) .attr('rx', 6) .attr('ry', 6) .attr('fill', 'white') .attr('stroke', '#e5e7eb') .attr('stroke-width', 1) .attr('opacity', 0.95); // Legend items branches.forEach((branch, i) => { const itemY = legendPadding + i * itemHeight + itemHeight / 2; legend.append('circle') .attr('cx', legendPadding + circleRadius) .attr('cy', itemY) .attr('r', circleRadius) .attr('fill', branch.color); legend.append('text') .attr('x', legendPadding + circleRadius * 2 + 8) .attr('y', itemY + 4) .text(branch.name) .attr('font-size', `${fontSize}px`) .attr('fill', '#374151') .attr('font-family', 'system-ui, -apple-system, sans-serif'); }); }, _updateSelectionVisuals() { if (!this._nodes || !this._lanes) return; const self = this; this._nodes.each(function(d) { const node = d3.select(this); const circle = node.select('circle'); const hash = d.hash; if (hash === self.selectedBase) { circle.attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669'); } else if (hash === self.selectedHead) { circle.attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { const lane = self._lanes[hash]; const info = self._findLaneInfo(lane); circle .attr('r', 6) .attr('fill', info ? info.color : '#3b82f6') .attr('stroke', info ? d3.color(info.color).darker(0.5) : '#1e40af'); } }); }, getRefName(hash) { const ref = this.refs.find(r => r.hash === hash); if (ref) return ref.name; const commit = this.commits.find(c => c.hash === hash); return commit ? commit.short_hash : hash.substring(0, 7); }, // Lane assignment: HEAD branch gets lane 0, other branches get sequential lanes // Merge commits stay on first-parent lane, second parent starts new lane assignLanes() { const lanes = {}; const commitMap = {}; this.commits.forEach(c => { commitMap[c.hash] = c; }); const visited = new Set(); let nextLane = 0; // Helper: BFS from a commit backwards through parents, assigning a lane const bfsFrom = (startHash, lane) => { const q = [startHash]; while (q.length > 0) { const hash = q.shift(); if (visited.has(hash)) continue; if (!commitMap[hash]) continue; visited.add(hash); lanes[hash] = lane; const commit = commitMap[hash]; const parents = commit.parent_ids || []; if (parents.length === 0) continue; if (parents.length === 1) { // Linear: parent continues in same lane if (!visited.has(parents[0])) q.push(parents[0]); } else { // Merge: first parent stays, others get new lanes later if (!visited.has(parents[0])) q.push(parents[0]); // Don't traverse other parents here — they'll be handled // when we process branch refs below } } }; // Step 1: HEAD branch gets lane 0 const headRef = this.refs.find(r => r.is_head); if (headRef) { bfsFrom(headRef.hash, nextLane); nextLane++; } else if (this.commits.length > 0) { bfsFrom(this.commits[0].hash, nextLane); nextLane++; } // Step 2: Each non-tag, non-HEAD ref gets its own lane this.refs.filter(r => !r.is_tag && !r.is_head).forEach(ref => { if (visited.has(ref.hash)) return; // already on some lane bfsFrom(ref.hash, nextLane); nextLane++; }); // Step 3: Handle merge second-parents that weren't reached by refs // Walk all commits; if a merge commit has a second parent not yet visited, // give it a new lane this.commits.forEach(commit => { const parents = commit.parent_ids || []; if (parents.length <= 1) return; for (let i = 1; i < parents.length; i++) { if (!visited.has(parents[i]) && commitMap[parents[i]]) { bfsFrom(parents[i], nextLane); nextLane++; } } }); // Assign any remaining unvisited commits to lane 0 this.commits.forEach(commit => { if (lanes[commit.hash] === undefined) { lanes[commit.hash] = 0; } }); return lanes; } }; window.GitGraph = GitGraph;