// Diff Viewer using diff2html with file tree sidebar // Optimized: incremental rendering, lazy indexing, collapsed-by-default 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 this._highlightObserver = null; // lazy hljs observer this._fileIndex = null; // filename → { wrapper, rows: Map, built: bool } this._lastActiveFile = null; this._rafPending = false; this._renderQueue = []; // per-file chunks waiting to be rendered this._rendering = false; this._onRenderComplete = null; // callback when incremental render finishes 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, _chunk: chunk, // keep chunk for incremental rendering }); } return files; }, // Split diff string into per-file chunks (returns array of strings) _splitDiffIntoChunks(diffString) { if (!diffString) return []; const fileRegex = /^diff --git a\/.*? b\/.*?$/gm; const positions = []; let match; while ((match = fileRegex.exec(diffString)) !== null) { positions.push(match.index); } if (positions.length === 0) return diffString ? [diffString] : []; const chunks = []; for (let i = 0; i < positions.length; i++) { const start = positions[i]; const end = i + 1 < positions.length ? positions[i + 1] : diffString.length; chunks.push(diffString.substring(start, end)); } return chunks; }, // 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) { if (depth > 0) { html += `
▶ ${node.name}/
`; } const childDepth = depth > 0 ? depth + 1 : depth; if (node.children) { node.children.forEach(child => { html += this._renderTreeNode(child, childDepth); }); } } else { 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'; } }, // ── Diff too large guard ──────────────────────────────────────── _isTooLarge(files) { const MAX_FILES = 300; const MAX_LINES = 10000; const totalAdd = files.reduce((s, f) => s + (f.additions || 0), 0); const totalDel = files.reduce((s, f) => s + (f.deletions || 0), 0); return files.length > MAX_FILES || (totalAdd + totalDel) > MAX_LINES; }, _renderTooLargeWarning(files, totalAdd, totalDel, onExpand) { const id = 'diff-expand-' + Math.random().toString(36).slice(2, 8); this.container.innerHTML = `
⚠️ 变更过大,已跳过渲染

共 ${files.length} 个文件, +${totalAdd} -${totalDel}

渲染大量变更可能导致浏览器卡顿

