feat: Phase 3 — 前端交互功能完成
This commit is contained in:
+446
-32
@@ -1,32 +1,219 @@
|
||||
// Diff Viewer using diff2html
|
||||
// Diff Viewer using diff2html with file tree sidebar
|
||||
const DiffViewer = {
|
||||
init(containerId) {
|
||||
init(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.options = options;
|
||||
this.rawDiff = null;
|
||||
this.currentMode = 'side-by-side';
|
||||
this.files = []; // parsed file info for tree
|
||||
this.observer = null; // scroll observer for highlight sync
|
||||
|
||||
if (!this.container) {
|
||||
console.error('Diff container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// Render unified diff string
|
||||
// Parse unified diff string to extract per-file info
|
||||
_parseDiffFiles(diffString) {
|
||||
if (!diffString) return [];
|
||||
const files = [];
|
||||
const fileRegex = /^diff --git a\/(.*?) b\/(.*?)$/gm;
|
||||
let match;
|
||||
const positions = [];
|
||||
|
||||
while ((match = fileRegex.exec(diffString)) !== null) {
|
||||
positions.push({ name: match[2] || match[1], index: match.index });
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const start = positions[i].index;
|
||||
const end = i + 1 < positions.length ? positions[i + 1].index : diffString.length;
|
||||
const chunk = diffString.substring(start, end);
|
||||
|
||||
// Count additions and deletions
|
||||
let additions = 0, deletions = 0;
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
||||
}
|
||||
|
||||
// Determine change type
|
||||
let changeType = 'modified';
|
||||
if (chunk.includes('new file mode')) changeType = 'added';
|
||||
else if (chunk.includes('deleted file mode')) changeType = 'deleted';
|
||||
else if (chunk.includes('rename from')) changeType = 'renamed';
|
||||
|
||||
files.push({
|
||||
name: positions[i].name,
|
||||
path: positions[i].name,
|
||||
additions,
|
||||
deletions,
|
||||
changeType,
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
},
|
||||
|
||||
// Build tree structure from flat file list
|
||||
_buildTree(files) {
|
||||
const root = { name: '', path: '', children: [], isDir: true };
|
||||
|
||||
files.forEach(file => {
|
||||
const parts = file.path.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
if (isFile) {
|
||||
current.children.push({
|
||||
...file,
|
||||
name: part,
|
||||
isDir: false,
|
||||
});
|
||||
} else {
|
||||
let child = current.children.find(c => c.isDir && c.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
path: parts.slice(0, i + 1).join('/'),
|
||||
children: [],
|
||||
isDir: true,
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
const sortTree = (node) => {
|
||||
if (node.children) {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortTree);
|
||||
}
|
||||
};
|
||||
sortTree(root);
|
||||
|
||||
return root;
|
||||
},
|
||||
|
||||
// Render file tree sidebar HTML
|
||||
_renderFileTree(tree) {
|
||||
const totalFiles = this.files.length;
|
||||
const totalAdd = this.files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDel = this.files.reduce((s, f) => s + f.deletions, 0);
|
||||
|
||||
let html = `
|
||||
<div class="file-tree-header">
|
||||
<div class="file-tree-stats">
|
||||
<span class="text-sm font-medium text-gray-700">${totalFiles} 文件</span>
|
||||
<span class="text-xs text-green-600">+${totalAdd}</span>
|
||||
<span class="text-xs text-red-600">-${totalDel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-tree-list">`;
|
||||
|
||||
html += this._renderTreeNode(tree, 0);
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
_renderTreeNode(node, depth) {
|
||||
let html = '';
|
||||
|
||||
if (node.isDir) {
|
||||
// Only render directory label if not root
|
||||
if (depth > 0) {
|
||||
html += `<div class="file-tree-dir" style="padding-left:${depth * 16}px" data-path="${node.path}">
|
||||
<span class="file-tree-toggle">▶</span>
|
||||
<span class="file-tree-dirname">${node.name}/</span>
|
||||
</div>`;
|
||||
}
|
||||
// Render children
|
||||
const childDepth = depth > 0 ? depth + 1 : depth;
|
||||
if (node.children) {
|
||||
node.children.forEach(child => {
|
||||
if (child.isDir) {
|
||||
html += this._renderTreeNode(child, childDepth);
|
||||
} else {
|
||||
html += this._renderTreeNode(child, childDepth);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// File node
|
||||
const icon = this._getFileIcon(node.changeType);
|
||||
html += `<div class="file-tree-file" style="padding-left:${(depth + 1) * 16}px"
|
||||
data-filename="${node.path}" title="${node.path}">
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="file-tree-name">${node.name}</span>
|
||||
<span class="file-tree-stats-inline">
|
||||
<span class="text-green-600">+${node.additions}</span>
|
||||
<span class="text-red-600">-${node.deletions}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
_getFileIcon(changeType) {
|
||||
switch (changeType) {
|
||||
case 'added': return '<span class="text-green-500">A</span>';
|
||||
case 'deleted': return '<span class="text-red-500">D</span>';
|
||||
case 'renamed': return '<span class="text-yellow-500">R</span>';
|
||||
default: return '<span class="text-gray-400">M</span>';
|
||||
}
|
||||
},
|
||||
|
||||
// Render diff with file tree sidebar
|
||||
renderDiff(diffString, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
// Store raw diff for view mode switching
|
||||
this.rawDiff = diffString;
|
||||
|
||||
// Parse files for tree
|
||||
this.files = this._parseDiffFiles(diffString);
|
||||
const tree = this._buildTree(this.files);
|
||||
|
||||
const config = {
|
||||
drawFileList: options.drawFileList !== false,
|
||||
fileListToggle: options.fileListToggle !== false,
|
||||
fileListStartVisible: options.fileListStartVisible !== false,
|
||||
fileContentToggle: options.fileContentToggle !== false,
|
||||
drawFileList: false, // We use our own file tree
|
||||
fileListToggle: false,
|
||||
fileContentToggle: true,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || 'side-by-side',
|
||||
outputFormat: options.outputFormat || this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
// Use diff2html to render
|
||||
this.currentMode = config.outputFormat === 'line-by-line' ? 'unified' : 'side-by-side';
|
||||
|
||||
// Build layout: sidebar + diff area
|
||||
const diffHtml = Diff2Html.html(diffString, config);
|
||||
this.container.innerHTML = diffHtml;
|
||||
const treeHtml = this._renderFileTree(tree);
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="diff-layout">
|
||||
<div class="diff-sidebar" id="diff-file-tree">
|
||||
${treeHtml}
|
||||
</div>
|
||||
<div class="diff-main" id="diff-content">
|
||||
${diffHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Add syntax highlighting
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
@@ -34,6 +221,15 @@ const DiffViewer = {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
|
||||
// Add data-filename attributes to file wrappers for scroll sync
|
||||
this._annotateFileSections();
|
||||
|
||||
// Bind file tree click events
|
||||
this._bindFileTreeEvents();
|
||||
|
||||
// Set up scroll spy
|
||||
this._setupScrollSpy();
|
||||
},
|
||||
|
||||
// Render per-file diffs
|
||||
@@ -45,9 +241,174 @@ const DiffViewer = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all patches
|
||||
// Store files for tree
|
||||
this.files = files.map(f => {
|
||||
let additions = 0, deletions = 0;
|
||||
const lines = (f.patch || '').split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
||||
}
|
||||
return {
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
additions,
|
||||
deletions,
|
||||
changeType: 'modified',
|
||||
};
|
||||
});
|
||||
|
||||
const combinedDiff = files.map(f => f.patch).join('\n');
|
||||
this.renderDiff(combinedDiff, options);
|
||||
this.rawDiff = combinedDiff;
|
||||
|
||||
const tree = this._buildTree(this.files);
|
||||
const config = {
|
||||
drawFileList: false,
|
||||
fileListToggle: false,
|
||||
fileContentToggle: true,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || (this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side'),
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
const diffHtml = Diff2Html.html(combinedDiff, config);
|
||||
const treeHtml = this._renderFileTree(tree);
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="diff-layout">
|
||||
<div class="diff-sidebar" id="diff-file-tree">
|
||||
${treeHtml}
|
||||
</div>
|
||||
<div class="diff-main" id="diff-content">
|
||||
${diffHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
if (window.hljs) {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
|
||||
this._annotateFileSections();
|
||||
this._bindFileTreeEvents();
|
||||
this._setupScrollSpy();
|
||||
},
|
||||
|
||||
// Annotate diff2html file wrappers with data-filename
|
||||
_annotateFileSections() {
|
||||
const fileWrappers = this.container.querySelectorAll('.d2h-file-wrapper');
|
||||
fileWrappers.forEach(wrapper => {
|
||||
const header = wrapper.querySelector('.d2h-file-name');
|
||||
if (header) {
|
||||
const name = header.textContent.trim();
|
||||
// Extract just the filename from the header (diff2html adds a prefix)
|
||||
const cleanName = name.replace(/^\s*(Modified|Added|Deleted|Rename)\s*/i, '').trim();
|
||||
wrapper.setAttribute('data-filename', cleanName);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Bind click events on file tree items
|
||||
_bindFileTreeEvents() {
|
||||
const treeItems = this.container.querySelectorAll('.file-tree-file');
|
||||
treeItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const filename = item.getAttribute('data-filename');
|
||||
this.scrollToFile(filename);
|
||||
|
||||
// Highlight active file in tree
|
||||
this.container.querySelectorAll('.file-tree-file').forEach(fi => fi.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Directory toggle
|
||||
const dirItems = this.container.querySelectorAll('.file-tree-dir');
|
||||
dirItems.forEach(dir => {
|
||||
dir.addEventListener('click', (e) => {
|
||||
const toggle = dir.querySelector('.file-tree-toggle');
|
||||
const path = dir.getAttribute('data-path');
|
||||
const isExpanded = toggle.textContent === '▼';
|
||||
|
||||
toggle.textContent = isExpanded ? '▶' : '▼';
|
||||
|
||||
// Toggle children visibility
|
||||
let sibling = dir.nextElementSibling;
|
||||
while (sibling) {
|
||||
// Check if this sibling is still a child (starts with same path or is a file at same level)
|
||||
const sibPath = sibling.getAttribute('data-path') || sibling.getAttribute('data-filename') || '';
|
||||
if (!sibPath.startsWith(path + '/') && sibling.classList.contains('file-tree-dir')) {
|
||||
break;
|
||||
}
|
||||
sibling.style.display = isExpanded ? 'none' : '';
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Scroll to a specific file section in the diff
|
||||
scrollToFile(filename) {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
for (const wrapper of fileWrappers) {
|
||||
const wrapperName = wrapper.getAttribute('data-filename');
|
||||
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
|
||||
wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// Brief highlight effect
|
||||
wrapper.style.outline = '2px solid #3b82f6';
|
||||
setTimeout(() => { wrapper.style.outline = ''; }, 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Set up scroll spy to highlight current file in tree
|
||||
_setupScrollSpy() {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
// Clean up old observer
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
if (fileWrappers.length === 0) return;
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const filename = entry.target.getAttribute('data-filename');
|
||||
if (filename) {
|
||||
// Highlight in tree
|
||||
this.container.querySelectorAll('.file-tree-file').forEach(item => {
|
||||
const treeName = item.getAttribute('data-filename');
|
||||
if (treeName && (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName))) {
|
||||
item.classList.add('active');
|
||||
// Scroll tree item into view if needed
|
||||
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: diffContent,
|
||||
rootMargin: '-10% 0px -80% 0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
fileWrappers.forEach(wrapper => {
|
||||
this.observer.observe(wrapper);
|
||||
});
|
||||
},
|
||||
|
||||
// Load and render diff from API
|
||||
@@ -77,7 +438,7 @@ const DiffViewer = {
|
||||
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>';
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="text-gray-500">Loading files...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
|
||||
@@ -93,29 +454,16 @@ const DiffViewer = {
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (!this.container || !this.rawDiff) return;
|
||||
this.currentMode = mode;
|
||||
this.renderDiff(this.rawDiff, { outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side' });
|
||||
},
|
||||
|
||||
// Toggle file list visibility
|
||||
toggleFileList() {
|
||||
const fileList = this.container.querySelector('.d2h-file-list');
|
||||
if (fileList) {
|
||||
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
|
||||
const sidebar = this.container.querySelector('.diff-sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.style.display = sidebar.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -128,6 +476,72 @@ const DiffViewer = {
|
||||
content.style.display = expand ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Insert a review suggestion inline at a specific line
|
||||
insertSuggestion(filename, line, side, severity, content, suggestionId) {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
// Find the target file wrapper
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
let targetWrapper = null;
|
||||
for (const wrapper of fileWrappers) {
|
||||
const wrapperName = wrapper.getAttribute('data-filename');
|
||||
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
|
||||
targetWrapper = wrapper;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!targetWrapper) return;
|
||||
|
||||
// Find the target line
|
||||
const table = targetWrapper.querySelector('.d2h-diff-table');
|
||||
if (!table) return;
|
||||
|
||||
const rows = table.querySelectorAll('tr');
|
||||
for (const row of rows) {
|
||||
// diff2html uses data-line-number on td elements
|
||||
const lineNumCell = side === 'left'
|
||||
? row.querySelector('.d2h-code-linenumber .d2h-code-side-linenumber')
|
||||
: row.querySelector('.d2h-code-linenumber:not(.d2h-code-side-linenumber)');
|
||||
|
||||
if (!lineNumCell) continue;
|
||||
|
||||
const lineText = lineNumCell.textContent.trim();
|
||||
const lineNum = parseInt(lineText);
|
||||
if (lineNum === line) {
|
||||
// Create suggestion card
|
||||
const severityStyles = {
|
||||
critical: 'border-l-4 border-red-500 bg-red-50',
|
||||
warning: 'border-l-4 border-yellow-500 bg-yellow-50',
|
||||
info: 'border-l-4 border-green-500 bg-green-50',
|
||||
};
|
||||
const severityLabels = {
|
||||
critical: '🔴 严重',
|
||||
warning: '🟡 建议',
|
||||
info: '🟢 提示',
|
||||
};
|
||||
|
||||
const suggestionRow = document.createElement('tr');
|
||||
suggestionRow.className = 'review-suggestion-row';
|
||||
suggestionRow.setAttribute('data-suggestion-id', suggestionId || '');
|
||||
suggestionRow.innerHTML = `
|
||||
<td colspan="3" class="p-0">
|
||||
<div class="review-suggestion-card ${severityStyles[severity] || severityStyles.info} p-3 mx-2 my-1 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium">${severityLabels[severity] || severityLabels.info}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">${content}</p>
|
||||
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>
|
||||
</div>
|
||||
</td>`;
|
||||
|
||||
// Insert after the current row
|
||||
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user