diff --git a/codeframe/core/git.py b/codeframe/core/git.py index 08c4478a..c67ae47f 100644 --- a/codeframe/core/git.py +++ b/codeframe/core/git.py @@ -8,7 +8,8 @@ """ import logging -from dataclasses import dataclass +import re +from dataclasses import dataclass, field from pathlib import Path from typing import Optional @@ -55,6 +56,27 @@ class CommitResult: files_changed: int +@dataclass +class FileChange: + """Per-file change statistics from a diff.""" + + path: str + change_type: str # "modified", "added", "deleted", "renamed" + insertions: int = 0 + deletions: int = 0 + + +@dataclass +class DiffStats: + """Parsed diff statistics.""" + + diff: str + files_changed: int + insertions: int + deletions: int + changed_files: list[FileChange] = field(default_factory=list) + + # ============================================================================ # Git Operations # ============================================================================ @@ -293,3 +315,163 @@ def is_clean(workspace: Workspace) -> bool: """ repo = _get_repo(workspace) return not repo.is_dirty(untracked_files=True) + + +def get_diff_stats(workspace: Workspace, staged: bool = False) -> DiffStats: + """Get diff with parsed statistics. + + Args: + workspace: Target workspace + staged: If True, show staged changes; if False, show unstaged + + Returns: + DiffStats with parsed per-file statistics + """ + repo = _get_repo(workspace) + diff_text = get_diff(workspace, staged=staged) + + if not diff_text.strip(): + return DiffStats(diff=diff_text, files_changed=0, insertions=0, deletions=0) + + # Use git diff --stat for accurate statistics + try: + if staged: + stat_output = repo.git.diff("--cached", "--numstat") if repo.head.is_valid() else "" + else: + stat_output = repo.git.diff("--numstat") + except git.GitCommandError: + stat_output = "" + + changed_files: list[FileChange] = [] + total_insertions = 0 + total_deletions = 0 + + for line in stat_output.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) >= 3: + ins_str, del_str, file_path = parts[0], parts[1], parts[2] + ins = int(ins_str) if ins_str != "-" else 0 + dels = int(del_str) if del_str != "-" else 0 + total_insertions += ins + total_deletions += dels + + # Extract per-file section from diff for accurate change type detection + file_section_match = re.search( + rf"diff --git a/.*? b/{re.escape(file_path)}\n(.*?)(?=diff --git|\Z)", + diff_text, + re.DOTALL, + ) + file_section = file_section_match.group(0) if file_section_match else "" + + change_type = "modified" + if "new file mode" in file_section: + change_type = "added" + elif "deleted file mode" in file_section: + change_type = "deleted" + elif "rename from" in file_section: + change_type = "renamed" + + changed_files.append(FileChange( + path=file_path, + change_type=change_type, + insertions=ins, + deletions=dels, + )) + + return DiffStats( + diff=diff_text, + files_changed=len(changed_files), + insertions=total_insertions, + deletions=total_deletions, + changed_files=changed_files, + ) + + +def get_patch(workspace: Workspace, staged: bool = False) -> str: + """Get patch-formatted diff for export. + + Args: + workspace: Target workspace + staged: If True, show staged changes; if False, show unstaged + + Returns: + Patch content as string (with full headers for git apply) + """ + repo = _get_repo(workspace) + + try: + if staged: + if repo.head.is_valid(): + return repo.git.diff("--cached", "--patch", "--full-index") + return "" + else: + return repo.git.diff("--patch", "--full-index") + except git.GitCommandError as e: + logger.warning(f"Failed to get patch: {e}") + return "" + + +def generate_commit_message(workspace: Workspace, staged: bool = False) -> str: + """Generate a commit message from the current diff. + + Uses heuristic analysis of changed files and diff content to suggest + a conventional commit message. Does not require LLM. + + Args: + workspace: Target workspace + staged: If True, analyze staged changes; if False, unstaged + + Returns: + Suggested commit message string + """ + stats = get_diff_stats(workspace, staged=staged) + + if not stats.changed_files: + return "" + + files = stats.changed_files + file_count = len(files) + + # Determine primary action from change types + added = [f for f in files if f.change_type == "added"] + deleted = [f for f in files if f.change_type == "deleted"] + modified = [f for f in files if f.change_type == "modified"] + + # Pick prefix based on dominant change type + if len(added) > len(modified) and len(added) > len(deleted): + prefix = "feat" + action = "add" + elif len(deleted) > len(modified): + prefix = "refactor" + action = "remove" + else: + prefix = "feat" + action = "update" + + # Detect common patterns + test_files = [f for f in files if "test" in f.path.lower()] + if test_files and len(test_files) == file_count: + prefix = "test" + action = "add" if added else "update" + + config_files = [f for f in files if f.path.endswith((".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"))] + if config_files and len(config_files) == file_count: + prefix = "chore" + action = "update" + + # Build description + if file_count == 1: + file_path = files[0].path + name = Path(file_path).stem + description = f"{action} {name}" + else: + # Find common directory + dirs = set(str(Path(f.path).parent) for f in files) + if len(dirs) == 1 and list(dirs)[0] != ".": + description = f"{action} {list(dirs)[0]} ({file_count} files)" + else: + description = f"{action} {file_count} files" + + return f"{prefix}: {description}" diff --git a/codeframe/ui/routers/review_v2.py b/codeframe/ui/routers/review_v2.py index feff3f9b..ae856e9c 100644 --- a/codeframe/ui/routers/review_v2.py +++ b/codeframe/ui/routers/review_v2.py @@ -14,7 +14,7 @@ from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_standard -from codeframe.core import review +from codeframe.core import review, git from codeframe.ui.dependencies import get_v2_workspace logger = logging.getLogger(__name__) @@ -71,6 +71,38 @@ class ReviewTaskRequest(BaseModel): files_modified: list[str] = Field(..., min_length=1, description="Modified files to review") +class FileChangeResponse(BaseModel): + """Per-file change statistics.""" + + path: str + change_type: str + insertions: int + deletions: int + + +class DiffStatsResponse(BaseModel): + """Response model for diff with statistics.""" + + diff: str + files_changed: int + insertions: int + deletions: int + changed_files: list[FileChangeResponse] + + +class PatchResponse(BaseModel): + """Response model for patch export.""" + + patch: str + filename: str + + +class CommitMessageResponse(BaseModel): + """Response model for generated commit message.""" + + message: str + + # ============================================================================ # Review Endpoints # ============================================================================ @@ -205,3 +237,124 @@ async def review_files_summary( except Exception as e: logger.error(f"Failed to get review summary: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# Diff & Patch Endpoints (for Review & Commit View) +# ============================================================================ + + +@router.get("/diff", response_model=DiffStatsResponse) +@rate_limit_standard() +async def get_review_diff( + request: Request, + staged: bool = False, + workspace: Workspace = Depends(get_v2_workspace), +) -> DiffStatsResponse: + """Get unified diff with parsed statistics. + + Returns the raw diff plus per-file change counts for display + in the Review & Commit View. + + Args: + request: HTTP request for rate limiting + staged: If True, show staged changes; if False, show unstaged + workspace: v2 Workspace + + Returns: + DiffStatsResponse with diff text and statistics + """ + try: + stats = git.get_diff_stats(workspace, staged=staged) + + return DiffStatsResponse( + diff=stats.diff, + files_changed=stats.files_changed, + insertions=stats.insertions, + deletions=stats.deletions, + changed_files=[ + FileChangeResponse( + path=f.path, + change_type=f.change_type, + insertions=f.insertions, + deletions=f.deletions, + ) + for f in stats.changed_files + ], + ) + + except ValueError as e: + logger.error(f"Get review diff failed: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to get review diff: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/patch", response_model=PatchResponse) +@rate_limit_standard() +async def get_review_patch( + request: Request, + staged: bool = False, + workspace: Workspace = Depends(get_v2_workspace), +) -> PatchResponse: + """Get patch-formatted diff for export. + + Returns the diff in patch format suitable for `git apply`. + + Args: + request: HTTP request for rate limiting + staged: If True, show staged changes; if False, show unstaged + workspace: v2 Workspace + + Returns: + PatchResponse with patch content and suggested filename + """ + try: + patch_content = git.get_patch(workspace, staged=staged) + branch = git.get_current_branch(workspace) + filename = f"{branch.replace('/', '-')}.patch" + + return PatchResponse( + patch=patch_content, + filename=filename, + ) + + except ValueError as e: + logger.error(f"Get patch failed: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to get patch: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/commit-message", response_model=CommitMessageResponse) +@rate_limit_standard() +async def generate_commit_message( + request: Request, + staged: bool = False, + workspace: Workspace = Depends(get_v2_workspace), +) -> CommitMessageResponse: + """Generate a commit message from the current diff. + + Analyzes changed files to suggest a conventional commit message. + + Args: + request: HTTP request for rate limiting + staged: If True, analyze staged changes; if False, unstaged + workspace: v2 Workspace + + Returns: + CommitMessageResponse with suggested message + """ + try: + message = git.generate_commit_message(workspace, staged=staged) + + return CommitMessageResponse(message=message) + + except ValueError as e: + logger.error(f"Generate commit message failed: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to generate commit message: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/tests/core/test_git_review.py b/tests/core/test_git_review.py new file mode 100644 index 00000000..a28757be --- /dev/null +++ b/tests/core/test_git_review.py @@ -0,0 +1,198 @@ +"""Tests for git review functions (diff stats, patch, commit message generation). + +Tests the new functions added in core/git.py for the Review & Commit View (Issue #334). +""" + +import pytest +from pathlib import Path + +from codeframe.core.git import ( + get_diff_stats, + get_patch, + generate_commit_message, + DiffStats, + FileChange, +) +from codeframe.core.workspace import Workspace + + +@pytest.fixture +def git_workspace(tmp_path: Path) -> Workspace: + """Create a workspace with a git repo that has uncommitted changes.""" + import subprocess + + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + + # Init git repo + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo_dir, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=repo_dir, + capture_output=True, + ) + + # Create initial file and commit + (repo_dir / "hello.py").write_text("def hello():\n return 'hello'\n") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial commit"], + cwd=repo_dir, + capture_output=True, + ) + + # Make a change (unstaged) + (repo_dir / "hello.py").write_text( + "def hello():\n return 'hello world'\n\ndef greet():\n return 'hi'\n" + ) + + ws = Workspace.__new__(Workspace) + ws.repo_path = str(repo_dir) + ws.state_dir = str(repo_dir / ".codeframe") + return ws + + +@pytest.fixture +def git_workspace_new_file(git_workspace: Workspace) -> Workspace: + """Workspace with an added new file (unstaged).""" + repo_dir = Path(git_workspace.repo_path) + (repo_dir / "new_module.py").write_text("# new module\ndef foo():\n pass\n") + return git_workspace + + +class TestGetDiffStats: + def test_returns_diff_stats_with_changes(self, git_workspace): + stats = get_diff_stats(git_workspace, staged=False) + assert isinstance(stats, DiffStats) + assert stats.files_changed >= 1 + assert stats.insertions >= 0 + assert stats.deletions >= 0 + assert len(stats.diff) > 0 + + def test_returns_empty_for_clean_repo(self, tmp_path): + """Clean repo should return zero stats.""" + import subprocess + + repo_dir = tmp_path / "clean_repo" + repo_dir.mkdir() + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "t@t.com"], + cwd=repo_dir, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "T"], + cwd=repo_dir, + capture_output=True, + ) + (repo_dir / "f.txt").write_text("hi") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True) + + ws = Workspace.__new__(Workspace) + ws.repo_path = str(repo_dir) + ws.state_dir = str(repo_dir / ".codeframe") + + stats = get_diff_stats(ws, staged=False) + assert stats.files_changed == 0 + assert stats.insertions == 0 + assert stats.deletions == 0 + + def test_changed_files_have_correct_structure(self, git_workspace): + stats = get_diff_stats(git_workspace, staged=False) + for fc in stats.changed_files: + assert isinstance(fc, FileChange) + assert fc.path + assert fc.change_type in ("modified", "added", "deleted", "renamed") + assert isinstance(fc.insertions, int) + assert isinstance(fc.deletions, int) + + def test_diff_text_is_included(self, git_workspace): + stats = get_diff_stats(git_workspace, staged=False) + assert "hello" in stats.diff + + +class TestGetPatch: + def test_returns_patch_content(self, git_workspace): + patch = get_patch(git_workspace, staged=False) + assert isinstance(patch, str) + assert len(patch) > 0 + # Patch should contain diff markers + assert "diff --git" in patch or "---" in patch + + def test_empty_for_clean_repo(self, tmp_path): + import subprocess + + repo_dir = tmp_path / "clean_repo2" + repo_dir.mkdir() + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=repo_dir, capture_output=True) + (repo_dir / "f.txt").write_text("hi") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True) + + ws = Workspace.__new__(Workspace) + ws.repo_path = str(repo_dir) + ws.state_dir = str(repo_dir / ".codeframe") + + patch = get_patch(ws, staged=False) + assert patch == "" + + +class TestGenerateCommitMessage: + def test_generates_message_for_modified_file(self, git_workspace): + msg = generate_commit_message(git_workspace, staged=False) + assert isinstance(msg, str) + assert len(msg) > 0 + assert ":" in msg # conventional commit format + + def test_empty_for_no_changes(self, tmp_path): + import subprocess + + repo_dir = tmp_path / "clean_repo3" + repo_dir.mkdir() + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=repo_dir, capture_output=True) + (repo_dir / "f.txt").write_text("hi") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True) + + ws = Workspace.__new__(Workspace) + ws.repo_path = str(repo_dir) + ws.state_dir = str(repo_dir / ".codeframe") + + msg = generate_commit_message(ws, staged=False) + assert msg == "" + + def test_detects_test_files(self, tmp_path): + """If only test files changed, prefix should be 'test'.""" + import subprocess + + repo_dir = tmp_path / "test_repo_tests" + repo_dir.mkdir() + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=repo_dir, capture_output=True) + (repo_dir / "test_something.py").write_text("def test_a(): pass\n") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True) + + # Modify test file + (repo_dir / "test_something.py").write_text( + "def test_a(): pass\ndef test_b(): pass\n" + ) + + ws = Workspace.__new__(Workspace) + ws.repo_path = str(repo_dir) + ws.state_dir = str(repo_dir / ".codeframe") + + msg = generate_commit_message(ws, staged=False) + assert msg.startswith("test:") diff --git a/web-ui/__tests__/components/layout/AppSidebar.test.tsx b/web-ui/__tests__/components/layout/AppSidebar.test.tsx index f61112c9..a2bc4c0c 100644 --- a/web-ui/__tests__/components/layout/AppSidebar.test.tsx +++ b/web-ui/__tests__/components/layout/AppSidebar.test.tsx @@ -83,15 +83,15 @@ describe('AppSidebar', () => { expect(screen.getByRole('link', { name: /prd/i })).toHaveAttribute('href', '/prd'); }); - it('renders disabled items as non-link spans', () => { + it('renders all navigation items as links when enabled', () => { mockGetWorkspacePath.mockReturnValue('/home/user/projects/test'); render(); - // Tasks, Execution, and Blockers are enabled; Review is still disabled + // All nav items are enabled including Review expect(screen.getByRole('link', { name: /^tasks$/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /^execution$/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /^blockers$/i })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /^review$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^review$/i })).toBeInTheDocument(); }); it('highlights the active route', () => { diff --git a/web-ui/src/__tests__/lib/diffParser.test.ts b/web-ui/src/__tests__/lib/diffParser.test.ts new file mode 100644 index 00000000..4b3d790f --- /dev/null +++ b/web-ui/src/__tests__/lib/diffParser.test.ts @@ -0,0 +1,137 @@ +import { parseDiff, getFilePath, getDirectory, getFilename } from '@/lib/diffParser'; + +const SAMPLE_DIFF = `diff --git a/src/main.py b/src/main.py +index abc1234..def5678 100644 +--- a/src/main.py ++++ b/src/main.py +@@ -1,4 +1,6 @@ + def hello(): +- return 'hello' ++ return 'hello world' ++ ++def greet(): ++ return 'hi' +diff --git a/README.md b/README.md +new file mode 100644 +--- /dev/null ++++ b/README.md +@@ -0,0 +1,3 @@ ++# My Project ++ ++A simple project. +`; + +const DELETED_FILE_DIFF = `diff --git a/old_file.py b/old_file.py +deleted file mode 100644 +index abc1234..0000000 +--- a/old_file.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def old_function(): +- pass +`; + +describe('parseDiff', () => { + it('parses a diff with multiple files', () => { + const files = parseDiff(SAMPLE_DIFF); + expect(files).toHaveLength(2); + }); + + it('extracts file paths correctly', () => { + const files = parseDiff(SAMPLE_DIFF); + expect(files[0].oldPath).toBe('src/main.py'); + expect(files[0].newPath).toBe('src/main.py'); + expect(files[1].newPath).toBe('README.md'); + }); + + it('counts insertions and deletions per file', () => { + const files = parseDiff(SAMPLE_DIFF); + // main.py: -1 deletion, +3 additions + expect(files[0].insertions).toBeGreaterThan(0); + expect(files[0].deletions).toBeGreaterThan(0); + // README.md: all additions + expect(files[1].insertions).toBe(3); + expect(files[1].deletions).toBe(0); + }); + + it('detects new files', () => { + const files = parseDiff(SAMPLE_DIFF); + expect(files[1].isNew).toBe(true); + expect(files[0].isNew).toBe(false); + }); + + it('detects deleted files', () => { + const files = parseDiff(DELETED_FILE_DIFF); + expect(files).toHaveLength(1); + expect(files[0].isDeleted).toBe(true); + }); + + it('parses hunk headers correctly', () => { + const files = parseDiff(SAMPLE_DIFF); + expect(files[0].hunks).toHaveLength(1); + expect(files[0].hunks[0].oldStart).toBe(1); + expect(files[0].hunks[0].newStart).toBe(1); + }); + + it('identifies line types correctly', () => { + const files = parseDiff(SAMPLE_DIFF); + const lines = files[0].hunks[0].lines; + const additions = lines.filter((l) => l.type === 'addition'); + const deletions = lines.filter((l) => l.type === 'deletion'); + const context = lines.filter((l) => l.type === 'context'); + + expect(additions.length).toBeGreaterThan(0); + expect(deletions.length).toBeGreaterThan(0); + expect(context.length).toBeGreaterThan(0); + }); + + it('assigns line numbers to additions and deletions', () => { + const files = parseDiff(SAMPLE_DIFF); + const lines = files[0].hunks[0].lines; + + const firstAddition = lines.find((l) => l.type === 'addition'); + expect(firstAddition?.newLineNumber).not.toBeNull(); + expect(firstAddition?.oldLineNumber).toBeNull(); + + const firstDeletion = lines.find((l) => l.type === 'deletion'); + expect(firstDeletion?.oldLineNumber).not.toBeNull(); + expect(firstDeletion?.newLineNumber).toBeNull(); + }); + + it('returns empty array for empty diff', () => { + expect(parseDiff('')).toEqual([]); + expect(parseDiff(' ')).toEqual([]); + }); +}); + +describe('getFilePath', () => { + it('returns newPath for modified files', () => { + const files = parseDiff(SAMPLE_DIFF); + expect(getFilePath(files[0])).toBe('src/main.py'); + }); + + it('returns oldPath for deleted files', () => { + const files = parseDiff(DELETED_FILE_DIFF); + expect(getFilePath(files[0])).toBe('old_file.py'); + }); +}); + +describe('getDirectory', () => { + it('extracts directory from path', () => { + expect(getDirectory('src/components/Button.tsx')).toBe('src/components'); + }); + + it('returns empty string for root files', () => { + expect(getDirectory('README.md')).toBe(''); + }); +}); + +describe('getFilename', () => { + it('extracts filename from path', () => { + expect(getFilename('src/components/Button.tsx')).toBe('Button.tsx'); + }); + + it('returns full string for root files', () => { + expect(getFilename('README.md')).toBe('README.md'); + }); +}); diff --git a/web-ui/src/app/review/page.tsx b/web-ui/src/app/review/page.tsx new file mode 100644 index 00000000..c2c67237 --- /dev/null +++ b/web-ui/src/app/review/page.tsx @@ -0,0 +1,328 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import Link from 'next/link'; +import useSWR from 'swr'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import { reviewApi, gatesApi, gitApi, prApi } from '@/lib/api'; +import { parseDiff, getFilePath } from '@/lib/diffParser'; +import type { + DiffStatsResponse, + GateResult, + ApiError, +} from '@/types'; + +import { FileTreePanel } from '@/components/review/FileTreePanel'; +import { DiffViewer } from '@/components/review/DiffViewer'; +import { DiffNavigation } from '@/components/review/DiffNavigation'; +import { ReviewHeader } from '@/components/review/ReviewHeader'; +import { CommitPanel } from '@/components/review/CommitPanel'; +import { ExportPatchModal } from '@/components/review/ExportPatchModal'; +import { PRCreatedModal } from '@/components/review/PRCreatedModal'; + +export default function ReviewPage() { + const [workspacePath, setWorkspacePath] = useState(null); + const [workspaceReady, setWorkspaceReady] = useState(false); + + // Core state + const [selectedFile, setSelectedFile] = useState(null); + const [selectedFileIndex, setSelectedFileIndex] = useState(0); + const [gateResult, setGateResult] = useState(null); + const [commitMessage, setCommitMessage] = useState(''); + + // Loading states + const [isRunningGates, setIsRunningGates] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [isCommitting, setIsCommitting] = useState(false); + const [isCreatingPR, setIsCreatingPR] = useState(false); + + // Modal states + const [showPatchModal, setShowPatchModal] = useState(false); + const [patchContent, setPatchContent] = useState(''); + const [patchFilename, setPatchFilename] = useState(''); + const [showPRModal, setShowPRModal] = useState(false); + const [prUrl, setPrUrl] = useState(''); + const [prNumber, setPrNumber] = useState(0); + + // Feedback + const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + setWorkspaceReady(true); + }, []); + + // Fetch diff data + const { + data: diffData, + error: diffError, + mutate: mutateDiff, + } = useSWR( + workspacePath ? `/api/v2/review/diff?path=${workspacePath}` : null, + () => reviewApi.getDiff(workspacePath!) + ); + + // Parse diff into structured files + const diffFiles = useMemo( + () => (diffData?.diff ? parseDiff(diffData.diff) : []), + [diffData?.diff] + ); + + // Clamp selectedFileIndex when diffFiles shrinks (e.g., after commit) + useEffect(() => { + if (diffFiles.length === 0) { + setSelectedFileIndex(0); + setSelectedFile(null); + } else if (selectedFileIndex >= diffFiles.length) { + const clamped = diffFiles.length - 1; + setSelectedFileIndex(clamped); + setSelectedFile(getFilePath(diffFiles[clamped])); + } + }, [diffFiles, selectedFileIndex]); + + // Auto-generate commit message on first load only + const hasAutoGenerated = useRef(false); + useEffect(() => { + if (!workspacePath || !diffData || hasAutoGenerated.current) return; + hasAutoGenerated.current = true; + reviewApi + .generateCommitMessage(workspacePath) + .then((res) => setCommitMessage(res.message)) + .catch(() => {}); + }, [workspacePath, diffData]); + + // Clear feedback after 5 seconds + useEffect(() => { + if (!feedback) return; + const timer = setTimeout(() => setFeedback(null), 5000); + return () => clearTimeout(timer); + }, [feedback]); + + const handleRunGates = useCallback(async () => { + if (!workspacePath) return; + setIsRunningGates(true); + try { + const result = await gatesApi.run(workspacePath); + setGateResult(result); + } catch (err) { + setFeedback({ type: 'error', message: (err as ApiError).detail || 'Failed to run gates' }); + } finally { + setIsRunningGates(false); + } + }, [workspacePath]); + + const handleExportPatch = useCallback(async () => { + if (!workspacePath) return; + try { + const result = await reviewApi.getPatch(workspacePath); + setPatchContent(result.patch); + setPatchFilename(result.filename); + setShowPatchModal(true); + } catch (err) { + setFeedback({ type: 'error', message: (err as ApiError).detail || 'Failed to export patch' }); + } + }, [workspacePath]); + + const handleGenerateMessage = useCallback(async () => { + if (!workspacePath) return; + setIsGenerating(true); + try { + const result = await reviewApi.generateCommitMessage(workspacePath); + setCommitMessage(result.message); + } catch (err) { + setFeedback({ type: 'error', message: (err as ApiError).detail || 'Failed to generate message' }); + } finally { + setIsGenerating(false); + } + }, [workspacePath]); + + const handleCommit = useCallback(async () => { + if (!workspacePath || !commitMessage.trim()) return; + const files = diffData?.changed_files.map((f) => f.path) ?? []; + if (files.length === 0) { + setFeedback({ type: 'error', message: 'No files to commit' }); + return; + } + setIsCommitting(true); + try { + const result = await gitApi.commit(workspacePath, files, commitMessage); + setFeedback({ + type: 'success', + message: `Committed ${result.files_changed} files: ${result.commit_hash.slice(0, 7)}`, + }); + setCommitMessage(''); + mutateDiff(); // Refresh diff + } catch (err) { + setFeedback({ type: 'error', message: (err as ApiError).detail || 'Failed to commit' }); + } finally { + setIsCommitting(false); + } + }, [workspacePath, commitMessage, diffData, mutateDiff]); + + const handleCreatePR = useCallback( + async (title: string, body: string) => { + if (!workspacePath) return; + setIsCreatingPR(true); + try { + const result = await prApi.create(workspacePath, { + branch: '', // Let backend use current branch + title, + body, + }); + setPrUrl(result.url); + setPrNumber(result.number); + setShowPRModal(true); + } catch (err) { + setFeedback({ type: 'error', message: (err as ApiError).detail || 'Failed to create PR' }); + } finally { + setIsCreatingPR(false); + } + }, + [workspacePath] + ); + + const handleFileSelect = useCallback( + (filePath: string) => { + setSelectedFile(filePath); + const idx = diffFiles.findIndex( + (f) => getFilePath(f).includes(filePath) || filePath.includes(getFilePath(f)) + ); + if (idx >= 0) setSelectedFileIndex(idx); + }, + [diffFiles] + ); + + const handlePrevFile = useCallback(() => { + if (selectedFileIndex <= 0) return; + const newIdx = selectedFileIndex - 1; + setSelectedFileIndex(newIdx); + setSelectedFile(getFilePath(diffFiles[newIdx])); + }, [selectedFileIndex, diffFiles]); + + const handleNextFile = useCallback(() => { + if (selectedFileIndex >= diffFiles.length - 1) return; + const newIdx = selectedFileIndex + 1; + setSelectedFileIndex(newIdx); + setSelectedFile(getFilePath(diffFiles[newIdx])); + }, [selectedFileIndex, diffFiles]); + + // Hydration guard + if (!workspaceReady) return null; + + // No workspace + if (!workspacePath) { + return ( +
+
+
+

