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:
Fusion
2026-05-19 13:43:49 +02:00
parent 92d0d5b891
commit 6807f449b7
16 changed files with 2330 additions and 2 deletions
+201
View File
@@ -14,6 +14,12 @@ from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.user import User 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_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url from src.utils.git_url_parser import parse_git_url
@@ -265,3 +271,198 @@ async def get_repository_commit(
return detail return detail
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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))
+391
View File
@@ -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)
+2 -2
View File
@@ -136,8 +136,8 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>} {project.description && <p className="muted">{project.description}</p>}
</div> </div>
<div className="project-actions"> <div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}/repositories`}> <Link className="ghost-button" to={`/projects/${project.id}`}>
Repositories Open Workspace
</Link> </Link>
<button <button
className="ghost-button" className="ghost-button"
+323
View File
@@ -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>
);
};
+2
View File
@@ -8,6 +8,7 @@ import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects"; import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories"; import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history"; import { GitHistoryPage } from "./pages/git-history";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys"; import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings"; import { SettingsPage } from "./pages/settings";
import { ToolTypesPage } from "./pages/tool-types"; import { ToolTypesPage } from "./pages/tool-types";
@@ -26,6 +27,7 @@ export const AppRouter = () => {
> >
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="projects" element={<ProjectsPage />} /> <Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} /> <Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} /> <Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} /> <Route path="ssh-keys" element={<SSHKeysPage />} />
+148
View File
@@ -694,3 +694,151 @@ a {
top: 1rem; 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
+269
View File
@@ -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
+131
View File
@@ -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