// Diff Viewer using diff2html with file tree sidebar const DiffViewer = { 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; } }, // 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 = `
${totalFiles} 文件 +${totalAdd} -${totalDel}
`; html += this._renderTreeNode(tree, 0); html += '
'; return html; }, _renderTreeNode(node, depth) { let html = ''; if (node.isDir) { // Only render directory label if not root if (depth > 0) { html += `
▶ ${node.name}/
`; } // 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 += `
${icon} ${node.name} +${node.additions} -${node.deletions}
`; } return html; }, _getFileIcon(changeType) { switch (changeType) { case 'added': return 'A'; case 'deleted': return 'D'; case 'renamed': return 'R'; default: return 'M'; } }, // 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: false, // We use our own file tree 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, }; this.currentMode = config.outputFormat === 'line-by-line' ? 'unified' : 'side-by-side'; // Build layout: sidebar + diff area const diffHtml = Diff2Html.html(diffString, config); const treeHtml = this._renderFileTree(tree); this.container.innerHTML = `
${treeHtml}
${diffHtml}
`; // Add syntax highlighting this.container.querySelectorAll('pre code').forEach(block => { if (window.hljs) { 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 renderFiles(files, options = {}) { if (!this.container) return; if (!files || files.length === 0) { this.container.innerHTML = '
No changes
'; return; } // 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.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 = `
${treeHtml}
${diffHtml}
`; 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 async loadDiff(repoId, base, head, options = {}) { if (!this.container) return; this.container.innerHTML = '

Loading diff...

'; 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 = `
Error loading diff: ${err.message}
`; } }, // Load per-file diffs async loadFiles(repoId, base, head) { if (!this.container) return; this.container.innerHTML = '

Loading files...

'; 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 = `
Error: ${err.message}
`; } }, // Switch view mode (side-by-side or line-by-line) setViewMode(mode) { 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 sidebar = this.container.querySelector('.diff-sidebar'); if (sidebar) { sidebar.style.display = sidebar.style.display === 'none' ? '' : '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'; } }); }, // 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 = `
${severityLabels[severity] || severityLabels.info}

${content}

`; // Insert after the current row row.parentNode.insertBefore(suggestionRow, row.nextSibling); return; } } } }; // Export for use window.DiffViewer = DiffViewer;