feat: Phase 2 — Git 核心功能,go-git 克隆、SSE 进度、D3.js 图形、diff2html 查看器
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Diff Viewer using diff2html
|
||||
const DiffViewer = {
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error('Diff container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// Render unified diff string
|
||||
renderDiff(diffString, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
const config = {
|
||||
drawFileList: options.drawFileList !== false,
|
||||
fileListToggle: options.fileListToggle !== false,
|
||||
fileListStartVisible: options.fileListStartVisible !== false,
|
||||
fileContentToggle: options.fileContentToggle !== false,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
// Use diff2html to render
|
||||
const diffHtml = Diff2Html.html(diffString, config);
|
||||
this.container.innerHTML = diffHtml;
|
||||
|
||||
// Add syntax highlighting
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
if (window.hljs) {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Render per-file diffs
|
||||
renderFiles(files, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No changes</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all patches
|
||||
const combinedDiff = files.map(f => f.patch).join('\n');
|
||||
this.renderDiff(combinedDiff, options);
|
||||
},
|
||||
|
||||
// Load and render diff from API
|
||||
async loadDiff(repoId, base, head, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading diff...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.diff) {
|
||||
this.renderDiff(data.diff, options);
|
||||
} else if (Array.isArray(data)) {
|
||||
this.renderFiles(data, options);
|
||||
}
|
||||
} catch (err) {
|
||||
this.container.innerHTML = `<div class="text-red-500 p-4">Error loading diff: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Load per-file diffs
|
||||
async loadFiles(repoId, base, head) {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading files...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const files = await resp.json();
|
||||
this.renderFiles(files);
|
||||
} catch (err) {
|
||||
this.container.innerHTML = `<div class="text-red-500 p-4">Error: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Switch view mode (side-by-side or line-by-line)
|
||||
setViewMode(mode) {
|
||||
if (!this.container) return;
|
||||
|
||||
const config = {
|
||||
drawFileList: true,
|
||||
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||
};
|
||||
|
||||
// Re-render with existing diff content
|
||||
const existingDiff = this.container.querySelector('.d2h-diff-table');
|
||||
if (existingDiff) {
|
||||
// Get the raw diff from the container's data
|
||||
const rawDiff = this.container.dataset.rawDiff;
|
||||
if (rawDiff) {
|
||||
this.renderDiff(rawDiff, config);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle file list visibility
|
||||
toggleFileList() {
|
||||
const fileList = this.container.querySelector('.d2h-file-list');
|
||||
if (fileList) {
|
||||
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// Expand/collapse all files
|
||||
toggleAllFiles(expand) {
|
||||
const fileContents = this.container.querySelectorAll('.d2h-file-wrapper');
|
||||
fileContents.forEach(file => {
|
||||
const content = file.querySelector('.d2h-file-diff');
|
||||
if (content) {
|
||||
content.style.display = expand ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Export for use
|
||||
window.DiffViewer = DiffViewer;
|
||||
@@ -0,0 +1,239 @@
|
||||
// Git Graph visualization using D3.js
|
||||
const GitGraph = {
|
||||
init(containerId, repoId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.repoId = repoId;
|
||||
this.commits = [];
|
||||
this.refs = [];
|
||||
this.edges = [];
|
||||
|
||||
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`);
|
||||
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>`;
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
if (this.commits.length === 0) {
|
||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const width = this.container.clientWidth || 800;
|
||||
const nodeHeight = 40;
|
||||
const height = Math.max(400, this.commits.length * nodeHeight + 60);
|
||||
const margin = { top: 20, right: 120, bottom: 20, left: 60 };
|
||||
|
||||
const svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Build node positions
|
||||
const nodeMap = {};
|
||||
const laneWidth = 24;
|
||||
|
||||
// Assign lanes to commits (simple algorithm: each branch gets its own lane)
|
||||
const lanes = this.assignLanes();
|
||||
|
||||
this.commits.forEach((commit, i) => {
|
||||
nodeMap[commit.hash] = {
|
||||
x: (lanes[commit.hash] || 0) * laneWidth + 20,
|
||||
y: i * nodeHeight + 20,
|
||||
commit
|
||||
};
|
||||
});
|
||||
|
||||
// Draw edges
|
||||
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 '';
|
||||
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);
|
||||
|
||||
// Draw commit nodes
|
||||
const nodes = g.selectAll('.node')
|
||||
.data(this.commits)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`);
|
||||
|
||||
nodes.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6')
|
||||
.attr('stroke', '#1e40af')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Add 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');
|
||||
|
||||
// Add 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');
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
nodes.on('mouseover', function(event, d) {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#2563eb');
|
||||
}).on('mouseout', function() {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6');
|
||||
});
|
||||
},
|
||||
|
||||
assignLanes() {
|
||||
// Simple lane assignment: find branches and assign lanes
|
||||
const lanes = {};
|
||||
const branchHeads = {};
|
||||
|
||||
// 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();
|
||||
|
||||
// First assign HEAD branch
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
nextLane++;
|
||||
});
|
||||
|
||||
// Assign any remaining commits
|
||||
this.commits.forEach(commit => {
|
||||
if (!lanes[commit.hash]) {
|
||||
lanes[commit.hash] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return lanes;
|
||||
}
|
||||
};
|
||||
|
||||
// Export for use
|
||||
window.GitGraph = GitGraph;
|
||||
Reference in New Issue
Block a user