perf: 增量 diff 渲染,消除主线程阻塞
This commit is contained in:
+258
-162
@@ -1,4 +1,5 @@
|
|||||||
// Diff Viewer using diff2html with file tree sidebar
|
// Diff Viewer using diff2html with file tree sidebar
|
||||||
|
// Optimized: incremental rendering, lazy indexing, collapsed-by-default
|
||||||
const DiffViewer = {
|
const DiffViewer = {
|
||||||
init(containerId, options = {}) {
|
init(containerId, options = {}) {
|
||||||
this.container = document.getElementById(containerId);
|
this.container = document.getElementById(containerId);
|
||||||
@@ -8,9 +9,12 @@ const DiffViewer = {
|
|||||||
this.files = []; // parsed file info for tree
|
this.files = []; // parsed file info for tree
|
||||||
this.observer = null; // scroll observer for highlight sync
|
this.observer = null; // scroll observer for highlight sync
|
||||||
this._highlightObserver = null; // lazy hljs observer
|
this._highlightObserver = null; // lazy hljs observer
|
||||||
this._fileIndex = null; // filename → { wrapper, rows: Map<lineNum, row> }
|
this._fileIndex = null; // filename → { wrapper, rows: Map<lineNum, row>, built: bool }
|
||||||
this._lastActiveFile = null; // last highlighted tree item
|
this._lastActiveFile = null;
|
||||||
this._rafPending = false; // rAF throttle flag for scroll spy
|
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) {
|
if (!this.container) {
|
||||||
console.error('Diff container not found:', containerId);
|
console.error('Diff container not found:', containerId);
|
||||||
@@ -55,12 +59,33 @@ const DiffViewer = {
|
|||||||
additions,
|
additions,
|
||||||
deletions,
|
deletions,
|
||||||
changeType,
|
changeType,
|
||||||
|
_chunk: chunk, // keep chunk for incremental rendering
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
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
|
// Build tree structure from flat file list
|
||||||
_buildTree(files) {
|
_buildTree(files) {
|
||||||
const root = { name: '', path: '', children: [], isDir: true };
|
const root = { name: '', path: '', children: [], isDir: true };
|
||||||
@@ -137,26 +162,19 @@ const DiffViewer = {
|
|||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (node.isDir) {
|
if (node.isDir) {
|
||||||
// Only render directory label if not root
|
|
||||||
if (depth > 0) {
|
if (depth > 0) {
|
||||||
html += `<div class="file-tree-dir" style="padding-left:${depth * 16}px" data-path="${node.path}">
|
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-toggle">▶</span>
|
||||||
<span class="file-tree-dirname">${node.name}/</span>
|
<span class="file-tree-dirname">${node.name}/</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// Render children
|
|
||||||
const childDepth = depth > 0 ? depth + 1 : depth;
|
const childDepth = depth > 0 ? depth + 1 : depth;
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
node.children.forEach(child => {
|
node.children.forEach(child => {
|
||||||
if (child.isDir) {
|
html += this._renderTreeNode(child, childDepth);
|
||||||
html += this._renderTreeNode(child, childDepth);
|
|
||||||
} else {
|
|
||||||
html += this._renderTreeNode(child, childDepth);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File node
|
|
||||||
const icon = this._getFileIcon(node.changeType);
|
const icon = this._getFileIcon(node.changeType);
|
||||||
html += `<div class="file-tree-file" style="padding-left:${(depth + 1) * 16}px"
|
html += `<div class="file-tree-file" style="padding-left:${(depth + 1) * 16}px"
|
||||||
data-filename="${node.path}" title="${node.path}">
|
data-filename="${node.path}" title="${node.path}">
|
||||||
@@ -181,64 +199,65 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Render diff with file tree sidebar
|
// ── Incremental rendering pipeline ──────────────────────────────
|
||||||
|
|
||||||
|
// Render diff with file tree sidebar — incremental, non-blocking
|
||||||
renderDiff(diffString, options = {}) {
|
renderDiff(diffString, options = {}) {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
// Store raw diff for view mode switching
|
// Cancel any in-progress incremental render
|
||||||
this.rawDiff = diffString;
|
this._cancelRender();
|
||||||
|
|
||||||
// Parse files for tree
|
this.rawDiff = diffString;
|
||||||
|
this._onRenderComplete = options.onComplete || null;
|
||||||
|
|
||||||
|
// Parse files for tree (fast — just regex + string ops)
|
||||||
this.files = this._parseDiffFiles(diffString);
|
this.files = this._parseDiffFiles(diffString);
|
||||||
const tree = this._buildTree(this.files);
|
const tree = this._buildTree(this.files);
|
||||||
|
|
||||||
const config = {
|
// Split diff into per-file chunks for incremental rendering
|
||||||
drawFileList: false, // We use our own file tree
|
this._chunks = this._splitDiffIntoChunks(diffString);
|
||||||
fileListToggle: false,
|
this._chunkIndex = 0;
|
||||||
fileContentToggle: true,
|
this._renderedCount = 0;
|
||||||
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';
|
// 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: sidebar + diff area
|
// Build layout shell: sidebar + empty diff area
|
||||||
const diffHtml = Diff2Html.html(diffString, config);
|
|
||||||
const treeHtml = this._renderFileTree(tree);
|
const treeHtml = this._renderFileTree(tree);
|
||||||
|
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="diff-layout">
|
<div class="diff-layout">
|
||||||
<div class="diff-sidebar" id="diff-file-tree">
|
<div class="diff-sidebar" id="diff-file-tree">
|
||||||
${treeHtml}
|
${treeHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-main" id="diff-content">
|
<div class="diff-main" id="diff-content">
|
||||||
${diffHtml}
|
<div id="diff-file-container"></div>
|
||||||
|
<div id="diff-render-progress" class="text-xs text-gray-400 p-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Add data-filename attributes to file wrappers for scroll sync
|
this._diffContainer = this.container.querySelector('#diff-file-container');
|
||||||
this._annotateFileSections();
|
this._progressEl = this.container.querySelector('#diff-render-progress');
|
||||||
|
|
||||||
// Build index for fast suggestion insertion
|
// Bind file tree events (fast — just the sidebar)
|
||||||
this._buildFileIndex();
|
|
||||||
|
|
||||||
// Bind file tree click events
|
|
||||||
this._bindFileTreeEvents();
|
this._bindFileTreeEvents();
|
||||||
|
|
||||||
// Set up scroll spy
|
// Reset lazy index
|
||||||
this._setupScrollSpy();
|
this._fileIndex = null;
|
||||||
|
|
||||||
// Lazy syntax highlighting (only when code blocks scroll into view)
|
// Start incremental render: first batch paints immediately
|
||||||
this._setupLazyHighlight();
|
this._renderBatch(outputFormat, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Render per-file diffs
|
// Render per-file diffs — incremental, non-blocking
|
||||||
renderFiles(files, options = {}) {
|
renderFiles(files, options = {}) {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
|
this._cancelRender();
|
||||||
|
this._onRenderComplete = options.onComplete || null;
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No changes</div>';
|
this.container.innerHTML = '<div class="text-gray-500 p-4">No changes</div>';
|
||||||
return;
|
return;
|
||||||
@@ -258,99 +277,194 @@ const DiffViewer = {
|
|||||||
additions,
|
additions,
|
||||||
deletions,
|
deletions,
|
||||||
changeType: 'modified',
|
changeType: 'modified',
|
||||||
|
_patch: f.patch,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build combined diff for rawDiff storage
|
||||||
const combinedDiff = files.map(f => f.patch).join('\n');
|
const combinedDiff = files.map(f => f.patch).join('\n');
|
||||||
this.rawDiff = combinedDiff;
|
this.rawDiff = combinedDiff;
|
||||||
|
|
||||||
|
// 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 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 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);
|
const treeHtml = this._renderFileTree(tree);
|
||||||
|
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="diff-layout">
|
<div class="diff-layout">
|
||||||
<div class="diff-sidebar" id="diff-file-tree">
|
<div class="diff-sidebar" id="diff-file-tree">
|
||||||
${treeHtml}
|
${treeHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-main" id="diff-content">
|
<div class="diff-main" id="diff-content">
|
||||||
${diffHtml}
|
<div id="diff-file-container"></div>
|
||||||
|
<div id="diff-render-progress" class="text-xs text-gray-400 p-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
this._annotateFileSections();
|
this._diffContainer = this.container.querySelector('#diff-file-container');
|
||||||
this._buildFileIndex();
|
this._progressEl = this.container.querySelector('#diff-render-progress');
|
||||||
|
|
||||||
this._bindFileTreeEvents();
|
this._bindFileTreeEvents();
|
||||||
this._setupScrollSpy();
|
this._fileIndex = null;
|
||||||
this._setupLazyHighlight();
|
|
||||||
|
this._renderBatch(outputFormat, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Annotate diff2html file wrappers with data-filename
|
// Cancel in-progress incremental render
|
||||||
_annotateFileSections() {
|
_cancelRender() {
|
||||||
const fileWrappers = this.container.querySelectorAll('.d2h-file-wrapper');
|
if (this._renderRaf) {
|
||||||
fileWrappers.forEach(wrapper => {
|
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');
|
const header = wrapper.querySelector('.d2h-file-name');
|
||||||
if (header) {
|
if (header) {
|
||||||
const name = header.textContent.trim();
|
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();
|
const cleanName = name.replace(/^\s*(Modified|Added|Deleted|Rename)\s*/i, '').trim();
|
||||||
wrapper.setAttribute('data-filename', cleanName);
|
wrapper.setAttribute('data-filename', cleanName);
|
||||||
}
|
}
|
||||||
});
|
// Collapse file content by default for faster initial render
|
||||||
},
|
const content = wrapper.querySelector('.d2h-file-diff');
|
||||||
|
if (content) {
|
||||||
// Build index: filename → { wrapper, rows: Map<lineNum, row> } for O(1) suggestion lookup
|
content.style.display = 'none';
|
||||||
_buildFileIndex() {
|
wrapper.classList.add('d2h-collapsed');
|
||||||
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
|
// ── 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() {
|
_setupLazyHighlight() {
|
||||||
if (this._highlightObserver) {
|
if (this._highlightObserver) {
|
||||||
this._highlightObserver.disconnect();
|
this._highlightObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeBlocks = this.container.querySelectorAll('pre code');
|
const codeBlocks = (this._diffContainer || this.container).querySelectorAll('pre code');
|
||||||
if (codeBlocks.length === 0 || !window.hljs) return;
|
if (codeBlocks.length === 0 || !window.hljs) return;
|
||||||
|
|
||||||
this._highlightObserver = new IntersectionObserver((entries) => {
|
this._highlightObserver = new IntersectionObserver((entries) => {
|
||||||
@@ -364,14 +478,15 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
rootMargin: '200px', // start highlighting 200px before visible
|
rootMargin: '200px',
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
codeBlocks.forEach(block => this._highlightObserver.observe(block));
|
codeBlocks.forEach(block => this._highlightObserver.observe(block));
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bind click events on file tree items
|
// ── File tree events ────────────────────────────────────────────
|
||||||
|
|
||||||
_bindFileTreeEvents() {
|
_bindFileTreeEvents() {
|
||||||
const treeItems = this.container.querySelectorAll('.file-tree-file');
|
const treeItems = this.container.querySelectorAll('.file-tree-file');
|
||||||
treeItems.forEach(item => {
|
treeItems.forEach(item => {
|
||||||
@@ -379,8 +494,9 @@ const DiffViewer = {
|
|||||||
const filename = item.getAttribute('data-filename');
|
const filename = item.getAttribute('data-filename');
|
||||||
this.scrollToFile(filename);
|
this.scrollToFile(filename);
|
||||||
|
|
||||||
// Highlight active file in tree
|
// Toggle active — only remove from previous, not full scan
|
||||||
this.container.querySelectorAll('.file-tree-file').forEach(fi => fi.classList.remove('active'));
|
const prev = this.container.querySelector('.file-tree-file.active');
|
||||||
|
if (prev) prev.classList.remove('active');
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -395,10 +511,8 @@ const DiffViewer = {
|
|||||||
|
|
||||||
toggle.textContent = isExpanded ? '▶' : '▼';
|
toggle.textContent = isExpanded ? '▶' : '▼';
|
||||||
|
|
||||||
// Toggle children visibility
|
|
||||||
let sibling = dir.nextElementSibling;
|
let sibling = dir.nextElementSibling;
|
||||||
while (sibling) {
|
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') || '';
|
const sibPath = sibling.getAttribute('data-path') || sibling.getAttribute('data-filename') || '';
|
||||||
if (!sibPath.startsWith(path + '/') && sibling.classList.contains('file-tree-dir')) {
|
if (!sibPath.startsWith(path + '/') && sibling.classList.contains('file-tree-dir')) {
|
||||||
break;
|
break;
|
||||||
@@ -410,7 +524,8 @@ const DiffViewer = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Scroll to a specific file section in the diff
|
// ── Scroll to file ──────────────────────────────────────────────
|
||||||
|
|
||||||
scrollToFile(filename) {
|
scrollToFile(filename) {
|
||||||
const diffContent = this.container.querySelector('#diff-content');
|
const diffContent = this.container.querySelector('#diff-content');
|
||||||
if (!diffContent) return;
|
if (!diffContent) return;
|
||||||
@@ -419,8 +534,13 @@ const DiffViewer = {
|
|||||||
for (const wrapper of fileWrappers) {
|
for (const wrapper of fileWrappers) {
|
||||||
const wrapperName = wrapper.getAttribute('data-filename');
|
const wrapperName = wrapper.getAttribute('data-filename');
|
||||||
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
|
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.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
// Brief highlight effect
|
|
||||||
wrapper.style.outline = '2px solid #3b82f6';
|
wrapper.style.outline = '2px solid #3b82f6';
|
||||||
setTimeout(() => { wrapper.style.outline = ''; }, 2000);
|
setTimeout(() => { wrapper.style.outline = ''; }, 2000);
|
||||||
return;
|
return;
|
||||||
@@ -428,12 +548,12 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Set up scroll spy to highlight current file in tree
|
// ── Scroll spy ──────────────────────────────────────────────────
|
||||||
|
|
||||||
_setupScrollSpy() {
|
_setupScrollSpy() {
|
||||||
const diffContent = this.container.querySelector('#diff-content');
|
const diffContent = this.container.querySelector('#diff-content');
|
||||||
if (!diffContent) return;
|
if (!diffContent) return;
|
||||||
|
|
||||||
// Clean up old observer
|
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
@@ -445,19 +565,17 @@ const DiffViewer = {
|
|||||||
if (fileWrappers.length === 0) return;
|
if (fileWrappers.length === 0) return;
|
||||||
|
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
this.observer = new IntersectionObserver((entries) => {
|
||||||
// Throttle: batch all entries per frame, only process the most visible one
|
|
||||||
if (this._rafPending) return;
|
if (this._rafPending) return;
|
||||||
this._rafPending = true;
|
this._rafPending = true;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this._rafPending = false;
|
this._rafPending = false;
|
||||||
|
|
||||||
// Find the most recently intersecting entry
|
|
||||||
let bestEntry = null;
|
let bestEntry = null;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
bestEntry = entry;
|
bestEntry = entry;
|
||||||
break; // first intersecting is enough
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!bestEntry) return;
|
if (!bestEntry) return;
|
||||||
@@ -466,17 +584,17 @@ const DiffViewer = {
|
|||||||
if (!filename || filename === this._lastActiveFile) return;
|
if (!filename || filename === this._lastActiveFile) return;
|
||||||
this._lastActiveFile = filename;
|
this._lastActiveFile = filename;
|
||||||
|
|
||||||
// Only toggle the changed items, not full scan
|
const prev = this.container.querySelector('.file-tree-file.active');
|
||||||
|
if (prev) prev.classList.remove('active');
|
||||||
|
|
||||||
const treeFiles = this.container.querySelectorAll('.file-tree-file');
|
const treeFiles = this.container.querySelectorAll('.file-tree-file');
|
||||||
for (const item of treeFiles) {
|
for (const item of treeFiles) {
|
||||||
const treeName = item.getAttribute('data-filename');
|
const treeName = item.getAttribute('data-filename');
|
||||||
if (!treeName) continue;
|
if (!treeName) continue;
|
||||||
const isActive = treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName);
|
if (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName)) {
|
||||||
if (isActive && !item.classList.contains('active')) {
|
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
} else if (!isActive && item.classList.contains('active')) {
|
break;
|
||||||
item.classList.remove('active');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -491,7 +609,8 @@ const DiffViewer = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load and render diff from API
|
// ── Load from API ───────────────────────────────────────────────
|
||||||
|
|
||||||
async loadDiff(repoId, base, head, options = {}) {
|
async loadDiff(repoId, base, head, options = {}) {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
@@ -514,7 +633,6 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load per-file diffs
|
|
||||||
async loadFiles(repoId, base, head) {
|
async loadFiles(repoId, base, head) {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
@@ -532,39 +650,19 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Switch view mode (side-by-side or line-by-line) — only rebuild diff content, not the whole layout
|
// ── View mode switch ────────────────────────────────────────────
|
||||||
|
|
||||||
setViewMode(mode) {
|
setViewMode(mode) {
|
||||||
if (!this.container || !this.rawDiff) return;
|
if (!this.container || !this.rawDiff) return;
|
||||||
this.currentMode = mode;
|
this.currentMode = mode;
|
||||||
|
// Full incremental re-render with new format
|
||||||
const outputFormat = mode === 'unified' ? 'line-by-line' : 'side-by-side';
|
this.renderDiff(this.rawDiff, {
|
||||||
const config = {
|
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||||
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
|
// ── Toggle helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
toggleFileList() {
|
toggleFileList() {
|
||||||
const sidebar = this.container.querySelector('.diff-sidebar');
|
const sidebar = this.container.querySelector('.diff-sidebar');
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
@@ -572,36 +670,35 @@ const DiffViewer = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Expand/collapse all files
|
|
||||||
toggleAllFiles(expand) {
|
toggleAllFiles(expand) {
|
||||||
const fileContents = this.container.querySelectorAll('.d2h-file-wrapper');
|
const fileContents = this.container.querySelectorAll('.d2h-file-wrapper');
|
||||||
fileContents.forEach(file => {
|
fileContents.forEach(file => {
|
||||||
const content = file.querySelector('.d2h-file-diff');
|
const content = file.querySelector('.d2h-file-diff');
|
||||||
if (content) {
|
if (content) {
|
||||||
content.style.display = expand ? 'block' : 'none';
|
content.style.display = expand ? 'block' : 'none';
|
||||||
|
file.classList.toggle('d2h-collapsed', !expand);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Insert a review suggestion inline at a specific line
|
// ── Insert suggestion (uses lazy index) ─────────────────────────
|
||||||
|
|
||||||
insertSuggestion(filename, line, side, severity, content, suggestionId) {
|
insertSuggestion(filename, line, side, severity, content, suggestionId) {
|
||||||
const diffContent = this.container.querySelector('#diff-content');
|
const diffContent = this.container.querySelector('#diff-content');
|
||||||
if (!diffContent) return;
|
if (!diffContent) return;
|
||||||
|
|
||||||
// Use pre-built index for O(1) file lookup
|
// Ensure this file's index is built
|
||||||
if (!this._fileIndex) return;
|
const fileEntry = this._ensureFileIndexed(filename);
|
||||||
|
|
||||||
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 (!fileEntry) return;
|
if (!fileEntry) return;
|
||||||
|
|
||||||
// O(1) row lookup via index
|
// 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 rowKey = side + '-' + line;
|
||||||
const row = fileEntry.rows.get(rowKey);
|
const row = fileEntry.rows.get(rowKey);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -632,7 +729,6 @@ const DiffViewer = {
|
|||||||
</div>
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
|
|
||||||
// Insert after the current row
|
|
||||||
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
|
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -280,12 +280,14 @@
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
DiffViewer.init('review-diff-container');
|
DiffViewer.init('review-diff-container');
|
||||||
DiffViewer.renderDiff(diffString);
|
DiffViewer.renderDiff(diffString, {
|
||||||
|
onComplete() {
|
||||||
// Insert suggestions inline
|
// Insert suggestions after all files are rendered
|
||||||
allSuggestions.forEach((s, i) => {
|
allSuggestions.forEach((s, i) => {
|
||||||
if (s.file && s.line) {
|
if (s.file && s.line) {
|
||||||
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
|
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user