Files
PR-Helper/static/js/graph.js
T
2026-06-21 14:45:50 +08:00

618 lines
22 KiB
JavaScript

// 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 = `<div class="text-red-500 p-4">Error loading graph: ${err.message}</div>`;
}
},
setSelection(baseHash, headHash) {
this.selectedBase = baseHash;
this.selectedHead = headHash;
this._updateSelectionVisuals();
},
render() {
if (this.commits.length === 0) {
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
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 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;