From 6807f449b7e1c57b4a4efbdf09fd77be29cc0b02 Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 19 May 2026 13:43:49 +0200 Subject: [PATCH] feat: add repository workspace as default project view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create git file utilities (list_tree, get_file_content, list_branches, commit_file) - Add file browsing API endpoints (list, content, branches, update) - Create RepoWorkspace page with sidebar + main content layout - Add FileTree component with directory navigation - Add FileViewer component for viewing file contents - Update project list to link to workspace - Add workspace CSS styles - Update router with workspace route Quality gates: ruff ✓, mypy ✓, typecheck ✓, build ✓ --- apps/api/src/api/git_repositories.py | 201 +++++++++ apps/api/src/utils/git_files.py | 391 ++++++++++++++++++ apps/web/src/pages/projects.tsx | 4 +- apps/web/src/pages/repo-workspace.tsx | 323 +++++++++++++++ apps/web/src/router.tsx | 2 + apps/web/src/styles.css | 148 +++++++ .../.openspec.yaml | 2 + .../design.md | 158 +++++++ .../proposal.md | 46 +++ .../specs/spec.md | 213 ++++++++++ .../tasks.md | 113 +++++ .../changes/repo-workspace/.openspec.yaml | 2 + openspec/changes/repo-workspace/design.md | 269 ++++++++++++ openspec/changes/repo-workspace/proposal.md | 72 ++++ openspec/changes/repo-workspace/specs/spec.md | 257 ++++++++++++ openspec/changes/repo-workspace/tasks.md | 131 ++++++ 16 files changed, 2330 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/utils/git_files.py create mode 100644 apps/web/src/pages/repo-workspace.tsx create mode 100644 openspec/changes/archive/2026-05-19-git-history-visualization/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-19-git-history-visualization/design.md create mode 100644 openspec/changes/archive/2026-05-19-git-history-visualization/proposal.md create mode 100644 openspec/changes/archive/2026-05-19-git-history-visualization/specs/spec.md create mode 100644 openspec/changes/archive/2026-05-19-git-history-visualization/tasks.md create mode 100644 openspec/changes/repo-workspace/.openspec.yaml create mode 100644 openspec/changes/repo-workspace/design.md create mode 100644 openspec/changes/repo-workspace/proposal.md create mode 100644 openspec/changes/repo-workspace/specs/spec.md create mode 100644 openspec/changes/repo-workspace/tasks.md diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 957e84b..ac4d067 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -14,6 +14,12 @@ from src.config import Settings from src.models.git_repository import GitRepository from src.models.project import Project from src.models.user import User +from src.utils.git_files import ( + commit_file, + get_file_content, + list_branches, + list_tree, +) from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_url_parser import parse_git_url @@ -265,3 +271,198 @@ async def get_repository_commit( return detail except (RuntimeError, ValueError) as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +# File browsing endpoints + + +class FileListResponse(BaseModel): + path: str + branch: str + entries: list[dict] + + +class FileContentResponse(BaseModel): + path: str + branch: str + content: str + size: int + encoding: str + language: str | None + is_binary: bool + last_commit: dict | None + + +class BranchesResponse(BaseModel): + branches: list[dict] + default_branch: str + + +class FileUpdateRequest(BaseModel): + path: str + branch: str + content: str + commit_message: str + + +class FileUpdateResponse(BaseModel): + commit_hash: str + message: str + branch: str + + +@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse) +async def list_repository_files( + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str = "main", + path: str = "", + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> FileListResponse: + """List files and directories in a repository path.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + try: + entries = list_tree(repo.path, branch=branch, path=path) + return FileListResponse( + path=path, + branch=branch, + entries=[ + { + "name": e.name, + "type": e.type, + "path": e.path, + "size": e.size, + "mode": e.mode, + "last_commit": e.last_commit, + } + for e in entries + ], + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse) +async def get_repository_file_content( + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str, + path: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> FileContentResponse: + """Get the content of a file.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + try: + file_content = get_file_content(repo.path, branch=branch, path=path) + return FileContentResponse( + path=file_content.path, + branch=file_content.branch, + content=file_content.content, + size=file_content.size, + encoding=file_content.encoding, + language=file_content.language, + is_binary=file_content.is_binary, + last_commit=file_content.last_commit, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse) +async def get_repository_branches( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> BranchesResponse: + """List all branches in the repository.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + try: + branches, default_branch = list_branches(repo.path) + return BranchesResponse( + branches=[ + { + "name": b.name, + "is_default": b.is_default, + "last_commit": b.last_commit, + } + for b in branches + ], + default_branch=default_branch, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse) +async def update_repository_file( + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: FileUpdateRequest, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> FileUpdateResponse: + """Update a file and create a commit.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + # Get user info for commit + user = await _get_user(session, user_id) + author_name = user.name or "Unknown" + author_email = user.email or "unknown@example.com" + + try: + commit_hash = commit_file( + repo_path=repo.path, + branch=data.branch, + path=data.path, + content=data.content, + commit_message=data.commit_message, + author_name=author_name, + author_email=author_email, + ) + return FileUpdateResponse( + commit_hash=commit_hash, + message=data.commit_message, + branch=data.branch, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/utils/git_files.py b/apps/api/src/utils/git_files.py new file mode 100644 index 0000000..ee6847d --- /dev/null +++ b/apps/api/src/utils/git_files.py @@ -0,0 +1,391 @@ +"""Git file utilities for browsing repository contents.""" + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass +class FileTreeEntry: + """Represents a file or directory in the repository.""" + + name: str + type: str # "file" or "directory" + path: str + size: int | None = None + mode: str | None = None + last_commit: dict[str, Any] | None = None + + +@dataclass +class BranchInfo: + """Represents a git branch.""" + + name: str + is_default: bool + last_commit: dict[str, Any] | None = None + + +@dataclass +class FileContent: + """Represents file content and metadata.""" + + path: str + branch: str + content: str + size: int + encoding: str + language: str | None + is_binary: bool + last_commit: dict[str, Any] | None = None + + +def _run_git_command(repo_path: str, *args: str) -> str: + """Run a git command in the repository directory.""" + result = subprocess.run( + ["git", *args], + cwd=repo_path, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Git command failed: {result.stderr}") + return result.stdout + + +def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]: + """List files and directories in a repository path. + + Args: + repo_path: Path to the git repository + branch: Branch name to list from + path: Directory path within the repository (empty for root) + + Returns: + List of FileTreeEntry objects + """ + tree_path = f"{branch}:{path}" if path else branch + + try: + output = _run_git_command(repo_path, "ls-tree", "-l", tree_path) + except RuntimeError: + # Try with HEAD if branch doesn't exist + tree_path = f"HEAD:{path}" if path else "HEAD" + output = _run_git_command(repo_path, "ls-tree", "-l", tree_path) + + entries = [] + for line in output.strip().split("\n"): + if not line: + continue + + # Format: \t + parts = line.split("\t", 1) + if len(parts) != 2: + continue + + meta, name = parts + meta_parts = meta.split() + if len(meta_parts) < 4: + continue + + mode = meta_parts[0] + obj_type = meta_parts[1] + _ = meta_parts[2] # object hash, not used + size = int(meta_parts[3]) if obj_type == "blob" else None + + entry_path = f"{path}/{name}" if path else name + + # Get last commit info for this entry + last_commit = _get_last_commit_for_path(repo_path, branch, entry_path) + + entries.append( + FileTreeEntry( + name=name, + type="directory" if obj_type == "tree" else "file", + path=entry_path, + size=size, + mode=mode, + last_commit=last_commit, + ) + ) + + return entries + + +def _get_last_commit_for_path(repo_path: str, branch: str, path: str) -> dict[str, Any] | None: + """Get the last commit that modified a path.""" + try: + output = _run_git_command( + repo_path, + "log", + "-1", + "--format=%H|%s|%an|%aI", + branch, + "--", + path, + ) + if not output.strip(): + return None + + parts = output.strip().split("|", 3) + if len(parts) != 4: + return None + + return { + "hash": parts[0], + "message": parts[1], + "author": parts[2], + "date": parts[3], + } + except RuntimeError: + return None + + +def get_file_content(repo_path: str, branch: str, path: str) -> FileContent: + """Get the content of a file. + + Args: + repo_path: Path to the git repository + branch: Branch name + path: File path within the repository + + Returns: + FileContent with content and metadata + """ + # Check if file exists + try: + _run_git_command(repo_path, "cat-file", "-e", f"{branch}:{path}") + except RuntimeError: + raise FileNotFoundError(f"File '{path}' not found in branch '{branch}'") + + # Get file size + size_output = _run_git_command(repo_path, "cat-file", "-s", f"{branch}:{path}") + size = int(size_output.strip()) + + # Check if binary + is_binary = _is_binary_file(repo_path, branch, path) + + # Get content (only for text files) + content = "" + if not is_binary: + content = _run_git_command(repo_path, "show", f"{branch}:{path}") + + # Detect language from extension + language = _detect_language(path) + + # Get last commit + last_commit = _get_last_commit_for_path(repo_path, branch, path) + + return FileContent( + path=path, + branch=branch, + content=content, + size=size, + encoding="utf-8", + language=language, + is_binary=is_binary, + last_commit=last_commit, + ) + + +def _is_binary_file(repo_path: str, branch: str, path: str) -> bool: + """Check if a file is binary.""" + try: + _run_git_command( + repo_path, + "diff", + "--numstat", + "--", + path, + ) + # Alternative: use git show and check for null bytes + content = _run_git_command(repo_path, "show", f"{branch}:{path}") + return b"\x00" in content.encode("utf-8", errors="replace") + except RuntimeError: + return True + + +def _detect_language(path: str) -> str | None: + """Detect programming language from file extension.""" + ext = Path(path).suffix.lower() + language_map = { + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".jsx": "jsx", + ".tsx": "tsx", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".json": "json", + ".md": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".sh": "bash", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".php": "php", + ".rb": "ruby", + ".sql": "sql", + ".dockerfile": "dockerfile", + ".vue": "vue", + ".svelte": "svelte", + } + return language_map.get(ext) + + +def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]: + """List all branches and identify the default branch. + + Args: + repo_path: Path to the git repository + + Returns: + Tuple of (list of BranchInfo, default branch name) + """ + # Get all branches + output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)") + + branches: list[BranchInfo] = [] + default_branch = "main" + + for line in output.strip().split("\n"): + if not line: + continue + + branch_name = line.strip() + # Skip remote tracking branches (they start with remotes/) + if branch_name.startswith("remotes/"): + # Extract just the branch name part + parts = branch_name.split("/", 2) + if len(parts) >= 3: + branch_name = parts[2] + else: + continue + + # Skip duplicates + if any(b.name == branch_name for b in branches): + continue + + # Check if this is the default branch (HEAD points to it) + try: + head_output = _run_git_command( + repo_path, + "symbolic-ref", + "HEAD", + ) + if head_output.strip() == f"refs/heads/{branch_name}": + default_branch = branch_name + except RuntimeError: + pass + + # Get last commit for branch + last_commit = _get_last_commit_for_path(repo_path, branch_name, ".") + + branches.append( + BranchInfo( + name=branch_name, + is_default=(branch_name == default_branch), + last_commit=last_commit, + ) + ) + + # If no branches found, try to get HEAD + if not branches: + try: + output = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD") + branch_name = output.strip() + if branch_name and branch_name != "HEAD": + last_commit = _get_last_commit_for_path(repo_path, branch_name, ".") + branches.append( + BranchInfo( + name=branch_name, + is_default=True, + last_commit=last_commit, + ) + ) + default_branch = branch_name + except RuntimeError: + pass + + return branches, default_branch + + +def commit_file( + repo_path: str, + branch: str, + path: str, + content: str, + commit_message: str, + author_name: str, + author_email: str, +) -> str: + """Commit a file change. + + Args: + repo_path: Path to the git repository + branch: Branch to commit to + path: File path within the repository + content: New file content + commit_message: Commit message + author_name: Author name + author_email: Author email + + Returns: + Commit hash + """ + # For bare repositories, we need to use git commands differently + # We'll create a temporary worktree, make changes, and commit + + import tempfile + import os + + # Create a temporary worktree + with tempfile.TemporaryDirectory() as worktree_path: + # Add worktree + _run_git_command( + repo_path, + "worktree", + "add", + "--detach", + worktree_path, + branch, + ) + + try: + # Write file content + file_path = os.path.join(worktree_path, path) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + # Configure git author + _run_git_command(worktree_path, "config", "user.name", author_name) + _run_git_command(worktree_path, "config", "user.email", author_email) + + # Stage and commit + _run_git_command(worktree_path, "add", path) + _run_git_command( + worktree_path, + "commit", + "-m", + commit_message, + ) + + # Get commit hash + commit_hash = _run_git_command( + worktree_path, + "rev-parse", + "HEAD", + ).strip() + + return commit_hash + + finally: + # Remove worktree + _run_git_command(repo_path, "worktree", "remove", worktree_path) diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 73003e6..29c2ba2 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -136,8 +136,8 @@ export const ProjectsPage = () => { {project.description &&

{project.description}

}
- - Repositories + + Open Workspace +
+ )} + + {status === "empty" && ( +
+

No repositories in this project yet.

+ + Add Repository + +
+ )} + + {status === "ready" && repositories.length > 0 && ( +
+ + +
+ {selectedRepoId && ( + + )} +
+
+ )} + + ); +}; + +// File Browser Component +const FileBrowser = ({ + projectId, + repoId, +}: { + projectId: string; + repoId: string; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const branch = searchParams.get("branch") || "main"; + const path = searchParams.get("path") || ""; + + const loadFiles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch( + `/api/projects/${projectId}/repositories/${repoId}/files?branch=${encodeURIComponent( + branch + )}&path=${encodeURIComponent(path)}`, + { credentials: "include" } + ); + if (!response.ok) { + throw new Error("Failed to load files"); + } + const data = await response.json(); + setEntries(data.entries || []); + } catch { + setError("Failed to load files"); + } finally { + setLoading(false); + } + }, [projectId, repoId, branch, path]); + + useEffect(() => { + void loadFiles(); + }, [loadFiles]); + + const handleEntryClick = (entry: any) => { + if (entry.type === "directory") { + const newParams = new URLSearchParams(searchParams); + newParams.set("path", entry.path); + setSearchParams(newParams); + } else { + const newParams = new URLSearchParams(searchParams); + newParams.set("file", entry.path); + setSearchParams(newParams); + } + }; + + const navigateUp = () => { + if (!path) return; + const parentPath = path.split("/").slice(0, -1).join("/"); + const newParams = new URLSearchParams(searchParams); + if (parentPath) { + newParams.set("path", parentPath); + } else { + newParams.delete("path"); + } + setSearchParams(newParams); + }; + + if (loading) return

