feat: add repository workspace as default project view
- 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 ✓
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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: <mode> <type> <hash> <size>\t<name>
|
||||
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)
|
||||
@@ -136,8 +136,8 @@ export const ProjectsPage = () => {
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}/repositories`}>
|
||||
Repositories
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo")
|
||||
);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
// If no repo selected, select the first one
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
<div className="workspace-header">
|
||||
<div className="workspace-title">
|
||||
<h1>Repository Workspace</h1>
|
||||
{selectedRepo && <span className="repo-name">{selectedRepo.name}</span>}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<Link
|
||||
className="secondary-button"
|
||||
to={`/projects/${projectId}/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/repositories`}
|
||||
>
|
||||
Add Repository
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="workspace-layout">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<FileBrowser
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileViewer projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// File Browser Component
|
||||
const FileBrowser = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
||||
📁 ..
|
||||
</button>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// File Viewer Component
|
||||
const FileViewer = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="file-viewer-empty">
|
||||
<p className="muted">Select a file to view its contents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted">Loading file...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-viewer">
|
||||
<div className="file-viewer-header">
|
||||
<div className="file-breadcrumbs">
|
||||
{filePath.split("/").map((part, i, arr) => (
|
||||
<span key={i}>
|
||||
{part}
|
||||
{i < arr.length - 1 && <span className="breadcrumb-sep">/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="file-content">
|
||||
{isBinary ? (
|
||||
<p className="muted">{content}</p>
|
||||
) : (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user