55bfdf08ce
Deploy PR-Helper / deploy (push) Successful in 31s
- Go 后端 handler 错误消息统一中文化(middleware/repos/generate/review/settings) - 前端 JS 加载和错误消息中文化(diff-viewer.js, graph.js) - 模板页面错误 fallback 和静态文本中文化(index/generate/review/base) - 消除中英文混杂,提升中文用户体验
876 lines
36 KiB
JavaScript
876 lines
36 KiB
JavaScript
// 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<lineNum, row>, 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 = `
|
|
<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) {
|
|
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>`;
|
|
}
|
|
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 += `<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>';
|
|
}
|
|
},
|
|
|
|
// ── 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 = `
|
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
|
<div class="text-yellow-600 text-lg font-medium mb-2">⚠️ 变更过大,已跳过渲染</div>
|
|
<p class="text-sm text-gray-600 mb-1">
|
|
共 <strong>${files.length}</strong> 个文件,
|
|
<span class="text-green-600">+${totalAdd}</span>
|
|
<span class="text-red-600">-${totalDel}</span>
|
|
</p>
|
|
<p class="text-xs text-gray-400 mb-4">渲染大量变更可能导致浏览器卡顿</p>
|
|
<button id="${id}" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
|
|
显示 Diff
|
|
</button>
|
|
</div>`;
|
|
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 = `
|
|
<div class="diff-layout">
|
|
<div class="diff-sidebar" id="diff-file-tree">
|
|
${treeHtml}
|
|
</div>
|
|
<div class="diff-main" id="diff-content">
|
|
<div id="diff-file-container"></div>
|
|
<div id="diff-render-progress" class="text-xs text-gray-400 p-2"></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<div class="text-gray-500 p-4">暂无变更</div>';
|
|
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 = `
|
|
<div class="diff-layout">
|
|
<div class="diff-sidebar" id="diff-file-tree">
|
|
${treeHtml}
|
|
</div>
|
|
<div class="diff-main" id="diff-content">
|
|
<div id="diff-file-container"></div>
|
|
<div id="diff-render-progress" class="text-xs text-gray-400 p-2"></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">加载 Diff 中...</p></div>';
|
|
|
|
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 = `<div class="text-red-500 p-4">加载 Diff 失败: ${err.message}</div>`;
|
|
}
|
|
},
|
|
|
|
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="text-gray-500">加载文件列表中...</p></div>';
|
|
|
|
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 = `<div class="text-red-500 p-4">加载失败: ${err.message}</div>`;
|
|
}
|
|
},
|
|
|
|
// ── 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 = `
|
|
<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>
|
|
<div class="text-sm text-gray-700 review-content">${typeof renderMarkdown === 'function' ? renderMarkdown(content) : content}</div>
|
|
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>
|
|
</div>
|
|
</td>`;
|
|
|
|
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 = `
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="text-xs font-medium">${severityLabels[severity] || severityLabels.info}</span>
|
|
</div>
|
|
<div class="text-sm text-gray-700 review-content">${typeof renderMarkdown === 'function' ? renderMarkdown(content) : content}</div>
|
|
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>`;
|
|
|
|
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;
|