From 5ed5e1c84bd4af6ebaf0661d3deebeb2ea2499e2 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 3 Jun 2026 08:30:28 +0000 Subject: [PATCH] fix: resolve four frontend/backend issues - Fix ProjectsPage tests by wrapping renders in MemoryRouter (9 passing) - Improve session auto-naming to 'project / repo / tool' format - Add missing /users/me/sessions endpoint for sidebar session loading - Handle git history 500s: catch RuntimeError in endpoints, graceful empty repo handling - Add git status badge and discard-changes button to FileEditor toolbar Quality gates: tsc pass, build pass, Python syntax pass --- apps/api/src/api/git_repositories.py | 20 +- apps/api/src/api/users.py | 41 +++ apps/api/src/schemas/tool_instance.py | 24 ++ apps/api/src/services/instance_lifecycle.py | 2 +- apps/api/src/utils/git_history.py | 27 +- .../components/features/git/FileEditor.tsx | 101 +++++-- apps/web/src/components/ui/Icon.tsx | 4 +- apps/web/src/pages/ProjectsPage.test.tsx | 285 ++++++++++-------- apps/web/src/pages/RepoWorkspacePage.tsx | 2 +- 9 files changed, 348 insertions(+), 158 deletions(-) diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 8b88dd4..9d71e73 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -3,7 +3,7 @@ import logging import uuid -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_current_user, get_db_session, get_owned_project @@ -94,7 +94,14 @@ async def get_repository_history( from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk repo = await get_repo_and_validate(session, repo_id, project_id) ensure_repo_on_disk(repo) - return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) + try: + return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) + except RuntimeError as e: + logger.warning("Git history failed for %s: %s", repo.path, str(e)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Git history unavailable: {str(e)}", + ) from e @router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}") @@ -110,7 +117,14 @@ async def get_repository_commit( from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk repo = await get_repo_and_validate(session, repo_id, project_id) ensure_repo_on_disk(repo) - return get_commit_detail(repo.path, commit_hash) + try: + return get_commit_detail(repo.path, commit_hash) + except RuntimeError as e: + logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Commit detail unavailable: {str(e)}", + ) from e # File browsing diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index ec82001..1955f1b 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -2,10 +2,13 @@ import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_current_user, get_db_session +from src.models.tool_instance import ToolInstance from src.models.user import User +from src.schemas.tool_instance import SessionItemResponse, SessionListResponse from src.schemas.user import UserProfileResponse, UserProfileUpdate router = APIRouter(prefix="/users", tags=["users"]) @@ -131,3 +134,41 @@ async def upload_avatar( await session.commit() await session.refresh(user) return user + + +@router.get( + "/me/sessions", + response_model=SessionListResponse, + summary="Get current user sessions", + description="Retrieve all tool instances (sessions) for the authenticated user.", +) +async def get_user_sessions( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> SessionListResponse: + """Return all tool instances for the current user with related names.""" + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.owner_id == user.id) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + sessions = [ + SessionItemResponse( + id=str(inst.id), + display_name=inst.display_name, + tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown", + tool_icon=inst.tool_type.icon if inst.tool_type else None, + tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [], + repository_name=inst.repository.name if inst.repository else "Unknown", + repository_id=str(inst.repository_id), + project_name=inst.project.name if inst.project else "Unknown", + project_id=str(inst.project_id), + status=inst.status, + url=inst.url, + ) + for inst in instances + ] + + return SessionListResponse(sessions=sessions) diff --git a/apps/api/src/schemas/tool_instance.py b/apps/api/src/schemas/tool_instance.py index b460c17..09454d7 100644 --- a/apps/api/src/schemas/tool_instance.py +++ b/apps/api/src/schemas/tool_instance.py @@ -15,3 +15,27 @@ class CreateInstanceRequest(BaseModel): config_profile_id: str | None = Field( default=None, description="Optional config profile ID to apply to the instance" ) + + +class SessionItemResponse(BaseModel): + """Lightweight session summary for sidebar and dashboard.""" + + model_config = {"extra": "ignore"} + + id: str = Field(description="Session (tool instance) ID") + display_name: str = Field(description="Display name of the session") + tool_type_name: str = Field(description="Name of the tool type") + tool_icon: str | None = Field(default=None, description="Icon URL for the tool type") + tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces") + repository_name: str = Field(description="Name of the repository") + repository_id: str = Field(description="Repository ID") + project_name: str = Field(description="Name of the project") + project_id: str = Field(description="Project ID") + status: str = Field(description="Current status") + url: str | None = Field(default=None, description="Access URL") + + +class SessionListResponse(BaseModel): + """Response wrapping a list of session summaries.""" + + sessions: list[SessionItemResponse] diff --git a/apps/api/src/services/instance_lifecycle.py b/apps/api/src/services/instance_lifecycle.py index 0c91915..e28a51a 100644 --- a/apps/api/src/services/instance_lifecycle.py +++ b/apps/api/src/services/instance_lifecycle.py @@ -54,7 +54,7 @@ async def create_new_instance( instance = ToolInstance( name=instance_name, - display_name=display_name or f"{tool_type.display_name} - {repo.name}", + display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}", tool_type_id=tool_type.id, repository_id=repo.id, project_id=project.id, diff --git a/apps/api/src/utils/git_history.py b/apps/api/src/utils/git_history.py index 7f18dcd..9001954 100644 --- a/apps/api/src/utils/git_history.py +++ b/apps/api/src/utils/git_history.py @@ -60,9 +60,12 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 Returns structured data including commits, branches, and graph information. """ - # Get list of branches - branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"]) - branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()] + # Get list of branches (may fail for empty repos) + try: + branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"]) + branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()] + except RuntimeError: + branches = [] # Build git log command - use NULL bytes as separators to avoid parsing issues log_args = [ @@ -76,7 +79,16 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 else: log_args.append("--all") - log_output = _run_git_command(repo_path, log_args) + try: + log_output = _run_git_command(repo_path, log_args) + except RuntimeError: + # Empty repo or no commits + return { + "commits": [], + "branches": branches, + "total_commits": 0, + "graph_data": {"nodes": [], "edges": []}, + } # Get branch info for each commit branch_map = _get_branch_map(repo_path) @@ -113,8 +125,11 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 ) # Get total commit count - count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"]) - total_commits = int(count_output.strip()) if count_output.strip() else 0 + try: + count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"]) + total_commits = int(count_output.strip()) if count_output.strip() else 0 + except RuntimeError: + total_commits = 0 # Build graph data and generate graph symbols graph_data = _build_graph_data(commits) diff --git a/apps/web/src/components/features/git/FileEditor.tsx b/apps/web/src/components/features/git/FileEditor.tsx index 8ff15f5..e458e70 100644 --- a/apps/web/src/components/features/git/FileEditor.tsx +++ b/apps/web/src/components/features/git/FileEditor.tsx @@ -9,14 +9,23 @@ import { Icon } from "../../ui/Icon"; import { SyntaxHighlighter } from "./SyntaxHighlighter"; import { detectLanguage } from "../../../utils/language"; +interface GitFileStatus { + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; +} + interface FileEditorProps { projectId: string; repoId: string; + gitStatus?: GitFileStatus | null; } export const FileEditor: React.FC = ({ projectId, repoId, + gitStatus, }) => { const [searchParams] = useSearchParams(); const { user } = useAuth(); @@ -31,9 +40,49 @@ export const FileEditor: React.FC = ({ const [isBinary, setIsBinary] = useState(false); const [saving, setSaving] = useState(false); + const handleDiscard = async () => { + if (!filePath) return; + try { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/files/content`, + { + params: { + branch, + path: filePath, + }, + } + ); + const data = response.data; + if (data.is_binary) { + setIsBinary(true); + setContent("Binary file - cannot display"); + setOriginalContent(""); + } else { + setIsBinary(false); + setContent(data.content); + setOriginalContent(data.content); + } + setMode("view"); + } catch { + setError("Failed to discard changes"); + } + }; + const branch = searchParams.get("branch") || "main"; const filePath = searchParams.get("file"); + const fileStatus = gitStatus + ? gitStatus.modified.includes(filePath || "") + ? "modified" + : gitStatus.added.includes(filePath || "") + ? "added" + : gitStatus.deleted.includes(filePath || "") + ? "deleted" + : gitStatus.untracked.includes(filePath || "") + ? "untracked" + : undefined + : undefined; + const loadFile = useCallback(async () => { if (!filePath) { setContent(""); @@ -158,7 +207,7 @@ export const FileEditor: React.FC = ({
- {filePath.split("/").map((part, i, arr) => ( + {filePath?.split("/").map((part, i, arr) => ( {part} {i < arr.length - 1 && ( @@ -168,6 +217,11 @@ export const FileEditor: React.FC = ({ ))}
+ {fileStatus && ( + + {fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"} + + )} {mode === "view" && !isBinary && ( + +