`; document.getElementById(id).addEventListener('click', onExpand); }, // ── Incremental rendering pipeline ────────────────────────────── // Render diff with file tree sidebar — incremental, non-blocking renderDiff(diffString, options = {}) { if (!this.container) return; // Cancel any in-progress incremental render this._cancelRender(); this.rawDiff = diffString; this._onRenderComplete = options.onComplete || null; // Parse files for tree (fast — just regex + string ops) this.files = this._parseDiffFiles(diffString); // Guard: if diff is too large, show warning instead of rendering if (!options.forceRender && this._isTooLarge(this.files)) { const totalAdd = this.files.reduce((s, f) => s + f.additions, 0); const totalDel = this.files.reduce((s, f) => s + f.deletions, 0); const self = this; this._renderTooLargeWarning(this.files, totalAdd, totalDel, function () { self.renderDiff(diffString, { ...options, forceRender: true }); }); return; } const tree = this._buildTree(this.files); // Split diff into per-file chunks for incremental rendering this._chunks = this._splitDiffIntoChunks(diffString); this._chunkIndex = 0; this._renderedCount = 0; // Determine initial mode const outputFormat = options.outputFormat || (this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side'); this.currentMode = outputFormat === 'line-by-line' ? 'unified' : 'side-by-side'; // Build layout shell: sidebar + empty diff area const treeHtml = this._renderFileTree(tree); this.container.innerHTML = `
${treeHtml}
`; this._diffContainer = this.container.querySelector('#diff-file-container'); this._progressEl = this.container.querySelector('#diff-render-progress'); // Bind file tree events (fast — just the sidebar) this._bindFileTreeEvents(); // Reset lazy index this._fileIndex = null; // Start incremental render: first batch paints immediately this._renderBatch(outputFormat, 0); }, // Render per-file diffs — incremental, non-blocking renderFiles(files, options = {}) { if (!this.container) return; this._cancelRender(); this._onRenderComplete = options.onComplete || null; if (!files || files.length === 0) { this.container.innerHTML = '
暂无变更
'; 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', _patch: f.patch, }; }); // Build combined diff for rawDiff storage const combinedDiff = files.map(f => f.patch).join('\n'); this.rawDiff = combinedDiff; // Guard: if diff is too large, show warning if (!options.forceRender && this._isTooLarge(this.files)) { const totalAdd = this.files.reduce((s, f) => s + f.additions, 0); const totalDel = this.files.reduce((s, f) => s + f.deletions, 0); const self = this; this._renderTooLargeWarning(this.files, totalAdd, totalDel, function () { self.renderFiles(files, { ...options, forceRender: true }); }); return; } // Split into per-file chunks this._chunks = this.files.map(f => f._patch).filter(Boolean); this._chunkIndex = 0; this._renderedCount = 0; const tree = this._buildTree(this.files); const outputFormat = options.outputFormat || (this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side'); this.currentMode = outputFormat === 'line-by-line' ? 'unified' : 'side-by-side'; const treeHtml = this._renderFileTree(tree); this.container.innerHTML = `
${treeHtml}
`; this._diffContainer = this.container.querySelector('#diff-file-container'); this._progressEl = this.container.querySelector('#diff-render-progress'); this._bindFileTreeEvents(); this._fileIndex = null; this._renderBatch(outputFormat, 0); }, // Cancel in-progress incremental render _cancelRender() { if (this._renderRaf) { cancelAnimationFrame(this._renderRaf); this._renderRaf = null; } this._rendering = false; this._renderQueue = []; }, // Render a batch of files per animation frame // Each frame renders up to FILES_PER_BATCH files, then yields _renderBatch(outputFormat, startIndex) { const FILES_PER_BATCH = 5; // render 5 files per frame const chunks = this._chunks; if (!chunks || startIndex >= chunks.length) { // All done this._rendering = false; if (this._progressEl) this._progressEl.textContent = ''; // Finalize: set up scroll spy after all content is rendered this._setupScrollSpy(); this._setupLazyHighlight(); // Fire completion callback if (this._onRenderComplete) { const cb = this._onRenderComplete; this._onRenderComplete = null; cb(); } return; } this._rendering = true; const endIndex = Math.min(startIndex + FILES_PER_BATCH, chunks.length); const batch = chunks.slice(startIndex, endIndex); // Generate HTML for this batch const config = { drawFileList: false, fileListToggle: false, fileContentToggle: true, matching: 'lines', outputFormat: outputFormat === 'unified' ? 'line-by-line' : 'side-by-side', synchronisedScroll: true, highlight: false, // we handle hljs lazily renderNothingWhenEmpty: false, }; // Render each file chunk individually (much smaller strings) const fragment = document.createDocumentFragment(); for (const chunk of batch) { const html = Diff2Html.html(chunk, config); const wrapper = document.createElement('div'); wrapper.innerHTML = html; // Diff2Html wraps each file in .d2h-file-wrapper, move it out const fileWrapper = wrapper.querySelector('.d2h-file-wrapper'); if (fileWrapper) { fragment.appendChild(fileWrapper); } } // Append to DOM in one operation (much smaller than full render) this._diffContainer.appendChild(fragment); // Update progress this._renderedCount = endIndex; if (this._progressEl && endIndex < chunks.length) { this._progressEl.textContent = `渲染中 ${endIndex}/${chunks.length} 文件...`; } // Annotate newly added file wrappers this._annotateNewFileWrappers(); // Schedule next batch this._renderRaf = requestAnimationFrame(() => { this._renderBatch(outputFormat, endIndex); }); }, // Annotate only newly added file wrappers (not all of them) _annotateNewFileWrappers() { const fileWrappers = this._diffContainer.querySelectorAll('.d2h-file-wrapper'); // Only annotate wrappers that don't have data-filename yet for (const wrapper of fileWrappers) { if (wrapper.hasAttribute('data-filename')) continue; const header = wrapper.querySelector('.d2h-file-name'); if (header) { const name = header.textContent.trim(); const cleanName = name.replace(/^\s*(Modified|Added|Deleted|Rename)\s*/i, '').trim(); wrapper.setAttribute('data-filename', cleanName); } // Collapse file content by default for faster initial render const content = wrapper.querySelector('.d2h-file-diff'); if (content) { content.style.display = 'none'; wrapper.classList.add('d2h-collapsed'); } } }, // ── Lazy file index ───────────────────────────────────────────── // Build index for a single file on demand (called by insertSuggestion) _ensureFileIndexed(filename) { if (!this._fileIndex) this._fileIndex = new Map(); // Check if already indexed if (this._fileIndex.has(filename)) return this._fileIndex.get(filename); // Find the wrapper const fileWrappers = this._diffContainer ? this._diffContainer.querySelectorAll('.d2h-file-wrapper') : this.container.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))) { const entry = { wrapper, rows: new Map() }; const table = wrapper.querySelector('.d2h-diff-table'); if (table) { table.querySelectorAll('tr').forEach(row => { const leftCell = row.querySelector('.d2h-code-linenumber .d2h-code-side-linenumber'); if (leftCell) { const num = parseInt(leftCell.textContent.trim()); if (num) entry.rows.set('left-' + num, row); } const rightCell = row.querySelector('.d2h-code-linenumber:not(.d2h-code-side-linenumber)'); if (rightCell) { const num = parseInt(rightCell.textContent.trim()); if (num) entry.rows.set('right-' + num, row); } }); } this._fileIndex.set(filename, entry); return entry; } } return null; }, // ── Lazy syntax highlighting ──────────────────────────────────── _setupLazyHighlight() { if (this._highlightObserver) { this._highlightObserver.disconnect(); } const codeBlocks = (this._diffContainer || this.container).querySelectorAll('pre code'); if (codeBlocks.length === 0 || !window.hljs) return; this._highlightObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const block = entry.target; if (!block.dataset.highlighted) { hljs.highlightElement(block); } this._highlightObserver.unobserve(block); } }); }, { rootMargin: '200px', threshold: 0, }); codeBlocks.forEach(block => this._highlightObserver.observe(block)); }, // ── File tree events ──────────────────────────────────────────── _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); // Toggle active — only remove from previous, not full scan const prev = this.container.querySelector('.file-tree-file.active'); if (prev) prev.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 ? '▶' : '▼'; let sibling = dir.nextElementSibling; while (sibling) { 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 file ────────────────────────────────────────────── 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))) { // Expand the file if collapsed const content = wrapper.querySelector('.d2h-file-diff'); if (content && content.style.display === 'none') { content.style.display = 'block'; wrapper.classList.remove('d2h-collapsed'); } wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' }); wrapper.style.outline = '2px solid #3b82f6'; setTimeout(() => { wrapper.style.outline = ''; }, 2000); return; } } }, // ── Scroll spy ────────────────────────────────────────────────── _setupScrollSpy() { const diffContent = this.container.querySelector('#diff-content'); if (!diffContent) return; if (this.observer) { this.observer.disconnect(); } this._lastActiveFile = null; this._rafPending = false; const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper'); if (fileWrappers.length === 0) return; this.observer = new IntersectionObserver((entries) => { if (this._rafPending) return; this._rafPending = true; requestAnimationFrame(() => { this._rafPending = false; let bestEntry = null; for (const entry of entries) { if (entry.isIntersecting) { bestEntry = entry; break; } } if (!bestEntry) return; const filename = bestEntry.target.getAttribute('data-filename'); if (!filename || filename === this._lastActiveFile) return; this._lastActiveFile = filename; const prev = this.container.querySelector('.file-tree-file.active'); if (prev) prev.classList.remove('active'); const treeFiles = this.container.querySelectorAll('.file-tree-file'); for (const item of treeFiles) { const treeName = item.getAttribute('data-filename'); if (!treeName) continue; if (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName)) { item.classList.add('active'); item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); break; } } }); }, { root: diffContent, rootMargin: '-10% 0px -80% 0px', threshold: 0, }); fileWrappers.forEach(wrapper => { this.observer.observe(wrapper); }); }, // ── Load from API ─────────────────────────────────────────────── async loadDiff(repoId, base, head, options = {}) { if (!this.container) return; this.container.innerHTML = '

加载 Diff 中...

'; try { const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`; const resp = await fetch(url, { credentials: 'same-origin' }); if (!resp.ok) throw new Error('加载 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 = `
加载 Diff 失败: ${err.message}
`; } }, async loadFiles(repoId, base, head) { if (!this.container) return; this.container.innerHTML = '

加载文件列表中...

'; try { const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`; const resp = await fetch(url, { credentials: 'same-origin' }); if (!resp.ok) throw new Error('加载 Diff 失败'); const files = await resp.json(); this.renderFiles(files); } catch (err) { this.container.innerHTML = `
加载失败: ${err.message}
`; } }, // ── View mode switch ──────────────────────────────────────────── setViewMode(mode) { if (!this.container || !this.rawDiff) return; this.currentMode = mode; // Full incremental re-render with new format this.renderDiff(this.rawDiff, { outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side', }); }, // ── Toggle helpers ────────────────────────────────────────────── toggleFileList() { const sidebar = this.container.querySelector('.diff-sidebar'); if (sidebar) { sidebar.style.display = sidebar.style.display === 'none' ? '' : 'none'; } }, 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'; file.classList.toggle('d2h-collapsed', !expand); } }); }, // ── Insert suggestion (uses lazy index) ───────────────────────── insertSuggestion(filename, line, side, severity, content, suggestionId) { const diffContent = this.container.querySelector('#diff-content'); if (!diffContent) return; // Ensure this file's index is built const fileEntry = this._ensureFileIndexed(filename); if (!fileEntry) return; // Expand the file if collapsed (so the row is visible) const fileContent = fileEntry.wrapper.querySelector('.d2h-file-diff'); if (fileContent && fileContent.style.display === 'none') { fileContent.style.display = 'block'; fileEntry.wrapper.classList.remove('d2h-collapsed'); } // O(1) row lookup const rowKey = side + '-' + line; const row = fileEntry.rows.get(rowKey); if (!row) return; // 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}
${typeof renderMarkdown === 'function' ? renderMarkdown(content) : content}
`; row.parentNode.insertBefore(suggestionRow, row.nextSibling); }, // ── Insert file-level suggestion (no line number required) ───── insertFileSuggestion(filename, severity, content, suggestionId) { const diffContent = this.container.querySelector('#diff-content'); if (!diffContent) return; // Find or index the file const fileEntry = this._ensureFileIndexed(filename); if (!fileEntry) return; const wrapper = fileEntry.wrapper; // Expand the file if collapsed const fileContent = wrapper.querySelector('.d2h-file-diff'); if (fileContent && fileContent.style.display === 'none') { fileContent.style.display = 'block'; wrapper.classList.remove('d2h-collapsed'); } // Find or create the file-level suggestions container let sugContainer = wrapper.querySelector('.file-suggestions-container'); if (!sugContainer) { sugContainer = document.createElement('div'); sugContainer.className = 'file-suggestions-container'; // Insert after the file header, before the diff content const header = wrapper.querySelector('.d2h-file-header'); if (header) { header.parentNode.insertBefore(sugContainer, header.nextSibling); } else { wrapper.prepend(sugContainer); } } 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 card = document.createElement('div'); card.className = `review-file-suggestion ${severityStyles[severity] || severityStyles.info} p-3 mx-2 my-1 rounded`; card.setAttribute('data-suggestion-id', suggestionId || ''); card.innerHTML = `
${severityLabels[severity] || severityLabels.info}
${typeof renderMarkdown === 'function' ? renderMarkdown(content) : content}
`; sugContainer.appendChild(card); }, // ── Mark file severity in sidebar ────────────────────────────── markFileSeverity(filename, severity) { const treeFiles = this.container.querySelectorAll('.file-tree-file'); const severityColors = { critical: 'bg-red-500', warning: 'bg-yellow-500', info: 'bg-green-500', }; const severityOrder = { critical: 3, warning: 2, info: 1 }; for (const item of treeFiles) { const treeName = item.getAttribute('data-filename'); if (treeName && (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName))) { let badge = item.querySelector('.file-tree-severity'); if (!badge) { badge = document.createElement('span'); badge.className = 'file-tree-severity inline-block w-2 h-2 rounded-full ml-1'; item.querySelector('.file-tree-name').appendChild(badge); } // Only upgrade severity, never downgrade const currentSev = badge.getAttribute('data-severity') || ''; if (!currentSev || (severityOrder[severity] || 0) > (severityOrder[currentSev] || 0)) { badge.className = `file-tree-severity inline-block w-2 h-2 rounded-full ml-1 ${severityColors[severity] || severityColors.info}`; badge.setAttribute('data-severity', severity); } break; } } } }; // Export for use window.DiffViewer = DiffViewer;