From f15d1c540e0afce2cd67f24eb9cff8d25f970643 Mon Sep 17 00:00:00 2001 From: wonder Date: Fri, 19 Jun 2026 20:23:15 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=20diff=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=B8=B2=E6=9F=93=EF=BC=8C=E6=B6=88=E9=99=A4=E5=8D=A1?= =?UTF-8?q?=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/diff-viewer.js | 256 ++++++++++++++++++++++++++------------- 1 file changed, 174 insertions(+), 82 deletions(-) diff --git a/static/js/diff-viewer.js b/static/js/diff-viewer.js index 8e74d94..8633c7b 100644 --- a/static/js/diff-viewer.js +++ b/static/js/diff-viewer.js @@ -7,6 +7,10 @@ const DiffViewer = { 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 } + this._lastActiveFile = null; // last highlighted tree item + this._rafPending = false; // rAF throttle flag for scroll spy if (!this.container) { console.error('Diff container not found:', containerId); @@ -215,21 +219,20 @@ const DiffViewer = { `; - // 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(); + // Build index for fast suggestion insertion + this._buildFileIndex(); + // Bind file tree click events this._bindFileTreeEvents(); // Set up scroll spy this._setupScrollSpy(); + + // Lazy syntax highlighting (only when code blocks scroll into view) + this._setupLazyHighlight(); }, // Render per-file diffs @@ -286,15 +289,11 @@ const DiffViewer = { `; - this.container.querySelectorAll('pre code').forEach(block => { - if (window.hljs) { - hljs.highlightElement(block); - } - }); - this._annotateFileSections(); + this._buildFileIndex(); this._bindFileTreeEvents(); this._setupScrollSpy(); + this._setupLazyHighlight(); }, // Annotate diff2html file wrappers with data-filename @@ -311,6 +310,67 @@ const DiffViewer = { }); }, + // Build index: filename → { wrapper, rows: Map } for O(1) suggestion lookup + _buildFileIndex() { + this._fileIndex = new Map(); + const diffContent = this.container.querySelector('#diff-content'); + if (!diffContent) return; + + const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper'); + fileWrappers.forEach(wrapper => { + const filename = wrapper.getAttribute('data-filename'); + if (!filename) return; + + const rows = new Map(); + const table = wrapper.querySelector('.d2h-diff-table'); + if (table) { + table.querySelectorAll('tr').forEach(row => { + // Index by left (old) line number + const leftCell = row.querySelector('.d2h-code-linenumber .d2h-code-side-linenumber'); + if (leftCell) { + const num = parseInt(leftCell.textContent.trim()); + if (num) rows.set('left-' + num, row); + } + // Index by right (new) line number + const rightCell = row.querySelector('.d2h-code-linenumber:not(.d2h-code-side-linenumber)'); + if (rightCell) { + const num = parseInt(rightCell.textContent.trim()); + if (num) rows.set('right-' + num, row); + } + }); + } + + this._fileIndex.set(filename, { wrapper, rows }); + }); + }, + + // Lazy syntax highlighting: only highlight code blocks when they scroll into view + _setupLazyHighlight() { + if (this._highlightObserver) { + this._highlightObserver.disconnect(); + } + + const codeBlocks = 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', // start highlighting 200px before visible + threshold: 0, + }); + + codeBlocks.forEach(block => this._highlightObserver.observe(block)); + }, + // Bind click events on file tree items _bindFileTreeEvents() { const treeItems = this.container.querySelectorAll('.file-tree-file'); @@ -378,25 +438,45 @@ const DiffViewer = { 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) => { - 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'); - } - }); + // Throttle: batch all entries per frame, only process the most visible one + if (this._rafPending) return; + this._rafPending = true; + + requestAnimationFrame(() => { + this._rafPending = false; + + // Find the most recently intersecting entry + let bestEntry = null; + for (const entry of entries) { + if (entry.isIntersecting) { + bestEntry = entry; + break; // first intersecting is enough + } + } + if (!bestEntry) return; + + const filename = bestEntry.target.getAttribute('data-filename'); + if (!filename || filename === this._lastActiveFile) return; + this._lastActiveFile = filename; + + // Only toggle the changed items, not full scan + const treeFiles = this.container.querySelectorAll('.file-tree-file'); + for (const item of treeFiles) { + const treeName = item.getAttribute('data-filename'); + if (!treeName) continue; + const isActive = treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName); + if (isActive && !item.classList.contains('active')) { + item.classList.add('active'); + item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } else if (!isActive && item.classList.contains('active')) { + item.classList.remove('active'); } } }); @@ -452,11 +532,36 @@ const DiffViewer = { } }, - // Switch view mode (side-by-side or line-by-line) + // Switch view mode (side-by-side or line-by-line) — only rebuild diff content, not the whole layout setViewMode(mode) { if (!this.container || !this.rawDiff) return; this.currentMode = mode; - this.renderDiff(this.rawDiff, { outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side' }); + + const outputFormat = mode === 'unified' ? 'line-by-line' : 'side-by-side'; + const config = { + drawFileList: false, + fileListToggle: false, + fileContentToggle: true, + matching: 'lines', + outputFormat: outputFormat, + synchronisedScroll: true, + highlight: true, + renderNothingWhenEmpty: false, + }; + + const diffContent = this.container.querySelector('#diff-content'); + if (!diffContent) { + // Fallback: full re-render + this.renderDiff(this.rawDiff, { outputFormat }); + return; + } + + // Only rebuild the diff content area + diffContent.innerHTML = Diff2Html.html(this.rawDiff, config); + this._annotateFileSections(); + this._buildFileIndex(); + this._setupScrollSpy(); + this._setupLazyHighlight(); }, // Toggle file list visibility @@ -483,65 +588,52 @@ const DiffViewer = { 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; + // Use pre-built index for O(1) file lookup + if (!this._fileIndex) return; + + let fileEntry = null; + // Try exact match first, then suffix match + for (const [key, val] of this._fileIndex) { + if (key === filename || key.endsWith('/' + filename) || filename.endsWith('/' + key)) { + fileEntry = val; break; } } - if (!targetWrapper) return; + if (!fileEntry) return; - // Find the target line - const table = targetWrapper.querySelector('.d2h-diff-table'); - if (!table) return; + // O(1) row lookup via index + const rowKey = side + '-' + line; + const row = fileEntry.rows.get(rowKey); + if (!row) 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)'); + // 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: '🟢 提示', + }; - if (!lineNumCell) continue; + const suggestionRow = document.createElement('tr'); + suggestionRow.className = 'review-suggestion-row'; + suggestionRow.setAttribute('data-suggestion-id', suggestionId || ''); + suggestionRow.innerHTML = ` + +
+
+ ${severityLabels[severity] || severityLabels.info} +
+

${content}

+
+
+ `; - 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; - } - } + // Insert after the current row + row.parentNode.insertBefore(suggestionRow, row.nextSibling); } };