diff --git a/CLAUDE.md b/CLAUDE.md
index 3a11a6a..5debab2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/PLAN.md b/PLAN.md
index 75fcb88..c63c55a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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)
diff --git a/static/css/style.css b/static/css/style.css
index 1e1cefd..a57964e 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -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;
+}
diff --git a/static/js/diff-viewer.js b/static/js/diff-viewer.js
index 1dca1c4..8e74d94 100644
--- a/static/js/diff-viewer.js
+++ b/static/js/diff-viewer.js
@@ -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 = `
+
+ `;
+
+ html += this._renderTreeNode(tree, 0);
+ html += '
';
+
+ return html;
+ },
+
+ _renderTreeNode(node, depth) {
+ let html = '';
+
+ if (node.isDir) {
+ // Only render directory label if not root
+ if (depth > 0) {
+ html += `
+ ▶
+ ${node.name}/
+
`;
+ }
+ // 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 += `
+ ${icon}
+ ${node.name}
+
+ +${node.additions}
+ -${node.deletions}
+
+
`;
+ }
+
+ return html;
+ },
+
+ _getFileIcon(changeType) {
+ switch (changeType) {
+ case 'added': return 'A';
+ case 'deleted': return 'D';
+ case 'renamed': return 'R';
+ default: return 'M';
+ }
+ },
+
+ // 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 = `
+ `;
// 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 = `
+ `;
+
+ 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 = '';
+ this.container.innerHTML = '';
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 = `
+
+
+
+ ${severityLabels[severity] || severityLabels.info}
+
+ ${content}
+
+
+ | `;
+
+ // Insert after the current row
+ row.parentNode.insertBefore(suggestionRow, row.nextSibling);
+ return;
+ }
+ }
}
};
diff --git a/static/js/graph.js b/static/js/graph.js
index 071e9b2..1b4585a 100644
--- a/static/js/graph.js
+++ b/static/js/graph.js
@@ -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 = 'No commits found
';
@@ -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() {
diff --git a/static/js/review-inline.js b/static/js/review-inline.js
new file mode 100644
index 0000000..cb19edf
--- /dev/null
+++ b/static/js/review-inline.js
@@ -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 `
+
+
+
+ ${style.icon} ${style.label}
+
+ ${suggestion.file ? `${suggestion.file}` : ''}
+ ${suggestion.line ? `行 ${suggestion.line}` : ''}
+
+
${suggestion.content || ''}
+ ${suggestion.code_example ? `
+
${this._escapeHtml(suggestion.code_example)}
+ ` : ''}
+
`;
+ },
+
+ // 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 = `
+
+
+
${fileReview.filename || ''}
+ ${fileReview.summary ? `
${fileReview.summary}
` : ''}
+
+
`;
+
+ suggestions.forEach(s => {
+ html += this.renderSuggestionCard(s);
+ });
+
+ html += `
+
+
`;
+
+ return html;
+ },
+
+ // Render overall summary
+ renderSummary(summary) {
+ if (!summary) return '';
+ return `
+ `;
+ },
+
+ _escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+};
+
+window.ReviewInline = ReviewInline;
diff --git a/static/js/sse.js b/static/js/sse.js
new file mode 100644
index 0000000..a73abbb
--- /dev/null
+++ b/static/js/sse.js
@@ -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;
diff --git a/templates/pages/generate.html b/templates/pages/generate.html
index ec2ceef..e87be5e 100644
--- a/templates/pages/generate.html
+++ b/templates/pages/generate.html
@@ -2,6 +2,7 @@
{{template "head" .}}
+
{{template "nav" .}}
@@ -56,7 +57,7 @@
@@ -70,10 +71,11 @@
-
-
@@ -82,6 +84,7 @@
+
@@ -39,14 +67,6 @@
-
-
-
调试信息:
-
仓库 ID:
-
Refs:
-
Graph:
-
-
@@ -60,6 +80,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💡 在 Git Graph 中点击节点选择 Base(绿)和 Head(橙)
+
+
+
+ →
+
+
+
+
+
@@ -75,46 +130,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -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();
diff --git a/templates/pages/review.html b/templates/pages/review.html
index 17a142a..c5c0fee 100644
--- a/templates/pages/review.html
+++ b/templates/pages/review.html
@@ -5,7 +5,9 @@
+
+
{{template "nav" .}}
@@ -29,7 +31,9 @@