diff --git a/static/js/graph.js b/static/js/graph.js index 1b4585a..24662ac 100644 --- a/static/js/graph.js +++ b/static/js/graph.js @@ -1,5 +1,20 @@ // 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; @@ -10,7 +25,7 @@ const GitGraph = { // Selection state this.selectedBase = null; this.selectedHead = null; - this.onSelectionChange = null; // callback(baseHash, headHash) + this.onSelectionChange = null; if (!this.container) { console.error('Graph container not found:', containerId); @@ -36,7 +51,6 @@ const GitGraph = { } }, - // Set selection programmatically (e.g. from dropdowns) setSelection(baseHash, headHash) { this.selectedBase = baseHash; this.selectedHead = headHash; @@ -49,17 +63,43 @@ const GitGraph = { return; } - // Clear container this.container.innerHTML = ''; - const width = this.container.clientWidth || 800; + 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: 120, bottom: 20, left: 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', width) + .attr('width', Math.max(width, graphWidth)) .attr('height', height); const g = svg.append('g') @@ -67,20 +107,112 @@ const GitGraph = { // Build node positions const nodeMap = {}; - const laneWidth = 24; - - // Assign lanes to commits - const lanes = this.assignLanes(); this.commits.forEach((commit, i) => { nodeMap[commit.hash] = { - x: (lanes[commit.hash] || 0) * laneWidth + 20, + x: (lanes[commit.hash] || 0) * laneWidth, y: i * nodeHeight + 20, + lane: lanes[commit.hash] || 0, commit }; }); - // Draw edges + // 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); + + // 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() @@ -90,31 +222,63 @@ const GitGraph = { 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', '#94a3b8') - .attr('stroke-width', 2); + .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); + }, - // Draw commit nodes + _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, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`) + .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', '#3b82f6') - .attr('stroke', '#1e40af') + .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); - // Add commit message + // Commit message nodes.append('text') .attr('x', 16) .attr('y', 4) @@ -122,7 +286,7 @@ const GitGraph = { .attr('font-size', '12px') .attr('fill', '#374151'); - // Add short hash + // Short hash nodes.append('text') .attr('x', 16) .attr('y', -8) @@ -131,53 +295,10 @@ const GitGraph = { .attr('fill', '#6b7280') .attr('font-family', 'monospace'); - // Draw refs - const refGroups = g.selectAll('.ref') - .data(this.refs) - .enter() - .append('g') - .attr('class', 'ref'); - - refGroups.each((ref, i, elements) => { - const commitNode = nodeMap[ref.hash]; - if (!commitNode) return; - - const group = d3.select(elements[i]); - const isTag = ref.is_tag; - const color = isTag ? '#10b981' : (ref.is_head ? '#8b5cf6' : '#3b82f6'); - - const bbox = { x: commitNode.x + 30, y: commitNode.y - 10 }; - - group.append('rect') - .attr('x', bbox.x) - .attr('y', bbox.y) - .attr('rx', 4) - .attr('ry', 4) - .attr('fill', color) - .attr('opacity', 0.1) - .attr('stroke', color) - .attr('stroke-width', 1); - - const text = group.append('text') - .attr('x', bbox.x + 6) - .attr('y', bbox.y + 14) - .text(ref.name) - .attr('font-size', '11px') - .attr('fill', color) - .attr('font-weight', '500'); - - // Size rect to text - const textBBox = text.node().getBBox(); - group.select('rect') - .attr('width', textBBox.width + 12) - .attr('height', textBBox.height + 6); - }); - // Hover effect nodes.on('mouseover', function(event, d) { d3.select(this).select('circle') - .attr('r', 8) - .attr('fill', '#2563eb'); + .attr('r', 8); }).on('mouseout', function(event, d) { const node = d3.select(this); const hash = d.hash; @@ -186,50 +307,130 @@ const GitGraph = { } else if (hash === self.selectedHead) { node.select('circle').attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { - node.select('circle').attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af'); + 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 for base/head selection + // Click handler nodes.on('click', function(event, d) { const hash = d.hash; if (!self.selectedBase) { - // First click: set base self.selectedBase = hash; } else if (!self.selectedHead) { - // Second click: set head if (hash === self.selectedBase) { - // Clicked same node, deselect base self.selectedBase = null; } else { self.selectedHead = hash; } } else { - // Both selected: reset and start new selection self.selectedBase = hash; self.selectedHead = null; } self._updateSelectionVisuals(); - // Notify callback if (self.onSelectionChange) { self.onSelectionChange(self.selectedBase, self.selectedHead); } }); - // Store refs for later updates this._nodes = nodes; - this._nodeMap = nodeMap; - - // Apply any pre-existing selection - this._updateSelectionVisuals(); }, - // Update visual state of selected 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); + }); + }, + _updateSelectionVisuals() { - if (!this._nodes) return; + if (!this._nodes || !this._lanes) return; const self = this; this._nodes.each(function(d) { @@ -242,12 +443,16 @@ const GitGraph = { } else if (hash === self.selectedHead) { circle.attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { - circle.attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af'); + 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'); } }); }, - // Get the ref name (branch/tag) for a commit hash, or short hash if none getRefName(hash) { const ref = this.refs.find(r => r.hash === hash); if (ref) return ref.name; @@ -255,63 +460,76 @@ const GitGraph = { 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() { - // Simple lane assignment: find branches and assign lanes const lanes = {}; - const branchHeads = {}; + const commitMap = {}; + this.commits.forEach(c => { commitMap[c.hash] = c; }); - // Find branch head commits - this.refs.forEach(ref => { - if (!ref.is_tag) { - branchHeads[ref.hash] = ref.name; - } - }); - - // BFS from branch heads to assign lanes - let nextLane = 0; const visited = new Set(); + let nextLane = 0; - // First assign HEAD branch + // 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) { - const queue = [headRef.hash]; - while (queue.length > 0) { - const hash = queue.shift(); - if (visited.has(hash)) continue; - visited.add(hash); - lanes[hash] = 0; - - // Find children (commits that have this as parent) - const children = this.edges.filter(e => e.target === hash).map(e => e.source); - children.forEach(child => { - if (!visited.has(child)) queue.push(child); - }); - } - nextLane = 1; + bfsFrom(headRef.hash, nextLane); + nextLane++; + } else if (this.commits.length > 0) { + bfsFrom(this.commits[0].hash, nextLane); + nextLane++; } - // Assign lanes to other branches - this.refs.filter(r => !r.is_head && !r.is_tag).forEach(ref => { - if (visited.has(ref.hash)) return; - - const queue = [ref.hash]; - while (queue.length > 0) { - const hash = queue.shift(); - if (visited.has(hash)) continue; - visited.add(hash); - lanes[hash] = nextLane; - - const children = this.edges.filter(e => e.target === hash).map(e => e.source); - children.forEach(child => { - if (!visited.has(child)) queue.push(child); - }); - } + // 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++; }); - // Assign any remaining commits + // 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 => { - if (!lanes[commit.hash]) { + 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; } }); @@ -320,5 +538,4 @@ const GitGraph = { } }; -// Export for use window.GitGraph = GitGraph;