From 7ac3a3726bfcc7b9f762a3fa3448449da6c054da Mon Sep 17 00:00:00 2001 From: wonder Date: Thu, 18 Jun 2026 23:37:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BA=A4=E4=BA=92=E5=8A=9F=E8=83=BD=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + PLAN.md | 28 +- static/css/style.css | 162 ++++++++++++ static/js/diff-viewer.js | 478 +++++++++++++++++++++++++++++++--- static/js/graph.js | 101 ++++++- static/js/review-inline.js | 126 +++++++++ static/js/sse.js | 86 ++++++ templates/pages/generate.html | 130 ++++++--- templates/pages/repo.html | 283 +++++++++++++------- templates/pages/review.html | 253 +++++++++++++----- 10 files changed, 1387 insertions(+), 261 deletions(-) create mode 100644 static/js/review-inline.js create mode 100644 static/js/sse.js 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 = ` +
+
+ ${totalFiles} 文件 + +${totalAdd} + -${totalDel} +
+
+
`; + + 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 = ` +
+
+ ${treeHtml} +
+
+ ${diffHtml} +
+
`; // 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 = ` +
+
+ ${treeHtml} +
+
+ ${diffHtml} +
+
`; + + 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 = '

Loading files...

'; + this.container.innerHTML = '

Loading files...

'; 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 ` +
+

整体评估

+
${summary}
+
`; + }, + + _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 @@ - - -
@@ -60,6 +80,41 @@ +
+ +
+ + +
@@ -65,6 +80,10 @@