Loading files...

; + if (error) return

{error}

; + + return ( +
+ {path && ( + + )} + {entries.map((entry) => ( + + ))} +
+ ); +}; + +// File Viewer Component +const FileViewer = ({ + projectId, + repoId, +}: { + projectId: string; + repoId: string; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isBinary, setIsBinary] = useState(false); + + const branch = searchParams.get("branch") || "main"; + const filePath = searchParams.get("file"); + + const loadFile = useCallback(async () => { + if (!filePath) { + setContent(null); + return; + } + + setLoading(true); + setError(null); + try { + const response = await fetch( + `/api/projects/${projectId}/repositories/${repoId}/files/content?branch=${encodeURIComponent( + branch + )}&path=${encodeURIComponent(filePath)}`, + { credentials: "include" } + ); + if (!response.ok) { + throw new Error("Failed to load file"); + } + const data = await response.json(); + if (data.is_binary) { + setIsBinary(true); + setContent("Binary file - cannot display"); + } else { + setIsBinary(false); + setContent(data.content); + } + } catch { + setError("Failed to load file"); + } finally { + setLoading(false); + } + }, [projectId, repoId, branch, filePath]); + + useEffect(() => { + void loadFile(); + }, [loadFile]); + + if (!filePath) { + return ( +
+

Select a file to view its contents

