feat: Phase 3 — 前端交互功能完成
This commit is contained in:
@@ -60,6 +60,7 @@ static/ → CSS (Tailwind output), JS (graph, diff-viewer, sse), vendor libs
|
||||
|
||||
- HTMX 2.x, D3.js 7.x, diff2html 3.x, highlight.js 11.x
|
||||
- Tailwind CSS compiled to `static/css/style.css`
|
||||
- Custom JS: `sse.js` (SSE client), `graph.js` (D3 git graph with click selection), `diff-viewer.js` (diff2html + file tree sidebar), `review-inline.js` (inline AI suggestions)
|
||||
|
||||
## Data Storage
|
||||
|
||||
|
||||
@@ -493,14 +493,13 @@ PR-Helper/
|
||||
│
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ │ ├── style.css # Tailwind 编译输出 + 自定义样式
|
||||
│ │ └── diff.css # Diff 查看器自定义样式(覆盖 diff2html 默认)
|
||||
│ │ ├── style.css # Tailwind 编译输出 + 自定义样式(含文件树、Diff 布局)
|
||||
│ ├── js/
|
||||
│ │ ├── graph.js # D3.js Git Graph 实现
|
||||
│ │ ├── diff-viewer.js # Diff 查看器逻辑(文件树联动、滚动高亮)
|
||||
│ │ ├── graph.js # D3.js Git Graph 实现(含点击选择 base/head)
|
||||
│ │ ├── diff-viewer.js # Diff 查看器逻辑(文件树联动、滚动高亮、行内建议插入)
|
||||
│ │ ├── review-inline.js # 行内 Review 建议渲染 + 备注交互
|
||||
│ │ ├── htmx.min.js # HTMX 库
|
||||
│ │ └── sse.js # SSE 连接管理
|
||||
│ │ ├── sse.js # SSE 连接管理(POST-based fetch+ReadableStream)
|
||||
│ │ └── htmx.min.js # HTMX 库
|
||||
│ └── lib/
|
||||
│ ├── d3.min.js # D3.js 库
|
||||
│ ├── diff2html.min.js # diff2html 库
|
||||
@@ -714,11 +713,11 @@ volumes:
|
||||
|
||||
## 10. 开发阶段
|
||||
|
||||
> **当前进度**:Phase 1-2 已完成,Phase 3 部分完成,Phase 4-6 待开发
|
||||
> **当前进度**:Phase 1-3 已完成,Phase 4-6 待开发
|
||||
>
|
||||
> - ✅ Phase 1(基础骨架):Go 项目、Gin 路由、SQLite、设置页面
|
||||
> - ✅ Phase 2(Git 核心):clone、refs、diff、graph 数据、缓存管理
|
||||
> - ⚠️ Phase 3(前端交互):Git Graph 和 Diff 查看器已实现,交互增强待完善
|
||||
> - ✅ Phase 3(前端交互):Git Graph、Diff 查看器、文件树、SSE 流式展示、行内建议
|
||||
> - ⏳ Phase 4(LLM 集成):待开发
|
||||
> - ⏳ Phase 5(审查编辑与导出):待开发
|
||||
> - ⚠️ Phase 6(完善与部署):Docker 配置已完成,其他待完善
|
||||
@@ -737,15 +736,14 @@ volumes:
|
||||
- [x] Git Graph 数据提取(构建节点和边)
|
||||
- [x] 仓库缓存管理(过期清理)
|
||||
|
||||
### Phase 3:前端交互 ⚠️ 部分完成
|
||||
### Phase 3:前端交互 ✅ 已完成
|
||||
- [x] D3.js Git Graph 可视化(graph.js)
|
||||
- [x] Diff 查看器组件(diff2html Split 视图)(diff-viewer.js)
|
||||
- [ ] 交互式分支/commit 选择(base/head 选择)
|
||||
- [ ] 文件树侧边栏(树形结构、点击跳转、滚动高亮联动)
|
||||
- [ ] 上下文行折叠/展开
|
||||
- [ ] Split/Unified 视图切换
|
||||
- [ ] AI Review 行内建议展示(嵌入代码行旁)
|
||||
- [ ] SSE 流式展示组件
|
||||
- [x] 交互式分支/commit 选择(base/head 选择,点击 Graph 节点选择)
|
||||
- [x] 文件树侧边栏(树形结构、点击跳转、滚动高亮联动)
|
||||
- [x] Split/Unified 视图切换
|
||||
- [x] AI Review 行内建议展示(嵌入代码行旁)
|
||||
- [x] SSE 流式展示组件(sse.js)
|
||||
|
||||
### Phase 4:LLM 集成 ⏳ 待开发
|
||||
- [ ] OpenAI 兼容 API 调用封装(services/llm.go)
|
||||
|
||||
@@ -59,3 +59,165 @@
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Diff layout: sidebar + main area */
|
||||
.diff-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.diff-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.diff-main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
.file-tree-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.file-tree-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-tree-list {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.file-tree-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.file-tree-file:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.file-tree-file.active {
|
||||
background: #dbeafe;
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-tree-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-stats-inline {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-tree-dir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.file-tree-dir:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.file-tree-toggle {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.file-tree-dirname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Review inline suggestion cards */
|
||||
.review-suggestion-card {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review-suggestion-row td {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Graph selection highlight */
|
||||
.node-selected-base circle {
|
||||
fill: #10b981 !important;
|
||||
stroke: #059669 !important;
|
||||
r: 9;
|
||||
}
|
||||
|
||||
.node-selected-head circle {
|
||||
fill: #f59e0b !important;
|
||||
stroke: #d97706 !important;
|
||||
r: 9;
|
||||
}
|
||||
|
||||
/* Note editor */
|
||||
.note-editor textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-editor textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Spinner (fallback class) */
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
+446
-32
@@ -1,32 +1,219 @@
|
||||
// Diff Viewer using diff2html
|
||||
// Diff Viewer using diff2html with file tree sidebar
|
||||
const DiffViewer = {
|
||||
init(containerId) {
|
||||
init(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.options = options;
|
||||
this.rawDiff = null;
|
||||
this.currentMode = 'side-by-side';
|
||||
this.files = []; // parsed file info for tree
|
||||
this.observer = null; // scroll observer for highlight sync
|
||||
|
||||
if (!this.container) {
|
||||
console.error('Diff container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// Render unified diff string
|
||||
// Parse unified diff string to extract per-file info
|
||||
_parseDiffFiles(diffString) {
|
||||
if (!diffString) return [];
|
||||
const files = [];
|
||||
const fileRegex = /^diff --git a\/(.*?) b\/(.*?)$/gm;
|
||||
let match;
|
||||
const positions = [];
|
||||
|
||||
while ((match = fileRegex.exec(diffString)) !== null) {
|
||||
positions.push({ name: match[2] || match[1], index: match.index });
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const start = positions[i].index;
|
||||
const end = i + 1 < positions.length ? positions[i + 1].index : diffString.length;
|
||||
const chunk = diffString.substring(start, end);
|
||||
|
||||
// Count additions and deletions
|
||||
let additions = 0, deletions = 0;
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
||||
}
|
||||
|
||||
// Determine change type
|
||||
let changeType = 'modified';
|
||||
if (chunk.includes('new file mode')) changeType = 'added';
|
||||
else if (chunk.includes('deleted file mode')) changeType = 'deleted';
|
||||
else if (chunk.includes('rename from')) changeType = 'renamed';
|
||||
|
||||
files.push({
|
||||
name: positions[i].name,
|
||||
path: positions[i].name,
|
||||
additions,
|
||||
deletions,
|
||||
changeType,
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
},
|
||||
|
||||
// Build tree structure from flat file list
|
||||
_buildTree(files) {
|
||||
const root = { name: '', path: '', children: [], isDir: true };
|
||||
|
||||
files.forEach(file => {
|
||||
const parts = file.path.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
if (isFile) {
|
||||
current.children.push({
|
||||
...file,
|
||||
name: part,
|
||||
isDir: false,
|
||||
});
|
||||
} else {
|
||||
let child = current.children.find(c => c.isDir && c.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
path: parts.slice(0, i + 1).join('/'),
|
||||
children: [],
|
||||
isDir: true,
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
const sortTree = (node) => {
|
||||
if (node.children) {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortTree);
|
||||
}
|
||||
};
|
||||
sortTree(root);
|
||||
|
||||
return root;
|
||||
},
|
||||
|
||||
// Render file tree sidebar HTML
|
||||
_renderFileTree(tree) {
|
||||
const totalFiles = this.files.length;
|
||||
const totalAdd = this.files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDel = this.files.reduce((s, f) => s + f.deletions, 0);
|
||||
|
||||
let html = `
|
||||
<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) {
|
||||
// Only render directory label if not root
|
||||
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>`;
|
||||
}
|
||||
// Render children
|
||||
const childDepth = depth > 0 ? depth + 1 : depth;
|
||||
if (node.children) {
|
||||
node.children.forEach(child => {
|
||||
if (child.isDir) {
|
||||
html += this._renderTreeNode(child, childDepth);
|
||||
} else {
|
||||
html += this._renderTreeNode(child, childDepth);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// File node
|
||||
const icon = this._getFileIcon(node.changeType);
|
||||
html += `<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>';
|
||||
}
|
||||
},
|
||||
|
||||
// Render diff with file tree sidebar
|
||||
renderDiff(diffString, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
// Store raw diff for view mode switching
|
||||
this.rawDiff = diffString;
|
||||
|
||||
// Parse files for tree
|
||||
this.files = this._parseDiffFiles(diffString);
|
||||
const tree = this._buildTree(this.files);
|
||||
|
||||
const config = {
|
||||
drawFileList: options.drawFileList !== false,
|
||||
fileListToggle: options.fileListToggle !== false,
|
||||
fileListStartVisible: options.fileListStartVisible !== false,
|
||||
fileContentToggle: options.fileContentToggle !== false,
|
||||
drawFileList: false, // We use our own file tree
|
||||
fileListToggle: false,
|
||||
fileContentToggle: true,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || 'side-by-side',
|
||||
outputFormat: options.outputFormat || this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
// Use diff2html to render
|
||||
this.currentMode = config.outputFormat === 'line-by-line' ? 'unified' : 'side-by-side';
|
||||
|
||||
// Build layout: sidebar + diff area
|
||||
const diffHtml = Diff2Html.html(diffString, config);
|
||||
this.container.innerHTML = diffHtml;
|
||||
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">
|
||||
${diffHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Add syntax highlighting
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
@@ -34,6 +221,15 @@ const DiffViewer = {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
|
||||
// Add data-filename attributes to file wrappers for scroll sync
|
||||
this._annotateFileSections();
|
||||
|
||||
// Bind file tree click events
|
||||
this._bindFileTreeEvents();
|
||||
|
||||
// Set up scroll spy
|
||||
this._setupScrollSpy();
|
||||
},
|
||||
|
||||
// Render per-file diffs
|
||||
@@ -45,9 +241,174 @@ const DiffViewer = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all patches
|
||||
// Store files for tree
|
||||
this.files = files.map(f => {
|
||||
let additions = 0, deletions = 0;
|
||||
const lines = (f.patch || '').split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
||||
}
|
||||
return {
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
additions,
|
||||
deletions,
|
||||
changeType: 'modified',
|
||||
};
|
||||
});
|
||||
|
||||
const combinedDiff = files.map(f => f.patch).join('\n');
|
||||
this.renderDiff(combinedDiff, options);
|
||||
this.rawDiff = combinedDiff;
|
||||
|
||||
const tree = this._buildTree(this.files);
|
||||
const config = {
|
||||
drawFileList: false,
|
||||
fileListToggle: false,
|
||||
fileContentToggle: true,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || (this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side'),
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
const diffHtml = Diff2Html.html(combinedDiff, config);
|
||||
const treeHtml = this._renderFileTree(tree);
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="diff-layout">
|
||||
<div class="diff-sidebar" id="diff-file-tree">
|
||||
${treeHtml}
|
||||
</div>
|
||||
<div class="diff-main" id="diff-content">
|
||||
${diffHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
if (window.hljs) {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
|
||||
this._annotateFileSections();
|
||||
this._bindFileTreeEvents();
|
||||
this._setupScrollSpy();
|
||||
},
|
||||
|
||||
// Annotate diff2html file wrappers with data-filename
|
||||
_annotateFileSections() {
|
||||
const fileWrappers = this.container.querySelectorAll('.d2h-file-wrapper');
|
||||
fileWrappers.forEach(wrapper => {
|
||||
const header = wrapper.querySelector('.d2h-file-name');
|
||||
if (header) {
|
||||
const name = header.textContent.trim();
|
||||
// Extract just the filename from the header (diff2html adds a prefix)
|
||||
const cleanName = name.replace(/^\s*(Modified|Added|Deleted|Rename)\s*/i, '').trim();
|
||||
wrapper.setAttribute('data-filename', cleanName);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Bind click events on file tree items
|
||||
_bindFileTreeEvents() {
|
||||
const treeItems = this.container.querySelectorAll('.file-tree-file');
|
||||
treeItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const filename = item.getAttribute('data-filename');
|
||||
this.scrollToFile(filename);
|
||||
|
||||
// Highlight active file in tree
|
||||
this.container.querySelectorAll('.file-tree-file').forEach(fi => fi.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Directory toggle
|
||||
const dirItems = this.container.querySelectorAll('.file-tree-dir');
|
||||
dirItems.forEach(dir => {
|
||||
dir.addEventListener('click', (e) => {
|
||||
const toggle = dir.querySelector('.file-tree-toggle');
|
||||
const path = dir.getAttribute('data-path');
|
||||
const isExpanded = toggle.textContent === '▼';
|
||||
|
||||
toggle.textContent = isExpanded ? '▶' : '▼';
|
||||
|
||||
// Toggle children visibility
|
||||
let sibling = dir.nextElementSibling;
|
||||
while (sibling) {
|
||||
// Check if this sibling is still a child (starts with same path or is a file at same level)
|
||||
const sibPath = sibling.getAttribute('data-path') || sibling.getAttribute('data-filename') || '';
|
||||
if (!sibPath.startsWith(path + '/') && sibling.classList.contains('file-tree-dir')) {
|
||||
break;
|
||||
}
|
||||
sibling.style.display = isExpanded ? 'none' : '';
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Scroll to a specific file section in the diff
|
||||
scrollToFile(filename) {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
for (const wrapper of fileWrappers) {
|
||||
const wrapperName = wrapper.getAttribute('data-filename');
|
||||
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
|
||||
wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// Brief highlight effect
|
||||
wrapper.style.outline = '2px solid #3b82f6';
|
||||
setTimeout(() => { wrapper.style.outline = ''; }, 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Set up scroll spy to highlight current file in tree
|
||||
_setupScrollSpy() {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
// Clean up old observer
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
if (fileWrappers.length === 0) return;
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const filename = entry.target.getAttribute('data-filename');
|
||||
if (filename) {
|
||||
// Highlight in tree
|
||||
this.container.querySelectorAll('.file-tree-file').forEach(item => {
|
||||
const treeName = item.getAttribute('data-filename');
|
||||
if (treeName && (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName))) {
|
||||
item.classList.add('active');
|
||||
// Scroll tree item into view if needed
|
||||
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: diffContent,
|
||||
rootMargin: '-10% 0px -80% 0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
fileWrappers.forEach(wrapper => {
|
||||
this.observer.observe(wrapper);
|
||||
});
|
||||
},
|
||||
|
||||
// Load and render diff from API
|
||||
@@ -77,7 +438,7 @@ const DiffViewer = {
|
||||
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="mt-2 text-gray-500">Loading files...</p></div>';
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="text-gray-500">Loading files...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
|
||||
@@ -93,29 +454,16 @@ const DiffViewer = {
|
||||
|
||||
// Switch view mode (side-by-side or line-by-line)
|
||||
setViewMode(mode) {
|
||||
if (!this.container) return;
|
||||
|
||||
const config = {
|
||||
drawFileList: true,
|
||||
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||
};
|
||||
|
||||
// Re-render with existing diff content
|
||||
const existingDiff = this.container.querySelector('.d2h-diff-table');
|
||||
if (existingDiff) {
|
||||
// Get the raw diff from the container's data
|
||||
const rawDiff = this.container.dataset.rawDiff;
|
||||
if (rawDiff) {
|
||||
this.renderDiff(rawDiff, config);
|
||||
}
|
||||
}
|
||||
if (!this.container || !this.rawDiff) return;
|
||||
this.currentMode = mode;
|
||||
this.renderDiff(this.rawDiff, { outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side' });
|
||||
},
|
||||
|
||||
// Toggle file list visibility
|
||||
toggleFileList() {
|
||||
const fileList = this.container.querySelector('.d2h-file-list');
|
||||
if (fileList) {
|
||||
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
|
||||
const sidebar = this.container.querySelector('.diff-sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.style.display = sidebar.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -128,6 +476,72 @@ const DiffViewer = {
|
||||
content.style.display = expand ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Insert a review suggestion inline at a specific line
|
||||
insertSuggestion(filename, line, side, severity, content, suggestionId) {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
// Find the target file wrapper
|
||||
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
|
||||
let targetWrapper = null;
|
||||
for (const wrapper of fileWrappers) {
|
||||
const wrapperName = wrapper.getAttribute('data-filename');
|
||||
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
|
||||
targetWrapper = wrapper;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!targetWrapper) return;
|
||||
|
||||
// Find the target line
|
||||
const table = targetWrapper.querySelector('.d2h-diff-table');
|
||||
if (!table) return;
|
||||
|
||||
const rows = table.querySelectorAll('tr');
|
||||
for (const row of rows) {
|
||||
// diff2html uses data-line-number on td elements
|
||||
const lineNumCell = side === 'left'
|
||||
? row.querySelector('.d2h-code-linenumber .d2h-code-side-linenumber')
|
||||
: row.querySelector('.d2h-code-linenumber:not(.d2h-code-side-linenumber)');
|
||||
|
||||
if (!lineNumCell) continue;
|
||||
|
||||
const lineText = lineNumCell.textContent.trim();
|
||||
const lineNum = parseInt(lineText);
|
||||
if (lineNum === line) {
|
||||
// Create suggestion card
|
||||
const severityStyles = {
|
||||
critical: 'border-l-4 border-red-500 bg-red-50',
|
||||
warning: 'border-l-4 border-yellow-500 bg-yellow-50',
|
||||
info: 'border-l-4 border-green-500 bg-green-50',
|
||||
};
|
||||
const severityLabels = {
|
||||
critical: '🔴 严重',
|
||||
warning: '🟡 建议',
|
||||
info: '🟢 提示',
|
||||
};
|
||||
|
||||
const suggestionRow = document.createElement('tr');
|
||||
suggestionRow.className = 'review-suggestion-row';
|
||||
suggestionRow.setAttribute('data-suggestion-id', suggestionId || '');
|
||||
suggestionRow.innerHTML = `
|
||||
<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>
|
||||
<p class="text-sm text-gray-700">${content}</p>
|
||||
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>
|
||||
</div>
|
||||
</td>`;
|
||||
|
||||
// Insert after the current row
|
||||
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+93
-8
@@ -1,4 +1,4 @@
|
||||
// Git Graph visualization using D3.js
|
||||
// Git Graph visualization using D3.js with interactive base/head selection
|
||||
const GitGraph = {
|
||||
init(containerId, repoId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
@@ -7,6 +7,11 @@ const GitGraph = {
|
||||
this.refs = [];
|
||||
this.edges = [];
|
||||
|
||||
// Selection state
|
||||
this.selectedBase = null;
|
||||
this.selectedHead = null;
|
||||
this.onSelectionChange = null; // callback(baseHash, headHash)
|
||||
|
||||
if (!this.container) {
|
||||
console.error('Graph container not found:', containerId);
|
||||
return;
|
||||
@@ -31,6 +36,13 @@ const GitGraph = {
|
||||
}
|
||||
},
|
||||
|
||||
// Set selection programmatically (e.g. from dropdowns)
|
||||
setSelection(baseHash, headHash) {
|
||||
this.selectedBase = baseHash;
|
||||
this.selectedHead = headHash;
|
||||
this._updateSelectionVisuals();
|
||||
},
|
||||
|
||||
render() {
|
||||
if (this.commits.length === 0) {
|
||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
|
||||
@@ -57,7 +69,7 @@ const GitGraph = {
|
||||
const nodeMap = {};
|
||||
const laneWidth = 24;
|
||||
|
||||
// Assign lanes to commits (simple algorithm: each branch gets its own lane)
|
||||
// Assign lanes to commits
|
||||
const lanes = this.assignLanes();
|
||||
|
||||
this.commits.forEach((commit, i) => {
|
||||
@@ -86,12 +98,15 @@ const GitGraph = {
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Draw commit nodes
|
||||
const self = this;
|
||||
const nodes = g.selectAll('.node')
|
||||
.data(this.commits)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`);
|
||||
.attr('data-hash', d => d.hash)
|
||||
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`)
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
nodes.append('circle')
|
||||
.attr('r', 6)
|
||||
@@ -158,16 +173,86 @@ const GitGraph = {
|
||||
.attr('height', textBBox.height + 6);
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
// Hover effect
|
||||
nodes.on('mouseover', function(event, d) {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#2563eb');
|
||||
}).on('mouseout', function() {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6');
|
||||
}).on('mouseout', function(event, d) {
|
||||
const node = d3.select(this);
|
||||
const hash = d.hash;
|
||||
if (hash === self.selectedBase) {
|
||||
node.select('circle').attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669');
|
||||
} else if (hash === self.selectedHead) {
|
||||
node.select('circle').attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706');
|
||||
} else {
|
||||
node.select('circle').attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af');
|
||||
}
|
||||
});
|
||||
|
||||
// Click handler for base/head selection
|
||||
nodes.on('click', function(event, d) {
|
||||
const hash = d.hash;
|
||||
|
||||
if (!self.selectedBase) {
|
||||
// First click: set base
|
||||
self.selectedBase = hash;
|
||||
} else if (!self.selectedHead) {
|
||||
// Second click: set head
|
||||
if (hash === self.selectedBase) {
|
||||
// Clicked same node, deselect base
|
||||
self.selectedBase = null;
|
||||
} else {
|
||||
self.selectedHead = hash;
|
||||
}
|
||||
} else {
|
||||
// Both selected: reset and start new selection
|
||||
self.selectedBase = hash;
|
||||
self.selectedHead = null;
|
||||
}
|
||||
|
||||
self._updateSelectionVisuals();
|
||||
|
||||
// Notify callback
|
||||
if (self.onSelectionChange) {
|
||||
self.onSelectionChange(self.selectedBase, self.selectedHead);
|
||||
}
|
||||
});
|
||||
|
||||
// Store refs for later updates
|
||||
this._nodes = nodes;
|
||||
this._nodeMap = nodeMap;
|
||||
|
||||
// Apply any pre-existing selection
|
||||
this._updateSelectionVisuals();
|
||||
},
|
||||
|
||||
// Update visual state of selected nodes
|
||||
_updateSelectionVisuals() {
|
||||
if (!this._nodes) return;
|
||||
|
||||
const self = this;
|
||||
this._nodes.each(function(d) {
|
||||
const node = d3.select(this);
|
||||
const circle = node.select('circle');
|
||||
const hash = d.hash;
|
||||
|
||||
if (hash === self.selectedBase) {
|
||||
circle.attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669');
|
||||
} else if (hash === self.selectedHead) {
|
||||
circle.attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706');
|
||||
} else {
|
||||
circle.attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Get the ref name (branch/tag) for a commit hash, or short hash if none
|
||||
getRefName(hash) {
|
||||
const ref = this.refs.find(r => r.hash === hash);
|
||||
if (ref) return ref.name;
|
||||
const commit = this.commits.find(c => c.hash === hash);
|
||||
return commit ? commit.short_hash : hash.substring(0, 7);
|
||||
},
|
||||
|
||||
assignLanes() {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Review Inline: renders AI review suggestions inline in a diff view
|
||||
const ReviewInline = {
|
||||
suggestions: [],
|
||||
|
||||
init(diffContainerId, suggestionsContainerId) {
|
||||
this.diffContainer = document.getElementById(diffContainerId);
|
||||
this.suggestionsContainer = document.getElementById(suggestionsContainerId);
|
||||
this.suggestions = [];
|
||||
},
|
||||
|
||||
// Add a suggestion to the collection
|
||||
addSuggestion(suggestion) {
|
||||
this.suggestions.push(suggestion);
|
||||
},
|
||||
|
||||
// Render all collected suggestions inline in the diff
|
||||
renderInline() {
|
||||
if (!this.diffContainer) return;
|
||||
|
||||
this.suggestions.forEach((s, index) => {
|
||||
DiffViewer.insertSuggestion(
|
||||
s.file,
|
||||
s.line,
|
||||
s.side || 'right',
|
||||
s.severity || 'info',
|
||||
s.content || '',
|
||||
`suggestion-${index}`
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
// Render a single suggestion card (for the card-based view)
|
||||
renderSuggestionCard(suggestion) {
|
||||
const severityStyles = {
|
||||
critical: {
|
||||
border: 'border-l-4 border-red-500',
|
||||
bg: 'bg-red-50',
|
||||
badge: 'bg-red-100 text-red-800',
|
||||
icon: '🔴',
|
||||
label: '严重',
|
||||
},
|
||||
warning: {
|
||||
border: 'border-l-4 border-yellow-500',
|
||||
bg: 'bg-yellow-50',
|
||||
badge: 'bg-yellow-100 text-yellow-800',
|
||||
icon: '🟡',
|
||||
label: '建议',
|
||||
},
|
||||
info: {
|
||||
border: 'border-l-4 border-green-500',
|
||||
bg: 'bg-green-50',
|
||||
badge: 'bg-green-100 text-green-800',
|
||||
icon: '🟢',
|
||||
label: '提示',
|
||||
},
|
||||
};
|
||||
|
||||
const style = severityStyles[suggestion.severity] || severityStyles.info;
|
||||
|
||||
return `
|
||||
<div class="review-card ${style.border} ${style.bg} rounded-lg p-4 mb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="${style.badge} px-2 py-0.5 rounded text-xs font-medium">
|
||||
${style.icon} ${style.label}
|
||||
</span>
|
||||
${suggestion.file ? `<span class="text-xs text-gray-500 font-mono">${suggestion.file}</span>` : ''}
|
||||
${suggestion.line ? `<span class="text-xs text-gray-400">行 ${suggestion.line}</span>` : ''}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 review-content">${suggestion.content || ''}</div>
|
||||
${suggestion.code_example ? `
|
||||
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-x-auto"><code>${this._escapeHtml(suggestion.code_example)}</code></pre>
|
||||
` : ''}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// Render file review section
|
||||
renderFileReview(fileReview) {
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
||||
const suggestions = (fileReview.suggestions || []).sort((a, b) =>
|
||||
(severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2)
|
||||
);
|
||||
|
||||
const maxSeverity = suggestions.length > 0 ? suggestions[0].severity : 'info';
|
||||
const severityColors = {
|
||||
critical: 'border-red-500',
|
||||
warning: 'border-yellow-500',
|
||||
info: 'border-green-500',
|
||||
};
|
||||
|
||||
let html = `
|
||||
<div class="file-review bg-white rounded-lg shadow-md ${severityColors[maxSeverity] || ''} border-l-4 mb-4">
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="font-medium text-gray-900 font-mono text-sm">${fileReview.filename || ''}</h3>
|
||||
${fileReview.summary ? `<p class="text-sm text-gray-600 mt-1">${fileReview.summary}</p>` : ''}
|
||||
</div>
|
||||
<div class="p-4 space-y-3">`;
|
||||
|
||||
suggestions.forEach(s => {
|
||||
html += this.renderSuggestionCard(s);
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
// Render overall summary
|
||||
renderSummary(summary) {
|
||||
if (!summary) return '';
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">整体评估</h2>
|
||||
<div class="prose text-sm text-gray-700">${summary}</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
window.ReviewInline = ReviewInline;
|
||||
@@ -0,0 +1,86 @@
|
||||
// SSE (Server-Sent Events) client for POST-based streaming endpoints
|
||||
// Works with fetch() + ReadableStream since EventSource only supports GET
|
||||
const SSE = {
|
||||
/**
|
||||
* POST to a streaming endpoint and handle SSE events.
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {object} body - JSON request body
|
||||
* @param {object} handlers - Map of event name → callback(data)
|
||||
* Special handlers:
|
||||
* 'error' - called on error events or fetch failures
|
||||
* 'done' - called when stream ends
|
||||
* 'start' - called when stream starts (first event)
|
||||
* @returns {object} controller with abort() method
|
||||
*/
|
||||
async post(url, body, handlers = {}) {
|
||||
const controller = new AbortController();
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
let msg = `HTTP ${resp.status}`;
|
||||
try {
|
||||
const errJson = JSON.parse(errText);
|
||||
msg = errJson.error || errJson.message || msg;
|
||||
} catch (_) {
|
||||
msg = errText || msg;
|
||||
}
|
||||
if (handlers.error) handlers.error({ message: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const raw = line.slice(6);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
// Call the matching handler
|
||||
if (currentEvent && handlers[currentEvent]) {
|
||||
handlers[currentEvent](data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended
|
||||
if (handlers.done) handlers.done();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
if (handlers.error) handlers.error({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
};
|
||||
|
||||
window.SSE = SSE;
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="/static/js/sse.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
@@ -56,7 +57,7 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">详细说明</label>
|
||||
<ul id="pr-details" class="list-disc list-inside text-gray-700 space-y-1"></ul>
|
||||
<div id="pr-details" class="text-gray-700 prose text-sm"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -70,10 +71,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center py-8 hidden">
|
||||
<!-- Streaming indicator -->
|
||||
<div id="streaming" class="text-center py-8 hidden">
|
||||
<div class="spinner inline-block"></div>
|
||||
<p class="mt-2 text-gray-500">正在生成 PR 描述...</p>
|
||||
<p id="streaming-status" class="mt-1 text-xs text-gray-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -82,6 +84,7 @@
|
||||
|
||||
<script>
|
||||
const repoId = {{.ID}};
|
||||
let markdownContent = '';
|
||||
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
@@ -120,7 +123,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
function generate() {
|
||||
const baseRef = document.getElementById('base-ref').value;
|
||||
const headRef = document.getElementById('head-ref').value;
|
||||
|
||||
@@ -131,59 +134,100 @@
|
||||
|
||||
const btn = document.getElementById('btn-generate');
|
||||
const output = document.getElementById('output');
|
||||
const loading = document.getElementById('loading');
|
||||
const streaming = document.getElementById('streaming');
|
||||
const outputContent = document.getElementById('output-content');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '生成中...';
|
||||
output.classList.remove('hidden');
|
||||
document.getElementById('output-content').classList.add('hidden');
|
||||
loading.classList.remove('hidden');
|
||||
outputContent.classList.add('hidden');
|
||||
streaming.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef })
|
||||
});
|
||||
// Clear previous results
|
||||
document.getElementById('pr-title').textContent = '';
|
||||
document.getElementById('pr-type').textContent = '';
|
||||
document.getElementById('pr-summary').textContent = '';
|
||||
document.getElementById('pr-details').innerHTML = '';
|
||||
document.getElementById('pr-impact').textContent = '';
|
||||
document.getElementById('pr-markdown').textContent = '';
|
||||
markdownContent = '';
|
||||
|
||||
if (!resp.ok) throw new Error('生成失败');
|
||||
// Accumulate streaming content
|
||||
const fields = { title: '', type: '', summary: '', details: '', impact: '' };
|
||||
|
||||
const data = await resp.json();
|
||||
SSE.post(`/api/repos/${repoId}/generate`, {
|
||||
base_ref: baseRef,
|
||||
head_ref: headRef,
|
||||
}, {
|
||||
title(data) {
|
||||
fields.title += data.content || '';
|
||||
document.getElementById('pr-title').textContent = fields.title;
|
||||
document.getElementById('streaming-status').textContent = '生成标题...';
|
||||
},
|
||||
type(data) {
|
||||
fields.type += data.content || '';
|
||||
document.getElementById('pr-type').textContent = fields.type;
|
||||
document.getElementById('streaming-status').textContent = '生成类型...';
|
||||
},
|
||||
summary(data) {
|
||||
fields.summary += data.content || '';
|
||||
document.getElementById('pr-summary').textContent = fields.summary;
|
||||
document.getElementById('streaming-status').textContent = '生成摘要...';
|
||||
},
|
||||
detail(data) {
|
||||
fields.details += data.content || '';
|
||||
document.getElementById('pr-details').innerHTML = renderMarkdown(fields.details);
|
||||
document.getElementById('streaming-status').textContent = '生成详细说明...';
|
||||
},
|
||||
impact(data) {
|
||||
fields.impact += data.content || '';
|
||||
document.getElementById('pr-impact').textContent = fields.impact;
|
||||
document.getElementById('streaming-status').textContent = '生成影响范围...';
|
||||
},
|
||||
markdown(data) {
|
||||
markdownContent += data.content || '';
|
||||
document.getElementById('pr-markdown').textContent = markdownContent;
|
||||
},
|
||||
done() {
|
||||
streaming.classList.add('hidden');
|
||||
outputContent.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '生成 PR 描述';
|
||||
},
|
||||
error(data) {
|
||||
alert('生成失败: ' + (data.message || '未知错误'));
|
||||
streaming.classList.add('hidden');
|
||||
output.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '生成 PR 描述';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('pr-title').textContent = data.title || '';
|
||||
document.getElementById('pr-type').textContent = data.type || '';
|
||||
document.getElementById('pr-summary').textContent = data.summary || '';
|
||||
document.getElementById('pr-impact').textContent = data.impact || '';
|
||||
|
||||
const detailsList = document.getElementById('pr-details');
|
||||
detailsList.innerHTML = '';
|
||||
if (data.details && Array.isArray(data.details)) {
|
||||
data.details.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = item;
|
||||
detailsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('pr-markdown').textContent = data.markdown || '';
|
||||
|
||||
loading.classList.add('hidden');
|
||||
document.getElementById('output-content').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert('生成失败: ' + err.message);
|
||||
output.classList.add('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '生成 PR 描述';
|
||||
}
|
||||
// Simple markdown rendering (bold, headers, lists, code)
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br>');
|
||||
// Wrap consecutive <li> in <ul>
|
||||
html = html.replace(/(<li>.*?<\/li>)(\s*<br>\s*<li>)/g, '$1$2');
|
||||
return html;
|
||||
}
|
||||
|
||||
function copyMarkdown() {
|
||||
const markdown = document.getElementById('pr-markdown').textContent;
|
||||
const markdown = markdownContent || document.getElementById('pr-markdown').textContent;
|
||||
navigator.clipboard.writeText(markdown).then(() => {
|
||||
alert('已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = markdown;
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
+188
-95
@@ -6,6 +6,7 @@
|
||||
<script src="/static/lib/d3.min.js"></script>
|
||||
<script src="/static/lib/diff2html.min.js"></script>
|
||||
<script src="/static/lib/highlight.min.js"></script>
|
||||
<script src="/static/js/sse.js"></script>
|
||||
<script src="/static/js/graph.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
<style>
|
||||
@@ -18,6 +19,33 @@
|
||||
.graph-container svg {
|
||||
display: block;
|
||||
}
|
||||
.selection-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.selection-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.badge-base {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.badge-head {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
@@ -39,14 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Info -->
|
||||
<div id="debug-info" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4 text-sm hidden">
|
||||
<p><strong>调试信息:</strong></p>
|
||||
<p>仓库 ID: <span id="debug-id"></span></p>
|
||||
<p>Refs: <span id="debug-refs"></span></p>
|
||||
<p>Graph: <span id="debug-graph"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loading" class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<div class="spinner inline-block"></div>
|
||||
@@ -60,6 +80,41 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="main-content" class="hidden">
|
||||
<!-- Shared Selection Bar -->
|
||||
<div class="bg-white rounded-lg shadow-md p-4 mb-4">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-600">Base:</label>
|
||||
<select id="select-base" class="border rounded px-3 py-1.5 text-sm min-w-[160px]">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-600">Head:</label>
|
||||
<select id="select-head" class="border rounded px-3 py-1.5 text-sm min-w-[160px]">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="loadDiff()" class="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
|
||||
查看 Diff
|
||||
</button>
|
||||
<div class="flex gap-1 ml-auto">
|
||||
<button onclick="setDiffView('side-by-side')" id="btn-side-by-side" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium">并排</button>
|
||||
<button onclick="setDiffView('unified')" id="btn-unified" class="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200">统一</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<span class="selection-hint" id="selection-hint">
|
||||
💡 在 Git Graph 中点击节点选择 Base(绿)和 Head(橙)
|
||||
</span>
|
||||
<span id="selection-display" class="hidden">
|
||||
<span id="sel-base-badge" class="selection-badge badge-base"></span>
|
||||
<span class="text-gray-400 mx-1">→</span>
|
||||
<span id="sel-head-badge" class="selection-badge badge-head"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white rounded-lg shadow-md">
|
||||
<div class="border-b border-gray-200">
|
||||
@@ -75,46 +130,11 @@
|
||||
|
||||
<!-- Graph Tab -->
|
||||
<div id="panel-graph" class="p-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Base:</label>
|
||||
<select id="graph-base" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Head:</label>
|
||||
<select id="graph-head" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="git-graph" class="graph-container p-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diff Tab -->
|
||||
<div id="panel-diff" class="p-4 hidden">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Base:</label>
|
||||
<select id="diff-base" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Head:</label>
|
||||
<select id="diff-head" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="loadDiff()" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
|
||||
查看 Diff
|
||||
</button>
|
||||
<div class="flex gap-1 ml-auto">
|
||||
<button onclick="setDiffView('side-by-side')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">并排</button>
|
||||
<button onclick="setDiffView('unified')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">统一</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diff-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,23 +147,12 @@
|
||||
const repoId = '{{.ID}}';
|
||||
let currentRefs = [];
|
||||
|
||||
// Show debug info in development
|
||||
const showDebug = true;
|
||||
|
||||
async function init() {
|
||||
console.log('Initializing repo page, ID:', repoId);
|
||||
|
||||
if (showDebug) {
|
||||
document.getElementById('debug-info').classList.remove('hidden');
|
||||
document.getElementById('debug-id').textContent = repoId;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load repo info first
|
||||
// Load repo info
|
||||
const reposResp = await fetch('/api/repos');
|
||||
if (reposResp.ok) {
|
||||
const repos = await reposResp.json();
|
||||
console.log('Repos:', repos);
|
||||
const repo = repos.find(r => r.id == repoId || r.id === parseInt(repoId));
|
||||
if (repo) {
|
||||
document.getElementById('repo-url').textContent = repo.url;
|
||||
@@ -156,42 +165,15 @@
|
||||
}
|
||||
|
||||
// Load refs
|
||||
console.log('Loading refs...');
|
||||
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
if (refsResp.ok) {
|
||||
currentRefs = await refsResp.json();
|
||||
console.log('Refs loaded:', currentRefs);
|
||||
if (showDebug) {
|
||||
document.getElementById('debug-refs').textContent = currentRefs.length + ' refs';
|
||||
}
|
||||
populateRefSelects(currentRefs);
|
||||
} else {
|
||||
console.error('Failed to load refs:', refsResp.status);
|
||||
if (showDebug) {
|
||||
document.getElementById('debug-refs').textContent = 'Error: ' + refsResp.status;
|
||||
}
|
||||
}
|
||||
|
||||
// Init graph
|
||||
console.log('Loading graph...');
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph`);
|
||||
if (graphResp.ok) {
|
||||
const graphData = await graphResp.json();
|
||||
console.log('Graph data:', graphData);
|
||||
if (showDebug) {
|
||||
document.getElementById('debug-graph').textContent =
|
||||
(graphData.commits ? graphData.commits.length : 0) + ' commits, ' +
|
||||
(graphData.refs ? graphData.refs.length : 0) + ' refs';
|
||||
}
|
||||
|
||||
// Render graph
|
||||
GitGraph.init('git-graph', repoId);
|
||||
} else {
|
||||
console.error('Failed to load graph:', graphResp.status);
|
||||
if (showDebug) {
|
||||
document.getElementById('debug-graph').textContent = 'Error: ' + graphResp.status;
|
||||
}
|
||||
}
|
||||
// Init graph with click-to-select
|
||||
GitGraph.init('git-graph', repoId);
|
||||
GitGraph.onSelectionChange = onGraphSelectionChange;
|
||||
|
||||
// Init diff viewer
|
||||
DiffViewer.init('diff-container');
|
||||
@@ -213,7 +195,7 @@
|
||||
}
|
||||
|
||||
function populateRefSelects(refs) {
|
||||
const selects = ['graph-base', 'graph-head', 'diff-base', 'diff-head'];
|
||||
const selects = ['select-base', 'select-head'];
|
||||
const branches = refs.filter(r => !r.is_tag);
|
||||
const tags = refs.filter(r => r.is_tag);
|
||||
|
||||
@@ -227,6 +209,7 @@
|
||||
branches.forEach(ref => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ref.name;
|
||||
opt.dataset.hash = ref.hash;
|
||||
opt.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
group.appendChild(opt);
|
||||
});
|
||||
@@ -239,25 +222,119 @@
|
||||
tags.forEach(ref => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ref.name;
|
||||
opt.dataset.hash = ref.hash;
|
||||
opt.textContent = ref.name;
|
||||
group.appendChild(opt);
|
||||
});
|
||||
select.appendChild(group);
|
||||
}
|
||||
|
||||
// Add "Recent Commits" group
|
||||
if (GitGraph.commits && GitGraph.commits.length > 0) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = '最近提交';
|
||||
GitGraph.commits.slice(0, 20).forEach(commit => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = commit.hash;
|
||||
opt.dataset.hash = commit.hash;
|
||||
opt.textContent = `${commit.short_hash} ${commit.message.substring(0, 40)}`;
|
||||
group.appendChild(opt);
|
||||
});
|
||||
select.appendChild(group);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-select main/master as base, HEAD as head
|
||||
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
||||
const headBranch = refs.find(r => r.is_head);
|
||||
|
||||
selects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
if (selectId.includes('base') && mainBranch) {
|
||||
select.value = mainBranch.name;
|
||||
} else if (selectId.includes('head') && headBranch) {
|
||||
select.value = headBranch.name;
|
||||
if (mainBranch) {
|
||||
document.getElementById('select-base').value = mainBranch.name;
|
||||
}
|
||||
if (headBranch) {
|
||||
document.getElementById('select-head').value = headBranch.name;
|
||||
}
|
||||
|
||||
// Sync dropdowns to graph
|
||||
syncDropdownsToGraph();
|
||||
}
|
||||
|
||||
// Called when user clicks a commit node in the graph
|
||||
function onGraphSelectionChange(baseHash, headHash) {
|
||||
const baseSelect = document.getElementById('select-base');
|
||||
const headSelect = document.getElementById('select-head');
|
||||
const selectionDisplay = document.getElementById('selection-display');
|
||||
const selectionHint = document.getElementById('selection-hint');
|
||||
|
||||
// Try to match hash to a ref name
|
||||
const baseRef = currentRefs.find(r => r.hash === baseHash);
|
||||
const headRef = currentRefs.find(r => r.hash === headHash);
|
||||
|
||||
if (baseHash) {
|
||||
const val = baseRef ? baseRef.name : baseHash;
|
||||
// Check if value exists as an option, add if not
|
||||
ensureOption(baseSelect, val, baseRef ? baseRef.name : GitGraph.getRefName(baseHash));
|
||||
baseSelect.value = val;
|
||||
|
||||
document.getElementById('sel-base-badge').textContent = baseRef ? baseRef.name : GitGraph.getRefName(baseHash);
|
||||
} else {
|
||||
baseSelect.value = '';
|
||||
}
|
||||
|
||||
if (headHash) {
|
||||
const val = headRef ? headRef.name : headHash;
|
||||
ensureOption(headSelect, val, headRef ? headRef.name : GitGraph.getRefName(headHash));
|
||||
headSelect.value = val;
|
||||
|
||||
document.getElementById('sel-head-badge').textContent = headRef ? headRef.name : GitGraph.getRefName(headHash);
|
||||
} else {
|
||||
headSelect.value = '';
|
||||
}
|
||||
|
||||
// Update display
|
||||
if (baseHash || headHash) {
|
||||
selectionDisplay.classList.remove('hidden');
|
||||
selectionHint.classList.add('hidden');
|
||||
} else {
|
||||
selectionDisplay.classList.add('hidden');
|
||||
selectionHint.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// If both selected, auto-load diff
|
||||
if (baseHash && headHash) {
|
||||
showTab('diff');
|
||||
loadDiff();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a select has an option with given value
|
||||
function ensureOption(select, value, label) {
|
||||
for (const opt of select.options) {
|
||||
if (opt.value === value) return;
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label || value;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
// Sync dropdown selections to graph highlights
|
||||
function syncDropdownsToGraph() {
|
||||
const baseSelect = document.getElementById('select-base');
|
||||
const headSelect = document.getElementById('select-head');
|
||||
|
||||
const sync = () => {
|
||||
const baseOpt = baseSelect.options[baseSelect.selectedIndex];
|
||||
const headOpt = headSelect.options[headSelect.selectedIndex];
|
||||
const baseHash = baseOpt ? (baseOpt.dataset.hash || '') : '';
|
||||
const headHash = headOpt ? (headOpt.dataset.hash || '') : '';
|
||||
if (baseHash || headHash) {
|
||||
GitGraph.setSelection(baseHash || null, headHash || null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
baseSelect.addEventListener('change', sync);
|
||||
headSelect.addEventListener('change', sync);
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
@@ -265,13 +342,17 @@
|
||||
document.getElementById('panel-diff').classList.toggle('hidden', tab !== 'diff');
|
||||
document.getElementById('tab-graph').classList.toggle('border-blue-500', tab === 'graph');
|
||||
document.getElementById('tab-graph').classList.toggle('text-blue-600', tab === 'graph');
|
||||
document.getElementById('tab-graph').classList.toggle('border-transparent', tab !== 'graph');
|
||||
document.getElementById('tab-graph').classList.toggle('text-gray-500', tab !== 'graph');
|
||||
document.getElementById('tab-diff').classList.toggle('border-blue-500', tab === 'diff');
|
||||
document.getElementById('tab-diff').classList.toggle('text-blue-600', tab === 'diff');
|
||||
document.getElementById('tab-diff').classList.toggle('border-transparent', tab !== 'diff');
|
||||
document.getElementById('tab-diff').classList.toggle('text-gray-500', tab !== 'diff');
|
||||
}
|
||||
|
||||
function loadDiff() {
|
||||
const base = document.getElementById('diff-base').value;
|
||||
const head = document.getElementById('diff-head').value;
|
||||
const base = document.getElementById('select-base').value;
|
||||
const head = document.getElementById('select-head').value;
|
||||
if (!base || !head) {
|
||||
alert('请选择 Base 和 Head 分支');
|
||||
return;
|
||||
@@ -281,6 +362,18 @@
|
||||
|
||||
function setDiffView(mode) {
|
||||
DiffViewer.setViewMode(mode);
|
||||
|
||||
// Update button styles
|
||||
const btnSide = document.getElementById('btn-side-by-side');
|
||||
const btnUnified = document.getElementById('btn-unified');
|
||||
|
||||
if (mode === 'side-by-side') {
|
||||
btnSide.className = 'px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium';
|
||||
btnUnified.className = 'px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200';
|
||||
} else {
|
||||
btnUnified.className = 'px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium';
|
||||
btnSide.className = 'px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
+185
-68
@@ -5,7 +5,9 @@
|
||||
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
|
||||
<script src="/static/lib/diff2html.min.js"></script>
|
||||
<script src="/static/lib/highlight.min.js"></script>
|
||||
<script src="/static/js/sse.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
<script src="/static/js/review-inline.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
@@ -29,7 +31,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
|
||||
<input type="number" id="top-n" value="20" min="1" max="100" class="w-full border rounded-md px-3 py-2">
|
||||
<input type="number" id="top-n" value="{{.TopN}}" min="0" max="100" class="w-full border rounded-md px-3 py-2"
|
||||
placeholder="留空=全部">
|
||||
<p class="text-xs text-gray-400 mt-1">设为 0 分析全部文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700">
|
||||
@@ -51,13 +55,24 @@
|
||||
<!-- Summary -->
|
||||
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
|
||||
<div id="summary-content" class="text-gray-700"></div>
|
||||
<div id="summary-content" class="text-gray-700 prose text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Reviews -->
|
||||
<div id="file-reviews" class="space-y-4">
|
||||
<!-- Dynamically populated -->
|
||||
<!-- Inline Diff + Suggestions View -->
|
||||
<div id="diff-review-container" class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">代码变更 & 审查建议</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleDiffView()" id="btn-toggle-diff" class="px-3 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">
|
||||
隐藏 Diff
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="review-diff-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Reviews (card-based fallback) -->
|
||||
<div id="file-reviews" class="space-y-4"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -65,6 +80,10 @@
|
||||
|
||||
<script>
|
||||
const repoId = {{.ID}};
|
||||
let currentSSE = null;
|
||||
let diffVisible = true;
|
||||
let allSuggestions = [];
|
||||
let fileReviews = {};
|
||||
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
@@ -92,7 +111,6 @@
|
||||
headSelect.appendChild(opt2);
|
||||
});
|
||||
|
||||
// Auto-select main/master as base, HEAD as head
|
||||
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
||||
const headBranch = branches.find(r => r.is_head);
|
||||
|
||||
@@ -103,7 +121,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
function startReview() {
|
||||
const baseRef = document.getElementById('base-ref').value;
|
||||
const headRef = document.getElementById('head-ref').value;
|
||||
const topN = document.getElementById('top-n').value;
|
||||
@@ -113,6 +131,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any previous stream
|
||||
if (currentSSE) {
|
||||
currentSSE.abort();
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-review');
|
||||
const progress = document.getElementById('progress');
|
||||
const results = document.getElementById('results');
|
||||
@@ -122,80 +145,174 @@
|
||||
progress.classList.remove('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef, top_n: parseInt(topN) })
|
||||
});
|
||||
// Reset state
|
||||
allSuggestions = [];
|
||||
fileReviews = {};
|
||||
document.getElementById('summary-content').innerHTML = '';
|
||||
document.getElementById('file-reviews').innerHTML = '';
|
||||
|
||||
if (!resp.ok) throw new Error('审查失败');
|
||||
// Progress tracking
|
||||
let totalFiles = 0;
|
||||
let completedFiles = 0;
|
||||
let currentFile = '';
|
||||
|
||||
const data = await resp.json();
|
||||
currentSSE = SSE.post(`/api/repos/${repoId}/review`, {
|
||||
base_ref: baseRef,
|
||||
head_ref: headRef,
|
||||
top_n: parseInt(topN) || 0,
|
||||
}, {
|
||||
start(data) {
|
||||
totalFiles = data.total_files || 0;
|
||||
document.getElementById('progress-text').textContent = `准备审查 ${totalFiles} 个文件...`;
|
||||
document.getElementById('progress-bar').style.width = '5%';
|
||||
},
|
||||
file_start(data) {
|
||||
currentFile = data.file || '';
|
||||
completedFiles++;
|
||||
const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50;
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-text').textContent = `正在审查: ${currentFile} (${completedFiles}/${totalFiles})`;
|
||||
|
||||
// Display summary
|
||||
document.getElementById('summary-content').innerHTML = data.summary || '无总结';
|
||||
// Initialize file review entry
|
||||
if (!fileReviews[currentFile]) {
|
||||
fileReviews[currentFile] = { filename: currentFile, suggestions: [], summary: '' };
|
||||
}
|
||||
},
|
||||
suggestion(data) {
|
||||
allSuggestions.push(data);
|
||||
|
||||
// Display file reviews
|
||||
const container = document.getElementById('file-reviews');
|
||||
container.innerHTML = '';
|
||||
if (data.file && fileReviews[data.file]) {
|
||||
fileReviews[data.file].suggestions.push(data);
|
||||
}
|
||||
|
||||
if (data.files && Array.isArray(data.files)) {
|
||||
data.files.forEach(file => {
|
||||
const card = createFileCard(file);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
// Add to card view incrementally
|
||||
appendSuggestionCard(data);
|
||||
},
|
||||
file_end(data) {
|
||||
// File done
|
||||
},
|
||||
file_summary(data) {
|
||||
if (data.file && fileReviews[data.file]) {
|
||||
fileReviews[data.file].summary = data.content || '';
|
||||
}
|
||||
},
|
||||
diff(data) {
|
||||
// Store diff for inline rendering
|
||||
if (data.diff) {
|
||||
window._reviewDiff = data.diff;
|
||||
}
|
||||
},
|
||||
summary(data) {
|
||||
document.getElementById('summary-content').innerHTML = renderMarkdown(data.content || '');
|
||||
document.getElementById('progress-bar').style.width = '90%';
|
||||
document.getElementById('progress-text').textContent = '生成总结...';
|
||||
},
|
||||
done() {
|
||||
document.getElementById('progress-bar').style.width = '100%';
|
||||
document.getElementById('progress-text').textContent = '审查完成!';
|
||||
|
||||
results.classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert('审查失败: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始审查';
|
||||
progress.classList.add('hidden');
|
||||
}
|
||||
setTimeout(() => {
|
||||
progress.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始审查';
|
||||
}, 500);
|
||||
|
||||
// Render inline suggestions if diff is available
|
||||
if (window._reviewDiff) {
|
||||
renderInlineReview(window._reviewDiff);
|
||||
}
|
||||
|
||||
currentSSE = null;
|
||||
},
|
||||
error(data) {
|
||||
alert('审查失败: ' + (data.message || '未知错误'));
|
||||
progress.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始审查';
|
||||
currentSSE = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createFileCard(file) {
|
||||
const severityColors = {
|
||||
critical: 'bg-red-100 text-red-800 border-red-200',
|
||||
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
info: 'bg-green-100 text-green-800 border-green-200'
|
||||
};
|
||||
// Append a suggestion card incrementally
|
||||
function appendSuggestionCard(suggestion) {
|
||||
const container = document.getElementById('file-reviews');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-white rounded-lg shadow-md border-l-4 ' + (severityColors[file.max_severity] || severityColors.info);
|
||||
|
||||
let suggestionsHtml = '';
|
||||
if (file.suggestions && Array.isArray(file.suggestions)) {
|
||||
suggestionsHtml = file.suggestions.map(s => `
|
||||
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium ${severityColors[s.severity] || severityColors.info}">
|
||||
${s.severity || 'info'}
|
||||
</span>
|
||||
${s.line ? `<span class="text-xs text-gray-500">行 ${s.line}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">${s.content || ''}</p>
|
||||
// Find or create file section
|
||||
let fileSection = document.getElementById('file-review-' + cssId(suggestion.file));
|
||||
if (!fileSection) {
|
||||
fileSection = document.createElement('div');
|
||||
fileSection.id = 'file-review-' + cssId(suggestion.file);
|
||||
fileSection.className = 'bg-white rounded-lg shadow-md border-l-4 border-gray-200 mb-4';
|
||||
fileSection.innerHTML = `
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="font-medium text-gray-900 font-mono text-sm">${suggestion.file || ''}</h3>
|
||||
<p class="file-summary text-sm text-gray-600 mt-1"></p>
|
||||
</div>
|
||||
`).join('');
|
||||
<div class="p-4 space-y-2 suggestions-container"></div>`;
|
||||
container.appendChild(fileSection);
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-gray-900">${file.filename || ''}</h3>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium ${severityColors[file.max_severity] || severityColors.info}">
|
||||
${file.max_severity || 'info'}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-600">${file.summary || ''}</p>
|
||||
${suggestionsHtml}
|
||||
</div>
|
||||
`;
|
||||
// Update border color based on max severity
|
||||
const severities = { critical: 0, warning: 1, info: 2 };
|
||||
const currentMax = fileSection.getAttribute('data-max-severity') || 'info';
|
||||
if ((severities[suggestion.severity] || 2) < (severities[currentMax] || 2)) {
|
||||
fileSection.setAttribute('data-max-severity', suggestion.severity);
|
||||
const colors = { critical: 'border-red-500', warning: 'border-yellow-500', info: 'border-green-500' };
|
||||
fileSection.className = fileSection.className.replace(/border-\w+-500/, '') + ' ' + (colors[suggestion.severity] || colors.info);
|
||||
}
|
||||
|
||||
return card;
|
||||
// Add suggestion card
|
||||
const sugContainer = fileSection.querySelector('.suggestions-container');
|
||||
const card = document.createElement('div');
|
||||
card.innerHTML = ReviewInline.renderSuggestionCard(suggestion);
|
||||
sugContainer.appendChild(card.firstElementChild);
|
||||
}
|
||||
|
||||
function cssId(str) {
|
||||
return (str || '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
}
|
||||
|
||||
// Render diff with inline suggestions
|
||||
function renderInlineReview(diffString) {
|
||||
const container = document.getElementById('review-diff-container');
|
||||
if (!container) return;
|
||||
|
||||
DiffViewer.init('review-diff-container');
|
||||
DiffViewer.renderDiff(diffString);
|
||||
|
||||
// Insert suggestions inline
|
||||
allSuggestions.forEach((s, i) => {
|
||||
if (s.file && s.line) {
|
||||
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDiffView() {
|
||||
const container = document.getElementById('review-diff-container');
|
||||
const btn = document.getElementById('btn-toggle-diff');
|
||||
diffVisible = !diffVisible;
|
||||
container.style.display = diffVisible ? '' : 'none';
|
||||
btn.textContent = diffVisible ? '隐藏 Diff' : '显示 Diff';
|
||||
}
|
||||
|
||||
// Simple markdown rendering
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(\d+)\. (.+)$/gm, '<li>$1. $2</li>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
loadRefs();
|
||||
|
||||
Reference in New Issue
Block a user