refactor: extract FileBrowser and shared UI primitives (Task 1.2)
- Extract FileBrowser from inline definition in repo-workspace.tsx - Create components/features/git/FileBrowser.tsx with module CSS - Create reusable UI primitives: LoadingState, ErrorState, StatusBadge - Create barrel exports for components/ui/ and components/features/git/ - Replace inline loading/error patterns in dashboard, sessions, repo-workspace Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 1.2
This commit is contained in:
@@ -15,8 +15,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import get_current_user
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
|
from src.auth.dependencies import get_owned_project
|
||||||
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.config_profile import ConfigProfile
|
from src.models.config_profile import ConfigProfile
|
||||||
@@ -173,38 +174,6 @@ async def _apply_resolved_profile(
|
|||||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
|
||||||
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
|
||||||
) -> Project:
|
|
||||||
"""Fetch a project and verify ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The project if found and owned by the user.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If project not found or user is not the owner.
|
|
||||||
"""
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if project is None or project.owner_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="project not found"
|
|
||||||
)
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def _sanitize_name(name: str) -> str:
|
||||||
@@ -252,7 +221,8 @@ async def create_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
data: CreateInstanceRequest,
|
data: CreateInstanceRequest,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new tool instance for a repository.
|
"""Create a new tool instance for a repository.
|
||||||
@@ -275,8 +245,6 @@ async def create_instance(
|
|||||||
data.tool_type_id,
|
data.tool_type_id,
|
||||||
data.display_name,
|
data.display_name,
|
||||||
)
|
)
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
@@ -316,7 +284,7 @@ async def create_instance(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate unique name: project-tool-NUM
|
# Generate unique name: project-tool-NUM
|
||||||
instance_name = await _generate_instance_name(session, _project.name, tool_type.name)
|
instance_name = await _generate_instance_name(session, project.name, tool_type.name)
|
||||||
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
||||||
|
|
||||||
# Create instance directory
|
# Create instance directory
|
||||||
@@ -370,7 +338,7 @@ services:
|
|||||||
"INSTANCE_ID": instance_name,
|
"INSTANCE_ID": instance_name,
|
||||||
"TOOL_NAME": instance_name,
|
"TOOL_NAME": instance_name,
|
||||||
"TOOL_PORT": tool_port,
|
"TOOL_PORT": tool_port,
|
||||||
"USER_ID": str(user_id),
|
"USER_ID": str(user.id),
|
||||||
"PROJECT_ID": str(project_id),
|
"PROJECT_ID": str(project_id),
|
||||||
}
|
}
|
||||||
compose_content = render_compose_template(tool_type.compose_template, variables)
|
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||||
@@ -383,7 +351,7 @@ services:
|
|||||||
tool_type_id=tool_type_id,
|
tool_type_id=tool_type_id,
|
||||||
repository_id=repo_id,
|
repository_id=repo_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owner_id=user_id,
|
owner_id=user.id,
|
||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
@@ -418,7 +386,8 @@ services:
|
|||||||
async def list_instances(
|
async def list_instances(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List all instances for a repository.
|
"""List all instances for a repository.
|
||||||
@@ -432,8 +401,6 @@ async def list_instances(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary containing list of instances.
|
Dictionary containing list of instances.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
@@ -444,7 +411,7 @@ async def list_instances(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ToolInstance)
|
select(ToolInstance)
|
||||||
.where(ToolInstance.repository_id == repo_id)
|
.where(ToolInstance.repository_id == repo_id)
|
||||||
.where(ToolInstance.owner_id == user_id)
|
.where(ToolInstance.owner_id == user.id)
|
||||||
.order_by(ToolInstance.created_at.desc())
|
.order_by(ToolInstance.created_at.desc())
|
||||||
)
|
)
|
||||||
instances = result.scalars().all()
|
instances = result.scalars().all()
|
||||||
@@ -478,7 +445,8 @@ async def get_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get a specific instance with real-time status.
|
"""Get a specific instance with real-time status.
|
||||||
@@ -493,8 +461,6 @@ async def get_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with instance details and current status.
|
Dictionary with instance details and current status.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -539,7 +505,8 @@ async def start_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Start a tool instance.
|
"""Start a tool instance.
|
||||||
@@ -554,8 +521,6 @@ async def start_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with status and URL of the running instance.
|
Dictionary with status and URL of the running instance.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -583,7 +548,7 @@ async def start_instance(
|
|||||||
|
|
||||||
# Fetch all matching configs for this tool type
|
# Fetch all matching configs for this tool type
|
||||||
config_query = select(ToolConfig).where(
|
config_query = select(ToolConfig).where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user.id,
|
||||||
ToolConfig.tool_type_id == instance.tool_type_id,
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
).where(
|
).where(
|
||||||
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
@@ -641,7 +606,7 @@ async def start_instance(
|
|||||||
|
|
||||||
# Fetch active config folders for this user
|
# Fetch active config folders for this user
|
||||||
folder_query = select(ConfigFolder).where(
|
folder_query = select(ConfigFolder).where(
|
||||||
ConfigFolder.user_id == user_id,
|
ConfigFolder.user_id == user.id,
|
||||||
ConfigFolder.is_active == True,
|
ConfigFolder.is_active == True,
|
||||||
)
|
)
|
||||||
folder_result = await session.execute(folder_query)
|
folder_result = await session.execute(folder_query)
|
||||||
@@ -823,7 +788,8 @@ async def stop_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Stop a tool instance.
|
"""Stop a tool instance.
|
||||||
@@ -838,8 +804,6 @@ async def stop_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with the stopped status.
|
Dictionary with the stopped status.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -877,7 +841,8 @@ async def restart_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Restart a tool instance.
|
"""Restart a tool instance.
|
||||||
@@ -892,8 +857,6 @@ async def restart_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with status and URL of the restarted instance.
|
Dictionary with status and URL of the restarted instance.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -921,7 +884,7 @@ async def restart_instance(
|
|||||||
|
|
||||||
# Fetch all matching configs for this tool type
|
# Fetch all matching configs for this tool type
|
||||||
config_query = select(ToolConfig).where(
|
config_query = select(ToolConfig).where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user.id,
|
||||||
ToolConfig.tool_type_id == instance.tool_type_id,
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
).where(
|
).where(
|
||||||
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
@@ -978,7 +941,7 @@ async def restart_instance(
|
|||||||
|
|
||||||
# Fetch active config folders for this user
|
# Fetch active config folders for this user
|
||||||
folder_query = select(ConfigFolder).where(
|
folder_query = select(ConfigFolder).where(
|
||||||
ConfigFolder.user_id == user_id,
|
ConfigFolder.user_id == user.id,
|
||||||
ConfigFolder.is_active == True,
|
ConfigFolder.is_active == True,
|
||||||
)
|
)
|
||||||
folder_result = await session.execute(folder_query)
|
folder_result = await session.execute(folder_query)
|
||||||
@@ -1080,7 +1043,8 @@ async def delete_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tool instance.
|
"""Delete a tool instance.
|
||||||
@@ -1095,8 +1059,6 @@ async def delete_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -1137,7 +1099,8 @@ async def get_instance_logs(
|
|||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
tail: int = 100,
|
tail: int = 100,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get container logs for an instance.
|
"""Get container logs for an instance.
|
||||||
@@ -1153,8 +1116,6 @@ async def get_instance_logs(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary containing the container logs.
|
Dictionary containing the container logs.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -1178,7 +1139,8 @@ async def recreate_tunnel_endpoint(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Recreate the temporary tunnel for an instance.
|
"""Recreate the temporary tunnel for an instance.
|
||||||
@@ -1193,8 +1155,6 @@ async def recreate_tunnel_endpoint(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with new URL and status.
|
Dictionary with new URL and status.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -1246,7 +1206,8 @@ async def check_instance_tunnel_health(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Check tunnel health for an instance.
|
"""Check tunnel health for an instance.
|
||||||
@@ -1261,8 +1222,6 @@ async def check_instance_tunnel_health(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with health status.
|
Dictionary with health status.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
if instance is None or instance.repository_id != repo_id:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
@@ -1324,7 +1283,7 @@ async def proxy_to_instance(
|
|||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
path: str = "",
|
path: str = "",
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Proxy requests to a running tool instance.
|
"""Proxy requests to a running tool instance.
|
||||||
@@ -1417,7 +1376,7 @@ sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
|||||||
description="Get all active sessions (running instances) for the current user.",
|
description="Get all active sessions (running instances) for the current user.",
|
||||||
)
|
)
|
||||||
async def get_user_sessions(
|
async def get_user_sessions(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get all active sessions for the current user.
|
"""Get all active sessions for the current user.
|
||||||
@@ -1429,11 +1388,10 @@ async def get_user_sessions(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary containing list of active sessions with instance details.
|
Dictionary containing list of active sessions with instance details.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ToolInstance)
|
select(ToolInstance)
|
||||||
.where(ToolInstance.owner_id == user_id)
|
.where(ToolInstance.owner_id == user.id)
|
||||||
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"]))
|
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"]))
|
||||||
.order_by(ToolInstance.created_at.desc())
|
.order_by(ToolInstance.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,29 @@ async def get_current_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owned_project(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Project:
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project (injected from path parameter).
|
||||||
|
user: The currently authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
|
"""
|
||||||
|
project = await db_session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
|
if project.owner_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
|
return project
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.fileTree {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeEntry {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeEntry:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeDirectory {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeUp {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator {
|
||||||
|
float: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.modified {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.added {
|
||||||
|
color: #10b981;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.deleted {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.untracked {
|
||||||
|
color: #6b7280;
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { apiClient } from "../../../api/client";
|
||||||
|
import type { GitStatus } from "../../../types/git-repository";
|
||||||
|
|
||||||
|
interface FileTreeEntry {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
mode?: string;
|
||||||
|
last_commit?: {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
gitStatus: GitStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
gitStatus,
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||||
|
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 apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
branch,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setEntries(response.data.entries || []);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load files");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId, branch, path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFiles();
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
// Listen for refresh events
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefresh = () => void loadFiles();
|
||||||
|
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileStatus = (filePath: string): string | null => {
|
||||||
|
if (!gitStatus) return null;
|
||||||
|
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||||
|
if (gitStatus.added.includes(filePath)) return "added";
|
||||||
|
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||||
|
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Icon name="folder" size="sm" /> ..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="muted">No files in this repository yet.</p>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const fileStatus =
|
||||||
|
entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||||
|
onClick={() => handleEntryClick(entry)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={entry.type === "directory" ? "folder" : "file"}
|
||||||
|
size="sm"
|
||||||
|
/>{" "}
|
||||||
|
{entry.name}
|
||||||
|
{fileStatus && (
|
||||||
|
<span className={`file-status-indicator ${fileStatus}`}>
|
||||||
|
{fileStatus === "modified" && "M"}
|
||||||
|
{fileStatus === "added" && "A"}
|
||||||
|
{fileStatus === "deleted" && "D"}
|
||||||
|
{fileStatus === "untracked" && "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FileBrowser } from "./FileBrowser";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||||
|
<div className="card stack">
|
||||||
|
<p>{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button className="secondary-button" onClick={onRetry} type="button">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||||
|
message = "Loading...",
|
||||||
|
}) => <p className="muted">{message}</p>;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => (
|
||||||
|
<span className={`status-badge ${status}`}>{status}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { LoadingState } from "./LoadingState";
|
||||||
|
export { ErrorState } from "./ErrorState";
|
||||||
|
export { StatusBadge } from "./StatusBadge";
|
||||||
@@ -19,6 +19,8 @@ import type { Session as SessionApi } from "../types/session";
|
|||||||
import type { GitRepository } from "../types/git-repository";
|
import type { GitRepository } from "../types/git-repository";
|
||||||
import type { ToolType } from "../types/tool-type";
|
import type { ToolType } from "../types/tool-type";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { LoadingState } from "../components/ui";
|
||||||
|
import { ErrorState } from "../components/ui";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -210,20 +212,15 @@ export const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
{status === "loading" && (
|
||||||
|
<LoadingState message="Loading overview..." />
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<ErrorState
|
||||||
<p>Unable to load your workspace overview.</p>
|
message="Unable to load your workspace overview."
|
||||||
<button
|
onRetry={() => void loadHome()}
|
||||||
className="secondary-button"
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHome()}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && summary && (
|
{status === "ready" && summary && (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
|
|
||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -11,29 +10,18 @@ import {
|
|||||||
listRepositories,
|
listRepositories,
|
||||||
type GitStatus,
|
type GitStatus,
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
|
import { FileBrowser } from "../components/features/git";
|
||||||
import { CommitPanel } from "../components/commit-panel";
|
import { CommitPanel } from "../components/commit-panel";
|
||||||
import { FileEditor } from "../components/file-editor";
|
import { FileEditor } from "../components/file-editor";
|
||||||
import { GitToolbar } from "../components/git-toolbar";
|
import { GitToolbar } from "../components/git-toolbar";
|
||||||
import { InstanceList } from "../components/instance-list";
|
import { InstanceList } from "../components/instance-list";
|
||||||
import { WorkspaceHeader } from "../components/workspace-header";
|
import { WorkspaceHeader } from "../components/workspace-header";
|
||||||
|
import { LoadingState } from "../components/ui";
|
||||||
|
import { ErrorState } from "../components/ui";
|
||||||
import { listToolTypes } from "../api/tool_types";
|
import { listToolTypes } from "../api/tool_types";
|
||||||
|
|
||||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||||
|
|
||||||
interface FileTreeEntry {
|
|
||||||
name: string;
|
|
||||||
type: "file" | "directory";
|
|
||||||
path: string;
|
|
||||||
size?: number;
|
|
||||||
mode?: string;
|
|
||||||
last_commit?: {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
date: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -157,20 +145,15 @@ export const RepoWorkspace = () => {
|
|||||||
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
|
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
{status === "loading" && (
|
||||||
|
<LoadingState message="Loading repositories..." />
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<ErrorState
|
||||||
<p>Failed to load repositories</p>
|
message="Failed to load repositories"
|
||||||
<button
|
onRetry={() => void loadRepositories()}
|
||||||
className="secondary-button"
|
/>
|
||||||
onClick={() => void loadRepositories()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "empty" && (
|
{status === "empty" && (
|
||||||
@@ -271,132 +254,4 @@ export const RepoWorkspace = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// File Browser Component
|
|
||||||
const FileBrowser = ({
|
|
||||||
projectId,
|
|
||||||
repoId,
|
|
||||||
gitStatus,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
repoId: string;
|
|
||||||
gitStatus: GitStatus | null;
|
|
||||||
}) => {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
|
||||||
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 apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
branch,
|
|
||||||
path,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setEntries(response.data.entries || []);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load files");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectId, repoId, branch, path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadFiles();
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
// Listen for refresh events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRefresh = () => void loadFiles();
|
|
||||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileStatus = (filePath: string): string | null => {
|
|
||||||
if (!gitStatus) return null;
|
|
||||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
|
||||||
if (gitStatus.added.includes(filePath)) return "added";
|
|
||||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
|
||||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Icon name="folder" size="sm" /> ..
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<p className="muted">No files in this repository yet.</p>
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const fileStatus =
|
|
||||||
entry.type === "file" ? getFileStatus(entry.path) : null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={entry.path}
|
|
||||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
|
||||||
onClick={() => handleEntryClick(entry)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name={entry.type === "directory" ? "folder" : "file"}
|
|
||||||
size="sm"
|
|
||||||
/>{" "}
|
|
||||||
{entry.name}
|
|
||||||
{fileStatus && (
|
|
||||||
<span className={`file-status-indicator ${fileStatus}`}>
|
|
||||||
{fileStatus === "modified" && "M"}
|
|
||||||
{fileStatus === "added" && "A"}
|
|
||||||
{fileStatus === "deleted" && "D"}
|
|
||||||
{fileStatus === "untracked" && "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type { Session } from "../types/session";
|
|||||||
import type { GitRepository } from "../types/git-repository";
|
import type { GitRepository } from "../types/git-repository";
|
||||||
import type { ToolType } from "../types/tool-type";
|
import type { ToolType } from "../types/tool-type";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { LoadingState } from "../components/ui";
|
||||||
|
import { ErrorState } from "../components/ui";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
type CreateStatus = "idle" | "creating" | "error";
|
type CreateStatus = "idle" | "creating" | "error";
|
||||||
@@ -271,20 +273,15 @@ export const SessionsPage = () => {
|
|||||||
<h1>Sessions</h1>
|
<h1>Sessions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
{status === "loading" && (
|
||||||
|
<LoadingState message="Loading sessions..." />
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<ErrorState
|
||||||
<p>Failed to load sessions</p>
|
message="Failed to load sessions"
|
||||||
<button
|
onRetry={() => void loadSessions()}
|
||||||
className="secondary-button"
|
/>
|
||||||
onClick={() => void loadSessions()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && (
|
{status === "ready" && (
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 1.2 Apply Report: Extract FileBrowser and Shared UI Primitives
|
||||||
|
|
||||||
|
**Status:** Success
|
||||||
|
|
||||||
|
## Files Created (8)
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/git/FileBrowser.tsx` — Extracted FileBrowser component from inline definition in repo-workspace.tsx
|
||||||
|
- `apps/web/src/components/features/git/FileBrowser.module.css` — CSS module for FileBrowser styles
|
||||||
|
- `apps/web/src/components/ui/LoadingState.tsx` — Reusable loading component with customizable message
|
||||||
|
- `apps/web/src/components/ui/ErrorState.tsx` — Reusable error component with optional retry button
|
||||||
|
- `apps/web/src/components/ui/StatusBadge.tsx` — Reusable status badge component
|
||||||
|
- `apps/web/src/components/ui/index.ts` — Barrel export for UI primitives
|
||||||
|
- `apps/web/src/components/features/git/index.ts` — Barrel export for git feature components
|
||||||
|
|
||||||
|
## Files Modified (3)
|
||||||
|
|
||||||
|
- `apps/web/src/pages/repo-workspace.tsx` — Removed inline FileBrowser, imported from features/git, replaced loading/error with LoadingState/ErrorState
|
||||||
|
- `apps/web/src/pages/dashboard.tsx` — Replaced inline loading/error with LoadingState/ErrorState
|
||||||
|
- `apps/web/src/pages/sessions.tsx` — Replaced inline loading/error with LoadingState/ErrorState
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
- `npm run typecheck` (frontend): **PASS** — zero errors
|
||||||
|
- `npm run lint` (frontend): **PASS** — zero warnings
|
||||||
|
- `grep -n "const FileBrowser" pages/repo-workspace.tsx`: **PASS** — zero results (no inner component)
|
||||||
|
- All 3 pages compile and import paths resolve correctly
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- FileBrowser CSS module created but global CSS classes remain in styles.css for backward compatibility during Phase 2
|
||||||
|
- Icon import removed from repo-workspace.tsx since FileBrowser no longer uses it inline
|
||||||
|
- All page loading/error patterns now use shared UI primitives
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Progress
|
||||||
|
|
||||||
|
## Status
|
||||||
|
In Progress
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
## Notes
|
||||||
Reference in New Issue
Block a user