+
+ ); + } + + if (loading) return

Loading file...

; + if (error) return

{error}

; + + return ( +
+
+
+ {filePath.split("/").map((part, i, arr) => ( + + {part} + {i < arr.length - 1 && /} + + ))} +
+
+
+ {isBinary ? ( +

{content}

+ ) : ( +
+            {content}
+          
+ )} +
+
+ ); +}; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 59ef873..459ffb8 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -8,6 +8,7 @@ import { ProfilePage } from "./pages/profile"; import { ProjectsPage } from "./pages/projects"; import { GitRepositoriesPage } from "./pages/git-repositories"; import { GitHistoryPage } from "./pages/git-history"; +import { RepoWorkspace } from "./pages/repo-workspace"; import { SSHKeysPage } from "./pages/ssh-keys"; import { SettingsPage } from "./pages/settings"; import { ToolTypesPage } from "./pages/tool-types"; @@ -26,6 +27,7 @@ export const AppRouter = () => { > } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 21cb41e..abe6043 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -694,3 +694,151 @@ a { top: 1rem; } } + +/* Repository Workspace */ +.repo-workspace { + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + overflow: hidden; +} + +.workspace-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.workspace-title { + display: flex; + align-items: center; + gap: 1rem; +} + +.workspace-title h1 { + margin: 0; + font-size: 1.25rem; +} + +.repo-name { + color: var(--muted); + font-size: 0.875rem; +} + +.workspace-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +.workspace-sidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid var(--border); + background: var(--panel); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-section { + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.sidebar-section label { + margin: 0; +} + +.workspace-main { + flex: 1; + overflow: auto; + padding: 1rem; + background: var(--bg); +} + +/* File Tree */ +.file-tree { + flex: 1; + overflow: auto; + padding: 0.5rem; +} + +.tree-entry { + display: block; + width: 100%; + padding: 0.375rem 0.5rem; + border: none; + background: none; + color: var(--ink); + text-align: left; + cursor: pointer; + border-radius: 4px; + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-entry:hover { + background: var(--bg); +} + +.tree-directory { + font-weight: 500; +} + +.tree-up { + color: var(--muted); + font-style: italic; +} + +/* File Viewer */ +.file-viewer { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.file-viewer-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.file-breadcrumbs { + font-size: 0.875rem; + font-family: monospace; +} + +.breadcrumb-sep { + color: var(--muted); + margin: 0 0.25rem; +} + +.file-content { + padding: 1rem; + overflow: auto; + max-height: calc(100vh - 200px); +} + +.file-content pre { + margin: 0; + font-family: "IBM Plex Mono", monospace; + font-size: 0.875rem; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; +} + +.file-viewer-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; +} diff --git a/openspec/changes/archive/2026-05-19-git-history-visualization/.openspec.yaml b/openspec/changes/archive/2026-05-19-git-history-visualization/.openspec.yaml new file mode 100644 index 0000000..28882f7 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-git-history-visualization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-19 diff --git a/openspec/changes/archive/2026-05-19-git-history-visualization/design.md b/openspec/changes/archive/2026-05-19-git-history-visualization/design.md new file mode 100644 index 0000000..56f49ac --- /dev/null +++ b/openspec/changes/archive/2026-05-19-git-history-visualization/design.md @@ -0,0 +1,158 @@ +# Git History Visualization - Design + +## Architecture + +``` +User → Frontend → Backend API → Git CLI → Repository + ↓ + Commit Data + ↓ + Frontend Rendering +``` + +## Backend Design + +### Git History Extraction + +Use `git log --graph` with custom format to get structured data: + +```bash +git log --all --graph --format="%H|%P|%an|%ae|%at|%s" --date=short +``` + +This gives us: +- Commit hash +- Parent hashes +- Author name +- Author email +- Author timestamp +- Subject line + +### API Endpoints + +#### GET /projects/{project_id}/repositories/{repo_id}/history + +**Query Parameters:** +- `view`: "graph" or "list" (default: "graph") +- `branch`: specific branch to filter (optional) +- `limit`: max commits to return (default: 100) + +**Response (Graph View):** +```json +{ + "commits": [ + { + "hash": "abc123...", + "short_hash": "abc123", + "parents": ["def456...", "ghi789..."], + "author": "John Doe", + "email": "john@example.com", + "date": "2026-05-19", + "timestamp": 1716123456, + "message": "feat: add new feature", + "branches": ["main", "feature-branch"], + "tags": ["v1.0.0"] + } + ], + "branches": ["main", "develop", "feature-branch"], + "graph_data": { + "columns": 3, + "rows": [ + { + "commit_hash": "abc123...", + "column": 0, + "connections": [ + {"from_column": 0, "to_column": 1, "type": "merge"} + ] + } + ] + } +} +``` + +#### GET /projects/{project_id}/repositories/{repo_id}/commits/{commit_hash} + +**Response:** +```json +{ + "hash": "abc123...", + "short_hash": "abc123", + "parents": ["def456..."], + "author": "John Doe", + "email": "john@example.com", + "date": "2026-05-19", + "timestamp": 1716123456, + "message": "feat: add new feature\n\nDetailed description here", + "stats": { + "files_changed": 3, + "insertions": 45, + "deletions": 12 + }, + "diff": "diff --git a/file.txt b/file.txt\n..." +} +``` + +## Frontend Design + +### Page Layout + +``` +┌─────────────────────────────────────────────────────────┐ +│ Repository Name > History [Graph] [List] │ +├─────────────────────────────────────────────────────────┤ +│ ┌──────────────────┐ ┌──────────────────────────────┐ │ +│ │ │ │ │ │ +│ │ Graph/List │ │ Commit Details │ │ +│ │ View │ │ (message, author, │ │ +│ │ │ │ diff) │ │ +│ │ │ │ │ │ +│ └──────────────────┘ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Graph View + +- SVG-based rendering +- Commits shown as circles +- Lines connect commits (straight or curved) +- Branch labels shown inline +- Color coding for different branches +- Click to select commit + +### List View + +- Linear list of commits +- Each row: hash, message, author, date +- Expandable for details +- Click to select commit + +### Commit Details Panel + +- Header: Commit message, author, date +- Stats: Files changed, insertions, deletions +- Diff view: Syntax highlighted changes + +## Data Flow + +1. User navigates to repository history page +2. Frontend fetches history data from backend +3. Backend executes git commands on bare repo +4. Backend parses output into structured JSON +5. Frontend renders graph or list based on user preference +6. User clicks commit → Frontend fetches commit details +7. Backend executes `git show` for specific commit +8. Frontend displays details panel with diff + +## Error Handling + +- **Empty repository**: Show "No commits yet" message +- **Git command failure**: Show error with retry button +- **Large repositories**: Implement pagination/lazy loading +- **Binary files in diff**: Show "Binary file changed" instead of diff + +## Performance Considerations + +- **Pagination**: Load commits in batches (100 at a time) +- **Lazy loading**: Load diff only when commit is selected +- **Caching**: Cache history data for 30 seconds +- **Graph complexity**: Limit graph to first 500 commits for performance diff --git a/openspec/changes/archive/2026-05-19-git-history-visualization/proposal.md b/openspec/changes/archive/2026-05-19-git-history-visualization/proposal.md new file mode 100644 index 0000000..de18fc0 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-git-history-visualization/proposal.md @@ -0,0 +1,46 @@ +# Git History Visualization + +## Problem + +Currently, users can create and manage git repositories, but they have no way to view the commit history, branches, or understand the repository structure. This makes it impossible to: +- See what commits exist in a repository +- Understand branch relationships and merges +- View commit details (message, author, date, changes) +- Explore the repository's evolution over time + +## Solution + +Build an interactive git history visualization similar to GitKraken that provides: +1. **Graph View**: Visual commit graph showing branches, merges, and commit relationships +2. **List View**: Linear commit log with details +3. **Commit Details**: Click any commit to see full details and diff +4. **Branch Visualization**: See all branches and their relationships + +## Benefits + +- **Understand repository structure** at a glance +- **Track changes** and see who made what changes when +- **Navigate history** easily without using command line +- **Review code** by examining commit diffs + +## Scope + +### What we're building: +- Backend API to extract git history from bare/mirror repos using git CLI +- Graph visualization component (SVG-based commit graph) +- List view component (linear commit log) +- Commit detail panel (message, author, date, diff) +- View toggle (graph vs list) +- Branch label display + +### Out of scope (future enhancements): +- Interactive branch operations (checkout, merge, rebase) +- Tag management +- File browser at specific commits +- Blame/annotation view +- Advanced filtering/search + +## Technical Approach + +**Backend**: Execute `git log --graph --format=...` commands to get structured commit data +**Frontend**: Custom SVG rendering for graph, React components for list view and details panel diff --git a/openspec/changes/archive/2026-05-19-git-history-visualization/specs/spec.md b/openspec/changes/archive/2026-05-19-git-history-visualization/specs/spec.md new file mode 100644 index 0000000..1da9448 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-git-history-visualization/specs/spec.md @@ -0,0 +1,213 @@ +# Git History Visualization Specification + +## Requirements + +### Functional Requirements + +1. **View Commit History**: Display all commits in a repository +2. **Graph Visualization**: Show commits as nodes with branch/merge lines +3. **List View**: Alternative linear view of commits +4. **Commit Details**: Click to view full commit info and diff +5. **Branch Display**: Show branch names on commits +6. **Tag Display**: Show tags on commits +7. **View Toggle**: Switch between graph and list views + +### Non-Functional Requirements + +1. **Performance**: Load first 100 commits in < 2 seconds +2. **Responsiveness**: Graph should render smoothly up to 500 commits +3. **Compatibility**: Work with bare repositories and mirror clones +4. **Read-only**: No write operations to repository + +## API Specification + +### GET /projects/{project_id}/repositories/{repo_id}/history + +Retrieve commit history for a repository. + +**Query Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| view | string | "graph" | "graph" or "list" | +| branch | string | null | Filter by branch name | +| limit | integer | 100 | Max commits to return | +| offset | integer | 0 | Skip first N commits | + +**Response 200:** +```json +{ + "commits": [ + { + "hash": "full-sha-hash", + "short_hash": "abc1234", + "parents": ["parent-hash-1", "parent-hash-2"], + "author": "John Doe", + "email": "john@example.com", + "date": "2026-05-19", + "timestamp": 1716123456, + "message": "feat: add new feature", + "branches": ["main"], + "tags": [] + } + ], + "branches": ["main", "develop", "feature/x"], + "total_commits": 250, + "graph_data": { + "nodes": [ + { + "hash": "abc1234", + "x": 0, + "y": 0, + "column": 0 + } + ], + "edges": [ + { + "from_hash": "abc1234", + "to_hash": "def5678", + "type": "parent" + } + ] + } +} +``` + +### GET /projects/{project_id}/repositories/{repo_id}/commits/{commit_hash} + +Get detailed information about a specific commit. + +**Response 200:** +```json +{ + "hash": "full-sha-hash", + "short_hash": "abc1234", + "parents": ["parent-hash"], + "author": "John Doe", + "email": "john@example.com", + "date": "2026-05-19", + "timestamp": 1716123456, + "message": "feat: add new feature", + "body": "Detailed description here", + "stats": { + "files_changed": 3, + "insertions": 45, + "deletions": 12 + }, + "files": [ + { + "path": "src/main.py", + "change_type": "modified", + "insertions": 20, + "deletions": 5, + "diff": "diff content here" + } + ] +} +``` + +## Frontend Components + +### GitHistoryPage +Main page component that orchestrates the view. + +### GraphView +SVG-based commit graph visualization. + +**Props:** +- `commits`: Commit[] +- `graphData`: GraphData +- `selectedCommit`: string | null +- `onCommitSelect`: (hash: string) => void + +### ListView +Linear commit list. + +**Props:** +- `commits`: Commit[] +- `selectedCommit`: string | null +- `onCommitSelect`: (hash: string) => void + +### CommitDetails +Panel showing commit details and diff. + +**Props:** +- `commit`: CommitDetail | null + +### DiffViewer +Component to display git diff with syntax highlighting. + +**Props:** +- `files`: FileChange[] + +## Data Models + +### Commit +```typescript +interface Commit { + hash: string; + short_hash: string; + parents: string[]; + author: string; + email: string; + date: string; + timestamp: number; + message: string; + branches: string[]; + tags: string[]; +} +``` + +### CommitDetail +```typescript +interface CommitDetail extends Commit { + body: string; + stats: { + files_changed: number; + insertions: number; + deletions: number; + }; + files: FileChange[]; +} +``` + +### FileChange +```typescript +interface FileChange { + path: string; + change_type: "added" | "modified" | "deleted" | "renamed"; + insertions: number; + deletions: number; + diff: string; +} +``` + +## URL Structure + +``` +/projects/:projectId/repositories/:repoId/history +``` + +## Error Codes + +| Error Code | Description | User Message | +|------------|-------------|--------------| +| REPO_EMPTY | Repository has no commits | "This repository has no commits yet" | +| GIT_ERROR | Git command failed | "Failed to load repository history" | +| COMMIT_NOT_FOUND | Commit hash not found | "Commit not found" | +| INVALID_BRANCH | Branch doesn't exist | "Branch not found" | + +## Testing Strategy + +### Backend Tests +- Test git log parsing with various repo structures +- Test commit detail extraction +- Test error handling for empty repos + +### Frontend Tests +- Test graph rendering with sample data +- Test list view rendering +- Test commit selection and details display +- Test view toggle + +### Integration Tests +- End-to-end flow: load history → select commit → view details diff --git a/openspec/changes/archive/2026-05-19-git-history-visualization/tasks.md b/openspec/changes/archive/2026-05-19-git-history-visualization/tasks.md new file mode 100644 index 0000000..57a9346 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-git-history-visualization/tasks.md @@ -0,0 +1,113 @@ +# Git History Visualization - Tasks + +## Phase 1: Backend API + +- [x] **Task 1.1**: Create git history extraction module + - Create `src/utils/git_history.py` + - Implement `get_commit_history(repo_path, limit=100)` function + - Parse `git log --graph --format=...` output + - Extract commit hashes, parents, authors, dates, messages + - Extract branch information + - Add comprehensive unit tests + +- [x] **Task 1.2**: Create commit detail extraction + - Implement `get_commit_detail(repo_path, commit_hash)` function + - Parse `git show --format=... --stat` output + - Extract full message, body, stats, file changes + - Extract diff for each file + - Add unit tests + +- [x] **Task 1.3**: Create graph data builder + - Implement `build_graph_data(commits)` function + - Calculate node positions (x, y coordinates) + - Calculate edge connections between commits + - Handle merge commits (multiple parents) + - Add unit tests + +- [x] **Task 1.4**: Create history API endpoints + - Add `GET /projects/{project_id}/repositories/{repo_id}/history` + - Add `GET /projects/{project_id}/repositories/{repo_id}/commits/{commit_hash}` + - Handle query parameters (view, branch, limit, offset) + - Return structured JSON response + - Handle errors (empty repo, invalid commit, etc.) + - Add integration tests + +## Phase 2: Frontend Components + +- [x] **Task 2.1**: Create API client for history + - Add `fetchHistory(projectId, repoId, options)` function + - Add `fetchCommitDetail(projectId, repoId, commitHash)` function + - Add TypeScript interfaces for all data types + +- [x] **Task 2.2**: Create GraphView component + - SVG-based commit graph rendering + - Draw commit nodes (circles) + - Draw connection lines (straight/curved) + - Show branch labels + - Handle click events + - Color coding for branches + +- [x] **Task 2.3**: Create ListView component + - Linear list of commits + - Show hash, message, author, date + - Handle click events + - Scrollable with virtualization for large lists + +- [x] **Task 2.4**: Create CommitDetails component + - Show commit message, author, date + - Show stats (files changed, insertions, deletions) + - Show file list with change types + - Expandable diff viewer + - Syntax highlighting for diffs + +- [x] **Task 2.5**: Create DiffViewer component + - Parse and display git diff format + - Show line numbers + - Color code: green for additions, red for deletions + - Handle binary files + - Collapsible file sections + +## Phase 3: Page Integration + +- [x] **Task 3.1**: Create GitHistoryPage + - Layout with view toggle (graph/list) + - Fetch history data on mount + - Manage selected commit state + - Show loading/error states + - Responsive layout (details panel on right, below on mobile) + +- [x] **Task 3.2**: Add navigation from repository list + - Add "View History" button to repository cards + - Link to history page + - Pass repository info + +- [x] **Task 3.3**: Add route + - Add `/projects/:projectId/repositories/:repoId/history` route + - Update router configuration + +## Phase 4: Testing & Polish + +- [ ] **Task 4.1**: Add backend tests + - Test git log parsing + - Test graph data building + - Test API endpoints + - Test error handling + +- [ ] **Task 4.2**: Add frontend tests + - Test component rendering + - Test user interactions (click, toggle) + - Test data transformations + +- [x] **Task 4.3**: Run quality gates + - Backend: ruff, mypy, pytest + - Frontend: typecheck, lint, build + +- [ ] **Task 4.4**: Performance optimization + - Implement pagination for large repositories + - Add caching for history data + - Optimize graph rendering + +- [ ] **Task 4.5**: Documentation + - Update README with feature description + - Add screenshots/diagrams + - Document API endpoints diff --git a/openspec/changes/repo-workspace/.openspec.yaml b/openspec/changes/repo-workspace/.openspec.yaml new file mode 100644 index 0000000..28882f7 --- /dev/null +++ b/openspec/changes/repo-workspace/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-19 diff --git a/openspec/changes/repo-workspace/design.md b/openspec/changes/repo-workspace/design.md new file mode 100644 index 0000000..30baf65 --- /dev/null +++ b/openspec/changes/repo-workspace/design.md @@ -0,0 +1,269 @@ +# Repository Workspace - Design + +## Architecture + +``` +Project List + ↓ (click project) +Repo Workspace (default view) + ├─ Sidebar (200px) + │ ├─ Repo Selector (dropdown) + │ ├─ Branch Selector (dropdown) + │ └─ File Tree (scrollable) + │ ├─ 📁 src/ + │ │ └─ 📄 main.py + │ ├─ 📁 tests/ + │ └─ 📄 README.md + │ + └─ Main Content + ├─ Breadcrumbs: src > main.py + ├─ Toolbar: [Edit] [Raw] [History] + └─ Content Area + ├─ File View (syntax highlighted) + └─ Edit View (textarea with save) +``` + +## Page Layout + +### Route: `/projects/:projectId` +This replaces the current placeholder and becomes the default project view. + +### Layout Structure +``` +┌─────────────────────────────────────────────────────────────┐ +│ App Shell (Header + Nav) │ +├─────────────────────────────────────────────────────────────┤ +│ Project Header │ +│ "My Project" [Repos] [History] [Settings]│ +├─────────────────┬───────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content │ +│ ┌───────────┐ │ ┌─────────────────────────────────────┐ │ +│ │ Repo ▼ │ │ │ Breadcrumbs: src > components │ │ +│ ├───────────┤ │ ├─────────────────────────────────────┤ │ +│ │ Branch ▼ │ │ │ [Edit] [History] [Blame] │ │ +│ ├───────────┤ │ ├─────────────────────────────────────┤ │ +│ │ 📁 src/ │ │ │ │ │ +│ │ 📁 tests/ │ │ │ function hello() { │ │ +│ │ 📄 README │ │ │ return "world"; │ │ +│ │ ... │ │ │ } │ │ +│ │ │ │ │ │ │ +│ └───────────┘ │ └─────────────────────────────────────┘ │ +│ │ │ +└─────────────────┴───────────────────────────────────────────┘ +``` + +## Data Flow + +### 1. Page Load +``` +Load /projects/:id +→ Fetch project details +→ Fetch repositories list +→ Fetch default branch file tree (first repo) +→ Render workspace +``` + +### 2. Repository Switch +``` +Select repo from dropdown +→ Fetch branches list +→ Fetch default branch file tree +→ Reset file viewer +``` + +### 3. Branch Switch +``` +Select branch from dropdown +→ Fetch file tree for branch +→ If viewing a file: re-fetch file content for branch +``` + +### 4. File Navigation +``` +Click file in tree +→ Fetch file content (with syntax highlighting hint) +→ Show in viewer +→ Update breadcrumbs +``` + +## API Endpoints + +### GET /projects/{project_id}/repositories/{repo_id}/files +List files at a path (like `ls` for git). + +**Query Parameters:** +- `branch`: Branch name (default: repo's default branch) +- `path`: Directory path (default: root) + +**Response:** +```json +{ + "path": "src", + "branch": "main", + "entries": [ + { + "name": "components", + "type": "directory", + "path": "src/components" + }, + { + "name": "main.py", + "type": "file", + "path": "src/main.py", + "size": 1234, + "last_commit": { + "hash": "abc123", + "message": "Initial commit", + "date": "2024-01-01T00:00:00Z" + } + } + ] +} +``` + +### GET /projects/{project_id}/repositories/{repo_id}/files/content +Get file content. + +**Query Parameters:** +- `branch`: Branch name +- `path`: File path + +**Response:** +```json +{ + "path": "src/main.py", + "branch": "main", + "content": "function hello() {\n return 'world';\n}", + "size": 42, + "encoding": "utf-8", + "language": "python" +} +``` + +### GET /projects/{project_id}/repositories/{repo_id}/branches +List branches. + +**Response:** +```json +{ + "branches": [ + { + "name": "main", + "is_default": true, + "last_commit": "abc123" + }, + { + "name": "feature/new-thing", + "is_default": false, + "last_commit": "def456" + } + ], + "default_branch": "main" +} +``` + +### POST /projects/{project_id}/repositories/{repo_id}/files/content +Update file content (for quick edits). + +**Request Body:** +```json +{ + "path": "src/main.py", + "branch": "main", + "content": "function hello() {\n return 'world!!!';\n}", + "commit_message": "Quick edit: update greeting", + "author_name": "User Name", + "author_email": "user@example.com" +} +``` + +## Components + +### RepoWorkspace (Page) +- Orchestrates layout: sidebar + main content +- Manages repo/branch/file state +- Handles URL params (projectId, optional repoId) + +### FileTree (Sidebar Component) +- Recursive tree view +- Expandable folders +- File icons based on extension +- Active file highlight +- Click to open file + +### RepoSelector (Component) +- Dropdown of project repositories +- Shows active repo name +- Switch triggers repo change + +### BranchSelector (Component) +- Dropdown of branches +- Shows active branch +- Switch triggers branch change + +### FileViewer (Component) +- Syntax highlighted content +- Line numbers +- View/Edit toggle +- Breadcrumb navigation + +### Breadcrumbs (Component) +- Path segments as clickable links +- Shows current file location + +## State Management + +### URL State +``` +/projects/:projectId?repo=:repoId&branch=:branch&path=:path +``` +- repo: selected repository ID +- branch: active branch name +- path: current file/directory path + +### React State (per workspace) +```typescript +interface WorkspaceState { + projectId: string; + selectedRepoId: string | null; + selectedBranch: string; + currentPath: string; + selectedFile: string | null; + fileContent: string | null; + isEditing: boolean; + fileTree: FileTreeEntry[]; + branches: Branch[]; + repositories: Repository[]; +} +``` + +## Implementation Order + +1. **Backend file APIs** - List files, get content, list branches +2. **Backend update API** - Save file changes (commit) +3. **Project list clickable** - Link to workspace +4. **Workspace page shell** - Layout with sidebar + main +5. **File tree component** - Recursive directory listing +6. **File viewer component** - Content display +7. **Repo/branch selectors** - Dropdowns with state +8. **Edit mode** - Toggle + save +9. **URL state sync** - Sync selections to URL +10. **Polish** - Icons, syntax highlighting, error handling + +## Error Handling + +- **Repo not found**: Show error, allow selecting another +- **Branch not found**: Show error, default to main +- **File not found**: Show 404 in viewer +- **Permission denied**: Show auth error +- **Binary files**: Show "Binary file, cannot display" message +- **Large files**: Show warning, offer download + +## Performance Considerations + +- **Lazy load file tree**: Only expand directories when clicked +- **Cache file content**: Don't re-fetch if file hasn't changed +- **Debounce tree loading**: When switching branches, debounce +- **Virtual scrolling**: For large directories (100+ files) +- **Syntax highlighting**: Use lightweight highlighter, async load diff --git a/openspec/changes/repo-workspace/proposal.md b/openspec/changes/repo-workspace/proposal.md new file mode 100644 index 0000000..25372b6 --- /dev/null +++ b/openspec/changes/repo-workspace/proposal.md @@ -0,0 +1,72 @@ +# Repository Workspace - Default Project View + +## Problem + +Currently, clicking on a project shows a generic placeholder. Users need to navigate to repositories separately. There's no easy way to browse repository files or see project content at a glance. + +## Solution + +Create a **Repository Workspace** as the default project view that provides: + +1. **File browser** for repositories - browse files and directories +2. **Branch selector** - switch between branches +3. **Mini file viewer** - view file contents with syntax highlighting +4. **Quick edit capability** - small edits without leaving the browser +5. **Repository overview** - see all repos in the project + +This becomes the default view when clicking on a project, making the project the central workspace. + +## Key Features + +### Default Project View +- Clicking any project opens the repository workspace +- Shows first repo by default (or repo selector if multiple) +- File browser on the left, content viewer on the right + +### File Browser +- Tree view of repository files and directories +- Expandable/collapsible folders +- Click file to view contents +- Breadcrumb navigation + +### Branch Management +- Branch selector dropdown +- Shows current branch +- Lists all branches (local + remote) +- Switch branches to view different states + +### File Viewer +- Syntax highlighting for common file types +- Line numbers +- View mode (read-only by default) +- Edit mode toggle for small changes + +### Repository Navigation +- List all repositories in the project +- Quick switch between repos +- Repository cards with metadata (last commit, branch count) + +## Benefits + +- **Central hub**: Project becomes the main workspace, not just a container +- **Quick access**: See code immediately without extra clicks +- **Contextual**: Browse files while viewing commit history +- **Familiar**: Similar to GitHub/GitLab file browser + +## Scope + +### What stays: +- Existing repository list page (moves to sub-page) +- Git history visualization +- Repository creation/deletion + +### What's new: +- Repository workspace page (default project view) +- File browser component +- File viewer component +- Branch selector component +- API endpoints for file operations + +### What's changed: +- Project list items become clickable links +- Default project route shows workspace instead of placeholder diff --git a/openspec/changes/repo-workspace/specs/spec.md b/openspec/changes/repo-workspace/specs/spec.md new file mode 100644 index 0000000..d5fe343 --- /dev/null +++ b/openspec/changes/repo-workspace/specs/spec.md @@ -0,0 +1,257 @@ +# Repository Workspace Specification + +## Requirements + +### Functional Requirements + +1. **Default Project View**: Clicking a project opens the repository workspace +2. **File Browser**: Tree view of repository files and directories +3. **Branch Navigation**: Switch between branches to view different states +4. **File Viewer**: View file contents with syntax highlighting +5. **Quick Edit**: Make small changes and commit them +6. **Repository Switching**: Switch between repositories in a project +7. **Breadcrumb Navigation**: Show current file path with clickable segments + +### Non-Functional Requirements + +1. **Performance**: File tree loads in < 1 second +2. **Responsiveness**: UI remains responsive during git operations +3. **Usability**: Familiar interface similar to GitHub/GitLab +4. **Accessibility**: Keyboard navigation, screen reader support + +## API Specification + +### GET /projects/{project_id}/repositories/{repo_id}/files +List files in a directory. + +**Query Parameters:** +- `branch` (optional): Branch name, defaults to repository default branch +- `path` (optional): Directory path, defaults to root + +**Response 200:** +```json +{ + "path": "src", + "branch": "main", + "entries": [ + { + "name": "components", + "type": "directory", + "path": "src/components", + "mode": "040000" + }, + { + "name": "main.py", + "type": "file", + "path": "src/main.py", + "size": 1234, + "mode": "100644", + "last_commit": { + "hash": "abc123", + "message": "Initial commit", + "author": "John Doe", + "date": "2024-01-01T00:00:00Z" + } + } + ] +} +``` + +**Response 404:** Branch or path not found + +### GET /projects/{project_id}/repositories/{repo_id}/files/content +Get file content. + +**Query Parameters:** +- `branch` (required): Branch name +- `path` (required): File path + +**Response 200:** +```json +{ + "path": "src/main.py", + "branch": "main", + "content": "function hello() {\n return 'world';\n}", + "size": 42, + "encoding": "utf-8", + "language": "python", + "is_binary": false, + "last_commit": { + "hash": "abc123", + "message": "Initial commit", + "author": "John Doe", + "date": "2024-01-01T00:00:00Z" + } +} +``` + +**Response 404:** File not found + +### GET /projects/{project_id}/repositories/{repo_id}/branches +List branches. + +**Response 200:** +```json +{ + "branches": [ + { + "name": "main", + "is_default": true, + "last_commit": { + "hash": "abc123", + "message": "Initial commit", + "date": "2024-01-01T00:00:00Z" + } + } + ], + "default_branch": "main" +} +``` + +### POST /projects/{project_id}/repositories/{repo_id}/files/content +Update file content. + +**Request Body:** +```json +{ + "path": "src/main.py", + "branch": "main", + "content": "new content", + "commit_message": "Update file", + "author_name": "User", + "author_email": "user@example.com" +} +``` + +**Response 200:** +```json +{ + "commit_hash": "def789", + "message": "Update file", + "branch": "main" +} +``` + +## Data Model + +### FileTreeEntry +```typescript +interface FileTreeEntry { + name: string; + type: 'file' | 'directory'; + path: string; + size?: number; + mode?: string; + last_commit?: { + hash: string; + message: string; + author: string; + date: string; + }; +} +``` + +### Branch +```typescript +interface Branch { + name: string; + is_default: boolean; + last_commit?: { + hash: string; + message: string; + date: string; + }; +} +``` + +### FileContent +```typescript +interface FileContent { + path: string; + branch: string; + content: string; + size: number; + encoding: string; + language: string | null; + is_binary: boolean; + last_commit?: { + hash: string; + message: string; + author: string; + date: string; + }; +} +``` + +## Frontend Specification + +### URL Structure +``` +/projects/:projectId → Default view (first repo, default branch) +/projects/:projectId?repo=:repoId → Specific repo +/projects/:projectId?repo=:repoId&branch=:branch&path=:path → Specific file +``` + +### Components + +**RepoWorkspace** +- Layout: Sidebar (250px) + Main Content (flex) +- State: manages repo, branch, path, file selections +- Effects: sync URL params, fetch data + +**FileTree** +- Props: entries, activePath, onFileClick, onDirectoryToggle +- Recursive rendering for nested directories +- Expand/collapse state per directory + +**FileViewer** +- Props: content, language, path, isEditing, onEdit +- View mode: preformatted text with syntax highlighting +- Edit mode: textarea with save/cancel + +**RepoSelector** +- Props: repositories, selectedRepoId, onSelect +- Dropdown with repo names + +**BranchSelector** +- Props: branches, selectedBranch, onSelect +- Dropdown with branch names, default branch marked + +### State Management +Use React state with URL synchronization: +```typescript +const [searchParams, setSearchParams] = useSearchParams(); +const repoId = searchParams.get('repo'); +const branch = searchParams.get('branch'); +const path = searchParams.get('path'); +``` + +## Error Codes + +| Error Code | Description | User Message | +|------------|-------------|--------------| +| REPO_NOT_FOUND | Repository doesn't exist | "Repository not found" | +| BRANCH_NOT_FOUND | Branch doesn't exist | "Branch not found, using default" | +| FILE_NOT_FOUND | File path doesn't exist | "File not found" | +| BINARY_FILE | File is binary | "Cannot display binary file" | +| PERMISSION_DENIED | No access to file | "Permission denied" | + +## Testing Strategy + +### Backend Tests +- Test file listing for various paths +- Test file content retrieval +- Test branch listing +- Test file update/commit +- Test error cases (missing files, invalid branches) + +### Frontend Tests +- Test file tree rendering +- Test file viewer display +- Test branch switching +- Test repo switching +- Test edit mode +- Test URL state sync + +### Integration Tests +- End-to-end: Click project → browse files → view content → switch branch diff --git a/openspec/changes/repo-workspace/tasks.md b/openspec/changes/repo-workspace/tasks.md new file mode 100644 index 0000000..7a20475 --- /dev/null +++ b/openspec/changes/repo-workspace/tasks.md @@ -0,0 +1,131 @@ +# Repository Workspace - Tasks + +## Phase 1: Backend File APIs + +- [ ] **Task 1.1**: Create git file utilities + - Create `src/utils/git_files.py` + - `list_tree()` - list files in directory using `git ls-tree` + - `get_file_content()` - get file content using `git show` + - `list_branches()` - list branches using `git branch` + - `commit_file()` - commit file changes using `git add` + `git commit` + - Add tests + +- [ ] **Task 1.2**: Add file listing endpoint + - Add `GET /projects/{id}/repositories/{id}/files` to git_repositories.py + - Query params: branch, path + - Returns FileTreeEntry list + - Handle errors (missing branch, missing path) + +- [ ] **Task 1.3**: Add file content endpoint + - Add `GET /projects/{id}/repositories/{id}/files/content` to git_repositories.py + - Query params: branch, path + - Returns FileContent with language detection + - Detect binary files + +- [ ] **Task 1.4**: Add branches endpoint + - Add `GET /projects/{id}/repositories/{id}/branches` to git_repositories.py + - Returns branch list with default branch marked + +- [ ] **Task 1.5**: Add file update endpoint + - Add `POST /projects/{id}/repositories/{id}/files/content` to git_repositories.py + - Body: path, branch, content, commit_message, author info + - Create commit with changes + - Return commit hash + +## Phase 2: Project List Navigation + +- [ ] **Task 2.1**: Make project list clickable + - Update ProjectsPage to link to workspace + - Route: `/projects/:projectId` + - Remove placeholder, use workspace + +- [ ] **Task 2.2**: Update app navigation + - Ensure project routes are correct + - Add breadcrumb or back button + +## Phase 3: Workspace Page Shell + +- [ ] **Task 3.1**: Create RepoWorkspace page + - Create `pages/repo-workspace.tsx` + - Layout: Sidebar + Main Content + - Fetch project repos on load + - Select first repo by default + +- [ ] **Task 3.2**: Create RepoSelector component + - Dropdown to switch between project repos + - Show active repo name + - Update URL when switching + +- [ ] **Task 3.3**: Create BranchSelector component + - Dropdown to switch branches + - Show active branch + - Mark default branch + - Fetch branches from API + +## Phase 4: File Browser + +- [ ] **Task 4.1**: Create FileTree component + - Recursive tree view + - Expandable/collapsible folders + - File icons by extension + - Click to open file + - Active file highlight + - Fetch tree data from API + +- [ ] **Task 4.2**: Add file tree loading + - Load root on repo/branch change + - Lazy load subdirectories + - Show loading state + +## Phase 5: File Viewer + +- [ ] **Task 5.1**: Create FileViewer component + - Display file content + - Line numbers + - Syntax highlighting (prismjs or similar) + - Breadcrumb navigation + - Show file metadata (size, last commit) + +- [ ] **Task 5.2**: Add edit mode + - Toggle between view/edit + - Textarea for editing + - Save button (calls update API) + - Cancel button + - Commit message input + +## Phase 6: Integration & Polish + +- [ ] **Task 6.1**: Sync URL state + - Repo ID in URL + - Branch in URL + - Path in URL + - Parse on load, update on change + +- [ ] **Task 6.2**: Add error handling + - Repo not found + - Branch not found + - File not found + - Binary files + - Network errors + +- [ ] **Task 6.3**: Add CSS styles + - Workspace layout + - File tree styles + - File viewer styles + - Sidebar styles + - Responsive design + +- [ ] **Task 6.4**: Run quality gates + - Backend: ruff, mypy, pytest + - Frontend: typecheck, lint, build + +## Phase 7: Route Updates + +- [ ] **Task 7.1**: Update router + - `/projects/:projectId` → RepoWorkspace (default) + - Move old project details to `/projects/:projectId/details` or remove + - Keep `/projects/:projectId/repositories` for repo management + +- [ ] **Task 7.2**: Update navigation + - Project list links to workspace + - Add "Manage Repositories" link in workspace