+ No workspace selected. Return to{' '} + + Workspace + {' '} + and select a project. +

+
+
+
+ ); + } + + return ( +
+ {/* Feedback banner */} + {feedback && ( +
+ {feedback.message} +
+ )} + + {/* Header */} +
+ +
+ + {/* Navigation */} + {diffFiles.length > 0 && ( +
+ 0 + ? getFilePath(diffFiles[selectedFileIndex]) + : '' + } + onPrevious={handlePrevFile} + onNext={handleNextFile} + /> +
+ )} + + {/* Main content area */} +
+ {/* File tree (left sidebar) */} + + + {/* Diff viewer (center) */} + + + {/* Commit panel (right sidebar) */} + f.path) ?? []} + onCreatePR={handleCreatePR} + /> +
+ + {/* Error state */} + {diffError && ( +
+ Failed to load diff: {(diffError as ApiError).detail || 'Unknown error'} +
+ )} + + {/* Modals */} + setShowPatchModal(false)} + patchContent={patchContent} + filename={patchFilename} + /> + + setShowPRModal(false)} + prUrl={prUrl} + prNumber={prNumber} + /> +
+ ); +} diff --git a/web-ui/src/components/layout/AppSidebar.tsx b/web-ui/src/components/layout/AppSidebar.tsx index 2ffc5063..c6de838d 100644 --- a/web-ui/src/components/layout/AppSidebar.tsx +++ b/web-ui/src/components/layout/AppSidebar.tsx @@ -29,7 +29,7 @@ const NAV_ITEMS: NavItem[] = [ { href: '/tasks', label: 'Tasks', icon: Task01Icon, enabled: true }, { href: '/execution', label: 'Execution', icon: PlayIcon, enabled: true }, { href: '/blockers', label: 'Blockers', icon: Alert02Icon, enabled: true }, - { href: '/review', label: 'Review', icon: GitBranchIcon, enabled: false }, + { href: '/review', label: 'Review', icon: GitBranchIcon, enabled: true }, ]; export function AppSidebar() { diff --git a/web-ui/src/components/review/CommitPanel.tsx b/web-ui/src/components/review/CommitPanel.tsx new file mode 100644 index 00000000..8c3916f7 --- /dev/null +++ b/web-ui/src/components/review/CommitPanel.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useState } from 'react'; +import { + Loading03Icon, + GitBranchIcon, + Idea01Icon, +} from '@hugeicons/react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; + +export interface CommitPanelProps { + commitMessage: string; + onCommitMessageChange: (message: string) => void; + onGenerateMessage: () => void; + onCommit: () => void; + isGenerating: boolean; + isCommitting: boolean; + isCreatingPR: boolean; + changedFiles: string[]; + onCreatePR: (title: string, body: string) => void; +} + +export function CommitPanel({ + commitMessage, + onCommitMessageChange, + onGenerateMessage, + onCommit, + isGenerating, + isCommitting, + isCreatingPR, + changedFiles, + onCreatePR, +}: CommitPanelProps) { + const [showPRForm, setShowPRForm] = useState(false); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + + return ( + + {/* Header */} +

Commit Changes

+ + {/* Commit message */} +
+ +